Persist GUI font settings
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -5789,6 +5789,8 @@ dependencies = [
|
||||
"egui",
|
||||
"egui_plot",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user