From b499db5d3598379eb0e13a763d1d861fa71e4723 Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Tue, 9 Jun 2026 15:55:45 +0800 Subject: [PATCH] Add GUI font configuration controls --- crates/xserial-gui/src/app.rs | 192 ++++++++++++++- crates/xserial-gui/src/main.rs | 1 + crates/xserial-gui/src/panels/config.rs | 43 +++- crates/xserial-gui/src/ui_fonts.rs | 300 ++++++++++++++++++++++++ tools/test_plot.py | 4 +- 5 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 crates/xserial-gui/src/ui_fonts.rs diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 8c98348..269c2a1 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -3,6 +3,7 @@ use std::time::Duration; use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer}; use crate::panels::{config, console, hex_view, plot_view, sidebar}; +use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings}; use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder}; use xserial_client::SessionManager; use xserial_client::config::SessionConfig; @@ -69,6 +70,11 @@ pub struct XserialApp { tabs: Vec, active: usize, display: DisplayOptions, + font_settings_open: bool, + font_candidates: Vec, + font_settings: UiFontSettings, + primary_font_search: String, + fallback_font_search: String, config_open: bool, config_target: Option, config_form: config::ConfigForm, @@ -83,6 +89,7 @@ impl XserialApp { ctx: egui::Context, ) -> Self { let (event_tx, event_rx) = mpsc::channel(); + let repaint_ctx = ctx.clone(); tokio::spawn(async move { loop { match rx.recv().await { @@ -90,7 +97,7 @@ impl XserialApp { if event_tx.send(event).is_err() { break; } - ctx.request_repaint(); + repaint_ctx.request_repaint(); } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => break, @@ -98,6 +105,10 @@ impl XserialApp { } }); + let font_candidates = ui_fonts::discover_font_candidates(); + let font_settings = UiFontSettings::default(); + ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates); + Self { manager, tabs: Vec::new(), @@ -107,6 +118,11 @@ impl XserialApp { show_direction: true, show_pipeline: true, }, + font_settings_open: false, + font_candidates, + font_settings, + primary_font_search: String::new(), + fallback_font_search: String::new(), config_open: false, config_target: None, config_form: config::ConfigForm::default(), @@ -371,6 +387,178 @@ impl XserialApp { } }); } + + fn render_top_bar(&mut self, ui: &mut egui::Ui) { + Panel::top("top_bar").show_inside(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.heading("xserial"); + ui.separator(); + ui.label(format!( + "Fonts: {} + {} {:.1} pt", + ui_fonts::font_choice_label( + &self.font_settings.primary_choice, + &self.font_candidates + ), + ui_fonts::font_choice_label( + &self.font_settings.fallback_choice, + &self.font_candidates + ), + self.font_settings.ui_font_size + )); + if ui.button("UI Settings").clicked() { + self.font_settings_open = true; + } + }); + }); + } + + fn render_font_settings_window(&mut self, ctx: &egui::Context) { + if !self.font_settings_open { + return; + } + + let mut open = self.font_settings_open; + let mut changed = false; + egui::Window::new("UI Settings") + .open(&mut open) + .default_width(520.0) + .resizable(true) + .show(ctx, |ui| { + ui.heading("Fonts"); + ui.horizontal(|ui| { + ui.label("Primary:"); + ui.monospace(ui_fonts::font_choice_label( + &self.font_settings.primary_choice, + &self.font_candidates, + )); + ui.label("Fallback:"); + ui.monospace(ui_fonts::font_choice_label( + &self.font_settings.fallback_choice, + &self.font_candidates, + )); + if ui.button("Refresh").clicked() { + self.font_candidates = ui_fonts::discover_font_candidates(); + } + }); + ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph."); + ui.add_space(6.0); + render_font_selector( + ui, + "Primary font", + "primary_font_choice", + &mut self.font_settings.primary_choice, + &mut self.primary_font_search, + &self.font_candidates, + true, + true, + 180.0, + &mut changed, + ); + ui.separator(); + render_font_selector( + ui, + "Fallback font", + "fallback_font_choice", + &mut self.font_settings.fallback_choice, + &mut self.fallback_font_search, + &self.font_candidates, + true, + true, + 140.0, + &mut changed, + ); + ui.separator(); + ui.heading("Sizes"); + ui.label("UI font size"); + changed |= ui + .add( + egui::Slider::new(&mut self.font_settings.ui_font_size, 10.0..=28.0) + .suffix(" pt"), + ) + .changed(); + ui.label("Monospace font size"); + changed |= ui + .add( + egui::Slider::new( + &mut self.font_settings.monospace_font_size, + 10.0..=28.0, + ) + .suffix(" pt"), + ) + .changed(); + ui.label("Heading size"); + changed |= ui + .add( + egui::Slider::new(&mut self.font_settings.heading_font_size, 14.0..=40.0) + .suffix(" pt"), + ) + .changed(); + ui.separator(); + ui.heading("Preview"); + ui.label("The quick brown fox jumps over the lazy dog."); + ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。"); + ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef"); + }); + + if changed { + ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates); + } + self.font_settings_open = open; + } +} + +fn render_font_selector( + ui: &mut egui::Ui, + title: &str, + id_prefix: &str, + choice: &mut FontChoice, + search: &mut String, + candidates: &[FontCandidate], + allow_auto: bool, + allow_default: bool, + max_height: f32, + changed: &mut bool, +) { + ui.label(title); + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(search); + }); + ui.horizontal_wrapped(|ui| { + if allow_auto { + *changed |= ui + .selectable_value(choice, FontChoice::Auto, "Auto") + .changed(); + } + if allow_default { + *changed |= ui + .selectable_value(choice, FontChoice::Default, "Default") + .changed(); + } + }); + ui.add_space(4.0); + egui::ScrollArea::vertical() + .id_salt(format!("{id_prefix}_scroll")) + .max_height(max_height) + .show(ui, |ui| { + let needle = search.trim().to_lowercase(); + for candidate in candidates.iter().filter(|candidate| { + needle.is_empty() + || candidate.label.to_lowercase().contains(&needle) + || candidate.family.to_lowercase().contains(&needle) + || candidate.style.to_lowercase().contains(&needle) + || candidate.path.to_lowercase().contains(&needle) + }) { + let label = if candidate.likely_cjk { + format!("{} [CJK]", candidate.label) + } else { + candidate.label.clone() + }; + let response = ui.selectable_value(choice, FontChoice::System(candidate.id.clone()), label); + *changed |= response.changed(); + response.on_hover_text(&candidate.path); + } + }); } impl eframe::App for XserialApp { @@ -381,6 +569,8 @@ impl eframe::App for XserialApp { if self.wants_live_plot_repaint() { ui.ctx().request_repaint_after(Duration::from_millis(16)); } + self.render_top_bar(ui); + self.render_font_settings_window(ui.ctx()); self.render_config_window(ui.ctx()); self.render_sidebar(ui); self.render_main_panel(ui); diff --git a/crates/xserial-gui/src/main.rs b/crates/xserial-gui/src/main.rs index 46147d2..fad8ec9 100644 --- a/crates/xserial-gui/src/main.rs +++ b/crates/xserial-gui/src/main.rs @@ -1,6 +1,7 @@ mod app; mod buffers; mod panels; +mod ui_fonts; use xserial_client::SessionManager; diff --git a/crates/xserial-gui/src/panels/config.rs b/crates/xserial-gui/src/panels/config.rs index 4960078..63ab570 100644 --- a/crates/xserial-gui/src/panels/config.rs +++ b/crates/xserial-gui/src/panels/config.rs @@ -1,6 +1,7 @@ use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; use xserial_core::protocol::plot::PlotFormat; +use xserial_core::protocol::text::TextEncoding; use xserial_core::transport::TransportConfig; use xserial_core::transport::serial::{ SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, @@ -30,6 +31,7 @@ pub struct PipelineForm { pub name: String, pub framer: FramerChoice, pub decoder: DecoderChoice, + pub text_encoding: TextEncoding, pub plot_channels: usize, pub plot_format: PlotFormat, } @@ -73,6 +75,7 @@ impl Default for ConfigForm { name: "text".into(), framer: FramerChoice::Line, decoder: DecoderChoice::Text, + text_encoding: TextEncoding::Utf8, plot_channels: 1, plot_format: PlotFormat::Interleaved, }], @@ -148,6 +151,11 @@ impl ConfigForm { DecoderConfig::Plot { channels, .. } => *channels, _ => 1, }; + let text_encoding = match &pipeline.decoder { + DecoderConfig::Text { encoding } => *encoding, + DecoderConfig::MixedTextPlot { encoding } => *encoding, + _ => TextEncoding::Utf8, + }; let plot_format = match &pipeline.decoder { DecoderConfig::Plot { format, .. } => *format, _ => PlotFormat::Interleaved, @@ -156,6 +164,7 @@ impl ConfigForm { name: pipeline.name.clone(), framer, decoder, + text_encoding, plot_channels, plot_format, } @@ -209,7 +218,7 @@ impl ConfigForm { }, decoder: match &pipeline.decoder { DecoderChoice::Text => DecoderConfig::Text { - encoding: xserial_core::protocol::text::TextEncoding::Utf8, + encoding: pipeline.text_encoding, }, DecoderChoice::Hex => DecoderConfig::Hex { uppercase: false, @@ -224,7 +233,7 @@ impl ConfigForm { format: pipeline.plot_format, }, DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot { - encoding: xserial_core::protocol::text::TextEncoding::Utf8, + encoding: pipeline.text_encoding, }, }, }) @@ -529,6 +538,21 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< } } + if matches!(pipeline.decoder, DecoderChoice::Text) { + ui.horizontal(|ui| { + ui.label("Text encoding:"); + render_text_encoding_combo(ui, &mut pipeline.text_encoding, i); + }); + } + + if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot) { + ui.horizontal(|ui| { + ui.label("Mixed text encoding:"); + render_text_encoding_combo(ui, &mut pipeline.text_encoding, i); + }); + ui.small("Plot sample type, endian, format, and channels come from the mixed packet header."); + } + if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot) && !matches!(pipeline.framer, FramerChoice::MixedTextPlot) { @@ -548,6 +572,7 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< name: format!("p{}", form.pipelines.len()), framer: FramerChoice::Line, decoder: DecoderChoice::Text, + text_encoding: TextEncoding::Utf8, plot_channels: 1, plot_format: PlotFormat::Interleaved, }); @@ -573,3 +598,17 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option< result } + +fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, index: usize) { + ComboBox::from_id_salt(format!("text_encoding{}", index)) + .selected_text(match encoding { + TextEncoding::Utf8 => "UTF-8", + TextEncoding::Latin1 => "Latin1", + TextEncoding::Ascii => "ASCII", + }) + .show_ui(ui, |ui| { + ui.selectable_value(encoding, TextEncoding::Utf8, "UTF-8"); + ui.selectable_value(encoding, TextEncoding::Latin1, "Latin1"); + ui.selectable_value(encoding, TextEncoding::Ascii, "ASCII"); + }); +} diff --git a/crates/xserial-gui/src/ui_fonts.rs b/crates/xserial-gui/src/ui_fonts.rs new file mode 100644 index 0000000..d80ca4c --- /dev/null +++ b/crates/xserial-gui/src/ui_fonts.rs @@ -0,0 +1,300 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle}; +use tracing::{info, warn}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FontChoice { + Auto, + Default, + System(String), +} + +#[derive(Clone, Debug)] +pub struct FontCandidate { + pub id: String, + pub label: String, + pub family: String, + pub style: String, + pub path: String, + pub likely_cjk: bool, +} + +#[derive(Clone, Debug)] +pub struct UiFontSettings { + pub primary_choice: FontChoice, + pub fallback_choice: FontChoice, + pub ui_font_size: f32, + pub monospace_font_size: f32, + pub heading_font_size: f32, +} + +impl Default for UiFontSettings { + fn default() -> Self { + Self { + primary_choice: FontChoice::Auto, + fallback_choice: FontChoice::Default, + ui_font_size: 14.0, + monospace_font_size: 14.0, + heading_font_size: 20.0, + } + } +} + +pub fn discover_font_candidates() -> Vec { + discover_via_fc_list().unwrap_or_else(discover_via_fallback_paths) +} + +pub fn apply_font_settings( + ctx: &egui::Context, + settings: &UiFontSettings, + candidates: &[FontCandidate], +) { + let mut fonts = FontDefinitions::default(); + let mut inserted = Vec::new(); + + for choice in [&settings.primary_choice, &settings.fallback_choice] { + if let Some((font_name, font_bytes)) = load_selected_font(choice, candidates, &inserted) { + fonts + .font_data + .insert(font_name.clone(), FontData::from_owned(font_bytes).into()); + inserted.push(font_name); + } + } + + if !inserted.is_empty() { + if let Some(family) = fonts.families.get_mut(&FontFamily::Proportional) { + for font_name in inserted.iter().rev() { + family.insert(0, font_name.clone()); + } + } + if let Some(family) = fonts.families.get_mut(&FontFamily::Monospace) { + for font_name in inserted.iter().rev() { + family.insert(0, font_name.clone()); + } + } + info!(fonts = ?inserted, "Applied egui font chain"); + } else { + if settings.primary_choice != FontChoice::Default + || settings.fallback_choice != FontChoice::Default + { + warn!("No configured fonts could be loaded; falling back to egui default fonts"); + } + } + + ctx.set_fonts(fonts); + + let mut style = (*ctx.global_style()).clone(); + apply_font_size(&mut style, settings); + ctx.set_global_style(style); +} + +pub fn font_choice_label(choice: &FontChoice, candidates: &[FontCandidate]) -> String { + match choice { + FontChoice::Auto => String::from("Auto"), + FontChoice::Default => String::from("Default"), + FontChoice::System(id) => candidates + .iter() + .find(|candidate| candidate.id == *id) + .map(|candidate| candidate.label.clone()) + .unwrap_or_else(|| id.clone()), + } +} + +fn load_selected_font( + choice: &FontChoice, + candidates: &[FontCandidate], + already_loaded: &[String], +) -> Option<(String, Vec)> { + match choice { + FontChoice::Default => None, + 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) { + return None; + } + match fs::read(&candidate.path) { + Ok(bytes) => Some((candidate.id.clone(), bytes)), + Err(err) => { + warn!(path = %candidate.path, error = %err, "Failed to load selected font"); + None + } + } + } + } +} + +fn load_auto_font( + candidates: &[FontCandidate], + already_loaded: &[String], +) -> Option<(String, Vec)> { + for candidate in candidates.iter().filter(|candidate| candidate.likely_cjk) { + if already_loaded.iter().any(|loaded| loaded == &candidate.id) { + continue; + } + match fs::read(&candidate.path) { + Ok(bytes) => return Some((candidate.id.clone(), bytes)), + Err(err) => { + warn!(path = %candidate.path, error = %err, "Failed to load preferred CJK font"); + } + } + } + for candidate in candidates { + if already_loaded.iter().any(|loaded| loaded == &candidate.id) { + continue; + } + match fs::read(&candidate.path) { + Ok(bytes) => return Some((candidate.id.clone(), bytes)), + Err(err) => { + warn!(path = %candidate.path, error = %err, "Failed to load candidate font"); + } + } + } + None +} + +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::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)); +} + +fn discover_via_fc_list() -> Option> { + let output = Command::new("fc-list") + .args([":", "file", "family", "style"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let mut candidates = Vec::new(); + + for line in stdout.lines() { + let (path, rest) = line.split_once(": ")?; + let (family_part, style_part) = rest.split_once(":style=").unwrap_or((rest, "")); + let family = family_part + .split(',') + .find(|name| !name.trim().is_empty()) + .unwrap_or(family_part) + .trim() + .to_owned(); + let style = style_part + .split(',') + .find(|name| !name.trim().is_empty()) + .unwrap_or(style_part) + .trim() + .to_owned(); + let path = path.trim(); + if !Path::new(path).is_file() { + continue; + } + + let stem = Path::new(path) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("font"); + let id = sanitize_id(&format!("{family}_{style}_{stem}")); + let label = if style.is_empty() || style == "Regular" { + family.clone() + } else { + format!("{family} ({style})") + }; + candidates.push(FontCandidate { + id, + label, + family: family.clone(), + style: style.clone(), + path: path.to_owned(), + likely_cjk: is_likely_cjk(&family, &style, path), + }); + } + + 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 + .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(), + family: stem.clone(), + style: String::from("Regular"), + path: path.to_owned(), + likely_cjk: true, + } + }) + }) + .collect() +} + +fn sanitize_id(input: &str) -> String { + input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} + +fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool { + let haystack = format!("{family} {style} {path}").to_lowercase(); + [ + "cjk", + "noto sans sc", + "noto sans tc", + "noto sans jp", + "noto sans kr", + "source han", + "sarasa", + "wenquanyi", + "fallback", + "han", + "hei", + "kai", + "song", + "gothic", + "mincho", + ] + .iter() + .any(|needle| haystack.contains(needle)) +} diff --git a/tools/test_plot.py b/tools/test_plot.py index d33b58c..c8951da 100755 --- a/tools/test_plot.py +++ b/tools/test_plot.py @@ -241,8 +241,8 @@ def main(): for index, value in enumerate(values) ) line = ( - f"t={now - started_at:7.3f}s " - f"sample={sample_index:7d} {joined}\n" + f"时间={now - started_at:7.3f}s " + f"样本={sample_index:7d} {joined}\n" ) conn.sendall(line.encode("utf-8")) text_count += 1