Add GUI font configuration controls
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod app;
|
||||
mod buffers;
|
||||
mod panels;
|
||||
mod ui_fonts;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
300
crates/xserial-gui/src/ui_fonts.rs
Normal file
300
crates/xserial-gui/src/ui_fonts.rs
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user