diff --git a/crates/xserial-client/src/config.rs b/crates/xserial-client/src/config.rs index e20f5a5..b007145 100644 --- a/crates/xserial-client/src/config.rs +++ b/crates/xserial-client/src/config.rs @@ -1,83 +1,149 @@ use serde::{Deserialize, Serialize}; use xserial_core::frame::{ - cobs::CobsFramer, fixed::FixedLengthFramer, + Endian, Framer, + cobs::CobsFramer, + fixed::FixedLengthFramer, length::{LengthConfig, LengthPrefixedFramer}, - line::{LineConfig, LineFramer}, Endian, Framer, + line::{LineConfig, LineFramer}, }; use xserial_core::protocol::{ + ProtocolDecoder, hex::{HexConfig, HexDecoder}, plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType}, - text::{TextDecoder, TextEncoding}, ProtocolDecoder, + text::{TextDecoder, TextEncoding}, }; use xserial_core::transport::TransportConfig; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum FramerConfig { Line { - #[serde(default = "default_true")] strip_cr: bool, - #[serde(default = "default_max_line")] max_line_len: usize, + #[serde(default = "default_true")] + strip_cr: bool, + #[serde(default = "default_max_line")] + max_line_len: usize, + }, + Fixed { + frame_len: usize, }, - Fixed { frame_len: usize }, Length { - #[serde(default = "default_len_bytes")] len_bytes: usize, - #[serde(default)] endian: Endian, - #[serde(default)] length_includes_self: bool, - #[serde(default = "default_max_payload")] max_payload: usize, + #[serde(default = "default_len_bytes")] + len_bytes: usize, + #[serde(default)] + endian: Endian, + #[serde(default)] + length_includes_self: bool, + #[serde(default = "default_max_payload")] + max_payload: usize, }, Cobs { - #[serde(default = "default_max_frame")] max_frame: usize, + #[serde(default = "default_max_frame")] + max_frame: usize, }, } -fn default_true() -> bool { true } -fn default_max_line() -> usize { 1024 * 1024 } -fn default_len_bytes() -> usize { 2 } -fn default_max_payload() -> usize { 1024 * 1024 } -fn default_max_frame() -> usize { 1024 * 1024 } +fn default_true() -> bool { + true +} +fn default_max_line() -> usize { + 1024 * 1024 +} +fn default_len_bytes() -> usize { + 2 +} +fn default_max_payload() -> usize { + 1024 * 1024 +} +fn default_max_frame() -> usize { + 1024 * 1024 +} impl FramerConfig { pub fn build(self) -> Box { match self { - FramerConfig::Line { strip_cr, max_line_len } => - Box::new(LineFramer::new(LineConfig { strip_cr, max_line_len })), - FramerConfig::Fixed { frame_len } => - Box::new(FixedLengthFramer::new(frame_len)), - FramerConfig::Length { len_bytes, endian, length_includes_self, max_payload } => - Box::new(LengthPrefixedFramer::new(LengthConfig { len_bytes, endian, length_includes_self, max_payload })), - FramerConfig::Cobs { max_frame } => - Box::new(CobsFramer::new(max_frame)), + FramerConfig::Line { + strip_cr, + max_line_len, + } => Box::new(LineFramer::new(LineConfig { + strip_cr, + max_line_len, + })), + FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)), + FramerConfig::Length { + len_bytes, + endian, + length_includes_self, + max_payload, + } => Box::new(LengthPrefixedFramer::new(LengthConfig { + len_bytes, + endian, + length_includes_self, + max_payload, + })), + FramerConfig::Cobs { max_frame } => Box::new(CobsFramer::new(max_frame)), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DecoderConfig { - Text { #[serde(default)] encoding: TextEncoding }, + Text { + #[serde(default)] + encoding: TextEncoding, + }, Hex { - #[serde(default)] uppercase: bool, - #[serde(default = "default_sep")] separator: String, - #[serde(default = "default_one")] bytes_per_group: usize, - #[serde(default)] endian: Endian, + #[serde(default)] + uppercase: bool, + #[serde(default = "default_sep")] + separator: String, + #[serde(default = "default_one")] + bytes_per_group: usize, + #[serde(default)] + endian: Endian, }, Plot { sample_type: SampleType, - #[serde(default)] endian: Endian, - #[serde(default = "default_one")] channels: usize, - #[serde(default)] format: PlotFormat, + #[serde(default)] + endian: Endian, + #[serde(default = "default_one")] + channels: usize, + #[serde(default)] + format: PlotFormat, }, } -fn default_sep() -> String { String::from(" ") } -fn default_one() -> usize { 1 } +fn default_sep() -> String { + String::from(" ") +} +fn default_one() -> usize { + 1 +} impl DecoderConfig { pub fn build(self) -> Box { match self { DecoderConfig::Text { encoding } => Box::new(TextDecoder::new(encoding)), - DecoderConfig::Hex { uppercase, separator, bytes_per_group, endian } => - Box::new(HexDecoder::new(HexConfig { uppercase, separator, bytes_per_group, endian })), - DecoderConfig::Plot { sample_type, endian, channels, format } => - Box::new(PlotDecoder::new(PlotConfig { sample_type, endian, channels, format })), + DecoderConfig::Hex { + uppercase, + separator, + bytes_per_group, + endian, + } => Box::new(HexDecoder::new(HexConfig { + uppercase, + separator, + bytes_per_group, + endian, + })), + DecoderConfig::Plot { + sample_type, + endian, + channels, + format, + } => Box::new(PlotDecoder::new(PlotConfig { + sample_type, + endian, + channels, + format, + })), } } } @@ -92,17 +158,24 @@ pub struct PipelineConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionConfig { pub transport: TransportConfig, - #[serde(default)] pub pipelines: Vec, - #[serde(default = "default_history")] pub history_limit: usize, - #[serde(default)] pub auto_reconnect: bool, + #[serde(default)] + pub pipelines: Vec, + #[serde(default = "default_history")] + pub history_limit: usize, + #[serde(default)] + pub auto_reconnect: bool, } -fn default_history() -> usize { 10_000 } +fn default_history() -> usize { + 10_000 +} impl Default for SessionConfig { fn default() -> Self { Self { - transport: TransportConfig::Tcp { addr: "127.0.0.1:8080".into() }, + transport: TransportConfig::Tcp { + addr: "127.0.0.1:8080".into(), + }, pipelines: Vec::new(), history_limit: default_history(), auto_reconnect: false, @@ -113,9 +186,9 @@ impl Default for SessionConfig { #[cfg(test)] mod tests { use super::*; - use xserial_core::protocol::text::TextEncoding; - use xserial_core::protocol::plot::{PlotFormat, SampleType}; use xserial_core::protocol::Endian; + use xserial_core::protocol::plot::{PlotFormat, SampleType}; + use xserial_core::protocol::text::TextEncoding; use xserial_core::transport::TransportConfig; #[test] @@ -123,7 +196,10 @@ mod tests { let json = r#"{"Line":{"strip_cr":true,"max_line_len":4096}}"#; let cfg: FramerConfig = serde_json::from_str(json).unwrap(); match &cfg { - FramerConfig::Line { strip_cr, max_line_len } => { + FramerConfig::Line { + strip_cr, + max_line_len, + } => { assert!(*strip_cr); assert_eq!(*max_line_len, 4096); } @@ -144,7 +220,12 @@ mod tests { let json = r#"{"Length":{"len_bytes":4,"endian":"Little","length_includes_self":true}}"#; let cfg: FramerConfig = serde_json::from_str(json).unwrap(); match cfg { - FramerConfig::Length { len_bytes, endian, length_includes_self, .. } => { + FramerConfig::Length { + len_bytes, + endian, + length_includes_self, + .. + } => { assert_eq!(len_bytes, 4); assert_eq!(endian, Endian::Little); assert!(length_includes_self); @@ -170,10 +251,16 @@ mod tests { #[test] fn decoder_hex_serde() { - let json = r#"{"Hex":{"uppercase":true,"separator":":","bytes_per_group":2,"endian":"Little"}}"#; + let json = + r#"{"Hex":{"uppercase":true,"separator":":","bytes_per_group":2,"endian":"Little"}}"#; let cfg: DecoderConfig = serde_json::from_str(json).unwrap(); match cfg { - DecoderConfig::Hex { uppercase, separator, bytes_per_group, endian } => { + DecoderConfig::Hex { + uppercase, + separator, + bytes_per_group, + endian, + } => { assert!(uppercase); assert_eq!(separator, ":"); assert_eq!(bytes_per_group, 2); @@ -188,7 +275,12 @@ mod tests { let json = r#"{"Plot":{"sample_type":"F32","endian":"Little","channels":2,"format":"Interleaved"}}"#; let cfg: DecoderConfig = serde_json::from_str(json).unwrap(); match cfg { - DecoderConfig::Plot { sample_type, endian, channels, format } => { + DecoderConfig::Plot { + sample_type, + endian, + channels, + format, + } => { assert_eq!(sample_type, SampleType::F32); assert_eq!(channels, 2); assert_eq!(format, PlotFormat::Interleaved); @@ -213,7 +305,9 @@ mod tests { assert!(cfg.auto_reconnect); assert_eq!(cfg.history_limit, 5000); assert_eq!(cfg.pipelines.len(), 2); - assert!(matches!(&cfg.transport, TransportConfig::Tcp { addr } if addr == "192.168.1.1:9999")); + assert!( + matches!(&cfg.transport, TransportConfig::Tcp { addr } if addr == "192.168.1.1:9999") + ); let back = serde_json::to_string_pretty(&cfg).unwrap(); let _: SessionConfig = serde_json::from_str(&back).unwrap(); } @@ -221,9 +315,17 @@ mod tests { #[test] fn framer_build_does_not_panic() { for cfg in [ - FramerConfig::Line { strip_cr: true, max_line_len: 256 }, + FramerConfig::Line { + strip_cr: true, + max_line_len: 256, + }, FramerConfig::Fixed { frame_len: 8 }, - FramerConfig::Length { len_bytes: 2, endian: Endian::Big, length_includes_self: false, max_payload: 65536 }, + FramerConfig::Length { + len_bytes: 2, + endian: Endian::Big, + length_includes_self: false, + max_payload: 65536, + }, FramerConfig::Cobs { max_frame: 1024 }, ] { let f = cfg.build(); @@ -233,8 +335,35 @@ mod tests { #[test] fn decoder_build_returns_correct_name() { - assert_eq!(DecoderConfig::Text { encoding: TextEncoding::Utf8 }.build().name(), "Text"); - assert_eq!(DecoderConfig::Hex { uppercase: false, separator: " ".into(), bytes_per_group: 1, endian: Endian::Big }.build().name(), "Hex"); - assert_eq!(DecoderConfig::Plot { sample_type: SampleType::I16, endian: Endian::Big, channels: 2, format: PlotFormat::XY }.build().name(), "Plot"); + assert_eq!( + DecoderConfig::Text { + encoding: TextEncoding::Utf8 + } + .build() + .name(), + "Text" + ); + assert_eq!( + DecoderConfig::Hex { + uppercase: false, + separator: " ".into(), + bytes_per_group: 1, + endian: Endian::Big + } + .build() + .name(), + "Hex" + ); + assert_eq!( + DecoderConfig::Plot { + sample_type: SampleType::I16, + endian: Endian::Big, + channels: 2, + format: PlotFormat::XY + } + .build() + .name(), + "Plot" + ); } } diff --git a/crates/xserial-client/src/history.rs b/crates/xserial-client/src/history.rs index 554247a..f0a4d19 100644 --- a/crates/xserial-client/src/history.rs +++ b/crates/xserial-client/src/history.rs @@ -1,5 +1,5 @@ -use std::collections::VecDeque; use crate::event::DecodedEntry; +use std::collections::VecDeque; pub struct RingBuffer { buf: VecDeque, @@ -8,17 +8,33 @@ pub struct RingBuffer { impl RingBuffer { pub fn new(limit: usize) -> Self { - Self { buf: VecDeque::with_capacity(limit.min(1024)), limit } + Self { + buf: VecDeque::with_capacity(limit.min(1024)), + limit, + } } pub fn push(&mut self, item: T) { - if self.buf.len() >= self.limit { self.buf.pop_front(); } + if self.buf.len() >= self.limit { + self.buf.pop_front(); + } self.buf.push_back(item); } - pub fn len(&self) -> usize { self.buf.len() } - pub fn is_empty(&self) -> bool { self.buf.is_empty() } - pub fn iter(&self) -> impl Iterator { self.buf.iter() } - pub fn clear(&mut self) { self.buf.clear(); } - pub fn drain_recent(&self, count: usize) -> Vec where T: Clone { + pub fn len(&self) -> usize { + self.buf.len() + } + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } + pub fn iter(&self) -> impl Iterator { + self.buf.iter() + } + pub fn clear(&mut self) { + self.buf.clear(); + } + pub fn drain_recent(&self, count: usize) -> Vec + where + T: Clone, + { let start = self.buf.len().saturating_sub(count); self.buf.iter().skip(start).cloned().collect() } @@ -26,6 +42,9 @@ impl RingBuffer { impl RingBuffer { pub fn entries_for_pipeline(&self, name: &str) -> Vec<&DecodedEntry> { - self.buf.iter().filter(|e| e.pipeline_name == name).collect() + self.buf + .iter() + .filter(|e| e.pipeline_name == name) + .collect() } } diff --git a/crates/xserial-client/src/lib.rs b/crates/xserial-client/src/lib.rs index 0726ad1..35543cc 100644 --- a/crates/xserial-client/src/lib.rs +++ b/crates/xserial-client/src/lib.rs @@ -1,11 +1,11 @@ -pub mod config; -pub mod event; pub mod cmd; +pub mod config; pub mod error; +pub mod event; pub mod history; -pub mod session; -pub mod manager; pub mod lua; +pub mod manager; +pub mod session; pub use config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; pub use error::{Error, Result}; diff --git a/crates/xserial-client/tests/session_lifecycle.rs b/crates/xserial-client/tests/session_lifecycle.rs index 4b42be2..25fc9e6 100644 --- a/crates/xserial-client/tests/session_lifecycle.rs +++ b/crates/xserial-client/tests/session_lifecycle.rs @@ -3,11 +3,9 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; -use xserial_client::config::{ - DecoderConfig, FramerConfig, PipelineConfig, SessionConfig, -}; -use xserial_client::session::{Session, SessionEvent}; use xserial_client::SessionManager; +use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; +use xserial_client::session::{Session, SessionEvent}; use xserial_core::protocol::DecodedData; use xserial_core::transport::TransportConfig; @@ -78,13 +76,17 @@ async fn session_multiple_reads() { SessionEvent::Data(_, entry) => { if let DecodedData::Text(s) = entry.data { texts.push(s); - if texts.len() == 3 { break; } + if texts.len() == 3 { + break; + } } } _ => {} } } - }).await.unwrap(); + }) + .await + .unwrap(); assert_eq!(texts, vec!["a", "b", "c"]); handle.close().await.unwrap(); @@ -128,12 +130,16 @@ async fn session_multi_pipeline_text_and_hex() { match rx.recv().await.unwrap() { SessionEvent::Data(_, entry) => { results.push((entry.pipeline_name.clone(), entry.data.clone())); - if results.len() == 2 { break; } + if results.len() == 2 { + break; + } } _ => {} } } - }).await.unwrap(); + }) + .await + .unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].0, "text"); @@ -313,11 +319,15 @@ async fn session_handle_subscribe_sees_events() { // Should see Connected then Data let e1 = tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await.unwrap().unwrap(); + .await + .unwrap() + .unwrap(); assert!(matches!(e1, SessionEvent::Connected(7))); let e2 = tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await.unwrap().unwrap(); + .await + .unwrap() + .unwrap(); match e2 { SessionEvent::Data(7, entry) => { assert_eq!(entry.pipeline_name, "text"); diff --git a/crates/xserial-core/src/frame/cobs.rs b/crates/xserial-core/src/frame/cobs.rs index 51aeb3d..c3b350f 100644 --- a/crates/xserial-core/src/frame/cobs.rs +++ b/crates/xserial-core/src/frame/cobs.rs @@ -1,4 +1,5 @@ use super::Framer; +use tracing::{debug, warn}; /// COBS (Consistent Overhead Byte Stuffing) framer. /// @@ -38,9 +39,16 @@ impl Framer for CobsFramer { for &byte in data { if byte == 0x00 { if !self.buf.is_empty() { - // Decode failed (corrupt packet) — discard and continue + let buf_len = self.buf.len(); if let Some(decoded) = cobs_decode(&self.buf) { + debug!( + encoded_len = buf_len, + decoded_len = decoded.len(), + "COBS frame decoded" + ); frames.push(decoded); + } else { + warn!(len = buf_len, "corrupt COBS packet discarded"); } self.buf.clear(); } diff --git a/crates/xserial-core/src/frame/length.rs b/crates/xserial-core/src/frame/length.rs index c017dc5..3dd4129 100644 --- a/crates/xserial-core/src/frame/length.rs +++ b/crates/xserial-core/src/frame/length.rs @@ -1,4 +1,5 @@ use super::{Endian, Framer}; +use tracing::{debug, warn}; /// Configuration for the length-prefixed framer. #[derive(Debug, Clone)] @@ -96,6 +97,11 @@ impl Framer for LengthPrefixedFramer { // Discard length, re-sync on corrupt frame if self.config.max_payload > 0 && payload_len > self.config.max_payload { + warn!( + claimed = payload_len, + max = self.config.max_payload, + "corrupt length frame, skipping" + ); self.buf.drain(..self.config.len_bytes); continue; } @@ -115,6 +121,7 @@ impl Framer for LengthPrefixedFramer { break; } let frame: Vec = self.buf.drain(..expected).collect(); + debug!(len = frame.len(), "length-prefixed frame extracted"); frames.push(frame); self.state = State::ReadingLength; } diff --git a/crates/xserial-core/src/frame/line.rs b/crates/xserial-core/src/frame/line.rs index 37d114d..a83ca65 100644 --- a/crates/xserial-core/src/frame/line.rs +++ b/crates/xserial-core/src/frame/line.rs @@ -1,4 +1,5 @@ use super::Framer; +use tracing::debug; /// Line-delimited framer. /// @@ -54,6 +55,13 @@ impl Framer for LineFramer { } } + if !frames.is_empty() { + debug!( + count = frames.len(), + pending = self.buf.len(), + "line frames extracted" + ); + } frames } diff --git a/crates/xserial-core/src/frame/mod.rs b/crates/xserial-core/src/frame/mod.rs index 0f507ff..3733f68 100644 --- a/crates/xserial-core/src/frame/mod.rs +++ b/crates/xserial-core/src/frame/mod.rs @@ -1,7 +1,7 @@ -pub mod line; +pub mod cobs; pub mod fixed; pub mod length; -pub mod cobs; +pub mod line; pub use crate::protocol::Endian; diff --git a/crates/xserial-core/src/lib.rs b/crates/xserial-core/src/lib.rs index 24a68e3..f396e58 100644 --- a/crates/xserial-core/src/lib.rs +++ b/crates/xserial-core/src/lib.rs @@ -1,7 +1,7 @@ pub mod error; -pub mod transport; -pub mod protocol; pub mod frame; pub mod pipeline; +pub mod protocol; +pub mod transport; pub use error::{Error, Result}; diff --git a/crates/xserial-core/src/pipeline.rs b/crates/xserial-core/src/pipeline.rs index 370974c..c90e370 100644 --- a/crates/xserial-core/src/pipeline.rs +++ b/crates/xserial-core/src/pipeline.rs @@ -1,5 +1,6 @@ use crate::frame::Framer; use crate::protocol::{DecodedData, ProtocolDecoder}; +use tracing::debug; /// A self-contained framing + decoding pipeline. /// @@ -118,6 +119,13 @@ impl MultiPipeline { }); } } + if !results.is_empty() { + debug!( + count = results.len(), + bytes = data.len(), + "pipeline produced results" + ); + } results } @@ -168,10 +176,10 @@ mod tests { use crate::frame::fixed::FixedLengthFramer; use crate::frame::length::{LengthConfig, LengthPrefixedFramer}; use crate::frame::line::{LineConfig, LineFramer}; + use crate::protocol::Endian; use crate::protocol::hex::{HexConfig, HexDecoder}; use crate::protocol::plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType}; use crate::protocol::text::{TextDecoder, TextEncoding}; - use crate::protocol::Endian; #[test] fn multi_text_and_hex_same_stream() { diff --git a/crates/xserial-core/src/protocol/mod.rs b/crates/xserial-core/src/protocol/mod.rs index 0cf142b..8ca7fc6 100644 --- a/crates/xserial-core/src/protocol/mod.rs +++ b/crates/xserial-core/src/protocol/mod.rs @@ -1,6 +1,6 @@ -pub mod text; pub mod hex; pub mod plot; +pub mod text; use serde::{Deserialize, Serialize}; diff --git a/crates/xserial-core/src/protocol/plot.rs b/crates/xserial-core/src/protocol/plot.rs index 0a6264c..7a97507 100644 --- a/crates/xserial-core/src/protocol/plot.rs +++ b/crates/xserial-core/src/protocol/plot.rs @@ -147,8 +147,7 @@ impl PlotDecoder { } SampleType::I64 => { let b = [ - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], ]; let val = match self.config.endian { Endian::Big => i64::from_be_bytes(b), @@ -158,8 +157,7 @@ impl PlotDecoder { } SampleType::U64 => { let b = [ - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], ]; let val = match self.config.endian { Endian::Big => u64::from_be_bytes(b), @@ -177,8 +175,7 @@ impl PlotDecoder { } SampleType::F64 => { let b = [ - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], ]; match self.config.endian { Endian::Big => f64::from_be_bytes(b), @@ -222,7 +219,8 @@ impl ProtocolDecoder for PlotDecoder { } // Distribute into channels - let mut channels: Vec> = vec![Vec::with_capacity(samples_per_channel); num_channels]; + let mut channels: Vec> = + vec![Vec::with_capacity(samples_per_channel); num_channels]; match self.config.format { PlotFormat::Interleaved | PlotFormat::XY => { for (i, val) in flat.into_iter().enumerate() { @@ -351,9 +349,14 @@ mod tests { let d = PlotDecoder::new(cfg); // ch0: 100, 300 ch1: 200, 400 let data = vec![ - 100u16.to_le_bytes(), 200u16.to_le_bytes(), - 300u16.to_le_bytes(), 400u16.to_le_bytes(), - ].into_iter().flatten().collect::>(); + 100u16.to_le_bytes(), + 200u16.to_le_bytes(), + 300u16.to_le_bytes(), + 400u16.to_le_bytes(), + ] + .into_iter() + .flatten() + .collect::>(); let result = d.decode(&data).unwrap(); match result { DecodedData::Plot(frame) => { @@ -376,9 +379,14 @@ mod tests { let d = PlotDecoder::new(cfg); // ch0: 10, 20 ch1: 30, 40 let data = vec![ - 10u16.to_be_bytes(), 20u16.to_be_bytes(), - 30u16.to_be_bytes(), 40u16.to_be_bytes(), - ].into_iter().flatten().collect::>(); + 10u16.to_be_bytes(), + 20u16.to_be_bytes(), + 30u16.to_be_bytes(), + 40u16.to_be_bytes(), + ] + .into_iter() + .flatten() + .collect::>(); let result = d.decode(&data).unwrap(); match result { DecodedData::Plot(frame) => { @@ -401,9 +409,14 @@ mod tests { let d = PlotDecoder::new(cfg); // (x,y) pairs: (10, 100), (20, 200) let data = vec![ - 10u16.to_le_bytes(), 100u16.to_le_bytes(), - 20u16.to_le_bytes(), 200u16.to_le_bytes(), - ].into_iter().flatten().collect::>(); + 10u16.to_le_bytes(), + 100u16.to_le_bytes(), + 20u16.to_le_bytes(), + 200u16.to_le_bytes(), + ] + .into_iter() + .flatten() + .collect::>(); let result = d.decode(&data).unwrap(); match result { DecodedData::Plot(frame) => { diff --git a/crates/xserial-core/src/transport/mod.rs b/crates/xserial-core/src/transport/mod.rs index 3b91b40..783d36a 100644 --- a/crates/xserial-core/src/transport/mod.rs +++ b/crates/xserial-core/src/transport/mod.rs @@ -1,11 +1,12 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncWrite}; +use tracing::info; +use crate::error::Result; use crate::transport::serial::SerialTransport; use crate::transport::tcp::TcpTransport; use crate::transport::udp::UdpTransport; -use crate::error::Result; pub mod serial; pub mod tcp; @@ -15,7 +16,7 @@ pub mod udp; pub enum TransportType { Serial, Tcp, - Udp + Udp, } impl std::fmt::Display for TransportType { @@ -23,7 +24,7 @@ impl std::fmt::Display for TransportType { match self { TransportType::Serial => write!(f, "Serial"), TransportType::Tcp => write!(f, "Tcp"), - TransportType::Udp => write!(f, "Udp") + TransportType::Udp => write!(f, "Udp"), } } } @@ -32,15 +33,15 @@ impl std::fmt::Display for TransportType { pub enum TransportConfig { Serial { port: String, - baud_rate: u32 + baud_rate: u32, }, Tcp { - addr: String + addr: String, }, Udp { bind_addr: String, - remote_addr: Option - } + remote_addr: Option, + }, } #[async_trait] @@ -98,6 +99,7 @@ impl Connection { } pub async fn connect(&mut self) -> Result<()> { + info!(transport = ?self.transport_type(), name = self.name(), "Connection connecting"); match self { Connection::Serial(t) => t.connect().await, Connection::Tcp(t) => t.connect().await, @@ -106,6 +108,7 @@ impl Connection { } pub async fn disconnect(&mut self) -> Result<()> { + info!(name = self.name(), "Connection disconnecting"); match self { Connection::Serial(t) => t.disconnect().await, Connection::Tcp(t) => t.disconnect().await, @@ -237,7 +240,11 @@ mod tests { #[test] fn test_transport_type_roundtrip() { - for original in [TransportType::Serial, TransportType::Tcp, TransportType::Udp] { + for original in [ + TransportType::Serial, + TransportType::Tcp, + TransportType::Udp, + ] { let json = serde_json::to_string(&original).unwrap(); let roundtripped: TransportType = serde_json::from_str(&json).unwrap(); assert_eq!(original, roundtripped); @@ -503,8 +510,7 @@ mod tests { unsafe fn wake(_: *const ()) {} unsafe fn wake_by_ref(_: *const ()) {} unsafe fn drop(_: *const ()) {} - static VTABLE: RawWakerVTable = - RawWakerVTable::new(clone, wake, wake_by_ref, drop); + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); unsafe { std::task::Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } } @@ -530,9 +536,9 @@ mod tests { #[test] fn test_connection_poll_read_not_connected() { - use tokio::io::ReadBuf; use std::pin::Pin; use std::task::{Context, Poll}; + use tokio::io::ReadBuf; for mut conn in [serial_conn(), tcp_conn(), udp_conn()] { let pinned = Pin::new(&mut conn); @@ -719,9 +725,13 @@ mod tests { let server = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); - AsyncWriteExt::write_all(&mut stream, b"hello").await.unwrap(); + AsyncWriteExt::write_all(&mut stream, b"hello") + .await + .unwrap(); let mut buf = [0u8; 5]; - AsyncReadExt::read_exact(&mut stream, &mut buf).await.unwrap(); + AsyncReadExt::read_exact(&mut stream, &mut buf) + .await + .unwrap(); assert_eq!(&buf, b"world"); }); @@ -761,7 +771,9 @@ mod tests { remote.send_to(b"world", from).await.unwrap(); let mut read_buf = [0u8; 5]; - AsyncReadExt::read_exact(&mut conn, &mut read_buf).await.unwrap(); + AsyncReadExt::read_exact(&mut conn, &mut read_buf) + .await + .unwrap(); assert_eq!(&read_buf, b"world"); } -} \ No newline at end of file +} diff --git a/crates/xserial-core/src/transport/serial.rs b/crates/xserial-core/src/transport/serial.rs index 0bebe9f..fffedb4 100644 --- a/crates/xserial-core/src/transport/serial.rs +++ b/crates/xserial-core/src/transport/serial.rs @@ -109,17 +109,22 @@ impl Transport for SerialTransport { async fn connect(&mut self) -> Result<()> { if self.is_connected() { - return Ok(()) + return Ok(()); } - info!("Opening serial port {} at {} baud", self.port_name, self.baud_rate); + info!( + "Opening serial port {} at {} baud", + self.port_name, self.baud_rate + ); let port = tokio_serial::new(&self.port_name, self.baud_rate) .open_native_async() - .map_err(|e| crate::error::Error::ConnectionFailed(format!( - "Failed to open {}: {}", - self.port_name, e - )))?; + .map_err(|e| { + crate::error::Error::ConnectionFailed(format!( + "Failed to open {}: {}", + self.port_name, e + )) + })?; debug!("Serial port {} opened successfully", self.port_name); self.port = Some(port); @@ -152,12 +157,8 @@ mod tests { unsafe fn raw_wake_by_ref(_: *const ()) {} unsafe fn raw_drop(_: *const ()) {} - static RAW_VTABLE: RawWakerVTable = RawWakerVTable::new( - raw_clone, - raw_wake, - raw_wake_by_ref, - raw_drop, - ); + static RAW_VTABLE: RawWakerVTable = + RawWakerVTable::new(raw_clone, raw_wake, raw_wake_by_ref, raw_drop); unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &RAW_VTABLE)) } } diff --git a/crates/xserial-core/src/transport/tcp.rs b/crates/xserial-core/src/transport/tcp.rs index 933be3b..997d3a4 100644 --- a/crates/xserial-core/src/transport/tcp.rs +++ b/crates/xserial-core/src/transport/tcp.rs @@ -1,4 +1,3 @@ - use async_trait::async_trait; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::TcpStream; @@ -10,15 +9,12 @@ use crate::error::Result; #[derive(Debug)] pub struct TcpTransport { stream: Option, - addr: String + addr: String, } impl TcpTransport { pub fn new(addr: String) -> Self { - Self { - stream: None, - addr - } + Self { stream: None, addr } } pub fn addr(&self) -> &str { @@ -281,7 +277,10 @@ mod tests { async fn test_disconnect_without_connect() { let mut transport = TcpTransport::new("127.0.0.1:8080".into()); let result = transport.disconnect().await; - assert!(result.is_ok(), "disconnect without connect should be a safe no-op"); + assert!( + result.is_ok(), + "disconnect without connect should be a safe no-op" + ); assert!(!transport.is_connected()); } @@ -322,4 +321,4 @@ mod tests { // Ensure server task completed without panic server_handle.await.unwrap(); } -} \ No newline at end of file +} diff --git a/crates/xserial-core/src/transport/udp.rs b/crates/xserial-core/src/transport/udp.rs index 971c625..c92d13b 100644 --- a/crates/xserial-core/src/transport/udp.rs +++ b/crates/xserial-core/src/transport/udp.rs @@ -17,7 +17,7 @@ pub struct UdpTransport { read_buf: Vec, read_pos: usize, - temp_recv_buf: Vec, + temp_recv_buf: Vec, } impl UdpTransport { @@ -49,7 +49,7 @@ impl AsyncRead for UdpTransport { if this.read_pos < this.read_buf.len() { let remaining = &this.read_buf[this.read_pos..]; let to_copy = std::cmp::min(remaining.len(), buf.remaining()); - + buf.put_slice(&remaining[..to_copy]); this.read_pos += to_copy; @@ -81,14 +81,16 @@ impl AsyncRead for UdpTransport { socket.poll_recv(cx, &mut temp_buf) } else { // poll_recv_from 会返回 (usize, SocketAddr),我们将其映射回统一的 () 类型 - socket.poll_recv_from(cx, &mut temp_buf).map(|res| res.map(|_| ())) + socket + .poll_recv_from(cx, &mut temp_buf) + .map(|res| res.map(|_| ())) }; match poll_result { Poll::Ready(Ok(())) => { let filled = temp_buf.filled(); let to_copy = std::cmp::min(filled.len(), buf.remaining()); - + buf.put_slice(&filled[..to_copy]); // 核心逻辑:如果本次读取到的 UDP 包大于外面提供的 buf 的剩余空间 @@ -268,9 +270,7 @@ mod tests { let result = Pin::new(&mut t).poll_read(&mut cx, &mut buf); match result { Poll::Ready(Err(ref e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotConnected), - other => panic!( - "expected Poll::Ready(Err(NotConnected)), got {other:?}" - ), + other => panic!("expected Poll::Ready(Err(NotConnected)), got {other:?}"), } } @@ -282,9 +282,7 @@ mod tests { let result = Pin::new(&mut t).poll_write(&mut cx, data); match result { Poll::Ready(Err(ref e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotConnected), - other => panic!( - "expected Poll::Ready(Err(NotConnected)), got {other:?}" - ), + other => panic!("expected Poll::Ready(Err(NotConnected)), got {other:?}"), } } @@ -322,7 +320,9 @@ mod tests { let remote_addr = remote.local_addr().unwrap().to_string(); let mut t = UdpTransport::new("127.0.0.1:0".into(), Some(remote_addr)); - t.connect().await.expect("connect with remote should succeed"); + t.connect() + .await + .expect("connect with remote should succeed"); assert!(t.is_connected()); t.disconnect().await.expect("disconnect should succeed"); diff --git a/crates/xserial-core/tests/pipeline.rs b/crates/xserial-core/tests/pipeline.rs index bac110b..d63ffcc 100644 --- a/crates/xserial-core/tests/pipeline.rs +++ b/crates/xserial-core/tests/pipeline.rs @@ -5,26 +5,23 @@ use tokio::net::{TcpListener, UdpSocket}; use tokio::time::timeout; use xserial_core::frame::{ + Endian, Framer, cobs::CobsFramer, fixed::FixedLengthFramer, length::{LengthConfig, LengthPrefixedFramer}, line::{LineConfig, LineFramer}, - Endian, Framer, }; use xserial_core::protocol::{ + DecodedData, ProtocolDecoder, hex::{HexConfig, HexDecoder}, plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType}, text::{TextDecoder, TextEncoding}, - DecodedData, ProtocolDecoder, }; use xserial_core::transport::{Connection, TransportConfig, TransportType}; const TEST_TIMEOUT: Duration = Duration::from_secs(5); -async fn read_to_framer( - conn: &mut Connection, - framer: &mut dyn Framer, -) -> Vec> { +async fn read_to_framer(conn: &mut Connection, framer: &mut dyn Framer) -> Vec> { let mut buf = [0u8; 4096]; let mut all_frames = Vec::new(); @@ -384,9 +381,7 @@ async fn connection_reconnect_tcp() { stream.write_all(b"first\n").await.unwrap(); }); - let mut conn = Connection::new(TransportConfig::Tcp { - addr: addr.clone(), - }); + let mut conn = Connection::new(TransportConfig::Tcp { addr: addr.clone() }); conn.connect().await.unwrap(); let mut framer = LineFramer::new(LineConfig::default()); diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs new file mode 100644 index 0000000..435583b --- /dev/null +++ b/crates/xserial-gui/src/app.rs @@ -0,0 +1,137 @@ +use crate::panels::{config, sidebar}; +use tracing::debug; +use xserial_client::SessionManager; +use xserial_client::session::SessionEvent; + +#[derive(Clone)] +pub enum ConnectionStatus { + Connected, + Disconnected, + Connecting, + Error(String), +} + +pub struct SessionTab { + pub id: u64, + pub status: ConnectionStatus, +} + +pub struct XserialApp { + manager: SessionManager, + tabs: Vec, + active: usize, + config_open: bool, + config_form: config::ConfigForm, + event_rx: Option>, + pending: Vec, +} + +impl XserialApp { + pub fn new(m: SessionManager, rx: tokio::sync::broadcast::Receiver) -> Self { + Self { + manager: m, + tabs: vec![], + active: 0, + config_open: false, + config_form: config::ConfigForm::default(), + event_rx: Some(rx), + pending: vec![], + } + } +} + +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)); + }); + } +} diff --git a/crates/xserial-gui/src/main.rs b/crates/xserial-gui/src/main.rs index 63a82f5..de7d993 100644 --- a/crates/xserial-gui/src/main.rs +++ b/crates/xserial-gui/src/main.rs @@ -1,3 +1,22 @@ +mod app; +mod panels; + +use xserial_client::SessionManager; + fn main() { - println!("xserial-gui"); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mgr = SessionManager::new(); + let rx = mgr.subscribe(); + + let _ = eframe::run_native( + "xserial", + eframe::NativeOptions::default(), + Box::new(|_cc| Ok(Box::new(app::XserialApp::new(mgr, rx)))), + ); } diff --git a/crates/xserial-gui/src/panels/config.rs b/crates/xserial-gui/src/panels/config.rs new file mode 100644 index 0000000..8cd9c79 --- /dev/null +++ b/crates/xserial-gui/src/panels/config.rs @@ -0,0 +1,245 @@ +use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; +use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; +use xserial_core::transport::TransportConfig; + +// ── 表单数据结构(简化版,方便 UI 渲染)──────────────────────────── + +#[derive(Clone)] +pub struct ConfigForm { + pub transport: TransportChoice, + pub port: String, + pub baud: u32, + pub addr: String, + pub udp_bind: String, + pub udp_remote: String, + pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>, + pub history: usize, +} + +#[derive(Clone, PartialEq)] +pub enum TransportChoice { + Serial, + Tcp, + Udp, +} + +#[derive(Clone, PartialEq)] +pub enum FramerChoice { + Line, + Fixed(usize), +} + +#[derive(Clone, PartialEq)] +pub enum DecoderChoice { + Text, + Hex, + Plot, +} + +impl Default for ConfigForm { + fn default() -> Self { + Self { + transport: TransportChoice::Tcp, + port: "/dev/ttyUSB0".into(), + baud: 115200, + addr: "127.0.0.1:8080".into(), + udp_bind: "0.0.0.0:9000".into(), + udp_remote: String::new(), + pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)], + history: 10000, + } + } +} + +impl ConfigForm { + // 把表单数据转换成 xserial-client 的 SessionConfig + pub fn to_session_config(&self) -> SessionConfig { + SessionConfig { + transport: match self.transport { + TransportChoice::Serial => TransportConfig::Serial { + port: self.port.clone(), + baud_rate: self.baud, + }, + TransportChoice::Tcp => TransportConfig::Tcp { + addr: self.addr.clone(), + }, + TransportChoice::Udp => TransportConfig::Udp { + bind_addr: self.udp_bind.clone(), + remote_addr: if self.udp_remote.is_empty() { + None + } else { + Some(self.udp_remote.clone()) + }, + }, + }, + pipelines: self + .pipelines + .iter() + .map(|(name, fc, dc)| PipelineConfig { + name: name.clone(), + framer: match fc { + FramerChoice::Line => FramerConfig::Line { + strip_cr: true, + max_line_len: 1_048_576, + }, + FramerChoice::Fixed(n) => FramerConfig::Fixed { frame_len: *n }, + }, + decoder: match dc { + DecoderChoice::Text => DecoderConfig::Text { + encoding: xserial_core::protocol::text::TextEncoding::Utf8, + }, + DecoderChoice::Hex => DecoderConfig::Hex { + uppercase: false, + separator: " ".into(), + bytes_per_group: 1, + endian: xserial_core::protocol::Endian::Big, + }, + DecoderChoice::Plot => DecoderConfig::Plot { + sample_type: xserial_core::protocol::plot::SampleType::F32, + endian: xserial_core::protocol::Endian::Little, + channels: 1, + format: xserial_core::protocol::plot::PlotFormat::Interleaved, + }, + }, + }) + .collect(), + history_limit: self.history, + auto_reconnect: false, + } + } +} + +// ── 渲染函数 ────────────────────────────────────────────────────── + +pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option { + let mut result = None; + + ScrollArea::vertical().show(ui, |ui| { + ui.heading("Transport"); + + // 传输类型下拉 + ComboBox::from_label("Type") + .selected_text(match form.transport { + TransportChoice::Serial => "Serial", + TransportChoice::Tcp => "TCP", + TransportChoice::Udp => "UDP", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut form.transport, TransportChoice::Serial, "Serial"); + ui.selectable_value(&mut form.transport, TransportChoice::Tcp, "TCP"); + ui.selectable_value(&mut form.transport, TransportChoice::Udp, "UDP"); + }); + + // 根据类型显示不同字段 + match form.transport { + TransportChoice::Serial => { + ui.horizontal(|ui| { + ui.label("Port:"); + ui.text_edit_singleline(&mut form.port); + }); + ui.horizontal(|ui| { + ui.label("Baud:"); + ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000)); + }); + } + TransportChoice::Tcp => { + ui.horizontal(|ui| { + ui.label("Addr:"); + ui.text_edit_singleline(&mut form.addr); + }); + } + TransportChoice::Udp => { + ui.horizontal(|ui| { + ui.label("Bind:"); + ui.text_edit_singleline(&mut form.udp_bind); + }); + ui.horizontal(|ui| { + ui.label("Remote:"); + ui.text_edit_singleline(&mut form.udp_remote); + }); + } + } + + ui.separator(); + ui.heading("Pipelines"); + + let mut remove = None; + + // 管道列表 + for (i, (name, fc, dc)) in form.pipelines.iter_mut().enumerate() { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add(TextEdit::singleline(name).desired_width(60.0)); + + // Framer 选择 + let fsel = match fc { + FramerChoice::Line => "Line".to_string(), + FramerChoice::Fixed(n) => format!("Fixed({})", n), + }; + ComboBox::from_id_salt(format!("f{}", i)) + .selected_text(fsel) + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Line").clicked() { + *fc = FramerChoice::Line; + } + if ui.selectable_label(false, "Fixed").clicked() { + *fc = FramerChoice::Fixed(8); + } + }); + if let FramerChoice::Fixed(n) = fc { + ui.add(DragValue::new(n).range(1..=65536)); + } + + // Decoder 选择 + ComboBox::from_id_salt(format!("d{}", i)) + .selected_text(match dc { + DecoderChoice::Text => "Text", + DecoderChoice::Hex => "Hex", + DecoderChoice::Plot => "Plot", + }) + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Text").clicked() { + *dc = DecoderChoice::Text; + } + if ui.selectable_label(false, "Hex").clicked() { + *dc = DecoderChoice::Hex; + } + if ui.selectable_label(false, "Plot").clicked() { + *dc = DecoderChoice::Plot; + } + }); + + if ui.button("✕").clicked() { + remove = Some(i); + } + }); + }); + } + + if let Some(i) = remove { + form.pipelines.remove(i); + } + + if ui.button("+ Add Pipeline").clicked() { + form.pipelines.push(( + format!("p{}", form.pipelines.len()), + FramerChoice::Line, + DecoderChoice::Text, + )); + } + + ui.separator(); + ui.horizontal(|ui| { + ui.label("History:"); + ui.add(DragValue::new(&mut form.history).range(100..=1_000_000)); + }); + + ui.separator(); + if ui.button("✓ Create Session").clicked() { + result = Some(form.to_session_config()); + } + }); + + result +} diff --git a/crates/xserial-gui/src/panels/mod.rs b/crates/xserial-gui/src/panels/mod.rs new file mode 100644 index 0000000..f1e3186 --- /dev/null +++ b/crates/xserial-gui/src/panels/mod.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod sidebar; diff --git a/crates/xserial-gui/src/panels/sidebar.rs b/crates/xserial-gui/src/panels/sidebar.rs new file mode 100644 index 0000000..e8ce752 --- /dev/null +++ b/crates/xserial-gui/src/panels/sidebar.rs @@ -0,0 +1,51 @@ +use crate::app::ConnectionStatus; +use egui::{Color32, RichText, Ui}; + +pub fn render( + ui: &mut Ui, + sessions: &[(u64, ConnectionStatus)], // (id, 连接状态) + active: &mut usize, // 当前选中的会话索引 + on_new: &mut bool, // 点击了"新建" + on_delete: &mut Option, // 点击了"删除" +) { + ui.heading("Sessions"); + + // 新建按钮 — 绿色文字 + if ui + .button(RichText::new("+ New Session").color(Color32::GREEN)) + .clicked() + { + *on_new = true; + } + + ui.separator(); + + // 会话列表 + for (i, (id, status)) in sessions.iter().enumerate() { + let (color, icon) = match status { + ConnectionStatus::Connected => (Color32::GREEN, "●"), + ConnectionStatus::Disconnected => (Color32::GRAY, "○"), + ConnectionStatus::Connecting => (Color32::YELLOW, "◌"), + ConnectionStatus::Error(_) => (Color32::RED, "⨉"), + }; + + // selectable_label: 点击高亮,clicked() 判断是否被点击 + let label = format!("{} Session {}", icon, id); + if ui + .selectable_label(*active == i, RichText::new(label).color(color)) + .clicked() + { + *active = i; + } + } + + ui.separator(); + + // 删除按钮 — 红色文字 + if ui + .button(RichText::new("Delete").color(Color32::RED)) + .clicked() + { + *on_delete = Some(*active); + } +} diff --git a/crates/xserial-tui/src/main.rs b/crates/xserial-tui/src/main.rs index 8a55e80..f328e4d 100644 --- a/crates/xserial-tui/src/main.rs +++ b/crates/xserial-tui/src/main.rs @@ -1,3 +1 @@ -fn main() { - -} \ No newline at end of file +fn main() {}