From 882b7f391f94fe3431650dfb7489324fece3154a Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Tue, 9 Jun 2026 21:57:45 +0800 Subject: [PATCH] Improve GUI configuration and plotting tooling --- AGENTS.md | 28 ++ crates/xserial-gui/src/app.rs | 74 ++++- crates/xserial-gui/src/panels/config.rs | 44 ++- crates/xserial-gui/src/panels/sidebar.rs | 35 ++- crates/xserial-gui/src/ui_fonts.rs | 355 +++++++++++++++++++---- tools/test_plot.py | 59 ++-- tools/xs_mixed_plot.h | 237 +++++++++++++++ 7 files changed, 737 insertions(+), 95 deletions(-) create mode 100644 AGENTS.md create mode 100644 tools/xs_mixed_plot.h diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..591e01a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`xserial` is a Rust workspace. The root [Cargo.toml](/D:/Dev/xserial/Cargo.toml) defines four crates in `crates/`: +- `xserial-core`: transport, framing, protocol, and pipeline primitives in `src/`, with integration tests in `tests/pipeline.rs`. +- `xserial-client`: session management, history, commands, and Lua bindings, with integration tests under `tests/`. +- `xserial-tui`: terminal app entrypoint in `src/main.rs`. +- `xserial-gui`: egui/eframe app, with UI panels in `src/panels/` and font helpers in `src/ui_fonts.rs`. + +Use `tools/test_plot.py` as a local waveform source for GUI plot testing. Treat `target/` as generated output. + +## Build, Test, and Development Commands +- `cargo check --workspace`: fast compile check for all crates. +- `cargo test --workspace`: run the workspace test suite, including `crates/xserial-core/tests` and `crates/xserial-client/tests`. +- `cargo run -p xserial-gui`: launch the desktop GUI. +- `cargo run -p xserial-tui`: launch the terminal UI. +- `cargo fmt --all`: apply standard Rust formatting. +- `cargo clippy --workspace --all-targets -- -D warnings`: catch lint issues before review. +- `python tools/test_plot.py --help`: inspect options for the TCP plot-frame generator. + +## Coding Style & Naming Conventions +Follow standard Rust formatting: 4-space indentation, trailing commas in multiline literals, and `snake_case` for modules, files, functions, and tests. Use `PascalCase` for structs/enums and `SCREAMING_SNAKE_CASE` for constants. Keep crate boundaries clean: protocol/transport code belongs in `xserial-core`; session and Lua integration belong in `xserial-client`; UI state stays in `xserial-gui` or `xserial-tui`. + +## Testing Guidelines +Prefer focused unit tests next to implementation and integration tests in `crates//tests/*.rs`. Name tests after behavior, for example `session_lifecycle` or `pipeline`. Run `cargo test --workspace` before opening a PR; add targeted regression tests for transport, framing, parsing, and session-state fixes. + +## Commit & Pull Request Guidelines +Recent history uses short, imperative subjects such as `Persist GUI font settings` and `Improve session controls and GUI views`, with occasional `feat:` prefixes for major additions. Keep subjects concise and action-oriented. PRs should state which crate(s) changed, list validation commands, and include screenshots or short recordings for GUI/TUI changes. Call out protocol, transport, or Lua API compatibility impacts explicitly. diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 08f4382..2b57039 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -9,6 +9,7 @@ use xserial_client::SessionManager; use xserial_client::config::SessionConfig; use xserial_client::session::SessionEvent; use xserial_core::protocol::DecodedData; +use xserial_core::transport::TransportConfig; #[derive(Clone)] pub enum ConnectionStatus { @@ -283,7 +284,11 @@ impl XserialApp { let sessions: Vec<_> = self .tabs .iter() - .map(|tab| (tab.id, tab.status.clone())) + .map(|tab| sidebar::SessionListItem { + id: tab.id, + status: tab.status.clone(), + transport_summary: transport_summary(&tab.session_config.transport), + }) .collect(); sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete); }); @@ -528,6 +533,20 @@ impl XserialApp { } } +fn transport_summary(transport: &TransportConfig) -> String { + match transport { + TransportConfig::Serial { port, .. } => format!("Serial {}", port), + TransportConfig::Tcp { addr } => format!("TCP {}", addr), + TransportConfig::Udp { + bind_addr, + remote_addr, + } => match remote_addr { + Some(remote_addr) => format!("UDP {} -> {}", bind_addr, remote_addr), + None => format!("UDP {}", bind_addr), + }, + } +} + fn render_font_selector( ui: &mut egui::Ui, title: &str, @@ -569,7 +588,8 @@ fn render_font_selector( .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)) { + if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index)) + { let response = ui.selectable_value( choice, FontChoice::System(candidate.id.clone()), @@ -802,3 +822,53 @@ fn build_payload(tab: &SessionTab) -> Result>, String> { } } } + +#[cfg(test)] +mod tests { + use super::transport_summary; + use xserial_core::transport::TransportConfig; + use xserial_core::transport::serial::{ + SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, + }; + + #[test] + fn transport_summary_formats_tcp_and_serial() { + assert_eq!( + transport_summary(&TransportConfig::Tcp { + addr: String::from("127.0.0.1:8080"), + }), + "TCP 127.0.0.1:8080" + ); + + assert_eq!( + transport_summary(&TransportConfig::Serial { + port: String::from("COM7"), + baud_rate: 115200, + data_bits: SerialDataBits::Eight, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, + }), + "Serial COM7" + ); + } + + #[test] + fn transport_summary_formats_udp() { + assert_eq!( + transport_summary(&TransportConfig::Udp { + bind_addr: String::from("0.0.0.0:9000"), + remote_addr: Some(String::from("127.0.0.1:9001")), + }), + "UDP 0.0.0.0:9000 -> 127.0.0.1:9001" + ); + + assert_eq!( + transport_summary(&TransportConfig::Udp { + bind_addr: String::from("127.0.0.1:0"), + remote_addr: None, + }), + "UDP 127.0.0.1:0" + ); + } +} diff --git a/crates/xserial-gui/src/panels/config.rs b/crates/xserial-gui/src/panels/config.rs index 63ab570..f7500be 100644 --- a/crates/xserial-gui/src/panels/config.rs +++ b/crates/xserial-gui/src/panels/config.rs @@ -1,5 +1,6 @@ use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; +use xserial_core::protocol::Endian; use xserial_core::protocol::plot::PlotFormat; use xserial_core::protocol::text::TextEncoding; use xserial_core::transport::TransportConfig; @@ -32,7 +33,9 @@ pub struct PipelineForm { pub framer: FramerChoice, pub decoder: DecoderChoice, pub text_encoding: TextEncoding, + pub hex_endian: Endian, pub plot_channels: usize, + pub plot_endian: Endian, pub plot_format: PlotFormat, } @@ -76,7 +79,9 @@ impl Default for ConfigForm { framer: FramerChoice::Line, decoder: DecoderChoice::Text, text_encoding: TextEncoding::Utf8, + hex_endian: Endian::Big, plot_channels: 1, + plot_endian: Endian::Little, plot_format: PlotFormat::Interleaved, }], history: 10000, @@ -151,6 +156,14 @@ impl ConfigForm { DecoderConfig::Plot { channels, .. } => *channels, _ => 1, }; + let hex_endian = match &pipeline.decoder { + DecoderConfig::Hex { endian, .. } => *endian, + _ => Endian::Big, + }; + let plot_endian = match &pipeline.decoder { + DecoderConfig::Plot { endian, .. } => *endian, + _ => Endian::Little, + }; let text_encoding = match &pipeline.decoder { DecoderConfig::Text { encoding } => *encoding, DecoderConfig::MixedTextPlot { encoding } => *encoding, @@ -165,7 +178,9 @@ impl ConfigForm { framer, decoder, text_encoding, + hex_endian, plot_channels, + plot_endian, plot_format, } }) @@ -224,11 +239,11 @@ impl ConfigForm { uppercase: false, separator: " ".into(), bytes_per_group: 1, - endian: xserial_core::protocol::Endian::Big, + endian: pipeline.hex_endian, }, DecoderChoice::Plot => DecoderConfig::Plot { sample_type: xserial_core::protocol::plot::SampleType::F32, - endian: xserial_core::protocol::Endian::Little, + endian: pipeline.plot_endian, channels: pipeline.plot_channels.max(1), format: pipeline.plot_format, }, @@ -465,6 +480,10 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< }); if matches!(pipeline.decoder, DecoderChoice::Plot) { + ui.horizontal(|ui| { + ui.label("Plot endian:"); + render_endian_combo(ui, &mut pipeline.plot_endian, format!("plot_endian{}", i)); + }); ui.horizontal(|ui| { ui.label("Plot channels:"); ui.add(DragValue::new(&mut pipeline.plot_channels).range(1..=32)); @@ -538,6 +557,13 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< } } + if matches!(pipeline.decoder, DecoderChoice::Hex) { + ui.horizontal(|ui| { + ui.label("Hex endian:"); + render_endian_combo(ui, &mut pipeline.hex_endian, format!("hex_endian{}", i)); + }); + } + if matches!(pipeline.decoder, DecoderChoice::Text) { ui.horizontal(|ui| { ui.label("Text encoding:"); @@ -573,7 +599,9 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< framer: FramerChoice::Line, decoder: DecoderChoice::Text, text_encoding: TextEncoding::Utf8, + hex_endian: Endian::Big, plot_channels: 1, + plot_endian: Endian::Little, plot_format: PlotFormat::Interleaved, }); } @@ -612,3 +640,15 @@ fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, index: u ui.selectable_value(encoding, TextEncoding::Ascii, "ASCII"); }); } + +fn render_endian_combo(ui: &mut Ui, endian: &mut Endian, id: impl std::hash::Hash) { + ComboBox::from_id_salt(id) + .selected_text(match endian { + Endian::Little => "Little", + Endian::Big => "Big", + }) + .show_ui(ui, |ui| { + ui.selectable_value(endian, Endian::Little, "Little"); + ui.selectable_value(endian, Endian::Big, "Big"); + }); +} diff --git a/crates/xserial-gui/src/panels/sidebar.rs b/crates/xserial-gui/src/panels/sidebar.rs index e8ce752..16e7c76 100644 --- a/crates/xserial-gui/src/panels/sidebar.rs +++ b/crates/xserial-gui/src/panels/sidebar.rs @@ -1,16 +1,21 @@ use crate::app::ConnectionStatus; use egui::{Color32, RichText, Ui}; +pub struct SessionListItem { + pub id: u64, + pub status: ConnectionStatus, + pub transport_summary: String, +} + pub fn render( ui: &mut Ui, - sessions: &[(u64, ConnectionStatus)], // (id, 连接状态) - active: &mut usize, // 当前选中的会话索引 - on_new: &mut bool, // 点击了"新建" - on_delete: &mut Option, // 点击了"删除" + sessions: &[SessionListItem], + 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() @@ -20,17 +25,18 @@ pub fn render( 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, "⨉"), + for (i, session) in sessions.iter().enumerate() { + let (color, status_tag) = match &session.status { + ConnectionStatus::Connected => (Color32::GREEN, "[connected]"), + ConnectionStatus::Disconnected => (Color32::GRAY, "[disconnected]"), + ConnectionStatus::Connecting => (Color32::YELLOW, "[connecting]"), + ConnectionStatus::Error(_) => (Color32::RED, "[error]"), }; - // selectable_label: 点击高亮,clicked() 判断是否被点击 - let label = format!("{} Session {}", icon, id); + let label = format!( + "{} Session {}\n{}", + status_tag, session.id, session.transport_summary + ); if ui .selectable_label(*active == i, RichText::new(label).color(color)) .clicked() @@ -41,7 +47,6 @@ pub fn render( ui.separator(); - // 删除按钮 — 红色文字 if ui .button(RichText::new("Delete").color(Color32::RED)) .clicked() diff --git a/crates/xserial-gui/src/ui_fonts.rs b/crates/xserial-gui/src/ui_fonts.rs index ff3e0ea..2a82b21 100644 --- a/crates/xserial-gui/src/ui_fonts.rs +++ b/crates/xserial-gui/src/ui_fonts.rs @@ -60,7 +60,21 @@ impl Default for UiFontSettings { } pub fn discover_font_candidates() -> Vec { - discover_via_fc_list().unwrap_or_else(discover_via_fallback_paths) + let mut candidates = discover_via_fc_list().unwrap_or_default(); + let mut seen_paths = candidates + .iter() + .map(|candidate| candidate.path.clone()) + .collect::>(); + + for candidate in discover_via_font_directories() { + if seen_paths.insert(candidate.path.clone()) { + candidates.push(candidate); + } + } + + candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path))); + candidates.dedup_by(|a, b| a.path == b.path); + candidates } pub fn load_font_settings() -> UiFontSettings { @@ -143,9 +157,7 @@ fn load_selected_font( ) -> Option<(String, Vec)> { match choice { FontChoice::Default => None, - FontChoice::Auto => { - load_auto_font(candidates, already_loaded) - } + FontChoice::Auto => load_auto_font(candidates, already_loaded), FontChoice::System(id) => { let candidate = candidates.iter().find(|candidate| &candidate.id == id)?; if already_loaded.iter().any(|loaded| loaded == &candidate.id) { @@ -195,19 +207,22 @@ fn apply_font_size(style: &mut Style, settings: &UiFontSettings) { style .text_styles .insert(TextStyle::Body, FontId::proportional(settings.ui_font_size)); - style - .text_styles - .insert(TextStyle::Button, FontId::proportional(settings.ui_font_size)); + style.text_styles.insert( + TextStyle::Button, + FontId::proportional(settings.ui_font_size), + ); style.text_styles.insert( TextStyle::Monospace, FontId::monospace(settings.monospace_font_size), ); - style - .text_styles - .insert(TextStyle::Small, FontId::proportional((settings.ui_font_size - 2.0).max(8.0))); - style - .text_styles - .insert(TextStyle::Heading, FontId::proportional(settings.heading_font_size)); + style.text_styles.insert( + TextStyle::Small, + FontId::proportional((settings.ui_font_size - 2.0).max(8.0)), + ); + style.text_styles.insert( + TextStyle::Heading, + FontId::proportional(settings.heading_font_size), + ); } fn discover_via_fc_list() -> Option> { @@ -262,38 +277,47 @@ fn discover_via_fc_list() -> Option> { }); } - candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path))); - candidates.dedup_by(|a, b| a.path == b.path); (!candidates.is_empty()).then_some(candidates) } -fn discover_via_fallback_paths() -> Vec { - let fallback_paths = [ - "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc", - "/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf", - ]; - - fallback_paths +fn discover_via_font_directories() -> Vec { + let mut candidates = Vec::new(); + let mut stack = platform_font_directories() .into_iter() - .filter_map(|path| { - fs::metadata(path).ok().map(|_| { - let stem = Path::new(path) - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("font") - .to_owned(); - FontCandidate { - id: sanitize_id(&stem), - label: stem.clone(), - display_label: format!("{stem} [CJK]"), - path: path.to_owned(), - likely_cjk: true, - search_key: build_search_key(&stem, "Regular", path), + .filter(|path| path.is_dir()) + .map(|path| (path, 0usize)) + .collect::>(); + + while let Some((dir, depth)) = stack.pop() { + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) => { + warn!(path = %dir.display(), error = %err, "Failed to read font directory"); + continue; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + + if file_type.is_dir() { + if depth < 4 { + stack.push((path, depth + 1)); } - }) - }) - .collect() + continue; + } + + if let Some(candidate) = font_candidate_from_path(&path) { + candidates.push(candidate); + } + } + } + + candidates } fn font_display_label(family: &str, style: &str, path: &str) -> String { @@ -315,24 +339,7 @@ fn build_search_key(family: &str, style: &str, path: &str) -> String { } fn font_settings_path() -> PathBuf { - if let Ok(path) = env::var("XDG_CONFIG_HOME") { - let trimmed = path.trim(); - if !trimmed.is_empty() { - return PathBuf::from(trimmed).join("xserial").join(FONT_SETTINGS_FILE_NAME); - } - } - - if let Ok(home) = env::var("HOME") { - let trimmed = home.trim(); - if !trimmed.is_empty() { - return PathBuf::from(trimmed) - .join(".config") - .join("xserial") - .join(FONT_SETTINGS_FILE_NAME); - } - } - - PathBuf::from(FONT_SETTINGS_FILE_NAME) + font_settings_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok()) } fn load_font_settings_from_path(path: &Path) -> io::Result { @@ -348,6 +355,165 @@ fn save_font_settings_to_path(settings: &UiFontSettings, path: &Path) -> io::Res fs::write(path, json) } +fn font_settings_path_for_os_and_env( + os: &str, + get_env: impl Fn(&str) -> Option, +) -> PathBuf { + match os { + "windows" => { + if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) { + return PathBuf::from(path) + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME); + } + if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) { + return PathBuf::from(path) + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME); + } + } + "macos" => { + if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) { + return PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME); + } + } + _ => { + if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) { + return PathBuf::from(path) + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME); + } + if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) { + return PathBuf::from(home) + .join(".config") + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME); + } + } + } + + PathBuf::from(FONT_SETTINGS_FILE_NAME) +} + +fn platform_font_directories() -> Vec { + let mut dirs = Vec::new(); + + #[cfg(target_os = "windows")] + { + if let Some(windir) = env::var("WINDIR") + .ok() + .filter(|path| !path.trim().is_empty()) + { + dirs.push(PathBuf::from(windir).join("Fonts")); + } else { + dirs.push(PathBuf::from(r"C:\Windows\Fonts")); + } + + if let Some(local_app_data) = env::var("LOCALAPPDATA") + .ok() + .filter(|path| !path.trim().is_empty()) + { + dirs.push( + PathBuf::from(local_app_data) + .join("Microsoft") + .join("Windows") + .join("Fonts"), + ); + } + } + + #[cfg(target_os = "macos")] + { + dirs.push(PathBuf::from("/System/Library/Fonts")); + dirs.push(PathBuf::from("/Library/Fonts")); + if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) { + dirs.push(PathBuf::from(home).join("Library").join("Fonts")); + } + } + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + dirs.push(PathBuf::from("/usr/share/fonts")); + dirs.push(PathBuf::from("/usr/local/share/fonts")); + if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) { + dirs.push( + PathBuf::from(&home) + .join(".local") + .join("share") + .join("fonts"), + ); + dirs.push(PathBuf::from(home).join(".fonts")); + } + } + + dirs +} + +fn font_candidate_from_path(path: &Path) -> Option { + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + if !matches!(extension.as_str(), "ttf" | "ttc" | "otf" | "otc") { + return None; + } + + let stem = path.file_stem()?.to_str()?.trim(); + if stem.is_empty() { + return None; + } + + let (family, style) = split_font_name(stem); + let path_string = path.to_string_lossy().into_owned(); + let id = sanitize_id(&format!("{family}_{style}_{stem}")); + let label = if style.is_empty() || style == "Regular" { + family.clone() + } else { + format!("{family} ({style})") + }; + + Some(FontCandidate { + id, + label, + display_label: font_display_label(&family, &style, &path_string), + path: path_string.clone(), + likely_cjk: is_likely_cjk(&family, &style, &path_string), + search_key: build_search_key(&family, &style, &path_string), + }) +} + +fn split_font_name(stem: &str) -> (String, String) { + let normalized = stem.replace(['_', '-'], " "); + let compact = normalized.split_whitespace().collect::>().join(" "); + let lower = compact.to_lowercase(); + + for style in [ + "ExtraBold Italic", + "Extra Light Italic", + "SemiBold Italic", + "Bold Italic", + "ExtraBold", + "SemiBold", + "Medium", + "Regular", + "Italic", + "Bold", + "Light", + "Thin", + ] { + let suffix = style.to_lowercase(); + if let Some(prefix) = lower.strip_suffix(&suffix) { + let family = compact[..prefix.trim_end().len()].trim().to_owned(); + if !family.is_empty() { + return (family, style.to_owned()); + } + } + } + + (compact, String::from("Regular")) +} + const fn default_ui_font_size() -> f32 { 14.0 } @@ -397,6 +563,21 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool { "hei", "kai", "song", + "fang", + "yahei", + "deng", + "ming", + "simsun", + "simhei", + "simkai", + "simfang", + "msyh", + "msjh", + "meiryo", + "msgothic", + "ms gothic", + "malgun", + "gulim", "gothic", "mincho", ] @@ -436,4 +617,62 @@ mod tests { let _ = fs::remove_file(&path); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn linux_config_path_uses_xdg() { + let path = font_settings_path_for_os_and_env("linux", |key| match key { + "XDG_CONFIG_HOME" => Some(String::from("/tmp/xdg-config")), + _ => None, + }); + assert_eq!( + path, + PathBuf::from("/tmp/xdg-config") + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME) + ); + } + + #[test] + fn windows_config_path_uses_appdata() { + let path = font_settings_path_for_os_and_env("windows", |key| match key { + "APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")), + _ => None, + }); + assert_eq!( + path, + PathBuf::from(r"C:\Users\Test\AppData\Roaming") + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME) + ); + } + + #[test] + fn macos_config_path_uses_application_support() { + let path = font_settings_path_for_os_and_env("macos", |key| match key { + "HOME" => Some(String::from("/Users/tester")), + _ => None, + }); + assert_eq!( + path, + PathBuf::from("/Users/tester") + .join("Library") + .join("Application Support") + .join("xserial") + .join(FONT_SETTINGS_FILE_NAME) + ); + } + + #[test] + fn detects_windows_cjk_fonts() { + assert!(is_likely_cjk( + "Microsoft YaHei UI", + "Regular", + r"C:\Windows\Fonts\msyh.ttc" + )); + assert!(is_likely_cjk( + "SimSun", + "Regular", + r"C:\Windows\Fonts\simsun.ttc" + )); + } } diff --git a/tools/test_plot.py b/tools/test_plot.py index c8951da..4fc4b1a 100755 --- a/tools/test_plot.py +++ b/tools/test_plot.py @@ -10,6 +10,11 @@ import time PLOT_ESCAPE = 0x1E PLOT_MARKER = ord("P") +DISCONNECT_WINERRORS = { + 10053, # Software caused connection abort + 10054, # Connection reset by peer + 10058, # Socket shutdown race on Windows +} def build_frame( @@ -98,44 +103,56 @@ def build_mixed_plot_frame( ) return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00" -def main(): - p = argparse.ArgumentParser(description="xserial plot test server") - p.add_argument("--host", default="127.0.0.1") - p.add_argument("--port", type=int, default=8080) - p.add_argument("--channels", type=int, default=1) - p.add_argument( + +def is_disconnect_error(err: OSError) -> bool: + return isinstance( + err, + ( + BrokenPipeError, + ConnectionAbortedError, + ConnectionResetError, + ), + ) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS + + +def main() -> None: + parser = argparse.ArgumentParser(description="xserial plot test server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8091) + parser.add_argument("--channels", type=int, default=1) + parser.add_argument( "--format", choices=("interleaved", "xy"), default="interleaved", help="plot format (default: interleaved)", ) - p.add_argument( + parser.add_argument( "--rate", type=float, default=1024, help="samples/sec per channel (default: 1024)", ) - p.add_argument("--freq", type=float, default=1.0, help="sine Hz") - p.add_argument("--amp", type=float, default=100) - p.add_argument( + parser.add_argument("--freq", type=float, default=1.0, help="sine Hz") + parser.add_argument("--amp", type=float, default=100) + parser.add_argument( "--text-interval", type=float, default=1.0, help="seconds between status text messages (default: 1.0)", ) - p.add_argument( + parser.add_argument( "--framelen", type=int, default=0, help="fixed frame bytes (0=one sample per channel per frame)", ) - p.add_argument( + parser.add_argument( "--wire-format", choices=("mixed", "raw"), default="mixed", help="mixed sends text+plot on one connection; raw sends plot only", ) - args = p.parse_args() + args = parser.parse_args() sample_size = 4 # f32 @@ -176,7 +193,7 @@ def main(): values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase)) return values - print(f"Listening {args.host}:{args.port} — Ctrl+C to stop") + print(f"Listening {args.host}:{args.port} - Ctrl+C to stop") print("GUI:") print(f" transport = TCP {args.host}:{args.port}") if args.wire_format == "mixed": @@ -241,8 +258,8 @@ def main(): for index, value in enumerate(values) ) line = ( - f"时间={now - started_at:7.3f}s " - f"样本={sample_index:7d} {joined}\n" + f"time={now - started_at:7.3f}s " + f"sample={sample_index:7d} {joined}\n" ) conn.sendall(line.encode("utf-8")) text_count += 1 @@ -255,8 +272,13 @@ def main(): wait = frame_count * frame_interval - elapsed if wait > 0: time.sleep(wait) - except (BrokenPipeError, ConnectionResetError): - print(f"[conn -] {addr} ({frame_count} plot frames, {text_count} text lines)") + except OSError as err: + if not is_disconnect_error(err): + raise + print( + f"[conn -] {addr} " + f"({frame_count} plot frames, {text_count} text lines)" + ) finally: conn.close() finally: @@ -274,5 +296,6 @@ def main(): stop_event.set() stream_thread.join(timeout=1.0) + if __name__ == "__main__": main() diff --git a/tools/xs_mixed_plot.h b/tools/xs_mixed_plot.h new file mode 100644 index 0000000..7935a69 --- /dev/null +++ b/tools/xs_mixed_plot.h @@ -0,0 +1,237 @@ +#ifndef XS_MIXED_PLOT_H +#define XS_MIXED_PLOT_H + +#include +#include + +/* MixedTextPlot plot frame: + * 0x1E 'P' + COBS(packet) + 0x00 + * + * Plot packet: + * "XP" + + * version:u8 + + * format:u8 + + * sample_type:u8 + + * endian:u8 + + * channels:u8 + + * samples_per_channel:u16le + + * payload_len:u32le + + * payload + */ + +#define XS_MIXED_PLOT_ESCAPE 0x1E +#define XS_MIXED_PLOT_MARKER 0x50 /* 'P' */ + +#define XS_PLOT_MAGIC_0 0x58 /* 'X' */ +#define XS_PLOT_MAGIC_1 0x50 /* 'P' */ +#define XS_PLOT_VERSION 1 + +#define XS_PLOT_FORMAT_INTERLEAVED 0 +#define XS_PLOT_FORMAT_BLOCK 1 +#define XS_PLOT_FORMAT_XY 2 + +#define XS_SAMPLE_TYPE_F32 8 +#define XS_ENDIAN_LITTLE 0 + +#define XS_PLOT_HEADER_SIZE 13 + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + XS_OK = 0, + XS_ERR_ARG = -1, + XS_ERR_BUF = -2 +} xs_status_t; + +static size_t xs_cobs_max_encoded_size(size_t input_len) { + return input_len + (input_len / 254u) + 1u; +} + +static void xs_write_u16_le(uint8_t *dst, uint16_t v) { + dst[0] = (uint8_t)(v & 0xFFu); + dst[1] = (uint8_t)((v >> 8) & 0xFFu); +} + +static void xs_write_u32_le(uint8_t *dst, uint32_t v) { + dst[0] = (uint8_t)(v & 0xFFu); + dst[1] = (uint8_t)((v >> 8) & 0xFFu); + dst[2] = (uint8_t)((v >> 16) & 0xFFu); + dst[3] = (uint8_t)((v >> 24) & 0xFFu); +} + +static void xs_write_f32_le(uint8_t *dst, float v) { + union { + float f; + uint8_t b[4]; + } u; + u.f = v; + +#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__) + dst[0] = u.b[3]; + dst[1] = u.b[2]; + dst[2] = u.b[1]; + dst[3] = u.b[0]; +#else + dst[0] = u.b[0]; + dst[1] = u.b[1]; + dst[2] = u.b[2]; + dst[3] = u.b[3]; +#endif +} + +/* output must have at least xs_cobs_max_encoded_size(input_len) bytes */ +static size_t xs_cobs_encode(const uint8_t *input, size_t input_len, uint8_t *output) { + size_t read_index = 0; + size_t write_index = 1; + size_t code_index = 0; + uint8_t code = 1; + + while (read_index < input_len) { + if (input[read_index] == 0) { + output[code_index] = code; + code = 1; + code_index = write_index++; + read_index++; + } else { + output[write_index++] = input[read_index++]; + code++; + if (code == 0xFFu) { + output[code_index] = code; + code = 1; + code_index = write_index++; + } + } + } + + output[code_index] = code; + return write_index; +} + +static xs_status_t xs_plot_build_packet_f32( + const float *samples, + uint8_t channels, + uint16_t samples_per_channel, + uint8_t plot_format, + uint8_t *out, + size_t out_cap, + size_t *out_len +) { + size_t i; + uint32_t value_count; + uint32_t payload_len; + size_t packet_len; + uint8_t *p; + + if (!samples || !out || !out_len) { + return XS_ERR_ARG; + } + if (channels == 0) { + return XS_ERR_ARG; + } + if (plot_format == XS_PLOT_FORMAT_XY && channels != 2) { + return XS_ERR_ARG; + } + if (plot_format > XS_PLOT_FORMAT_XY) { + return XS_ERR_ARG; + } + + value_count = (uint32_t)channels * (uint32_t)samples_per_channel; + payload_len = value_count * 4u; + packet_len = XS_PLOT_HEADER_SIZE + (size_t)payload_len; + + if (out_cap < packet_len) { + return XS_ERR_BUF; + } + + out[0] = XS_PLOT_MAGIC_0; + out[1] = XS_PLOT_MAGIC_1; + out[2] = XS_PLOT_VERSION; + out[3] = plot_format; + out[4] = XS_SAMPLE_TYPE_F32; + out[5] = XS_ENDIAN_LITTLE; + out[6] = channels; + xs_write_u16_le(&out[7], samples_per_channel); + xs_write_u32_le(&out[9], payload_len); + + p = &out[13]; + for (i = 0; i < (size_t)value_count; ++i) { + xs_write_f32_le(p, samples[i]); + p += 4; + } + + *out_len = packet_len; + return XS_OK; +} + +static xs_status_t xs_mixed_build_plot_frame_from_packet( + const uint8_t *packet, + size_t packet_len, + uint8_t *out, + size_t out_cap, + size_t *out_len +) { + size_t encoded_len; + + if (!packet || !out || !out_len) { + return XS_ERR_ARG; + } + if (out_cap < 2u + xs_cobs_max_encoded_size(packet_len) + 1u) { + return XS_ERR_BUF; + } + + out[0] = XS_MIXED_PLOT_ESCAPE; + out[1] = XS_MIXED_PLOT_MARKER; + encoded_len = xs_cobs_encode(packet, packet_len, &out[2]); + out[2 + encoded_len] = 0x00; + + *out_len = 2u + encoded_len + 1u; + return XS_OK; +} + +static xs_status_t xs_mixed_build_plot_frame_f32( + const float *samples, + uint8_t channels, + uint16_t samples_per_channel, + uint8_t plot_format, + uint8_t *packet_buf, + size_t packet_buf_cap, + uint8_t *frame_buf, + size_t frame_buf_cap, + size_t *frame_len +) { + xs_status_t rc; + size_t packet_len = 0; + + if (!packet_buf || !frame_buf || !frame_len) { + return XS_ERR_ARG; + } + + rc = xs_plot_build_packet_f32( + samples, + channels, + samples_per_channel, + plot_format, + packet_buf, + packet_buf_cap, + &packet_len + ); + if (rc != XS_OK) { + return rc; + } + + return xs_mixed_build_plot_frame_from_packet( + packet_buf, + packet_len, + frame_buf, + frame_buf_cap, + frame_len + ); +} + +#ifdef __cplusplus +} +#endif + +#endif /* XS_MIXED_PLOT_H */