From 84563ecfc870ef9e26da8f58838b3a310af0ceab Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Mon, 22 Jun 2026 16:06:27 +0800 Subject: [PATCH] fix(gui): disable text selection on UI labels, add edge-scroll during drag UI labels (status, headings, control text) no longer selectable. Console/hex content remains selectable for copy. ScrollArea now nudges when drag-selecting near the bottom edge (~240px/s). --- crates/pipeview-gui/src/app.rs | 37 +++++++---- crates/pipeview-gui/src/panels/console.rs | 63 ++++++++++++++----- crates/pipeview-gui/src/panels/hex_view.rs | 63 ++++++++++++++----- crates/pipeview-gui/src/panels/plot_view.rs | 4 +- crates/pipeview-gui/src/panels/send_panel.rs | 19 +++--- .../src/panels/session_controls.rs | 15 +++-- crates/pipeview-gui/src/panels/sidebar.rs | 4 +- 7 files changed, 145 insertions(+), 60 deletions(-) diff --git a/crates/pipeview-gui/src/app.rs b/crates/pipeview-gui/src/app.rs index e5fdcf8..1c2f866 100644 --- a/crates/pipeview-gui/src/app.rs +++ b/crates/pipeview-gui/src/app.rs @@ -8,7 +8,7 @@ use crate::panels::{config, console, font_settings, hex_view, plot_view, send_pa use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot}; use crate::shortcuts::{self, default_bindings}; use crate::ui_fonts::{self, FontCandidate, UiFontSettings}; -use egui::{Color32, Layout, Panel, Pos2, Rect, UiBuilder}; +use egui::{Color32, Label, Layout, Panel, Pos2, Rect, UiBuilder}; use pipeview_client::SessionManager; use pipeview_client::config::SessionConfig; use pipeview_client::session::SessionEvent; @@ -537,7 +537,7 @@ impl XserialApp { fn render_main_panel(&mut self, ui: &mut egui::Ui) { egui::CentralPanel::default().show_inside(ui, |ui| { if self.tabs.is_empty() { - ui.heading("No sessions."); + ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false)); return; } @@ -557,10 +557,10 @@ impl XserialApp { let (badge, status_text) = tab.status.badge(); ui.horizontal(|ui| { - ui.heading(format!("Session {}", tab.id)); - ui.label(badge); + ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false)); + ui.add(Label::new(badge).selectable(false)); ui.separator(); - ui.label(status_text); + ui.add(Label::new(status_text).selectable(false)); ui.separator(); if ui .selectable_label(tab.view == View::Text, "Text") @@ -584,7 +584,7 @@ impl XserialApp { let mut display_changed = false; ui.horizontal_wrapped(|ui| { - ui.label("Show:"); + ui.add(Label::new("Show:").selectable(false)); display_changed |= ui .checkbox(&mut display.show_timestamp, "Timestamp") .changed(); @@ -598,7 +598,7 @@ impl XserialApp { session_controls::render_search_bar(ui, tab); if let ConnectionStatus::Error(message) = &tab.status { - ui.label(egui::RichText::new(message).color(Color32::RED)); + ui.add(Label::new(egui::RichText::new(message).color(Color32::RED)).selectable(false)); } let full = ui.available_rect_before_wrap(); @@ -629,11 +629,22 @@ impl XserialApp { } View::Plot => { if tab.plot_view.detached { - ui.heading(format!( - "Plot window detached for Session {}", - tab.id - )); - ui.label("The plot is currently shown in a floating window."); + ui.add( + Label::new( + egui::RichText::new(format!( + "Plot window detached for Session {}", + tab.id, + )) + .heading(), + ) + .selectable(false), + ); + ui.add( + Label::new( + "The plot is currently shown in a floating window.", + ) + .selectable(false), + ); if ui.button("Dock Plot Back").clicked() { tab.plot_view.detached = false; } @@ -720,7 +731,7 @@ impl XserialApp { fn render_top_bar(&mut self, ui: &mut egui::Ui) { Panel::top("top_bar").show_inside(ui, |ui| { ui.horizontal_wrapped(|ui| { - ui.heading("pipeview"); + ui.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false)); ui.separator(); // ui.label(format!( // "Fonts: {} + {} {:.1} pt", diff --git a/crates/pipeview-gui/src/panels/console.rs b/crates/pipeview-gui/src/panels/console.rs index 73f9604..a8ed925 100644 --- a/crates/pipeview-gui/src/panels/console.rs +++ b/crates/pipeview-gui/src/panels/console.rs @@ -6,6 +6,11 @@ use egui::{ text::{LayoutJob, TextFormat}, }; +/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag +const EDGE_SCROLL_NUDGE: f32 = 4.0; +/// Distance from the bottom edge (pixels) that triggers auto-scroll +const EDGE_SCROLL_ZONE: f32 = 30.0; + pub fn render( ui: &mut Ui, buf: &TextBuffer, @@ -14,21 +19,51 @@ pub fn render( ) -> usize { let line_count = buf.len(); let row_height = ui.text_style_height(&TextStyle::Monospace); - ScrollArea::both().stick_to_bottom(true).show_rows( - ui, - row_height, - line_count, - |ui, row_range| { - for row in row_range { - if let Some(line) = buf.get(row) { - ui.add( - Label::new(format_console_line(line, display, search, ui.style())) - .wrap_mode(TextWrapMode::Extend), - ); - } + let mut near_bottom_edge = false; + + let scroll_area = ScrollArea::both() + .id_salt("console_text_area") + .stick_to_bottom(true); + + let output = scroll_area.show_viewport(ui, |ui, viewport| { + let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize; + let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count); + + // Check if user is drag-selecting near the bottom edge of the scroll area. + // We compare pointer position against the screen-space bottom of the + // allocated scroll area (ui.max_rect) rather than the content viewport, + // so that any overflow content area counts. + near_bottom_edge = ui.ctx().input(|input| { + input.pointer.button_down(egui::PointerButton::Primary) + && input.pointer.hover_pos().is_some_and(|pos| { + let area = ui.max_rect(); + pos.y > area.bottom() - EDGE_SCROLL_ZONE + && pos.y < area.bottom() + 20.0 + }) + }); + + for row in start_row..end_row { + if let Some(line) = buf.get(row) { + ui.add( + Label::new(format_console_line(line, display, search, ui.style())) + .wrap_mode(TextWrapMode::Extend), + ); } - }, - ); + } + }); + + // If the user is dragging a selection near the bottom edge, nudge the scroll + // offset for the next frame and request a repaint for smooth continuous scrolling. + if near_bottom_edge && line_count > 0 { + let mut state = output.state; + let max_offset = (line_count as f32 * row_height) + - output.inner_rect.height() + + EDGE_SCROLL_ZONE; + state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0)); + state.store(ui.ctx(), output.id); + ui.ctx().request_repaint(); + } + line_count } diff --git a/crates/pipeview-gui/src/panels/hex_view.rs b/crates/pipeview-gui/src/panels/hex_view.rs index 9de76c8..0ce36d1 100644 --- a/crates/pipeview-gui/src/panels/hex_view.rs +++ b/crates/pipeview-gui/src/panels/hex_view.rs @@ -5,6 +5,11 @@ use egui::{ text::{LayoutJob, TextFormat}, }; +/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag +const EDGE_SCROLL_NUDGE: f32 = 4.0; +/// Distance from the bottom edge (pixels) that triggers auto-scroll +const EDGE_SCROLL_ZONE: f32 = 30.0; + pub fn render( ui: &mut Ui, buf: &HexBuffer, @@ -13,25 +18,53 @@ pub fn render( ) -> usize { let line_count = buf.len(); if line_count == 0 { - ui.label("no hex data"); + ui.add(Label::new("no hex data").selectable(false)); return 0; } + let row_height = ui.text_style_height(&TextStyle::Monospace); - ScrollArea::both().stick_to_bottom(true).show_rows( - ui, - row_height, - line_count, - |ui, row_range| { - for row in row_range { - if let Some(line) = buf.get(row) { - ui.add( - Label::new(format_hex_line(line, display, search, ui.style())) - .wrap_mode(TextWrapMode::Extend), - ); - } + let mut near_bottom_edge = false; + + let scroll_area = ScrollArea::both() + .id_salt("hex_text_area") + .stick_to_bottom(true); + + let output = scroll_area.show_viewport(ui, |ui, viewport| { + let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize; + let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count); + + // Check if user is drag-selecting near the bottom edge of the scroll area + near_bottom_edge = ui.ctx().input(|input| { + input.pointer.button_down(egui::PointerButton::Primary) + && input.pointer.hover_pos().is_some_and(|pos| { + let area = ui.max_rect(); + pos.y > area.bottom() - EDGE_SCROLL_ZONE + && pos.y < area.bottom() + 20.0 + }) + }); + + for row in start_row..end_row { + if let Some(line) = buf.get(row) { + ui.add( + Label::new(format_hex_line(line, display, search, ui.style())) + .wrap_mode(TextWrapMode::Extend), + ); } - }, - ); + } + }); + + // If the user is dragging a selection near the bottom edge, nudge the scroll + // offset for the next frame and request a repaint for smooth continuous scrolling. + if near_bottom_edge { + let mut state = output.state; + let max_offset = (line_count as f32 * row_height) + - output.inner_rect.height() + + EDGE_SCROLL_ZONE; + state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0)); + state.store(ui.ctx(), output.id); + ui.ctx().request_repaint(); + } + line_count } diff --git a/crates/pipeview-gui/src/panels/plot_view.rs b/crates/pipeview-gui/src/panels/plot_view.rs index fa9eb65..35215cb 100644 --- a/crates/pipeview-gui/src/panels/plot_view.rs +++ b/crates/pipeview-gui/src/panels/plot_view.rs @@ -1,6 +1,6 @@ use crate::buffers::{PlotBuffer, PlotSeriesKind}; use crate::perf::PlotRenderStats; -use egui::{Button, Ui, vec2}; +use egui::{Button, Label, Ui, vec2}; use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -46,7 +46,7 @@ pub fn render( if ui.button(toggle_label).clicked() { toggle_detached = true; } - ui.label("no plot data"); + ui.add(Label::new("no plot data").selectable(false)); }); return PlotRenderOutput { stats: PlotRenderStats { diff --git a/crates/pipeview-gui/src/panels/send_panel.rs b/crates/pipeview-gui/src/panels/send_panel.rs index 5a0b99f..6b7b5af 100644 --- a/crates/pipeview-gui/src/panels/send_panel.rs +++ b/crates/pipeview-gui/src/panels/send_panel.rs @@ -3,12 +3,12 @@ use std::path::PathBuf; use crate::app_state; use crate::logging::{self, LogWriter}; use crate::models::{LineEnding, SendMode, SessionTab}; -use egui::{Color32, TextEdit}; +use egui::{Color32, Label, TextEdit}; use pipeview_client::SessionManager; pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) { ui.set_width(ui.available_width()); - ui.heading("Send"); + ui.add(Label::new(egui::RichText::new("Send").heading()).selectable(false)); ui.horizontal(|ui| { ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text"); ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex"); @@ -95,12 +95,15 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut ui.horizontal(|ui| { send_clicked = ui.button("Send").clicked(); if let Some(status) = &tab.send_status { - ui.label( - egui::RichText::new(status).color(if status.starts_with("Send failed") { - Color32::RED - } else { - Color32::GRAY - }), + ui.add( + Label::new( + egui::RichText::new(status).color(if status.starts_with("Send failed") { + Color32::RED + } else { + Color32::GRAY + }), + ) + .selectable(false), ); } }); diff --git a/crates/pipeview-gui/src/panels/session_controls.rs b/crates/pipeview-gui/src/panels/session_controls.rs index cb520a6..0c7fccc 100644 --- a/crates/pipeview-gui/src/panels/session_controls.rs +++ b/crates/pipeview-gui/src/panels/session_controls.rs @@ -1,5 +1,5 @@ use crate::models::{ConnectionStatus, SessionTab, View}; -use egui::TextEdit; +use egui::{Label, TextEdit}; use pipeview_client::SessionManager; use pipeview_core::transport::TransportConfig; @@ -30,11 +30,14 @@ pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) { tab.search.next(); } - ui.label(format!( - "{}/{}", - tab.search.current_display(), - tab.search.match_count() - )); + ui.add( + Label::new(format!( + "{}/{}", + tab.search.current_display(), + tab.search.match_count() + )) + .selectable(false), + ); if ui.button("▲").clicked() { tab.search.prev(); diff --git a/crates/pipeview-gui/src/panels/sidebar.rs b/crates/pipeview-gui/src/panels/sidebar.rs index 94d709c..cbf323c 100644 --- a/crates/pipeview-gui/src/panels/sidebar.rs +++ b/crates/pipeview-gui/src/panels/sidebar.rs @@ -1,5 +1,5 @@ use crate::app::ConnectionStatus; -use egui::{Color32, RichText, Ui}; +use egui::{Color32, Label, RichText, Ui}; pub struct SessionListItem { pub id: u64, @@ -15,7 +15,7 @@ pub fn render( on_edit: &mut Option, on_delete: &mut Option, ) { - ui.heading("Sessions"); + ui.add(Label::new(egui::RichText::new("Sessions").heading()).selectable(false)); if ui .button(RichText::new("+ New Session").color(Color32::GREEN))