Compare commits
2 Commits
594637ec50
...
d489bc0831
| Author | SHA1 | Date | |
|---|---|---|---|
| d489bc0831 | |||
| 882b7f391f |
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -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/<crate>/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.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::app_state::{self, PersistedGuiState};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ use xserial_client::SessionManager;
|
|||||||
use xserial_client::config::SessionConfig;
|
use xserial_client::config::SessionConfig;
|
||||||
use xserial_client::session::SessionEvent;
|
use xserial_client::session::SessionEvent;
|
||||||
use xserial_core::protocol::DecodedData;
|
use xserial_core::protocol::DecodedData;
|
||||||
|
use xserial_core::transport::TransportConfig;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ConnectionStatus {
|
pub enum ConnectionStatus {
|
||||||
@@ -112,15 +114,16 @@ impl XserialApp {
|
|||||||
let font_candidates = ui_fonts::discover_font_candidates();
|
let font_candidates = ui_fonts::discover_font_candidates();
|
||||||
let font_settings = ui_fonts::load_font_settings();
|
let font_settings = ui_fonts::load_font_settings();
|
||||||
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
|
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
|
||||||
|
let saved_state = app_state::load_gui_state();
|
||||||
|
|
||||||
Self {
|
let mut app = Self {
|
||||||
manager,
|
manager,
|
||||||
tabs: Vec::new(),
|
tabs: Vec::new(),
|
||||||
active: 0,
|
active: 0,
|
||||||
display: DisplayOptions {
|
display: DisplayOptions {
|
||||||
show_timestamp: true,
|
show_timestamp: saved_state.show_timestamp,
|
||||||
show_direction: true,
|
show_direction: saved_state.show_direction,
|
||||||
show_pipeline: true,
|
show_pipeline: saved_state.show_pipeline,
|
||||||
},
|
},
|
||||||
font_settings_open: false,
|
font_settings_open: false,
|
||||||
font_candidates,
|
font_candidates,
|
||||||
@@ -136,7 +139,9 @@ impl XserialApp {
|
|||||||
config_form: config::ConfigForm::default(),
|
config_form: config::ConfigForm::default(),
|
||||||
event_rx,
|
event_rx,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
}
|
};
|
||||||
|
app.restore_saved_sessions(saved_state);
|
||||||
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_create_config(&mut self) {
|
fn open_create_config(&mut self) {
|
||||||
@@ -145,6 +150,54 @@ impl XserialApp {
|
|||||||
self.config_open = true;
|
self.config_open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
|
||||||
|
for session_config in saved_state.sessions {
|
||||||
|
self.add_session_tab(session_config);
|
||||||
|
}
|
||||||
|
if !self.tabs.is_empty() {
|
||||||
|
self.active = saved_state.active.min(self.tabs.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_session_tab(&mut self, session_config: SessionConfig) {
|
||||||
|
let history_limit = session_config.history_limit;
|
||||||
|
let auto_reconnect = session_config.auto_reconnect;
|
||||||
|
let handle = self.manager.create(session_config.clone());
|
||||||
|
self.tabs.push(SessionTab {
|
||||||
|
id: handle.id(),
|
||||||
|
session_config,
|
||||||
|
status: ConnectionStatus::Connecting,
|
||||||
|
console: TextBuffer::new(history_limit),
|
||||||
|
hex: HexBuffer::new(history_limit),
|
||||||
|
plot: PlotBuffer::new(history_limit),
|
||||||
|
plot_view: plot_view::PlotViewState::default(),
|
||||||
|
view: View::Text,
|
||||||
|
auto_reconnect,
|
||||||
|
send_input: String::new(),
|
||||||
|
send_mode: SendMode::Text,
|
||||||
|
append_newline: true,
|
||||||
|
send_status: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_gui_state(&self) {
|
||||||
|
app_state::save_gui_state(&PersistedGuiState {
|
||||||
|
sessions: self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| tab.session_config.clone())
|
||||||
|
.collect(),
|
||||||
|
active: if self.tabs.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.active.min(self.tabs.len() - 1)
|
||||||
|
},
|
||||||
|
show_timestamp: self.display.show_timestamp,
|
||||||
|
show_direction: self.display.show_direction,
|
||||||
|
show_pipeline: self.display.show_pipeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn open_edit_config(&mut self, id: u64) {
|
fn open_edit_config(&mut self, id: u64) {
|
||||||
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
|
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
|
||||||
self.config_target = Some(id);
|
self.config_target = Some(id);
|
||||||
@@ -165,6 +218,7 @@ impl XserialApp {
|
|||||||
tab.status = ConnectionStatus::Connecting;
|
tab.status = ConnectionStatus::Connecting;
|
||||||
tab.send_status = Some(String::from("Session reconfigured"));
|
tab.send_status = Some(String::from("Session reconfigured"));
|
||||||
}
|
}
|
||||||
|
self.persist_gui_state();
|
||||||
|
|
||||||
if let Some(handle) = handle {
|
if let Some(handle) = handle {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -245,25 +299,9 @@ impl XserialApp {
|
|||||||
if let Some(id) = self.config_target {
|
if let Some(id) = self.config_target {
|
||||||
self.apply_session_config(id, session_config);
|
self.apply_session_config(id, session_config);
|
||||||
} else {
|
} else {
|
||||||
let history_limit = session_config.history_limit;
|
self.add_session_tab(session_config);
|
||||||
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),
|
|
||||||
plot: PlotBuffer::new(history_limit),
|
|
||||||
plot_view: plot_view::PlotViewState::default(),
|
|
||||||
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;
|
self.active = self.tabs.len() - 1;
|
||||||
|
self.persist_gui_state();
|
||||||
}
|
}
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
@@ -276,6 +314,7 @@ impl XserialApp {
|
|||||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||||
let mut on_new = false;
|
let mut on_new = false;
|
||||||
let mut on_delete = None;
|
let mut on_delete = None;
|
||||||
|
let previous_active = self.active;
|
||||||
|
|
||||||
Panel::left("sidebar")
|
Panel::left("sidebar")
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
@@ -283,7 +322,11 @@ impl XserialApp {
|
|||||||
let sessions: Vec<_> = self
|
let sessions: Vec<_> = self
|
||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
|
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
|
||||||
});
|
});
|
||||||
@@ -300,6 +343,9 @@ impl XserialApp {
|
|||||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||||
self.active = self.tabs.len() - 1;
|
self.active = self.tabs.len() - 1;
|
||||||
}
|
}
|
||||||
|
self.persist_gui_state();
|
||||||
|
} else if self.active != previous_active {
|
||||||
|
self.persist_gui_state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +363,7 @@ impl XserialApp {
|
|||||||
let manager = self.manager.clone();
|
let manager = self.manager.clone();
|
||||||
let display = &mut self.display;
|
let display = &mut self.display;
|
||||||
let mut edit_session = None;
|
let mut edit_session = None;
|
||||||
|
let mut persist_state = false;
|
||||||
|
|
||||||
{
|
{
|
||||||
let tab = &mut self.tabs[self.active];
|
let tab = &mut self.tabs[self.active];
|
||||||
@@ -345,16 +392,25 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if render_session_controls(ui, &manager, tab) {
|
let (configure_clicked, auto_reconnect_changed) =
|
||||||
|
render_session_controls(ui, &manager, tab);
|
||||||
|
if configure_clicked {
|
||||||
edit_session = Some(tab.id);
|
edit_session = Some(tab.id);
|
||||||
}
|
}
|
||||||
|
persist_state |= auto_reconnect_changed;
|
||||||
|
|
||||||
|
let mut display_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label("Show:");
|
ui.label("Show:");
|
||||||
ui.checkbox(&mut display.show_timestamp, "Timestamp");
|
display_changed |= ui
|
||||||
ui.checkbox(&mut display.show_direction, "Direction");
|
.checkbox(&mut display.show_timestamp, "Timestamp")
|
||||||
ui.checkbox(&mut display.show_pipeline, "Pipe");
|
.changed();
|
||||||
|
display_changed |= ui
|
||||||
|
.checkbox(&mut display.show_direction, "Direction")
|
||||||
|
.changed();
|
||||||
|
display_changed |= ui.checkbox(&mut display.show_pipeline, "Pipe").changed();
|
||||||
});
|
});
|
||||||
|
persist_state |= display_changed;
|
||||||
|
|
||||||
if let ConnectionStatus::Error(message) = &tab.status {
|
if let ConnectionStatus::Error(message) = &tab.status {
|
||||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||||
@@ -393,6 +449,9 @@ impl XserialApp {
|
|||||||
if let Some(id) = edit_session {
|
if let Some(id) = edit_session {
|
||||||
self.open_edit_config(id);
|
self.open_edit_config(id);
|
||||||
}
|
}
|
||||||
|
if persist_state {
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +587,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(
|
fn render_font_selector(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -569,7 +642,8 @@ fn render_font_selector(
|
|||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
|
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
|
||||||
for row in 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(
|
let response = ui.selectable_value(
|
||||||
choice,
|
choice,
|
||||||
FontChoice::System(candidate.id.clone()),
|
FontChoice::System(candidate.id.clone()),
|
||||||
@@ -628,8 +702,9 @@ fn render_session_controls(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
manager: &SessionManager,
|
manager: &SessionManager,
|
||||||
tab: &mut SessionTab,
|
tab: &mut SessionTab,
|
||||||
) -> bool {
|
) -> (bool, bool) {
|
||||||
let mut configure_clicked = false;
|
let mut configure_clicked = false;
|
||||||
|
let mut auto_reconnect_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
if ui.button("Connect").clicked() {
|
if ui.button("Connect").clicked() {
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
@@ -677,6 +752,8 @@ fn render_session_controls(
|
|||||||
|
|
||||||
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
||||||
if response.changed() {
|
if response.changed() {
|
||||||
|
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
||||||
|
auto_reconnect_changed = true;
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
let enabled = tab.auto_reconnect;
|
let enabled = tab.auto_reconnect;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -687,7 +764,7 @@ fn render_session_controls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
configure_clicked
|
(configure_clicked, auto_reconnect_changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||||
@@ -802,3 +879,53 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
156
crates/xserial-gui/src/app_state.rs
Normal file
156
crates/xserial-gui/src/app_state.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
use xserial_client::config::SessionConfig;
|
||||||
|
|
||||||
|
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedGuiState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: usize,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_timestamp: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_direction: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_pipeline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_gui_state() -> PersistedGuiState {
|
||||||
|
match load_gui_state_from_path(&gui_state_path()) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedGuiState::default(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "Failed to load persisted GUI state");
|
||||||
|
PersistedGuiState::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_gui_state(state: &PersistedGuiState) {
|
||||||
|
if let Err(err) = save_gui_state_to_path(state, &gui_state_path()) {
|
||||||
|
warn!(error = %err, "Failed to persist GUI state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gui_state_path() -> PathBuf {
|
||||||
|
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
|
match os {
|
||||||
|
"windows" => {
|
||||||
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_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(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(GUI_STATE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_gui_state_from_path(path: &Path) -> io::Result<PersistedGuiState> {
|
||||||
|
let text = fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&text).map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_gui_state_to_path(state: &PersistedGuiState, path: &Path) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
|
||||||
|
fs::write(path, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use xserial_core::transport::TransportConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_gui_state_path_uses_appdata() {
|
||||||
|
let path = gui_state_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(GUI_STATE_FILE_NAME)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gui_state_roundtrip() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
|
||||||
|
let path = dir.join(GUI_STATE_FILE_NAME);
|
||||||
|
let state = PersistedGuiState {
|
||||||
|
sessions: vec![SessionConfig {
|
||||||
|
transport: TransportConfig::Tcp {
|
||||||
|
addr: String::from("127.0.0.1:9000"),
|
||||||
|
},
|
||||||
|
..SessionConfig::default()
|
||||||
|
}],
|
||||||
|
active: 0,
|
||||||
|
show_timestamp: false,
|
||||||
|
show_direction: true,
|
||||||
|
show_pipeline: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
save_gui_state_to_path(&state, &path).unwrap();
|
||||||
|
let loaded = load_gui_state_from_path(&path).unwrap();
|
||||||
|
assert_eq!(loaded.sessions.len(), 1);
|
||||||
|
assert!(!loaded.show_timestamp);
|
||||||
|
assert!(loaded.show_direction);
|
||||||
|
assert!(!loaded.show_pipeline);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod ui_fonts;
|
mod ui_fonts;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
|||||||
use crate::app::ConnectionStatus;
|
use crate::app::ConnectionStatus;
|
||||||
use egui::{Color32, RichText, Ui};
|
use egui::{Color32, RichText, Ui};
|
||||||
|
|
||||||
|
pub struct SessionListItem {
|
||||||
|
pub id: u64,
|
||||||
|
pub status: ConnectionStatus,
|
||||||
|
pub transport_summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render(
|
pub fn render(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
sessions: &[(u64, ConnectionStatus)], // (id, 连接状态)
|
sessions: &[SessionListItem],
|
||||||
active: &mut usize, // 当前选中的会话索引
|
active: &mut usize,
|
||||||
on_new: &mut bool, // 点击了"新建"
|
on_new: &mut bool,
|
||||||
on_delete: &mut Option<usize>, // 点击了"删除"
|
on_delete: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
ui.heading("Sessions");
|
ui.heading("Sessions");
|
||||||
|
|
||||||
// 新建按钮 — 绿色文字
|
|
||||||
if ui
|
if ui
|
||||||
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -20,17 +25,18 @@ pub fn render(
|
|||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// 会话列表
|
for (i, session) in sessions.iter().enumerate() {
|
||||||
for (i, (id, status)) in sessions.iter().enumerate() {
|
let (color, status_tag) = match &session.status {
|
||||||
let (color, icon) = match status {
|
ConnectionStatus::Connected => (Color32::GREEN, "[connected]"),
|
||||||
ConnectionStatus::Connected => (Color32::GREEN, "●"),
|
ConnectionStatus::Disconnected => (Color32::GRAY, "[disconnected]"),
|
||||||
ConnectionStatus::Disconnected => (Color32::GRAY, "○"),
|
ConnectionStatus::Connecting => (Color32::YELLOW, "[connecting]"),
|
||||||
ConnectionStatus::Connecting => (Color32::YELLOW, "◌"),
|
ConnectionStatus::Error(_) => (Color32::RED, "[error]"),
|
||||||
ConnectionStatus::Error(_) => (Color32::RED, "⨉"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// selectable_label: 点击高亮,clicked() 判断是否被点击
|
let label = format!(
|
||||||
let label = format!("{} Session {}", icon, id);
|
"{} Session {}\n{}",
|
||||||
|
status_tag, session.id, session.transport_summary
|
||||||
|
);
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(*active == i, RichText::new(label).color(color))
|
.selectable_label(*active == i, RichText::new(label).color(color))
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -41,7 +47,6 @@ pub fn render(
|
|||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// 删除按钮 — 红色文字
|
|
||||||
if ui
|
if ui
|
||||||
.button(RichText::new("Delete").color(Color32::RED))
|
.button(RichText::new("Delete").color(Color32::RED))
|
||||||
.clicked()
|
.clicked()
|
||||||
|
|||||||
@@ -60,7 +60,21 @@ impl Default for UiFontSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn discover_font_candidates() -> Vec<FontCandidate> {
|
pub fn discover_font_candidates() -> Vec<FontCandidate> {
|
||||||
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::<std::collections::BTreeSet<_>>();
|
||||||
|
|
||||||
|
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 {
|
pub fn load_font_settings() -> UiFontSettings {
|
||||||
@@ -143,9 +157,7 @@ fn load_selected_font(
|
|||||||
) -> Option<(String, Vec<u8>)> {
|
) -> Option<(String, Vec<u8>)> {
|
||||||
match choice {
|
match choice {
|
||||||
FontChoice::Default => None,
|
FontChoice::Default => None,
|
||||||
FontChoice::Auto => {
|
FontChoice::Auto => load_auto_font(candidates, already_loaded),
|
||||||
load_auto_font(candidates, already_loaded)
|
|
||||||
}
|
|
||||||
FontChoice::System(id) => {
|
FontChoice::System(id) => {
|
||||||
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
|
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
|
||||||
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
|
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
|
||||||
@@ -195,19 +207,22 @@ fn apply_font_size(style: &mut Style, settings: &UiFontSettings) {
|
|||||||
style
|
style
|
||||||
.text_styles
|
.text_styles
|
||||||
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
|
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
|
||||||
style
|
style.text_styles.insert(
|
||||||
.text_styles
|
TextStyle::Button,
|
||||||
.insert(TextStyle::Button, FontId::proportional(settings.ui_font_size));
|
FontId::proportional(settings.ui_font_size),
|
||||||
|
);
|
||||||
style.text_styles.insert(
|
style.text_styles.insert(
|
||||||
TextStyle::Monospace,
|
TextStyle::Monospace,
|
||||||
FontId::monospace(settings.monospace_font_size),
|
FontId::monospace(settings.monospace_font_size),
|
||||||
);
|
);
|
||||||
style
|
style.text_styles.insert(
|
||||||
.text_styles
|
TextStyle::Small,
|
||||||
.insert(TextStyle::Small, FontId::proportional((settings.ui_font_size - 2.0).max(8.0)));
|
FontId::proportional((settings.ui_font_size - 2.0).max(8.0)),
|
||||||
style
|
);
|
||||||
.text_styles
|
style.text_styles.insert(
|
||||||
.insert(TextStyle::Heading, FontId::proportional(settings.heading_font_size));
|
TextStyle::Heading,
|
||||||
|
FontId::proportional(settings.heading_font_size),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
|
fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
|
||||||
@@ -262,38 +277,47 @@ fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
(!candidates.is_empty()).then_some(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_via_fallback_paths() -> Vec<FontCandidate> {
|
fn discover_via_font_directories() -> Vec<FontCandidate> {
|
||||||
let fallback_paths = [
|
let mut candidates = Vec::new();
|
||||||
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
let mut stack = platform_font_directories()
|
||||||
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
|
|
||||||
"/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf",
|
|
||||||
];
|
|
||||||
|
|
||||||
fallback_paths
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|path| {
|
.filter(|path| path.is_dir())
|
||||||
fs::metadata(path).ok().map(|_| {
|
.map(|path| (path, 0usize))
|
||||||
let stem = Path::new(path)
|
.collect::<Vec<_>>();
|
||||||
.file_stem()
|
|
||||||
.and_then(|name| name.to_str())
|
while let Some((dir, depth)) = stack.pop() {
|
||||||
.unwrap_or("font")
|
let entries = match fs::read_dir(&dir) {
|
||||||
.to_owned();
|
Ok(entries) => entries,
|
||||||
FontCandidate {
|
Err(err) => {
|
||||||
id: sanitize_id(&stem),
|
warn!(path = %dir.display(), error = %err, "Failed to read font directory");
|
||||||
label: stem.clone(),
|
continue;
|
||||||
display_label: format!("{stem} [CJK]"),
|
|
||||||
path: path.to_owned(),
|
|
||||||
likely_cjk: true,
|
|
||||||
search_key: build_search_key(&stem, "Regular", path),
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
})
|
|
||||||
.collect()
|
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));
|
||||||
|
}
|
||||||
|
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 {
|
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 {
|
fn font_settings_path() -> PathBuf {
|
||||||
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
|
font_settings_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
|
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
|
||||||
@@ -348,6 +355,165 @@ fn save_font_settings_to_path(settings: &UiFontSettings, path: &Path) -> io::Res
|
|||||||
fs::write(path, json)
|
fs::write(path, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn font_settings_path_for_os_and_env(
|
||||||
|
os: &str,
|
||||||
|
get_env: impl Fn(&str) -> Option<String>,
|
||||||
|
) -> 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<PathBuf> {
|
||||||
|
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<FontCandidate> {
|
||||||
|
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::<Vec<_>>().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 {
|
const fn default_ui_font_size() -> f32 {
|
||||||
14.0
|
14.0
|
||||||
}
|
}
|
||||||
@@ -397,6 +563,21 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
|
|||||||
"hei",
|
"hei",
|
||||||
"kai",
|
"kai",
|
||||||
"song",
|
"song",
|
||||||
|
"fang",
|
||||||
|
"yahei",
|
||||||
|
"deng",
|
||||||
|
"ming",
|
||||||
|
"simsun",
|
||||||
|
"simhei",
|
||||||
|
"simkai",
|
||||||
|
"simfang",
|
||||||
|
"msyh",
|
||||||
|
"msjh",
|
||||||
|
"meiryo",
|
||||||
|
"msgothic",
|
||||||
|
"ms gothic",
|
||||||
|
"malgun",
|
||||||
|
"gulim",
|
||||||
"gothic",
|
"gothic",
|
||||||
"mincho",
|
"mincho",
|
||||||
]
|
]
|
||||||
@@ -436,4 +617,62 @@ mod tests {
|
|||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
let _ = fs::remove_dir_all(&dir);
|
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"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import time
|
|||||||
|
|
||||||
PLOT_ESCAPE = 0x1E
|
PLOT_ESCAPE = 0x1E
|
||||||
PLOT_MARKER = ord("P")
|
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(
|
def build_frame(
|
||||||
@@ -98,44 +103,56 @@ def build_mixed_plot_frame(
|
|||||||
)
|
)
|
||||||
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser(description="xserial plot test server")
|
def is_disconnect_error(err: OSError) -> bool:
|
||||||
p.add_argument("--host", default="127.0.0.1")
|
return isinstance(
|
||||||
p.add_argument("--port", type=int, default=8080)
|
err,
|
||||||
p.add_argument("--channels", type=int, default=1)
|
(
|
||||||
p.add_argument(
|
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",
|
"--format",
|
||||||
choices=("interleaved", "xy"),
|
choices=("interleaved", "xy"),
|
||||||
default="interleaved",
|
default="interleaved",
|
||||||
help="plot format (default: interleaved)",
|
help="plot format (default: interleaved)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--rate",
|
"--rate",
|
||||||
type=float,
|
type=float,
|
||||||
default=1024,
|
default=1024,
|
||||||
help="samples/sec per channel (default: 1024)",
|
help="samples/sec per channel (default: 1024)",
|
||||||
)
|
)
|
||||||
p.add_argument("--freq", type=float, default=1.0, help="sine Hz")
|
parser.add_argument("--freq", type=float, default=1.0, help="sine Hz")
|
||||||
p.add_argument("--amp", type=float, default=100)
|
parser.add_argument("--amp", type=float, default=100)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--text-interval",
|
"--text-interval",
|
||||||
type=float,
|
type=float,
|
||||||
default=1.0,
|
default=1.0,
|
||||||
help="seconds between status text messages (default: 1.0)",
|
help="seconds between status text messages (default: 1.0)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--framelen",
|
"--framelen",
|
||||||
type=int,
|
type=int,
|
||||||
default=0,
|
default=0,
|
||||||
help="fixed frame bytes (0=one sample per channel per frame)",
|
help="fixed frame bytes (0=one sample per channel per frame)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
parser.add_argument(
|
||||||
"--wire-format",
|
"--wire-format",
|
||||||
choices=("mixed", "raw"),
|
choices=("mixed", "raw"),
|
||||||
default="mixed",
|
default="mixed",
|
||||||
help="mixed sends text+plot on one connection; raw sends plot only",
|
help="mixed sends text+plot on one connection; raw sends plot only",
|
||||||
)
|
)
|
||||||
args = p.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
sample_size = 4 # f32
|
sample_size = 4 # f32
|
||||||
|
|
||||||
@@ -176,7 +193,7 @@ def main():
|
|||||||
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
||||||
return values
|
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("GUI:")
|
||||||
print(f" transport = TCP {args.host}:{args.port}")
|
print(f" transport = TCP {args.host}:{args.port}")
|
||||||
if args.wire_format == "mixed":
|
if args.wire_format == "mixed":
|
||||||
@@ -241,8 +258,8 @@ def main():
|
|||||||
for index, value in enumerate(values)
|
for index, value in enumerate(values)
|
||||||
)
|
)
|
||||||
line = (
|
line = (
|
||||||
f"时间={now - started_at:7.3f}s "
|
f"time={now - started_at:7.3f}s "
|
||||||
f"样本={sample_index:7d} {joined}\n"
|
f"sample={sample_index:7d} {joined}\n"
|
||||||
)
|
)
|
||||||
conn.sendall(line.encode("utf-8"))
|
conn.sendall(line.encode("utf-8"))
|
||||||
text_count += 1
|
text_count += 1
|
||||||
@@ -255,8 +272,13 @@ def main():
|
|||||||
wait = frame_count * frame_interval - elapsed
|
wait = frame_count * frame_interval - elapsed
|
||||||
if wait > 0:
|
if wait > 0:
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
except OSError as err:
|
||||||
print(f"[conn -] {addr} ({frame_count} plot frames, {text_count} text lines)")
|
if not is_disconnect_error(err):
|
||||||
|
raise
|
||||||
|
print(
|
||||||
|
f"[conn -] {addr} "
|
||||||
|
f"({frame_count} plot frames, {text_count} text lines)"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
finally:
|
finally:
|
||||||
@@ -274,5 +296,6 @@ def main():
|
|||||||
stop_event.set()
|
stop_event.set()
|
||||||
stream_thread.join(timeout=1.0)
|
stream_thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
237
tools/xs_mixed_plot.h
Normal file
237
tools/xs_mixed_plot.h
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
#ifndef XS_MIXED_PLOT_H
|
||||||
|
#define XS_MIXED_PLOT_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
Reference in New Issue
Block a user