Add GUI font configuration controls

This commit is contained in:
2026-06-09 15:55:45 +08:00
parent d5b6bb52c8
commit b499db5d35
5 changed files with 535 additions and 5 deletions

View File

@@ -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<SessionTab>,
active: usize,
display: DisplayOptions,
font_settings_open: bool,
font_candidates: Vec<FontCandidate>,
font_settings: UiFontSettings,
primary_font_search: String,
fallback_font_search: String,
config_open: bool,
config_target: Option<u64>,
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);

View File

@@ -1,6 +1,7 @@
mod app;
mod buffers;
mod panels;
mod ui_fonts;
use xserial_client::SessionManager;

View File

@@ -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");
});
}

View File

@@ -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<FontCandidate> {
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<u8>)> {
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<u8>)> {
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<Vec<FontCandidate>> {
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<FontCandidate> {
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))
}

View File

@@ -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