Persist GUI font settings
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -5789,6 +5789,8 @@ dependencies = [
|
|||||||
"egui",
|
"egui",
|
||||||
"egui_plot",
|
"egui_plot",
|
||||||
"hex",
|
"hex",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ eframe = { workspace = true }
|
|||||||
egui_plot = { workspace = true }
|
egui_plot = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ pub struct XserialApp {
|
|||||||
font_settings: UiFontSettings,
|
font_settings: UiFontSettings,
|
||||||
primary_font_search: String,
|
primary_font_search: String,
|
||||||
fallback_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_open: bool,
|
||||||
config_target: Option<u64>,
|
config_target: Option<u64>,
|
||||||
config_form: config::ConfigForm,
|
config_form: config::ConfigForm,
|
||||||
@@ -106,7 +110,7 @@ impl XserialApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let font_candidates = ui_fonts::discover_font_candidates();
|
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);
|
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -123,6 +127,10 @@ impl XserialApp {
|
|||||||
font_settings,
|
font_settings,
|
||||||
primary_font_search: String::new(),
|
primary_font_search: String::new(),
|
||||||
fallback_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_open: false,
|
||||||
config_target: None,
|
config_target: None,
|
||||||
config_form: config::ConfigForm::default(),
|
config_form: config::ConfigForm::default(),
|
||||||
@@ -393,18 +401,18 @@ impl XserialApp {
|
|||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.heading("xserial");
|
ui.heading("xserial");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(format!(
|
// ui.label(format!(
|
||||||
"Fonts: {} + {} {:.1} pt",
|
// "Fonts: {} + {} {:.1} pt",
|
||||||
ui_fonts::font_choice_label(
|
// ui_fonts::font_choice_label(
|
||||||
&self.font_settings.primary_choice,
|
// &self.font_settings.primary_choice,
|
||||||
&self.font_candidates
|
// &self.font_candidates
|
||||||
),
|
// ),
|
||||||
ui_fonts::font_choice_label(
|
// ui_fonts::font_choice_label(
|
||||||
&self.font_settings.fallback_choice,
|
// &self.font_settings.fallback_choice,
|
||||||
&self.font_candidates
|
// &self.font_candidates
|
||||||
),
|
// ),
|
||||||
self.font_settings.ui_font_size
|
// self.font_settings.ui_font_size
|
||||||
));
|
// ));
|
||||||
if ui.button("UI Settings").clicked() {
|
if ui.button("UI Settings").clicked() {
|
||||||
self.font_settings_open = true;
|
self.font_settings_open = true;
|
||||||
}
|
}
|
||||||
@@ -438,6 +446,7 @@ impl XserialApp {
|
|||||||
));
|
));
|
||||||
if ui.button("Refresh").clicked() {
|
if ui.button("Refresh").clicked() {
|
||||||
self.font_candidates = ui_fonts::discover_font_candidates();
|
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.");
|
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.font_settings.primary_choice,
|
||||||
&mut self.primary_font_search,
|
&mut self.primary_font_search,
|
||||||
&self.font_candidates,
|
&self.font_candidates,
|
||||||
|
&mut self.primary_filtered_fonts,
|
||||||
|
&mut self.primary_filter_cache_key,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
180.0,
|
180.0,
|
||||||
@@ -462,6 +473,8 @@ impl XserialApp {
|
|||||||
&mut self.font_settings.fallback_choice,
|
&mut self.font_settings.fallback_choice,
|
||||||
&mut self.fallback_font_search,
|
&mut self.fallback_font_search,
|
||||||
&self.font_candidates,
|
&self.font_candidates,
|
||||||
|
&mut self.fallback_filtered_fonts,
|
||||||
|
&mut self.fallback_filter_cache_key,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
140.0,
|
140.0,
|
||||||
@@ -502,9 +515,17 @@ impl XserialApp {
|
|||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates);
|
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;
|
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(
|
fn render_font_selector(
|
||||||
@@ -514,6 +535,8 @@ fn render_font_selector(
|
|||||||
choice: &mut FontChoice,
|
choice: &mut FontChoice,
|
||||||
search: &mut String,
|
search: &mut String,
|
||||||
candidates: &[FontCandidate],
|
candidates: &[FontCandidate],
|
||||||
|
filtered: &mut Vec<usize>,
|
||||||
|
cache_key: &mut String,
|
||||||
allow_auto: bool,
|
allow_auto: bool,
|
||||||
allow_default: bool,
|
allow_default: bool,
|
||||||
max_height: f32,
|
max_height: f32,
|
||||||
@@ -537,30 +560,54 @@ fn render_font_selector(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
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()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt(format!("{id_prefix}_scroll"))
|
.id_salt(format!("{id_prefix}_scroll"))
|
||||||
.max_height(max_height)
|
.max_height(max_height)
|
||||||
.show(ui, |ui| {
|
.auto_shrink([false, false])
|
||||||
let needle = search.trim().to_lowercase();
|
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
|
||||||
for candidate in candidates.iter().filter(|candidate| {
|
for row in row_range {
|
||||||
needle.is_empty()
|
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index)) {
|
||||||
|| candidate.label.to_lowercase().contains(&needle)
|
let response = ui.selectable_value(
|
||||||
|| candidate.family.to_lowercase().contains(&needle)
|
choice,
|
||||||
|| candidate.style.to_lowercase().contains(&needle)
|
FontChoice::System(candidate.id.clone()),
|
||||||
|| candidate.path.to_lowercase().contains(&needle)
|
candidate.display_label.as_str(),
|
||||||
}) {
|
);
|
||||||
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();
|
*changed |= response.changed();
|
||||||
response.on_hover_text(&candidate.path);
|
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 {
|
impl eframe::App for XserialApp {
|
||||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,49 @@
|
|||||||
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle};
|
use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{info, warn};
|
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 {
|
pub enum FontChoice {
|
||||||
Auto,
|
Auto,
|
||||||
Default,
|
Default,
|
||||||
System(String),
|
System(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for FontChoice {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FontCandidate {
|
pub struct FontCandidate {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub family: String,
|
pub display_label: String,
|
||||||
pub style: String,
|
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub likely_cjk: bool,
|
pub likely_cjk: bool,
|
||||||
|
pub search_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct UiFontSettings {
|
pub struct UiFontSettings {
|
||||||
|
#[serde(default = "default_primary_choice")]
|
||||||
pub primary_choice: FontChoice,
|
pub primary_choice: FontChoice,
|
||||||
|
#[serde(default = "default_fallback_choice")]
|
||||||
pub fallback_choice: FontChoice,
|
pub fallback_choice: FontChoice,
|
||||||
|
#[serde(default = "default_ui_font_size")]
|
||||||
pub ui_font_size: f32,
|
pub ui_font_size: f32,
|
||||||
|
#[serde(default = "default_monospace_font_size")]
|
||||||
pub monospace_font_size: f32,
|
pub monospace_font_size: f32,
|
||||||
|
#[serde(default = "default_heading_font_size")]
|
||||||
pub heading_font_size: f32,
|
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)
|
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(
|
pub fn apply_font_settings(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
settings: &UiFontSettings,
|
settings: &UiFontSettings,
|
||||||
@@ -222,10 +255,10 @@ fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
|
|||||||
candidates.push(FontCandidate {
|
candidates.push(FontCandidate {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
family: family.clone(),
|
display_label: font_display_label(&family, &style, path),
|
||||||
style: style.clone(),
|
|
||||||
path: path.to_owned(),
|
path: path.to_owned(),
|
||||||
likely_cjk: is_likely_cjk(&family, &style, path),
|
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 {
|
FontCandidate {
|
||||||
id: sanitize_id(&stem),
|
id: sanitize_id(&stem),
|
||||||
label: stem.clone(),
|
label: stem.clone(),
|
||||||
family: stem.clone(),
|
display_label: format!("{stem} [CJK]"),
|
||||||
style: String::from("Regular"),
|
|
||||||
path: path.to_owned(),
|
path: path.to_owned(),
|
||||||
likely_cjk: true,
|
likely_cjk: true,
|
||||||
|
search_key: build_search_key(&stem, "Regular", path),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.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 {
|
fn sanitize_id(input: &str) -> String {
|
||||||
input
|
input
|
||||||
.chars()
|
.chars()
|
||||||
@@ -298,3 +403,37 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|needle| haystack.contains(needle))
|
.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