diff --git a/Cargo.lock b/Cargo.lock index 6845578..80104a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5789,6 +5789,8 @@ dependencies = [ "egui", "egui_plot", "hex", + "serde", + "serde_json", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/xserial-gui/Cargo.toml b/crates/xserial-gui/Cargo.toml index 07e8058..1eedc2d 100644 --- a/crates/xserial-gui/Cargo.toml +++ b/crates/xserial-gui/Cargo.toml @@ -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" diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 269c2a1..08f4382 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -75,6 +75,10 @@ pub struct XserialApp { font_settings: UiFontSettings, primary_font_search: String, fallback_font_search: String, + primary_filtered_fonts: Vec, + fallback_filtered_fonts: Vec, + primary_filter_cache_key: String, + fallback_filter_cache_key: String, config_open: bool, config_target: Option, 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, + 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, + 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) {} diff --git a/crates/xserial-gui/src/ui_fonts.rs b/crates/xserial-gui/src/ui_fonts.rs index d80ca4c..ff3e0ea 100644 --- a/crates/xserial-gui/src/ui_fonts.rs +++ b/crates/xserial-gui/src/ui_fonts.rs @@ -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 { 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> { 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 { 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 { + 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); + } +}