use crate::app_state::{self, PersistedGuiState, SessionLogConfig}; use std::path::PathBuf; 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, hex_view, plot_view, sidebar}; use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot}; use crate::shortcuts::{self, default_bindings}; use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings}; use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, 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); #[derive(Clone)] pub enum ConnectionStatus { Connected, Disconnected, Connecting, Error(String), } impl ConnectionStatus { fn badge(&self) -> (&'static str, &'static str) { match self { Self::Connected => ("[connected]", "Connected"), Self::Disconnected => ("[disconnected]", "Disconnected"), Self::Connecting => ("[connecting]", "Connecting"), Self::Error(_) => ("[error]", "Error"), } } } #[derive(Clone, Copy, PartialEq)] pub enum View { Text, Hex, Plot, } #[derive(Clone, Copy, PartialEq)] pub enum SendMode { Text, Hex, } #[derive(Clone, Copy, PartialEq)] #[allow(clippy::upper_case_acronyms)] pub enum LineEnding { None, LF, CR, CRLF, } impl std::fmt::Display for LineEnding { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::None => write!(f, "None"), Self::LF => write!(f, "LF (\\n)"), Self::CR => write!(f, "CR (\\r)"), Self::CRLF => write!(f, "CRLF (\\r\\n)"), } } } #[derive(Clone, Copy)] pub struct DisplayOptions { pub show_timestamp: bool, pub show_direction: bool, pub show_pipeline: bool, } #[derive(Clone, Default)] pub struct SearchState { pub query: String, pub matches: Vec, pub current_match: usize, pub case_sensitive: bool, pub active: bool, pub just_opened: bool, } impl SearchState { pub fn clear(&mut self) { self.query.clear(); self.matches.clear(); self.current_match = 0; self.active = false; self.just_opened = false; } pub fn next(&mut self) { if !self.matches.is_empty() { self.current_match = (self.current_match + 1) % self.matches.len(); } } pub fn prev(&mut self) { if !self.matches.is_empty() { self.current_match = if self.current_match == 0 { self.matches.len() - 1 } else { self.current_match - 1 }; } } // pub fn current_line_index(&self) -> Option { // self.matches.get(self.current_match).copied() // } pub fn match_count(&self) -> usize { self.matches.len() } pub fn current_display(&self) -> usize { if self.matches.is_empty() { 0 } else { self.current_match + 1 } } } pub struct SessionTab { pub id: u64, pub session_config: SessionConfig, pub status: ConnectionStatus, pub console: TextBuffer, pub hex: HexBuffer, pub plot: PlotBuffer, pub plot_view: plot_view::PlotViewState, pub view: View, pub auto_reconnect: bool, pub dtr: bool, pub rts: bool, pub send_input: String, pub send_mode: SendMode, pub line_ending: LineEnding, pub send_status: Option, pub search: SearchState, pub log_enabled: bool, pub log_path: String, pub log_writer: Option, } pub struct XserialApp { manager: SessionManager, tabs: Vec, active: usize, display: DisplayOptions, font_settings_open: bool, font_candidates: Vec, font_settings: UiFontSettings, primary_font_search: String, fallback_font_search: String, primary_filtered_fonts: Vec, fallback_filtered_fonts: Vec, primary_filter_cache_key: String, fallback_filter_cache_key: String, config_open: bool, config_target: Option, config_form: config::ConfigForm, event_rx: mpsc::Receiver, pending: Vec, profiler: GuiProfiler, shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>, } impl XserialApp { pub fn new( manager: SessionManager, mut rx: tokio::sync::broadcast::Receiver, 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(), 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.heading("No sessions."); 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.heading(format!("Session {}", tab.id)); ui.label(badge); ui.separator(); ui.label(status_text); 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 = render_session_controls(ui, &manager, tab); persist_state |= auto_reconnect_changed; let mut display_changed = false; ui.horizontal_wrapped(|ui| { ui.label("Show:"); 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; render_search_bar(ui, tab); if let ConnectionStatus::Error(message) = &tab.status { ui.label(egui::RichText::new(message).color(Color32::RED)); } 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.heading(format!( "Plot window detached for Session {}", tab.id )); ui.label("The plot is currently shown in a floating window."); 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| 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.heading("pipeview"); 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) { if !self.font_settings_open { return; } let mut open = self.font_settings_open; let mut changed = false; egui::Window::new("UI Settings") .open(&mut open) .default_width(520.0) .resizable(true) .show(ctx, |ui| { ui.heading("Fonts"); ui.horizontal(|ui| { ui.label("Primary:"); ui.monospace(ui_fonts::font_choice_label( &self.font_settings.primary_choice, &self.font_candidates, )); ui.label("Fallback:"); ui.monospace(ui_fonts::font_choice_label( &self.font_settings.fallback_choice, &self.font_candidates, )); if ui.button("Refresh").clicked() { self.font_candidates = ui_fonts::discover_font_candidates(); self.invalidate_font_filters(); } }); ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph."); ui.add_space(6.0); render_font_selector( ui, "Primary font", "primary_font_choice", &mut self.font_settings.primary_choice, &mut self.primary_font_search, &self.font_candidates, &mut self.primary_filtered_fonts, &mut self.primary_filter_cache_key, true, true, 180.0, &mut changed, ); ui.separator(); render_font_selector( ui, "Fallback font", "fallback_font_choice", &mut self.font_settings.fallback_choice, &mut self.fallback_font_search, &self.font_candidates, &mut self.fallback_filtered_fonts, &mut self.fallback_filter_cache_key, true, true, 140.0, &mut changed, ); ui.separator(); ui.heading("Sizes"); ui.label("UI font size"); changed |= ui .add( egui::Slider::new(&mut self.font_settings.ui_font_size, 10.0..=28.0) .suffix(" pt"), ) .changed(); ui.label("Monospace font size"); changed |= ui .add( egui::Slider::new( &mut self.font_settings.monospace_font_size, 10.0..=28.0, ) .suffix(" pt"), ) .changed(); ui.label("Heading size"); changed |= ui .add( egui::Slider::new(&mut self.font_settings.heading_font_size, 14.0..=40.0) .suffix(" pt"), ) .changed(); ui.separator(); ui.heading("Preview"); ui.label("The quick brown fox jumps over the lazy dog."); ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。"); ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef"); }); if changed { ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates); ui_fonts::save_font_settings(&self.font_settings); } self.font_settings_open = open; } 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), }, } } #[allow(clippy::too_many_arguments)] fn render_font_selector( ui: &mut egui::Ui, title: &str, id_prefix: &str, choice: &mut FontChoice, search: &mut String, candidates: &[FontCandidate], filtered: &mut Vec, cache_key: &mut String, allow_auto: bool, allow_default: bool, max_height: f32, changed: &mut bool, ) { ui.label(title); ui.horizontal(|ui| { ui.label("Search:"); ui.text_edit_singleline(search); }); ui.horizontal_wrapped(|ui| { if allow_auto { *changed |= ui .selectable_value(choice, FontChoice::Auto, "Auto") .changed(); } if allow_default { *changed |= ui .selectable_value(choice, FontChoice::Default, "Default") .changed(); } }); ui.add_space(4.0); refresh_font_filter(search, candidates, filtered, cache_key); ui.small(format!("{} fonts", filtered.len())); let row_height = ui.spacing().interact_size.y; egui::ScrollArea::vertical() .id_salt(format!("{id_prefix}_scroll")) .max_height(max_height) .auto_shrink([false, false]) .show_rows(ui, row_height, filtered.len(), |ui, row_range| { for row in row_range { if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index)) { let response = ui.selectable_value( choice, FontChoice::System(candidate.id.clone()), candidate.display_label.as_str(), ); *changed |= response.changed(); response.on_hover_text(&candidate.path); } } }); } fn refresh_font_filter( search: &str, candidates: &[FontCandidate], filtered: &mut Vec, cache_key: &mut String, ) { let needle = search.trim().to_lowercase(); if *cache_key == needle { return; } filtered.clear(); if needle.is_empty() { filtered.extend(0..candidates.len()); } else { filtered.extend( candidates .iter() .enumerate() .filter(|(_, candidate)| candidate.search_key.contains(&needle)) .map(|(index, _)| index), ); } *cache_key = needle; } 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()); } } fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) { if !tab.search.active { return; } ui.horizontal(|ui| { let response = ui.add( TextEdit::singleline(&mut tab.search.query) .hint_text("Search...") .desired_width(200.0), ); if tab.search.just_opened { response.request_focus(); tab.search.just_opened = false; } if response.changed() { let matches = match tab.view { View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive), View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive), View::Plot => Vec::new(), }; tab.search.matches = matches; tab.search.current_match = 0; } if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { tab.search.next(); } ui.label(format!( "{}/{}", tab.search.current_display(), tab.search.match_count() )); if ui.button("▲").clicked() { tab.search.prev(); } if ui.button("▼").clicked() { tab.search.next(); } if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() { let matches = match tab.view { View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive), View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive), View::Plot => Vec::new(), }; tab.search.matches = matches; tab.search.current_match = 0; } if ui.button("✕").clicked() { tab.search.clear(); } }); } fn render_session_controls( ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab, ) -> bool { let mut auto_reconnect_changed = false; ui.horizontal_wrapped(|ui| { let connected = matches!( tab.status, ConnectionStatus::Connected | ConnectionStatus::Connecting ); let connect_label = if connected { "Disconnect" } else { "Connect" }; if ui.button(connect_label).clicked() { if let Some(handle) = manager.get(tab.id) { if connected { tab.status = ConnectionStatus::Disconnected; tokio::spawn(async move { let _ = handle.disconnect().await; }); } else { tab.status = ConnectionStatus::Connecting; tokio::spawn(async move { let _ = handle.connect().await; }); } } else { tab.status = ConnectionStatus::Error(String::from("session not found")); } } // if ui.button("Reconnect").clicked() { // if let Some(handle) = manager.get(tab.id) { // tab.status = ConnectionStatus::Connecting; // tokio::spawn(async move { // let _ = handle.reconnect().await; // }); // } else { // tab.status = ConnectionStatus::Error(String::from("session not found")); // } // } if ui.button("Clear").clicked() { tab.console.clear(); tab.hex.clear(); tab.plot.clear(); tab.send_status = Some(String::from("Cleared")); } let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect"); if response.changed() { tab.session_config.auto_reconnect = tab.auto_reconnect; auto_reconnect_changed = true; if let Some(handle) = manager.get(tab.id) { let enabled = tab.auto_reconnect; tokio::spawn(async move { let _ = handle.set_auto_reconnect(enabled).await; }); } else { tab.status = ConnectionStatus::Error(String::from("session not found")); } } if matches!(tab.status, ConnectionStatus::Connected) && matches!(tab.session_config.transport, TransportConfig::Serial { .. }) { let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed(); let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed(); if dtr_changed || rts_changed { if let Some(handle) = manager.get(tab.id) { let dtr = tab.dtr; let rts = tab.rts; tokio::spawn(async move { if dtr_changed { let _ = handle.set_dtr(dtr).await; } if rts_changed { let _ = handle.set_rts(rts).await; } }); } else { tab.status = ConnectionStatus::Error(String::from("session not found")); } } } }); auto_reconnect_changed } fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) { ui.set_width(ui.available_width()); ui.heading("Send"); ui.horizontal(|ui| { ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text"); ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex"); if tab.send_mode == SendMode::Text { ui.add_space(6.0); egui::ComboBox::from_label("Line ending") .selected_text(tab.line_ending.to_string()) .show_ui(ui, |ui| { ui.selectable_value( &mut tab.line_ending, LineEnding::None, LineEnding::None.to_string(), ); ui.selectable_value( &mut tab.line_ending, LineEnding::LF, LineEnding::LF.to_string(), ); ui.selectable_value( &mut tab.line_ending, LineEnding::CR, LineEnding::CR.to_string(), ); ui.selectable_value( &mut tab.line_ending, LineEnding::CRLF, LineEnding::CRLF.to_string(), ); }); } }); ui.add_space(6.0); let log_toggled = ui .checkbox(&mut tab.log_enabled, "Log to file") .changed(); if log_toggled { if tab.log_enabled { tab.log_path = default_log_path(tab.id) .to_string_lossy() .to_string(); tab.log_writer = LogWriter::open(&tab.log_path).ok(); } else { tab.log_writer = None; } } if tab.log_enabled { ui.horizontal(|ui| { let changed = ui .text_edit_singleline(&mut tab.log_path) .lost_focus(); if changed && !tab.log_path.is_empty() { tab.log_writer = LogWriter::open(&tab.log_path).ok(); } }); } ui.add_space(3.0); let hint = match tab.send_mode { SendMode::Text => "Enter text to send", SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F", }; let response = ui.add( TextEdit::multiline(&mut tab.send_input) .desired_rows(6) .desired_width(f32::INFINITY) .hint_text(hint), ); let wants_submit = response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only()); let mut send_clicked = false; 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 }), ); } }); if !(send_clicked || wants_submit) { return; } match build_payload(tab) { Ok(Some(payload)) => { if let Some(handle) = manager.get(tab.id) { match tab.send_mode { SendMode::Text => { let mut text = tab.send_input.trim_end_matches('\n').to_string(); if !text.is_empty() { match tab.line_ending { LineEnding::None => {} LineEnding::LF => text.push('\n'), LineEnding::CR => text.push('\r'), LineEnding::CRLF => text.push_str("\r\n"), } tab.console.push_outbound(text.clone()); if let Some(ref writer) = tab.log_writer { writer.write_line(&logging::format_sent_log(&text)); } } } SendMode::Hex => { let hex = tab .send_input .split_whitespace() .collect::>() .join(" "); if !hex.is_empty() { tab.hex.push_outbound(hex.clone()); if let Some(ref writer) = tab.log_writer { writer.write_line(&logging::format_sent_log(&hex)); } } } } tokio::spawn(async move { let _ = handle.send(payload).await; }); tab.send_input.clear(); tab.send_status = Some(String::from("Sent")); } else { tab.send_status = Some(String::from("Send failed: session not found")); } } Ok(None) => { tab.send_status = Some(String::from("Nothing to send")); } Err(message) => { tab.send_status = Some(format!("Send failed: {message}")); } } } fn default_log_path(session_id: u64) -> PathBuf { use std::time::{SystemTime, UNIX_EPOCH}; let dir = app_state::config_dir().join("logs"); let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); dir.join(format!("session_{session_id}_{ts}.log")) } fn build_payload(tab: &SessionTab) -> Result>, String> { let trimmed = tab.send_input.trim(); if trimmed.is_empty() { return Ok(None); } match tab.send_mode { SendMode::Text => { let mut text = tab.send_input.trim_end_matches('\n').to_string(); match tab.line_ending { LineEnding::None => {} LineEnding::LF => text.push('\n'), LineEnding::CR => text.push('\r'), LineEnding::CRLF => text.push_str("\r\n"), } Ok(Some(text.into_bytes())) } SendMode::Hex => { let compact: String = trimmed .chars() .filter(|ch| !ch.is_ascii_whitespace()) .collect(); hex::decode(compact) .map(Some) .map_err(|err| format!("invalid hex input ({err})")) } } } #[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" ); } }