From 25433038d5008cab3f7a561362133d055d8f7512 Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Mon, 8 Jun 2026 22:27:23 +0800 Subject: [PATCH] Improve session controls and GUI views --- Cargo.lock | 1 + crates/xserial-client/src/cmd.rs | 4 + crates/xserial-client/src/session.rs | 137 +++- .../xserial-client/tests/session_lifecycle.rs | 4 +- crates/xserial-gui/Cargo.toml | 1 + crates/xserial-gui/src/app.rs | 590 +++++++++++++++--- crates/xserial-gui/src/buffers.rs | 131 ++++ crates/xserial-gui/src/main.rs | 3 +- crates/xserial-gui/src/panels/config.rs | 60 +- crates/xserial-gui/src/panels/console.rs | 36 ++ crates/xserial-gui/src/panels/hex_view.rs | 41 ++ crates/xserial-gui/src/panels/mod.rs | 2 + 12 files changed, 881 insertions(+), 129 deletions(-) create mode 100644 crates/xserial-gui/src/buffers.rs create mode 100644 crates/xserial-gui/src/panels/console.rs create mode 100644 crates/xserial-gui/src/panels/hex_view.rs diff --git a/Cargo.lock b/Cargo.lock index 85ccd13..6845578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5788,6 +5788,7 @@ dependencies = [ "eframe", "egui", "egui_plot", + "hex", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/xserial-client/src/cmd.rs b/crates/xserial-client/src/cmd.rs index f34c018..e1b9a78 100644 --- a/crates/xserial-client/src/cmd.rs +++ b/crates/xserial-client/src/cmd.rs @@ -2,6 +2,10 @@ use crate::config::SessionConfig; pub enum SessionCmd { Send(Vec), + Connect, + Disconnect, + Reconnect, + SetAutoReconnect(bool), Close, Reconfigure(SessionConfig), } diff --git a/crates/xserial-client/src/session.rs b/crates/xserial-client/src/session.rs index da7bc93..cffd766 100644 --- a/crates/xserial-client/src/session.rs +++ b/crates/xserial-client/src/session.rs @@ -3,6 +3,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, }; use std::time::Duration; +use std::{future, io}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::select; @@ -105,6 +106,38 @@ impl SessionHandle { .map_err(|_| String::from("session closed")) } + pub async fn connect(&self) -> Result<(), String> { + self.inner + .cmd_tx + .send(SessionCmd::Connect) + .await + .map_err(|_| String::from("session closed")) + } + + pub async fn disconnect(&self) -> Result<(), String> { + self.inner + .cmd_tx + .send(SessionCmd::Disconnect) + .await + .map_err(|_| String::from("session closed")) + } + + pub async fn reconnect(&self) -> Result<(), String> { + self.inner + .cmd_tx + .send(SessionCmd::Reconnect) + .await + .map_err(|_| String::from("session closed")) + } + + pub async fn set_auto_reconnect(&self, enabled: bool) -> Result<(), String> { + self.inner + .cmd_tx + .send(SessionCmd::SetAutoReconnect(enabled)) + .await + .map_err(|_| String::from("session closed")) + } + pub async fn read(&self, timeout_ms: u64) -> Option { let mut rx = self.inner.event_tx.subscribe(); let deadline = Duration::from_millis(timeout_ms); @@ -161,6 +194,7 @@ pub struct Session { cmd_rx: mpsc::Receiver, event_tx: broadcast::Sender, close_requested: bool, + desired_connected: bool, } impl Session { @@ -176,6 +210,7 @@ impl Session { cmd_rx, event_tx: event_tx.clone(), close_requested: false, + desired_connected: true, }; let task = tokio::spawn(async move { session.run().await }); let inner = Arc::new(SessionHandleInner::new(id, cmd_tx, event_tx, task)); @@ -200,12 +235,10 @@ impl Session { if let Err(err) = self.connect().await { warn!(session_id = self.id, error = %err, "initial connect failed"); self.emit_error(format!("connect failed: {err}")); - self.emit_closed(); - info!(session_id = self.id, "session ended"); - return; } loop { + let reconnect_timer = self.reconnect_timer(); select! { cmd = self.cmd_rx.recv() => { if !self.handle_command(cmd).await { @@ -217,6 +250,11 @@ impl Session { break; } } + _ = reconnect_timer => { + if let Err(err) = self.connect().await { + self.emit_error(format!("connect failed: {err}")); + } + } } } @@ -229,8 +267,35 @@ impl Session { match cmd { Some(SessionCmd::Close) => { self.close_requested = true; + self.desired_connected = false; false } + Some(SessionCmd::Connect) => { + self.desired_connected = true; + if self.conn.is_none() { + if let Err(err) = self.connect().await { + self.emit_error(format!("connect failed: {err}")); + } + } + true + } + Some(SessionCmd::Disconnect) => { + self.desired_connected = false; + self.disconnect(true).await; + true + } + Some(SessionCmd::Reconnect) => { + self.desired_connected = true; + self.disconnect(true).await; + if let Err(err) = self.connect().await { + self.emit_error(format!("reconnect: {err}")); + } + true + } + Some(SessionCmd::SetAutoReconnect(enabled)) => { + self.config.auto_reconnect = enabled; + true + } Some(SessionCmd::Send(data)) => { self.handle_send(data).await; true @@ -283,22 +348,29 @@ impl Session { error!(session_id = self.id, error = %err, "read error"); self.emit_error(err.to_string()); self.disconnect(true).await; - - if self.config.auto_reconnect && !self.close_requested { - warn!(session_id = self.id, "auto-reconnecting"); - tokio::time::sleep(Duration::from_secs(1)).await; - if let Err(err) = self.connect().await { - self.emit_error(format!("reconnect: {err}")); - } - true - } else { - false - } + true } } } - async fn connect(&mut self) -> Result<(), std::io::Error> { + fn reconnect_timer(&self) -> impl std::future::Future + Send + 'static { + let should_retry = self.conn.is_none() + && self.desired_connected + && self.config.auto_reconnect + && !self.close_requested; + async move { + if should_retry { + tokio::time::sleep(Duration::from_secs(1)).await; + } else { + future::pending::<()>().await; + } + } + } + + async fn connect(&mut self) -> Result<(), io::Error> { + if self.conn.is_some() { + return Ok(()); + } let transport = self.config.transport.clone(); debug!(session_id = self.id, ?transport, "connecting session"); let mut conn = Connection::new(transport); @@ -332,19 +404,18 @@ impl Session { async fn try_read( conn: &mut Option, pipeline: &mut MultiPipeline, -) -> Result, std::io::Error> { - let conn = match conn.as_mut() { - Some(conn) => conn, - None => { - tokio::time::sleep(Duration::from_millis(20)).await; - return Ok(Vec::new()); - } - }; +) -> Result, io::Error> { + if conn.is_none() { + future::pending::<()>().await; + unreachable!("pending future should never resolve"); + } + + let conn = conn.as_mut().expect("connection presence checked"); let mut buf = [0u8; 4096]; match conn.read(&mut buf).await { - Ok(0) => Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, + Ok(0) => Err(io::Error::new( + io::ErrorKind::UnexpectedEof, "connection closed", )), Ok(n) => Ok(pipeline @@ -360,7 +431,7 @@ async fn try_read( } fn to_io_error(err: xserial_core::error::Error) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::Other, err.to_string()) + io::Error::new(io::ErrorKind::Other, err.to_string()) } #[cfg(test)] @@ -499,6 +570,20 @@ mod tests { handle.join().await; } + #[tokio::test] + async fn try_read_without_connection_stays_pending() { + let mut conn = None; + let mut pipeline = MultiPipeline::new(); + + let result = tokio::time::timeout( + Duration::from_millis(50), + try_read(&mut conn, &mut pipeline), + ) + .await; + + assert!(result.is_err()); + } + #[tokio::test] async fn handle_drop_closes_session() { let handle = Session::spawn(8, tcp_config()); diff --git a/crates/xserial-client/tests/session_lifecycle.rs b/crates/xserial-client/tests/session_lifecycle.rs index 25fc9e6..02049fe 100644 --- a/crates/xserial-client/tests/session_lifecycle.rs +++ b/crates/xserial-client/tests/session_lifecycle.rs @@ -203,7 +203,7 @@ async fn manager_create_and_list() { tokio::time::sleep(Duration::from_secs(10)).await; }); - let mut mgr = SessionManager::new(); + let mgr = SessionManager::new(); assert_eq!(mgr.count(), 0); assert!(mgr.list_ids().is_empty()); @@ -236,7 +236,7 @@ async fn manager_event_subscription() { stream.write_all(b"data\n").await.unwrap(); }); - let mut mgr = SessionManager::new(); + let mgr = SessionManager::new(); let mut rx = mgr.subscribe(); let handle = mgr.create(tcp_config(addr)); diff --git a/crates/xserial-gui/Cargo.toml b/crates/xserial-gui/Cargo.toml index 6f5f272..07e8058 100644 --- a/crates/xserial-gui/Cargo.toml +++ b/crates/xserial-gui/Cargo.toml @@ -16,3 +16,4 @@ eframe = { workspace = true } egui_plot = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +hex = "0.4" diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 435583b..3d9a2ed 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -1,7 +1,12 @@ -use crate::panels::{config, sidebar}; -use tracing::debug; +use std::sync::mpsc; + +use crate::buffers::{HexBuffer, TextBuffer}; +use crate::panels::{config, console, hex_view, sidebar}; +use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder}; use xserial_client::SessionManager; +use xserial_client::config::SessionConfig; use xserial_client::session::SessionEvent; +use xserial_core::protocol::DecodedData; #[derive(Clone)] pub enum ConnectionStatus { @@ -11,127 +16,518 @@ pub enum ConnectionStatus { 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, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum SendMode { + Text, + Hex, +} + +#[derive(Clone, Copy)] +pub struct DisplayOptions { + pub show_timestamp: bool, + pub show_direction: bool, + pub show_pipeline: bool, +} + pub struct SessionTab { pub id: u64, + pub session_config: SessionConfig, pub status: ConnectionStatus, + pub console: TextBuffer, + pub hex: HexBuffer, + pub view: View, + pub auto_reconnect: bool, + pub send_input: String, + pub send_mode: SendMode, + pub append_newline: bool, + pub send_status: Option, } pub struct XserialApp { manager: SessionManager, tabs: Vec, active: usize, + display: DisplayOptions, config_open: bool, + config_target: Option, config_form: config::ConfigForm, - event_rx: Option>, + event_rx: mpsc::Receiver, pending: Vec, } impl XserialApp { - pub fn new(m: SessionManager, rx: tokio::sync::broadcast::Receiver) -> Self { + pub fn new( + manager: SessionManager, + mut rx: tokio::sync::broadcast::Receiver, + ctx: egui::Context, + ) -> Self { + let (event_tx, event_rx) = mpsc::channel(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + if event_tx.send(event).is_err() { + break; + } + ctx.request_repaint(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + Self { - manager: m, - tabs: vec![], + manager, + tabs: Vec::new(), active: 0, + display: DisplayOptions { + show_timestamp: true, + show_direction: true, + show_pipeline: true, + }, config_open: false, + config_target: None, config_form: config::ConfigForm::default(), - event_rx: Some(rx), - pending: vec![], + event_rx, + pending: Vec::new(), } } + + fn open_create_config(&mut self) { + self.config_target = None; + self.config_form = config::ConfigForm::default(); + self.config_open = true; + } + + 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 = TextBuffer::new(history_limit); + tab.hex = HexBuffer::new(history_limit); + tab.status = ConnectionStatus::Connecting; + tab.send_status = Some(String::from("Session reconfigured")); + } + + 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) { + while let Ok(event) = self.event_rx.try_recv() { + self.pending.push(event); + } + + for event in self.pending.drain(..) { + 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) => { + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { + match &entry.data { + DecodedData::Text(_) => tab.console.push(&entry), + DecodedData::Hex(_) => tab.hex.push(&entry), + DecodedData::Binary(_) | DecodedData::Plot(_) => {} + } + } + } + } + } + } + + 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 { + let history_limit = session_config.history_limit; + let auto_reconnect = session_config.auto_reconnect; + 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), + view: View::Text, + auto_reconnect, + send_input: String::new(), + send_mode: SendMode::Text, + append_newline: true, + send_status: None, + }); + self.active = self.tabs.len() - 1; + } + 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_delete = None; + + Panel::left("sidebar") + .resizable(false) + .show_inside(ui, |ui| { + let sessions: Vec<_> = self + .tabs + .iter() + .map(|tab| (tab.id, tab.status.clone())) + .collect(); + sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete); + }); + + if on_new { + self.open_create_config(); + } + + 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; + } + } + } + + 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 edit_session = 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 render_session_controls(ui, &manager, tab) { + edit_session = Some(tab.id); + } + + ui.horizontal_wrapped(|ui| { + ui.label("Show:"); + ui.checkbox(&mut display.show_timestamp, "Timestamp"); + ui.checkbox(&mut display.show_direction, "Direction"); + ui.checkbox(&mut display.show_pipeline, "Pipe"); + }); + + 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| match tab.view { + View::Text => console::render(ui, &tab.console, *display), + View::Hex => hex_view::render(ui, &tab.hex, *display), + }, + ); + + 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(id) = edit_session { + self.open_edit_config(id); + } + }); + } } 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) { - // 拉取 SessionManager 事件 - if let Some(ref mut rx) = self.event_rx { - while let Ok(e) = rx.try_recv() { - debug!(event = ?e, "GUI received event"); - self.pending.push(e); - } - } - for e in self.pending.drain(..) { - match e { - SessionEvent::Connected(id) => { - debug!(session_id = id, "GUI: Connected"); - if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) { - t.status = ConnectionStatus::Connected; - } - } - SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => { - debug!(session_id = id, "GUI: Disconnected/Closed"); - if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) { - t.status = ConnectionStatus::Disconnected; - } - } - SessionEvent::Error(id, msg) => { - debug!(session_id = id, error = %msg, "GUI: Error"); - if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) { - t.status = ConnectionStatus::Error(msg); - } - } - SessionEvent::Data(id, entry) => { - debug!(session_id = id, pipeline = %entry.pipeline_name, "GUI: Data"); - } - } - } - - // Config 弹窗 - if self.config_open { - egui::Window::new("Session Config").show(ui.ctx(), |ui| { - if let Some(cfg) = config::render(ui, &mut self.config_form) { - let h = self.manager.create(cfg); - self.tabs.push(SessionTab { - id: h.id, - status: ConnectionStatus::Connecting, - }); - self.active = self.tabs.len() - 1; - self.config_open = false; - } - }); - } - - // Sidebar - let mut on_new = false; - let mut on_delete: Option = None; - egui::Panel::left("sidebar") - .resizable(false) - .show_inside(ui, |ui| { - let ss: Vec<(u64, ConnectionStatus)> = - self.tabs.iter().map(|t| (t.id, t.status.clone())).collect(); - sidebar::render(ui, &ss, &mut self.active, &mut on_new, &mut on_delete); - }); - if on_new { - self.config_open = true; - } - if let Some(i) = on_delete { - if let Some(tab) = self.tabs.get(i) { - self.manager.remove(tab.id); - } - self.tabs.remove(i); - if self.active >= self.tabs.len() && !self.tabs.is_empty() { - self.active = self.tabs.len() - 1; - } - } - - // Central - 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 tab = &self.tabs[self.active]; - let txt = match &tab.status { - ConnectionStatus::Connected => "🟢 Connected", - ConnectionStatus::Disconnected => "⚫ Disconnected", - ConnectionStatus::Connecting => "🟡 Connecting...", - ConnectionStatus::Error(e) => &format!("🔴 {}", e), - }; - ui.heading(format!("Session {} — {}", tab.id, txt)); - }); + self.drain_events(); + self.render_config_window(ui.ctx()); + self.render_sidebar(ui); + self.render_main_panel(ui); + } +} + +fn render_session_controls( + ui: &mut egui::Ui, + manager: &SessionManager, + tab: &mut SessionTab, +) -> bool { + let mut configure_clicked = false; + ui.horizontal_wrapped(|ui| { + if ui.button("Connect").clicked() { + if let Some(handle) = manager.get(tab.id) { + 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("Disconnect").clicked() { + if let Some(handle) = manager.get(tab.id) { + tab.status = ConnectionStatus::Disconnected; + tokio::spawn(async move { + let _ = handle.disconnect().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("Configure").clicked() { + configure_clicked = true; + } + + let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect"); + if response.changed() { + 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")); + } + } + }); + configure_clicked +} + +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.checkbox(&mut tab.append_newline, "Append newline"); + } + }); + ui.add_space(6.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 text = if tab.append_newline { + tab.send_input.trim_end_matches('\n').to_string() + } else { + tab.send_input.clone() + }; + if !text.is_empty() { + tab.console.push_outbound(text); + } + } + SendMode::Hex => { + let hex = tab + .send_input + .split_whitespace() + .collect::>() + .join(" "); + if !hex.is_empty() { + tab.hex.push_outbound(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 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.clone(); + if tab.append_newline && !text.ends_with('\n') { + text.push('\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/xserial-gui/src/buffers.rs b/crates/xserial-gui/src/buffers.rs new file mode 100644 index 0000000..a1a5e6b --- /dev/null +++ b/crates/xserial-gui/src/buffers.rs @@ -0,0 +1,131 @@ +use std::time::{Duration, Instant}; +use xserial_client::RingBuffer; +use xserial_client::event::DecodedEntry; +use xserial_core::protocol::DecodedData; + +#[derive(Clone)] +pub enum LineDirection { + In, + Out, +} + +#[derive(Clone)] +pub struct ConsoleLine { + pub elapsed: Duration, + pub pipeline: String, + pub text: String, + pub direction: LineDirection, +} + +pub struct TextBuffer { + started_at: Instant, + lines: RingBuffer, +} + +impl TextBuffer { + pub fn new(cap: usize) -> Self { + Self { + started_at: Instant::now(), + lines: RingBuffer::new(cap), + } + } + pub fn push(&mut self, entry: &DecodedEntry) { + if let DecodedData::Text(s) = &entry.data { + self.lines.push(ConsoleLine { + elapsed: self.started_at.elapsed(), + pipeline: entry.pipeline_name.clone(), + text: s.clone(), + direction: LineDirection::In, + }); + } + } + + pub fn push_outbound(&mut self, text: String) { + self.lines.push(ConsoleLine { + elapsed: self.started_at.elapsed(), + pipeline: String::from("OUT"), + text, + direction: LineDirection::Out, + }); + } + + pub fn iter(&self) -> impl Iterator { + self.lines.iter() + } +} + +#[derive(Clone)] +pub struct HexLine { + pub elapsed: Duration, + pub pipeline: String, + pub hex: String, + pub ascii: String, + pub direction: LineDirection, +} + +pub struct HexBuffer { + started_at: Instant, + lines: RingBuffer, +} + +impl HexBuffer { + pub fn new(cap: usize) -> Self { + Self { + started_at: Instant::now(), + lines: RingBuffer::new(cap), + } + } + pub fn push(&mut self, entry: &DecodedEntry) { + if let DecodedData::Hex(s) = &entry.data { + let ascii = hex::decode(s.replace(' ', "")) + .map(|bytes| { + bytes + .into_iter() + .map(|b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect() + }) + .unwrap_or_else(|_| String::from("[invalid hex]")); + self.lines.push(HexLine { + elapsed: self.started_at.elapsed(), + pipeline: entry.pipeline_name.clone(), + hex: s.clone(), + ascii, + direction: LineDirection::In, + }); + } + } + + pub fn push_outbound(&mut self, hex: String) { + let ascii = hex::decode(hex.replace(' ', "")) + .map(|bytes| { + bytes + .into_iter() + .map(|b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect() + }) + .unwrap_or_else(|_| String::from("[invalid hex]")); + self.lines.push(HexLine { + elapsed: self.started_at.elapsed(), + pipeline: String::from("OUT"), + hex, + ascii, + direction: LineDirection::Out, + }); + } + + pub fn iter(&self) -> impl Iterator { + self.lines.iter() + } +} diff --git a/crates/xserial-gui/src/main.rs b/crates/xserial-gui/src/main.rs index de7d993..46147d2 100644 --- a/crates/xserial-gui/src/main.rs +++ b/crates/xserial-gui/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod buffers; mod panels; use xserial_client::SessionManager; @@ -17,6 +18,6 @@ fn main() { let _ = eframe::run_native( "xserial", eframe::NativeOptions::default(), - Box::new(|_cc| Ok(Box::new(app::XserialApp::new(mgr, rx)))), + Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))), ); } diff --git a/crates/xserial-gui/src/panels/config.rs b/crates/xserial-gui/src/panels/config.rs index 8cd9c79..d882c6d 100644 --- a/crates/xserial-gui/src/panels/config.rs +++ b/crates/xserial-gui/src/panels/config.rs @@ -14,6 +14,7 @@ pub struct ConfigForm { pub udp_remote: String, pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>, pub history: usize, + pub auto_reconnect: bool, } #[derive(Clone, PartialEq)] @@ -47,11 +48,63 @@ impl Default for ConfigForm { udp_remote: String::new(), pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)], history: 10000, + auto_reconnect: false, } } } impl ConfigForm { + pub fn from_session_config(config: &SessionConfig) -> Self { + Self { + transport: match &config.transport { + TransportConfig::Serial { .. } => TransportChoice::Serial, + TransportConfig::Tcp { .. } => TransportChoice::Tcp, + TransportConfig::Udp { .. } => TransportChoice::Udp, + }, + port: match &config.transport { + TransportConfig::Serial { port, .. } => port.clone(), + _ => String::from("/dev/ttyUSB0"), + }, + baud: match &config.transport { + TransportConfig::Serial { baud_rate, .. } => *baud_rate, + _ => 115200, + }, + addr: match &config.transport { + TransportConfig::Tcp { addr } => addr.clone(), + _ => String::from("127.0.0.1:8080"), + }, + udp_bind: match &config.transport { + TransportConfig::Udp { bind_addr, .. } => bind_addr.clone(), + _ => String::from("0.0.0.0:9000"), + }, + udp_remote: match &config.transport { + TransportConfig::Udp { remote_addr, .. } => remote_addr.clone().unwrap_or_default(), + _ => String::new(), + }, + pipelines: config + .pipelines + .iter() + .map(|pipeline| { + let framer = match &pipeline.framer { + FramerConfig::Line { .. } => FramerChoice::Line, + FramerConfig::Fixed { frame_len } => FramerChoice::Fixed(*frame_len), + FramerConfig::Length { .. } | FramerConfig::Cobs { .. } => { + FramerChoice::Line + } + }; + let decoder = match &pipeline.decoder { + DecoderConfig::Text { .. } => DecoderChoice::Text, + DecoderConfig::Hex { .. } => DecoderChoice::Hex, + DecoderConfig::Plot { .. } => DecoderChoice::Plot, + }; + (pipeline.name.clone(), framer, decoder) + }) + .collect(), + history: config.history_limit, + auto_reconnect: config.auto_reconnect, + } + } + // 把表单数据转换成 xserial-client 的 SessionConfig pub fn to_session_config(&self) -> SessionConfig { SessionConfig { @@ -104,14 +157,14 @@ impl ConfigForm { }) .collect(), history_limit: self.history, - auto_reconnect: false, + auto_reconnect: self.auto_reconnect, } } } // ── 渲染函数 ────────────────────────────────────────────────────── -pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option { +pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option { let mut result = None; ScrollArea::vertical().show(ui, |ui| { @@ -234,9 +287,10 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option { ui.label("History:"); ui.add(DragValue::new(&mut form.history).range(100..=1_000_000)); }); + ui.checkbox(&mut form.auto_reconnect, "Auto reconnect"); ui.separator(); - if ui.button("✓ Create Session").clicked() { + if ui.button(submit_label).clicked() { result = Some(form.to_session_config()); } }); diff --git a/crates/xserial-gui/src/panels/console.rs b/crates/xserial-gui/src/panels/console.rs new file mode 100644 index 0000000..06c1a26 --- /dev/null +++ b/crates/xserial-gui/src/panels/console.rs @@ -0,0 +1,36 @@ +use crate::app::DisplayOptions; +use crate::buffers::{LineDirection, TextBuffer}; +use egui::{ScrollArea, Ui}; + +pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) { + ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| { + for line in buf.iter() { + let mut prefix = Vec::new(); + if display.show_timestamp { + let ts = line.elapsed.as_secs_f64(); + let time = if ts < 60.0 { + format!("{:05.2}", ts) + } else { + format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64) + }; + prefix.push(format!("[{time}]")); + } + if display.show_direction { + let dir = match line.direction { + LineDirection::In => "IN", + LineDirection::Out => "OUT", + }; + prefix.push(format!("[{dir}]")); + } + if display.show_pipeline { + prefix.push(format!("[{}]", line.pipeline)); + } + let prefix = if prefix.is_empty() { + String::new() + } else { + format!("{} ", prefix.join(" ")) + }; + ui.monospace(format!("{prefix}{}", line.text)); + } + }); +} diff --git a/crates/xserial-gui/src/panels/hex_view.rs b/crates/xserial-gui/src/panels/hex_view.rs new file mode 100644 index 0000000..2273180 --- /dev/null +++ b/crates/xserial-gui/src/panels/hex_view.rs @@ -0,0 +1,41 @@ +use crate::app::DisplayOptions; +use crate::buffers::{HexBuffer, LineDirection}; +use egui::{ScrollArea, Ui}; + +pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) { + let mut lines = buf.iter().peekable(); + if lines.peek().is_none() { + ui.label("no hex data"); + return; + } + ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| { + for line in lines { + let mut prefix = Vec::new(); + if display.show_timestamp { + let ts = line.elapsed.as_secs_f64(); + let time = if ts < 60.0 { + format!("{:05.2}", ts) + } else { + format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64) + }; + prefix.push(format!("[{time}]")); + } + if display.show_direction { + let dir = match line.direction { + LineDirection::In => "IN", + LineDirection::Out => "OUT", + }; + prefix.push(format!("[{dir}]")); + } + if display.show_pipeline { + prefix.push(format!("[{}]", line.pipeline)); + } + let prefix = if prefix.is_empty() { + String::new() + } else { + format!("{} ", prefix.join(" ")) + }; + ui.monospace(format!("{prefix}{} |{}|", line.hex, line.ascii)); + } + }); +} diff --git a/crates/xserial-gui/src/panels/mod.rs b/crates/xserial-gui/src/panels/mod.rs index f1e3186..38cf6a1 100644 --- a/crates/xserial-gui/src/panels/mod.rs +++ b/crates/xserial-gui/src/panels/mod.rs @@ -1,2 +1,4 @@ pub mod config; +pub mod console; +pub mod hex_view; pub mod sidebar;