Files
xserial/crates/pipeview-gui/src/app.rs
FallenSigh 84563ecfc8 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).
2026-06-22 16:06:27 +08:00

861 lines
32 KiB
Rust

use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::logging::{self, LogWriter};
use crate::panels::{config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar};
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::shortcuts::{self, default_bindings};
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
use egui::{Color32, Label, Layout, Panel, Pos2, Rect, UiBuilder};
use pipeview_client::SessionManager;
use pipeview_client::config::SessionConfig;
use pipeview_client::session::SessionEvent;
use pipeview_core::protocol::DecodedData;
use pipeview_core::transport::TransportConfig;
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
pub use crate::models::*;
pub struct XserialApp {
pub(crate) manager: SessionManager,
pub(crate) tabs: Vec<SessionTab>,
pub(crate) active: usize,
pub(crate) display: DisplayOptions,
pub(crate) font_settings_open: bool,
pub(crate) font_candidates: Vec<FontCandidate>,
pub(crate) font_settings: UiFontSettings,
pub(crate) primary_font_search: String,
pub(crate) fallback_font_search: String,
pub(crate) primary_filtered_fonts: Vec<usize>,
pub(crate) fallback_filtered_fonts: Vec<usize>,
pub(crate) primary_filter_cache_key: String,
pub(crate) fallback_filter_cache_key: String,
pub(crate) config_open: bool,
pub(crate) config_target: Option<u64>,
pub(crate) config_form: config::ConfigForm,
pub(crate) event_rx: mpsc::Receiver<SessionEvent>,
pub(crate) pending: Vec<SessionEvent>,
pub(crate) profiler: GuiProfiler,
pub(crate) shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
}
impl XserialApp {
pub fn new(
manager: SessionManager,
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
ctx: egui::Context,
) -> Self {
let profiler = GuiProfiler::from_env();
let repaint_counter = profiler.repaint_counter();
let (event_tx, event_rx) = mpsc::channel();
let repaint_ctx = ctx.clone();
tokio::spawn(async move {
let mut last_repaint = Instant::now()
.checked_sub(DATA_REPAINT_INTERVAL)
.unwrap_or_else(Instant::now);
let mut delayed_repaint_pending = false;
loop {
match rx.recv().await {
Ok(event) => {
if event_tx.send(event).is_err() {
break;
}
let elapsed = last_repaint.elapsed();
if elapsed >= DATA_REPAINT_INTERVAL {
repaint_counter.increment();
repaint_ctx.request_repaint();
last_repaint = Instant::now();
delayed_repaint_pending = false;
} else if !delayed_repaint_pending {
repaint_counter.increment();
repaint_ctx.request_repaint_after(DATA_REPAINT_INTERVAL - elapsed);
delayed_repaint_pending = true;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let font_candidates = ui_fonts::discover_font_candidates();
let font_settings = ui_fonts::load_font_settings();
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
let saved_state = app_state::load_gui_state();
let mut app = Self {
manager,
tabs: Vec::new(),
active: 0,
display: DisplayOptions {
show_timestamp: saved_state.show_timestamp,
show_direction: saved_state.show_direction,
show_pipeline: saved_state.show_pipeline,
},
font_settings_open: false,
font_candidates,
font_settings,
primary_font_search: String::new(),
fallback_font_search: String::new(),
primary_filtered_fonts: Vec::new(),
fallback_filtered_fonts: Vec::new(),
primary_filter_cache_key: String::from("\0"),
fallback_filter_cache_key: String::from("\0"),
config_open: false,
config_target: None,
config_form: config::ConfigForm::default(),
event_rx,
pending: Vec::new(),
profiler,
shortcut_bindings: default_bindings(),
};
app.restore_saved_sessions(saved_state);
app
}
fn execute(&mut self, action: shortcuts::Action) {
use shortcuts::Action::*;
match action {
NextTab => {
if self.tabs.len() > 1 {
self.active = (self.active + 1) % self.tabs.len();
self.persist_gui_state();
}
}
PrevTab => {
if self.tabs.len() > 1 {
self.active = if self.active == 0 {
self.tabs.len() - 1
} else {
self.active - 1
};
self.persist_gui_state();
}
}
ViewText => {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.view = View::Text;
}
}
ViewHex => {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.view = View::Hex;
}
}
ViewPlot => {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.view = View::Plot;
}
}
NewSession => self.open_create_config(),
EditSession => {
if let Some(tab) = self.tabs.get(self.active) {
self.open_edit_config(tab.id);
}
}
DeleteSession => {
if !self.tabs.is_empty() {
let id = self.tabs[self.active].id;
self.manager.remove(id);
self.tabs.remove(self.active);
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
self.active = self.tabs.len() - 1;
}
self.persist_gui_state();
}
}
ToggleConnect => {
if let Some(tab) = self.tabs.get_mut(self.active) {
let connected = matches!(
tab.status,
ConnectionStatus::Connected | ConnectionStatus::Connecting
);
if let Some(handle) = self.manager.get(tab.id) {
if connected {
tab.status = ConnectionStatus::Disconnected;
let handle = handle.clone();
tokio::spawn(async move {
let _ = handle.disconnect().await;
});
} else {
tab.status = ConnectionStatus::Connecting;
let handle = handle.clone();
tokio::spawn(async move {
let _ = handle.connect().await;
});
}
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
}
Clear => {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.console.clear();
tab.hex.clear();
tab.plot.clear();
tab.send_status = Some(String::from("Cleared"));
}
}
UiSettings => self.font_settings_open = true,
CloseOverlay => {
self.config_open = false;
self.font_settings_open = false;
}
Search => {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.search.active = true;
tab.search.just_opened = true;
}
}
SearchNext => {
if let Some(tab) = self.tabs.get_mut(self.active)
&& tab.search.active
{
tab.search.next();
}
}
SearchPrev => {
if let Some(tab) = self.tabs.get_mut(self.active)
&& tab.search.active
{
tab.search.prev();
}
}
}
}
fn open_create_config(&mut self) {
self.config_target = None;
self.config_form = config::ConfigForm::default();
self.config_open = true;
}
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
for (i, session_config) in saved_state.sessions.into_iter().enumerate() {
self.add_session_tab(session_config);
if let Some(tab) = self.tabs.last_mut()
&& let Some(log_cfg) = saved_state.sessions_log.get(i)
{
tab.log_enabled = log_cfg.enabled;
tab.log_path = log_cfg.file_path.clone();
if log_cfg.enabled && !log_cfg.file_path.is_empty() {
tab.log_writer = LogWriter::open(&log_cfg.file_path).ok();
}
}
}
if !self.tabs.is_empty() {
self.active = saved_state.active.min(self.tabs.len() - 1);
}
}
fn add_session_tab(&mut self, session_config: SessionConfig) {
let history_limit = session_config.history_limit;
let auto_reconnect = session_config.auto_reconnect;
let (dtr, rts) = match &session_config.transport {
TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts),
_ => (false, false),
};
let handle = self.manager.create(session_config.clone());
self.tabs.push(SessionTab {
id: handle.id(),
session_config,
status: ConnectionStatus::Connecting,
console: TextBuffer::new(history_limit),
hex: HexBuffer::new(history_limit),
plot: PlotBuffer::new(history_limit),
plot_view: plot_view::PlotViewState::default(),
view: View::Text,
auto_reconnect,
dtr,
rts,
send_input: String::new(),
send_mode: SendMode::Text,
line_ending: LineEnding::None,
send_status: None,
search: SearchState::default(),
log_enabled: false,
log_path: String::new(),
show_sent: true,
log_writer: None,
});
}
fn persist_gui_state(&self) {
app_state::save_gui_state(&PersistedGuiState {
sessions: self
.tabs
.iter()
.map(|tab| tab.session_config.clone())
.collect(),
sessions_log: self
.tabs
.iter()
.map(|tab| SessionLogConfig {
enabled: tab.log_enabled,
file_path: tab.log_path.clone(),
})
.collect(),
active: if self.tabs.is_empty() {
0
} else {
self.active.min(self.tabs.len() - 1)
},
show_timestamp: self.display.show_timestamp,
show_direction: self.display.show_direction,
show_pipeline: self.display.show_pipeline,
});
}
fn open_edit_config(&mut self, id: u64) {
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
self.config_target = Some(id);
self.config_form = config::ConfigForm::from_session_config(&tab.session_config);
self.config_open = true;
}
}
fn apply_session_config(&mut self, id: u64, session_config: SessionConfig) {
let handle = self.manager.get(id);
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
let history_limit = session_config.history_limit;
tab.session_config = session_config.clone();
tab.auto_reconnect = session_config.auto_reconnect;
tab.console.set_limit(history_limit);
tab.hex.set_limit(history_limit);
tab.plot.set_limit(history_limit);
tab.status = ConnectionStatus::Connecting;
tab.send_status = Some(String::from("Session reconfigured"));
}
self.persist_gui_state();
if let Some(handle) = handle {
tokio::spawn(async move {
let _ = handle.reconfigure(session_config).await;
});
} else if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
fn drain_events(&mut self) {
let started = Instant::now();
let mut stats = DrainStats {
drained_events: 0,
drained_data_events: 0,
drained_text_events: 0,
drained_hex_events: 0,
drained_plot_events: 0,
pending_events: 0,
};
while let Ok(event) = self.event_rx.try_recv() {
self.pending.push(event);
}
stats.pending_events = self.pending.len();
for event in self.pending.drain(..) {
stats.drained_events += 1;
match event {
SessionEvent::Connected(id) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Connected;
}
}
SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Disconnected;
}
}
SessionEvent::Error(id, message) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.status = ConnectionStatus::Error(message);
}
}
SessionEvent::Data(id, entry) => {
stats.drained_data_events += 1;
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
if let Some(ref writer) = tab.log_writer
&& let Some(line) = logging::format_data_log(
&entry,
"IN",
self.display.show_timestamp,
self.display.show_direction,
self.display.show_pipeline,
)
{
writer.write_line(&line);
}
match &entry.data {
DecodedData::Text(_) => {
stats.drained_text_events += 1;
tab.console.push(&entry);
}
DecodedData::Hex(_) => {
stats.drained_hex_events += 1;
tab.hex.push(&entry);
}
DecodedData::Plot(_) => {
stats.drained_plot_events += 1;
tab.plot.push(&entry);
}
DecodedData::Binary(_) => {}
}
}
}
}
}
self.profiler.record_drain(started.elapsed(), stats);
}
fn wants_live_plot_repaint(&self) -> bool {
self.tabs.iter().enumerate().any(|(index, tab)| {
matches!(tab.status, ConnectionStatus::Connected)
&& (tab.plot_view.detached
|| (index == self.active && matches!(tab.view, View::Plot)))
})
}
fn profiler_snapshot(&self) -> GuiSnapshot {
if let Some(tab) = self.tabs.get(self.active) {
GuiSnapshot {
tabs: self.tabs.len(),
active_view: match tab.view {
View::Text => "text",
View::Hex => "hex",
View::Plot => "plot",
},
active_text_lines: tab.console.len(),
active_hex_lines: tab.hex.len(),
active_plot_series: tab.plot.series_len(),
active_plot_points: tab.plot.total_points(),
}
} else {
GuiSnapshot {
tabs: self.tabs.len(),
active_view: "none",
active_text_lines: 0,
active_hex_lines: 0,
active_plot_series: 0,
active_plot_points: 0,
}
}
}
fn render_config_window(&mut self, ctx: &egui::Context) {
if !self.config_open {
return;
}
let mut open = self.config_open;
let title = if self.config_target.is_some() {
"Edit Session Config"
} else {
"Session Config"
};
let submit_label = if self.config_target.is_some() {
"Save Session Config"
} else {
"Create Session"
};
let mut submitted = None;
egui::Window::new(title).open(&mut open).show(ctx, |ui| {
submitted = config::render(ui, &mut self.config_form, submit_label);
});
if let Some(session_config) = submitted {
if let Some(id) = self.config_target {
self.apply_session_config(id, session_config);
} else {
self.add_session_tab(session_config);
self.active = self.tabs.len() - 1;
self.persist_gui_state();
}
open = false;
}
if !open {
self.config_target = None;
}
self.config_open = open;
}
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
let mut on_new = false;
let mut on_edit = None;
let mut on_delete = None;
let previous_active = self.active;
Panel::left("sidebar")
.resizable(false)
.show_inside(ui, |ui| {
let sessions: Vec<_> = self
.tabs
.iter()
.map(|tab| sidebar::SessionListItem {
id: tab.id,
status: tab.status.clone(),
transport_summary: transport_summary(&tab.session_config.transport),
})
.collect();
sidebar::render(
ui,
&sessions,
&mut self.active,
&mut on_new,
&mut on_edit,
&mut on_delete,
);
});
if on_new {
self.open_create_config();
}
if let Some(index) = on_edit
&& let Some(tab) = self.tabs.get(index)
{
self.open_edit_config(tab.id);
}
if let Some(index) = on_delete {
if let Some(tab) = self.tabs.get(index) {
self.manager.remove(tab.id);
}
self.tabs.remove(index);
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
self.active = self.tabs.len() - 1;
}
self.persist_gui_state();
} else if self.active != previous_active {
self.persist_gui_state();
}
}
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
egui::CentralPanel::default().show_inside(ui, |ui| {
if self.tabs.is_empty() {
ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false));
return;
}
if self.active >= self.tabs.len() {
self.active = self.tabs.len() - 1;
}
let manager = self.manager.clone();
let display = &mut self.display;
let mut persist_state = false;
let mut text_render = None;
let mut hex_render = None;
let mut plot_render = None;
{
let tab = &mut self.tabs[self.active];
let (badge, status_text) = tab.status.badge();
ui.horizontal(|ui| {
ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
ui.add(Label::new(badge).selectable(false));
ui.separator();
ui.add(Label::new(status_text).selectable(false));
ui.separator();
if ui
.selectable_label(tab.view == View::Text, "Text")
.clicked()
{
tab.view = View::Text;
}
if ui.selectable_label(tab.view == View::Hex, "Hex").clicked() {
tab.view = View::Hex;
}
if ui
.selectable_label(tab.view == View::Plot, "Plot")
.clicked()
{
tab.view = View::Plot;
}
});
let auto_reconnect_changed = session_controls::render_session_controls(ui, &manager, tab);
persist_state |= auto_reconnect_changed;
let mut display_changed = false;
ui.horizontal_wrapped(|ui| {
ui.add(Label::new("Show:").selectable(false));
display_changed |= ui
.checkbox(&mut display.show_timestamp, "Timestamp")
.changed();
display_changed |= ui
.checkbox(&mut display.show_direction, "Direction")
.changed();
display_changed |= ui.checkbox(&mut display.show_pipeline, "Pipe").changed();
});
persist_state |= display_changed;
session_controls::render_search_bar(ui, tab);
if let ConnectionStatus::Error(message) = &tab.status {
ui.add(Label::new(egui::RichText::new(message).color(Color32::RED)).selectable(false));
}
let full = ui.available_rect_before_wrap();
let send_height = 240.0;
let separator = 8.0;
let receive_height = (full.height() - send_height - separator).max(0.0);
let receive_rect = Rect::from_min_max(
full.min,
Pos2::new(full.max.x, full.min.y + receive_height),
);
let send_rect =
Rect::from_min_max(Pos2::new(full.min.x, full.max.y - send_height), full.max);
ui.scope_builder(
UiBuilder::new()
.max_rect(receive_rect)
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|ui| {
let started = Instant::now();
match tab.view {
View::Text => {
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
text_render = Some((started.elapsed(), line_count));
}
View::Hex => {
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
hex_render = Some((started.elapsed(), line_count));
}
View::Plot => {
if tab.plot_view.detached {
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;
}
} else {
let output = plot_view::render(
ui,
&mut tab.plot,
tab.id,
&mut tab.plot_view,
);
if output.toggle_detached {
tab.plot_view.detached = true;
}
plot_render = Some((started.elapsed(), output.stats));
}
}
}
},
);
ui.scope_builder(
UiBuilder::new()
.max_rect(send_rect)
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|ui| send_panel::render_send_panel(ui, &manager, tab),
);
}
if let Some((elapsed, line_count)) = text_render {
self.profiler.record_text_render(elapsed, line_count);
}
if let Some((elapsed, line_count)) = hex_render {
self.profiler.record_hex_render(elapsed, line_count);
}
if let Some((elapsed, stats)) = plot_render {
self.profiler.record_plot_render(elapsed, stats);
}
if persist_state {
self.persist_gui_state();
}
});
}
fn render_detached_plot_windows(&mut self, ctx: &egui::Context) {
let mut plot_renders = Vec::new();
for tab in &mut self.tabs {
if !tab.plot_view.detached {
continue;
}
let mut open = true;
let mut output = None;
egui::Window::new(format!("Plot - Session {}", tab.id))
.open(&mut open)
.default_width(720.0)
.default_height(480.0)
.resizable(true)
.vscroll(false)
.show(ctx, |ui| {
let started = Instant::now();
let render_output =
plot_view::render(ui, &mut tab.plot, tab.id, &mut tab.plot_view);
output = Some((started.elapsed(), render_output));
});
if let Some((elapsed, render_output)) = output {
if render_output.toggle_detached {
tab.plot_view.detached = false;
}
plot_renders.push((elapsed, render_output.stats));
}
if !open {
tab.plot_view.detached = false;
}
}
for (elapsed, stats) in plot_renders {
self.profiler.record_plot_render(elapsed, stats);
}
}
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
Panel::top("top_bar").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false));
ui.separator();
// ui.label(format!(
// "Fonts: {} + {} {:.1} pt",
// ui_fonts::font_choice_label(
// &self.font_settings.primary_choice,
// &self.font_candidates
// ),
// ui_fonts::font_choice_label(
// &self.font_settings.fallback_choice,
// &self.font_candidates
// ),
// self.font_settings.ui_font_size
// ));
if ui.button("UI Settings").clicked() {
self.font_settings_open = true;
}
});
});
}
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
font_settings::render_font_settings_window(self, ctx);
}
pub(crate) fn invalidate_font_filters(&mut self) {
self.primary_filtered_fonts.clear();
self.fallback_filtered_fonts.clear();
self.primary_filter_cache_key = String::from("\0");
self.fallback_filter_cache_key = String::from("\0");
}
}
fn transport_summary(transport: &TransportConfig) -> String {
match transport {
TransportConfig::Serial { port, .. } => format!("Serial {}", port),
TransportConfig::Tcp { addr } => format!("TCP {}", addr),
TransportConfig::Udp {
bind_addr,
remote_addr,
} => match remote_addr {
Some(remote_addr) => format!("UDP {} -> {}", bind_addr, remote_addr),
None => format!("UDP {}", bind_addr),
},
}
}
impl eframe::App for XserialApp {
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
{
let supress = ui.ctx().egui_wants_keyboard_input();
let actions = shortcuts::process(ui.ctx(), supress, &self.shortcut_bindings);
for action in actions {
self.execute(action);
}
}
let frame_started = Instant::now();
self.drain_events();
if self.wants_live_plot_repaint() {
ui.ctx().request_repaint_after(Duration::from_millis(16));
}
self.render_top_bar(ui);
self.render_font_settings_window(ui.ctx());
self.render_config_window(ui.ctx());
self.render_sidebar(ui);
self.render_main_panel(ui);
self.render_detached_plot_windows(ui.ctx());
self.profiler
.record_frame(frame_started.elapsed(), self.profiler_snapshot());
}
}
#[cfg(test)]
mod tests {
use super::transport_summary;
use pipeview_core::transport::TransportConfig;
use pipeview_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
#[test]
fn transport_summary_formats_tcp_and_serial() {
assert_eq!(
transport_summary(&TransportConfig::Tcp {
addr: String::from("127.0.0.1:8080"),
}),
"TCP 127.0.0.1:8080"
);
assert_eq!(
transport_summary(&TransportConfig::Serial {
port: String::from("COM7"),
baud_rate: 115200,
data_bits: SerialDataBits::Eight,
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
}),
"Serial COM7"
);
}
#[test]
fn transport_summary_formats_udp() {
assert_eq!(
transport_summary(&TransportConfig::Udp {
bind_addr: String::from("0.0.0.0:9000"),
remote_addr: Some(String::from("127.0.0.1:9001")),
}),
"UDP 0.0.0.0:9000 -> 127.0.0.1:9001"
);
assert_eq!(
transport_summary(&TransportConfig::Udp {
bind_addr: String::from("127.0.0.1:0"),
remote_addr: None,
}),
"UDP 127.0.0.1:0"
);
}
}