Persist GUI font settings

This commit is contained in:
2026-06-09 17:59:35 +08:00
parent b499db5d35
commit 594637ec50
4 changed files with 229 additions and 39 deletions

2
Cargo.lock generated
View File

@@ -5789,6 +5789,8 @@ dependencies = [
"egui",
"egui_plot",
"hex",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",

View File

@@ -16,4 +16,6 @@ eframe = { workspace = true }
egui_plot = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
hex = "0.4"

View File

@@ -75,6 +75,10 @@ pub struct XserialApp {
font_settings: UiFontSettings,
primary_font_search: String,
fallback_font_search: String,
primary_filtered_fonts: Vec<usize>,
fallback_filtered_fonts: Vec<usize>,
primary_filter_cache_key: String,
fallback_filter_cache_key: String,
config_open: bool,
config_target: Option<u64>,
config_form: config::ConfigForm,
@@ -106,7 +110,7 @@ impl XserialApp {
});
let font_candidates = ui_fonts::discover_font_candidates();
let font_settings = UiFontSettings::default();
let font_settings = ui_fonts::load_font_settings();
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
Self {
@@ -123,6 +127,10 @@ impl XserialApp {
font_settings,
primary_font_search: String::new(),
fallback_font_search: String::new(),
primary_filtered_fonts: Vec::new(),
fallback_filtered_fonts: Vec::new(),
primary_filter_cache_key: String::from("\0"),
fallback_filter_cache_key: String::from("\0"),
config_open: false,
config_target: None,
config_form: config::ConfigForm::default(),
@@ -393,18 +401,18 @@ impl XserialApp {
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
));
// 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;
}
@@ -438,6 +446,7 @@ impl XserialApp {
));
if ui.button("Refresh").clicked() {
self.font_candidates = ui_fonts::discover_font_candidates();
self.invalidate_font_filters();
}
});
ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph.");
@@ -449,6 +458,8 @@ impl XserialApp {
&mut self.font_settings.primary_choice,
&mut self.primary_font_search,
&self.font_candidates,
&mut self.primary_filtered_fonts,
&mut self.primary_filter_cache_key,
true,
true,
180.0,
@@ -462,6 +473,8 @@ impl XserialApp {
&mut self.font_settings.fallback_choice,
&mut self.fallback_font_search,
&self.font_candidates,
&mut self.fallback_filtered_fonts,
&mut self.fallback_filter_cache_key,
true,
true,
140.0,
@@ -502,9 +515,17 @@ impl XserialApp {
if changed {
ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates);
ui_fonts::save_font_settings(&self.font_settings);
}
self.font_settings_open = open;
}
fn invalidate_font_filters(&mut self) {
self.primary_filtered_fonts.clear();
self.fallback_filtered_fonts.clear();
self.primary_filter_cache_key = String::from("\0");
self.fallback_filter_cache_key = String::from("\0");
}
}
fn render_font_selector(
@@ -514,6 +535,8 @@ fn render_font_selector(
choice: &mut FontChoice,
search: &mut String,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
allow_auto: bool,
allow_default: bool,
max_height: f32,
@@ -537,30 +560,54 @@ fn render_font_selector(
}
});
ui.add_space(4.0);
refresh_font_filter(search, candidates, filtered, cache_key);
ui.small(format!("{} fonts", filtered.len()));
let row_height = ui.spacing().interact_size.y;
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);
.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)) {
let response = ui.selectable_value(
choice,
FontChoice::System(candidate.id.clone()),
candidate.display_label.as_str(),
);
*changed |= response.changed();
response.on_hover_text(&candidate.path);
}
}
});
}
fn refresh_font_filter(
search: &str,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
) {
let needle = search.trim().to_lowercase();
if *cache_key == needle {
return;
}
filtered.clear();
if needle.is_empty() {
filtered.extend(0..candidates.len());
} else {
filtered.extend(
candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.search_key.contains(&needle))
.map(|(index, _)| index),
);
}
*cache_key = needle;
}
impl eframe::App for XserialApp {
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}

View File

@@ -1,33 +1,49 @@
use std::env;
use std::fs;
use std::path::Path;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
#[derive(Clone, Debug, PartialEq, Eq)]
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FontChoice {
Auto,
Default,
System(String),
}
impl Default for FontChoice {
fn default() -> Self {
Self::Default
}
}
#[derive(Clone, Debug)]
pub struct FontCandidate {
pub id: String,
pub label: String,
pub family: String,
pub style: String,
pub display_label: String,
pub path: String,
pub likely_cjk: bool,
pub search_key: String,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiFontSettings {
#[serde(default = "default_primary_choice")]
pub primary_choice: FontChoice,
#[serde(default = "default_fallback_choice")]
pub fallback_choice: FontChoice,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default = "default_monospace_font_size")]
pub monospace_font_size: f32,
#[serde(default = "default_heading_font_size")]
pub heading_font_size: f32,
}
@@ -47,6 +63,23 @@ pub fn discover_font_candidates() -> Vec<FontCandidate> {
discover_via_fc_list().unwrap_or_else(discover_via_fallback_paths)
}
pub fn load_font_settings() -> UiFontSettings {
match load_font_settings_from_path(&font_settings_path()) {
Ok(settings) => settings,
Err(err) if err.kind() == io::ErrorKind::NotFound => UiFontSettings::default(),
Err(err) => {
warn!(error = %err, "Failed to load persisted font settings");
UiFontSettings::default()
}
}
}
pub fn save_font_settings(settings: &UiFontSettings) {
if let Err(err) = save_font_settings_to_path(settings, &font_settings_path()) {
warn!(error = %err, "Failed to persist font settings");
}
}
pub fn apply_font_settings(
ctx: &egui::Context,
settings: &UiFontSettings,
@@ -222,10 +255,10 @@ fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
candidates.push(FontCandidate {
id,
label,
family: family.clone(),
style: style.clone(),
display_label: font_display_label(&family, &style, path),
path: path.to_owned(),
likely_cjk: is_likely_cjk(&family, &style, path),
search_key: build_search_key(&family, &style, path),
});
}
@@ -253,16 +286,88 @@ fn discover_via_fallback_paths() -> Vec<FontCandidate> {
FontCandidate {
id: sanitize_id(&stem),
label: stem.clone(),
family: stem.clone(),
style: String::from("Regular"),
display_label: format!("{stem} [CJK]"),
path: path.to_owned(),
likely_cjk: true,
search_key: build_search_key(&stem, "Regular", path),
}
})
})
.collect()
}
fn font_display_label(family: &str, style: &str, path: &str) -> String {
let likely_cjk = is_likely_cjk(family, style, path);
let base = if style.is_empty() || style == "Regular" {
family.to_owned()
} else {
format!("{family} ({style})")
};
if likely_cjk {
format!("{base} [CJK]")
} else {
base
}
}
fn build_search_key(family: &str, style: &str, path: &str) -> String {
format!("{family}\n{style}\n{path}").to_lowercase()
}
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)
}
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
let text = fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
fn save_font_settings_to_path(settings: &UiFontSettings, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(settings).map_err(io::Error::other)?;
fs::write(path, json)
}
const fn default_ui_font_size() -> f32 {
14.0
}
const fn default_monospace_font_size() -> f32 {
14.0
}
const fn default_heading_font_size() -> f32 {
20.0
}
fn default_primary_choice() -> FontChoice {
FontChoice::Auto
}
fn default_fallback_choice() -> FontChoice {
FontChoice::Default
}
fn sanitize_id(input: &str) -> String {
input
.chars()
@@ -298,3 +403,37 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
.iter()
.any(|needle| haystack.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn font_settings_roundtrip() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = env::temp_dir().join(format!("xserial-gui-font-settings-{unique}"));
let path = dir.join("gui-fonts.json");
let settings = UiFontSettings {
primary_choice: FontChoice::System(String::from("primary")),
fallback_choice: FontChoice::System(String::from("fallback")),
ui_font_size: 15.5,
monospace_font_size: 13.0,
heading_font_size: 22.0,
};
save_font_settings_to_path(&settings, &path).unwrap();
let loaded = load_font_settings_from_path(&path).unwrap();
assert_eq!(loaded.primary_choice, settings.primary_choice);
assert_eq!(loaded.fallback_choice, settings.fallback_choice);
assert_eq!(loaded.ui_font_size, settings.ui_font_size);
assert_eq!(loaded.monospace_font_size, settings.monospace_font_size);
assert_eq!(loaded.heading_font_size, settings.heading_font_size);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
}