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).
This commit is contained in:
2026-06-22 16:06:27 +08:00
parent 1da034c9b1
commit 84563ecfc8
7 changed files with 145 additions and 60 deletions

View File

@@ -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::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::shortcuts::{self, default_bindings}; use crate::shortcuts::{self, default_bindings};
use crate::ui_fonts::{self, FontCandidate, UiFontSettings}; 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::SessionManager;
use pipeview_client::config::SessionConfig; use pipeview_client::config::SessionConfig;
use pipeview_client::session::SessionEvent; use pipeview_client::session::SessionEvent;
@@ -537,7 +537,7 @@ impl XserialApp {
fn render_main_panel(&mut self, ui: &mut egui::Ui) { fn render_main_panel(&mut self, ui: &mut egui::Ui) {
egui::CentralPanel::default().show_inside(ui, |ui| { egui::CentralPanel::default().show_inside(ui, |ui| {
if self.tabs.is_empty() { if self.tabs.is_empty() {
ui.heading("No sessions."); ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false));
return; return;
} }
@@ -557,10 +557,10 @@ impl XserialApp {
let (badge, status_text) = tab.status.badge(); let (badge, status_text) = tab.status.badge();
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading(format!("Session {}", tab.id)); ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
ui.label(badge); ui.add(Label::new(badge).selectable(false));
ui.separator(); ui.separator();
ui.label(status_text); ui.add(Label::new(status_text).selectable(false));
ui.separator(); ui.separator();
if ui if ui
.selectable_label(tab.view == View::Text, "Text") .selectable_label(tab.view == View::Text, "Text")
@@ -584,7 +584,7 @@ impl XserialApp {
let mut display_changed = false; let mut display_changed = false;
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.label("Show:"); ui.add(Label::new("Show:").selectable(false));
display_changed |= ui display_changed |= ui
.checkbox(&mut display.show_timestamp, "Timestamp") .checkbox(&mut display.show_timestamp, "Timestamp")
.changed(); .changed();
@@ -598,7 +598,7 @@ impl XserialApp {
session_controls::render_search_bar(ui, tab); session_controls::render_search_bar(ui, tab);
if let ConnectionStatus::Error(message) = &tab.status { 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(); let full = ui.available_rect_before_wrap();
@@ -629,11 +629,22 @@ impl XserialApp {
} }
View::Plot => { View::Plot => {
if tab.plot_view.detached { if tab.plot_view.detached {
ui.heading(format!( ui.add(
"Plot window detached for Session {}", Label::new(
tab.id egui::RichText::new(format!(
)); "Plot window detached for Session {}",
ui.label("The plot is currently shown in a floating window."); 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() { if ui.button("Dock Plot Back").clicked() {
tab.plot_view.detached = false; tab.plot_view.detached = false;
} }
@@ -720,7 +731,7 @@ impl XserialApp {
fn render_top_bar(&mut self, ui: &mut egui::Ui) { fn render_top_bar(&mut self, ui: &mut egui::Ui) {
Panel::top("top_bar").show_inside(ui, |ui| { Panel::top("top_bar").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.heading("pipeview"); ui.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false));
ui.separator(); ui.separator();
// ui.label(format!( // ui.label(format!(
// "Fonts: {} + {} {:.1} pt", // "Fonts: {} + {} {:.1} pt",

View File

@@ -6,6 +6,11 @@ use egui::{
text::{LayoutJob, TextFormat}, 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( pub fn render(
ui: &mut Ui, ui: &mut Ui,
buf: &TextBuffer, buf: &TextBuffer,
@@ -14,21 +19,51 @@ pub fn render(
) -> usize { ) -> usize {
let line_count = buf.len(); let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace); let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().stick_to_bottom(true).show_rows( let mut near_bottom_edge = false;
ui,
row_height, let scroll_area = ScrollArea::both()
line_count, .id_salt("console_text_area")
|ui, row_range| { .stick_to_bottom(true);
for row in row_range {
if let Some(line) = buf.get(row) { let output = scroll_area.show_viewport(ui, |ui, viewport| {
ui.add( let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
Label::new(format_console_line(line, display, search, ui.style())) let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
.wrap_mode(TextWrapMode::Extend),
); // 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 line_count
} }

View File

@@ -5,6 +5,11 @@ use egui::{
text::{LayoutJob, TextFormat}, 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( pub fn render(
ui: &mut Ui, ui: &mut Ui,
buf: &HexBuffer, buf: &HexBuffer,
@@ -13,25 +18,53 @@ pub fn render(
) -> usize { ) -> usize {
let line_count = buf.len(); let line_count = buf.len();
if line_count == 0 { if line_count == 0 {
ui.label("no hex data"); ui.add(Label::new("no hex data").selectable(false));
return 0; return 0;
} }
let row_height = ui.text_style_height(&TextStyle::Monospace); let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().stick_to_bottom(true).show_rows( let mut near_bottom_edge = false;
ui,
row_height, let scroll_area = ScrollArea::both()
line_count, .id_salt("hex_text_area")
|ui, row_range| { .stick_to_bottom(true);
for row in row_range {
if let Some(line) = buf.get(row) { let output = scroll_area.show_viewport(ui, |ui, viewport| {
ui.add( let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
Label::new(format_hex_line(line, display, search, ui.style())) let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
.wrap_mode(TextWrapMode::Extend),
); // 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 line_count
} }

View File

@@ -1,6 +1,6 @@
use crate::buffers::{PlotBuffer, PlotSeriesKind}; use crate::buffers::{PlotBuffer, PlotSeriesKind};
use crate::perf::PlotRenderStats; use crate::perf::PlotRenderStats;
use egui::{Button, Ui, vec2}; use egui::{Button, Label, Ui, vec2};
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints}; use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -46,7 +46,7 @@ pub fn render(
if ui.button(toggle_label).clicked() { if ui.button(toggle_label).clicked() {
toggle_detached = true; toggle_detached = true;
} }
ui.label("no plot data"); ui.add(Label::new("no plot data").selectable(false));
}); });
return PlotRenderOutput { return PlotRenderOutput {
stats: PlotRenderStats { stats: PlotRenderStats {

View File

@@ -3,12 +3,12 @@ use std::path::PathBuf;
use crate::app_state; use crate::app_state;
use crate::logging::{self, LogWriter}; use crate::logging::{self, LogWriter};
use crate::models::{LineEnding, SendMode, SessionTab}; use crate::models::{LineEnding, SendMode, SessionTab};
use egui::{Color32, TextEdit}; use egui::{Color32, Label, TextEdit};
use pipeview_client::SessionManager; use pipeview_client::SessionManager;
pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) { pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
ui.heading("Send"); ui.add(Label::new(egui::RichText::new("Send").heading()).selectable(false));
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text"); ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex"); 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| { ui.horizontal(|ui| {
send_clicked = ui.button("Send").clicked(); send_clicked = ui.button("Send").clicked();
if let Some(status) = &tab.send_status { if let Some(status) = &tab.send_status {
ui.label( ui.add(
egui::RichText::new(status).color(if status.starts_with("Send failed") { Label::new(
Color32::RED egui::RichText::new(status).color(if status.starts_with("Send failed") {
} else { Color32::RED
Color32::GRAY } else {
}), Color32::GRAY
}),
)
.selectable(false),
); );
} }
}); });

View File

@@ -1,5 +1,5 @@
use crate::models::{ConnectionStatus, SessionTab, View}; use crate::models::{ConnectionStatus, SessionTab, View};
use egui::TextEdit; use egui::{Label, TextEdit};
use pipeview_client::SessionManager; use pipeview_client::SessionManager;
use pipeview_core::transport::TransportConfig; use pipeview_core::transport::TransportConfig;
@@ -30,11 +30,14 @@ pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
tab.search.next(); tab.search.next();
} }
ui.label(format!( ui.add(
"{}/{}", Label::new(format!(
tab.search.current_display(), "{}/{}",
tab.search.match_count() tab.search.current_display(),
)); tab.search.match_count()
))
.selectable(false),
);
if ui.button("").clicked() { if ui.button("").clicked() {
tab.search.prev(); tab.search.prev();

View File

@@ -1,5 +1,5 @@
use crate::app::ConnectionStatus; use crate::app::ConnectionStatus;
use egui::{Color32, RichText, Ui}; use egui::{Color32, Label, RichText, Ui};
pub struct SessionListItem { pub struct SessionListItem {
pub id: u64, pub id: u64,
@@ -15,7 +15,7 @@ pub fn render(
on_edit: &mut Option<usize>, on_edit: &mut Option<usize>,
on_delete: &mut Option<usize>, on_delete: &mut Option<usize>,
) { ) {
ui.heading("Sessions"); ui.add(Label::new(egui::RichText::new("Sessions").heading()).selectable(false));
if ui if ui
.button(RichText::new("+ New Session").color(Color32::GREEN)) .button(RichText::new("+ New Session").color(Color32::GREEN))