From 5923ad01433b7e997b6d320b314a1a164e5be9a6 Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Mon, 22 Jun 2026 15:33:25 +0800 Subject: [PATCH] refactor(gui): split app.rs into models and panel modules Extract types (models.rs), font settings, session controls, and send panel into separate modules. Reduces app.rs from 1488 to 848 lines. Zero behavior change. - models.rs: ConnectionStatus, View, SendMode, LineEnding, DisplayOptions, SearchState, SessionTab - panels/font_settings.rs: font selector and settings window - panels/session_controls.rs: session connect/disconnect UI - panels/send_panel.rs: send input and payload building --- crates/pipeview-gui/src/app.rs | 698 +----------------- crates/pipeview-gui/src/main.rs | 1 + crates/pipeview-gui/src/models.rs | 133 ++++ .../pipeview-gui/src/panels/font_settings.rs | 184 +++++ crates/pipeview-gui/src/panels/mod.rs | 3 + crates/pipeview-gui/src/panels/send_panel.rs | 194 +++++ .../src/panels/session_controls.rs | 147 ++++ 7 files changed, 691 insertions(+), 669 deletions(-) create mode 100644 crates/pipeview-gui/src/models.rs create mode 100644 crates/pipeview-gui/src/panels/font_settings.rs create mode 100644 crates/pipeview-gui/src/panels/send_panel.rs create mode 100644 crates/pipeview-gui/src/panels/session_controls.rs diff --git a/crates/pipeview-gui/src/app.rs b/crates/pipeview-gui/src/app.rs index 671233a..7be3b26 100644 --- a/crates/pipeview-gui/src/app.rs +++ b/crates/pipeview-gui/src/app.rs @@ -1,15 +1,14 @@ 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::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, FontChoice, UiFontSettings}; -use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder}; +use crate::ui_fonts::{self, FontCandidate, UiFontSettings}; +use egui::{Color32, Layout, Panel, Pos2, Rect, UiBuilder}; use pipeview_client::SessionManager; use pipeview_client::config::SessionConfig; use pipeview_client::session::SessionEvent; @@ -18,160 +17,29 @@ 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 use crate::models::*; 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)>, + pub(crate) manager: SessionManager, + pub(crate) tabs: Vec, + pub(crate) active: usize, + pub(crate) display: DisplayOptions, + pub(crate) font_settings_open: bool, + pub(crate) font_candidates: Vec, + pub(crate) font_settings: UiFontSettings, + pub(crate) primary_font_search: String, + pub(crate) fallback_font_search: String, + pub(crate) primary_filtered_fonts: Vec, + pub(crate) fallback_filtered_fonts: Vec, + pub(crate) primary_filter_cache_key: String, + pub(crate) fallback_filter_cache_key: String, + pub(crate) config_open: bool, + pub(crate) config_target: Option, + pub(crate) config_form: config::ConfigForm, + pub(crate) event_rx: mpsc::Receiver, + pub(crate) pending: Vec, + pub(crate) profiler: GuiProfiler, + pub(crate) shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>, } impl XserialApp { @@ -710,7 +578,7 @@ impl XserialApp { } }); - let auto_reconnect_changed = render_session_controls(ui, &manager, tab); + let auto_reconnect_changed = session_controls::render_session_controls(ui, &manager, tab); persist_state |= auto_reconnect_changed; let mut display_changed = false; @@ -726,7 +594,7 @@ impl XserialApp { }); persist_state |= display_changed; - render_search_bar(ui, tab); + session_controls::render_search_bar(ui, tab); if let ConnectionStatus::Error(message) = &tab.status { ui.label(egui::RichText::new(message).color(Color32::RED)); @@ -789,7 +657,7 @@ impl XserialApp { UiBuilder::new() .max_rect(send_rect) .layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)), - |ui| render_send_panel(ui, &manager, tab), + |ui| send_panel::render_send_panel(ui, &manager, tab), ); } @@ -873,106 +741,10 @@ impl XserialApp { } 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; + font_settings::render_font_settings_window(self, ctx); } - fn invalidate_font_filters(&mut self) { + 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"); @@ -994,88 +766,6 @@ fn transport_summary(transport: &TransportConfig) -> String { } } -#[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) {} @@ -1105,336 +795,6 @@ impl eframe::App for XserialApp { } } -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 to send, Ctrl+Enter for newline", - 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) - .return_key(egui::KeyboardShortcut::new( - egui::Modifiers::COMMAND, - egui::Key::Enter, - )) - .hint_text(hint), - ); - - let wants_submit = response.has_focus() - && ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none()); - - 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; diff --git a/crates/pipeview-gui/src/main.rs b/crates/pipeview-gui/src/main.rs index 7329806..6f772d9 100644 --- a/crates/pipeview-gui/src/main.rs +++ b/crates/pipeview-gui/src/main.rs @@ -5,6 +5,7 @@ mod app; mod app_state; mod buffers; mod logging; +mod models; mod panels; mod perf; mod shortcuts; diff --git a/crates/pipeview-gui/src/models.rs b/crates/pipeview-gui/src/models.rs new file mode 100644 index 0000000..409f9d0 --- /dev/null +++ b/crates/pipeview-gui/src/models.rs @@ -0,0 +1,133 @@ +use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer}; +use crate::logging::LogWriter; +use crate::panels::plot_view; +use pipeview_client::config::SessionConfig; + +#[derive(Clone)] +pub enum ConnectionStatus { + Connected, + Disconnected, + Connecting, + Error(String), +} + +impl ConnectionStatus { + pub 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 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, +} diff --git a/crates/pipeview-gui/src/panels/font_settings.rs b/crates/pipeview-gui/src/panels/font_settings.rs new file mode 100644 index 0000000..7b22180 --- /dev/null +++ b/crates/pipeview-gui/src/panels/font_settings.rs @@ -0,0 +1,184 @@ +use crate::app::XserialApp; +use crate::ui_fonts::{self, FontCandidate, FontChoice}; + +pub fn render_font_settings_window(app: &mut XserialApp, ctx: &egui::Context) { + if !app.font_settings_open { + return; + } + + let mut open = app.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( + &app.font_settings.primary_choice, + &app.font_candidates, + )); + ui.label("Fallback:"); + ui.monospace(ui_fonts::font_choice_label( + &app.font_settings.fallback_choice, + &app.font_candidates, + )); + if ui.button("Refresh").clicked() { + app.font_candidates = ui_fonts::discover_font_candidates(); + app.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 app.font_settings.primary_choice, + &mut app.primary_font_search, + &app.font_candidates, + &mut app.primary_filtered_fonts, + &mut app.primary_filter_cache_key, + true, + true, + 180.0, + &mut changed, + ); + ui.separator(); + render_font_selector( + ui, + "Fallback font", + "fallback_font_choice", + &mut app.font_settings.fallback_choice, + &mut app.fallback_font_search, + &app.font_candidates, + &mut app.fallback_filtered_fonts, + &mut app.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 app.font_settings.ui_font_size, 10.0..=28.0) + .suffix(" pt"), + ) + .changed(); + ui.label("Monospace font size"); + changed |= ui + .add( + egui::Slider::new( + &mut app.font_settings.monospace_font_size, + 10.0..=28.0, + ) + .suffix(" pt"), + ) + .changed(); + ui.label("Heading size"); + changed |= ui + .add( + egui::Slider::new(&mut app.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, &app.font_settings, &app.font_candidates); + ui_fonts::save_font_settings(&app.font_settings); + } + app.font_settings_open = open; +} + +#[allow(clippy::too_many_arguments)] +pub 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); + } + } + }); +} + +pub 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; +} diff --git a/crates/pipeview-gui/src/panels/mod.rs b/crates/pipeview-gui/src/panels/mod.rs index 1d0c696..7564c68 100644 --- a/crates/pipeview-gui/src/panels/mod.rs +++ b/crates/pipeview-gui/src/panels/mod.rs @@ -1,5 +1,8 @@ pub mod config; pub mod console; +pub mod font_settings; pub mod hex_view; pub mod plot_view; +pub mod send_panel; +pub mod session_controls; pub mod sidebar; diff --git a/crates/pipeview-gui/src/panels/send_panel.rs b/crates/pipeview-gui/src/panels/send_panel.rs new file mode 100644 index 0000000..36607c6 --- /dev/null +++ b/crates/pipeview-gui/src/panels/send_panel.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use crate::app_state; +use crate::logging::{self, LogWriter}; +use crate::models::{LineEnding, SendMode, SessionTab}; +use egui::{Color32, 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.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 to send, Ctrl+Enter for newline", + 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) + .return_key(egui::KeyboardShortcut::new( + egui::Modifiers::COMMAND, + egui::Key::Enter, + )) + .hint_text(hint), + ); + + let wants_submit = response.has_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none()); + + 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}")); + } + } +} + +pub 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")) +} + +pub 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})")) + } + } +} diff --git a/crates/pipeview-gui/src/panels/session_controls.rs b/crates/pipeview-gui/src/panels/session_controls.rs new file mode 100644 index 0000000..cb520a6 --- /dev/null +++ b/crates/pipeview-gui/src/panels/session_controls.rs @@ -0,0 +1,147 @@ +use crate::models::{ConnectionStatus, SessionTab, View}; +use egui::TextEdit; +use pipeview_client::SessionManager; +use pipeview_core::transport::TransportConfig; + +pub 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(); + } + }); +} + +pub 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 +}