system settings
git clone https://git.lucas.co/cce-system-interface.git
feat: Add preferred typeface settings page and adjust notification layout
src/app.rs | 4 +
src/main.rs | 142 +++++++++++---
src/pages/audio.rs | 4 +-
src/pages/display.rs | 3 +-
src/pages/input.rs | 11 +-
src/pages/mod.rs | 6 +-
src/pages/notifications.rs | 3 +-
src/pages/typeface.rs | 458 +++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 600 insertions(+), 31 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 326424b..5e86034 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -12,6 +12,7 @@ use crate::pages::status;
use crate::pages::storage;
use crate::pages::system_info;
use crate::pages::backup;
+use crate::pages::typeface;
use crate::pages::Page;
pub struct AppState {
@@ -28,6 +29,7 @@ pub struct AppState {
pub storage: storage::StorageState,
pub notifications: notifications::NotificationsState,
pub backup: backup::BackupState,
+ pub typeface: typeface::TypefaceState,
}
impl Default for AppState {
@@ -46,6 +48,7 @@ impl Default for AppState {
storage: storage::StorageState::default(),
notifications: notifications::read_notifications_config(),
backup: backup::BackupState::default(),
+ typeface: typeface::TypefaceState::default(),
}
}
}
@@ -64,6 +67,7 @@ pub enum AppAction {
Storage(storage::StorageMessage),
Notifications(notifications::NotificationsMessage),
Backup(backup::BackupMessage),
+ Typeface(typeface::TypefaceMessage),
}
pub struct PageContent {
diff --git a/src/main.rs b/src/main.rs
index f4106db..885caa8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,8 @@ use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes};
+#[cfg(target_os = "linux")]
+use winit::platform::wayland::WindowAttributesExtWayland;
use clear_ui::color;
use clear_ui::widget::{Spinbox, Widget};
@@ -127,6 +129,7 @@ struct SystemInterface {
rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsState>,
rx_backup_state: std::sync::mpsc::Receiver<pages::backup::BackupState>,
+ rx_typeface: std::sync::mpsc::Receiver<pages::typeface::TypefaceState>,
tx_backup: std::sync::mpsc::Sender<pages::backup::BackupMessage>,
rx_backup: std::sync::mpsc::Receiver<pages::backup::BackupMessage>,
tx_color_selector: std::sync::mpsc::Sender<ColorSelectorAction>,
@@ -287,6 +290,7 @@ impl SystemInterface {
rx
};
let rx_backup_state = spawn_bg(30, || pages::backup::fetch_backup_state());
+ let rx_typeface = spawn_bg(30, || pages::typeface::fetch_typeface_state());
let (tx_backup, rx_backup) = std::sync::mpsc::channel();
let (tx_color_selector, rx_color_selector) = std::sync::mpsc::channel();
@@ -297,12 +301,12 @@ impl SystemInterface {
app,
font_system, swash_cache, text_atlas, text_renderer, text_viewport,
widgets: Vec::new(), text_items: Vec::new(), page_buttons: Vec::new(),
- sidebar_width: 140.0, header_height: 40.0, status_height: 28.0,
+ sidebar_width: 140.0, header_height: 0.0, status_height: 28.0,
cursor_x: 0.0, cursor_y: 0.0,
scale_factor,
rx_power, rx_audio, rx_display, rx_network, rx_layout, rx_input,
rx_processors, rx_system, rx_status, rx_storage, rx_notifications,
- rx_backup_state, tx_backup, rx_backup,
+ rx_backup_state, rx_typeface, tx_backup, rx_backup,
tx_color_selector, rx_color_selector,
width: size.width, height: size.height,
needs_rebuild: true,
@@ -321,17 +325,7 @@ impl SystemInterface {
let hdr_h = self.header_height * s;
let st_h = self.status_height * s;
- // Header
- widgets.push(AppWidget {
- x: 0.0, y: 0.0, w: sw, h: hdr_h,
- color: color::HEADER_BG, hover_color: color::HEADER_BG,
- hovering: false, kind: WidgetKind::Static,
- });
- text_items.push(TextItem {
- buffer: make_text_buffer(&mut self.font_system, "Clear System Interface", 14.0 * s),
- x: 12.0 * s, y: 12.0 * s,
- color: glyphon::Color::rgb(0xcc, 0xcc, 0xd4),
- });
+
// Sidebar bg
widgets.push(AppWidget {
@@ -453,6 +447,7 @@ impl SystemInterface {
Page::Storage => storage::view(&self.app.storage, cx, cy, cw, ch),
Page::Notifications => notifications::view(&mut self.app.notifications, cx, cy, cw, ch),
Page::Backup => backup::view(&self.app.backup, cx, cy, cw, ch),
+ Page::Typeface => typeface::view(&mut self.app.typeface, cx, cy, cw, ch),
}
}
@@ -563,6 +558,10 @@ impl SystemInterface {
pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::Refreshed(s));
self.needs_rebuild = true;
}
+ while let Ok(s) = self.rx_typeface.try_recv() {
+ typeface::update(&mut self.app.typeface, typeface::TypefaceMessage::Refreshed(s));
+ self.needs_rebuild = true;
+ }
while let Ok(m) = self.rx_backup.try_recv() {
self.handle_action(&AppAction::Backup(m));
self.needs_rebuild = true;
@@ -628,6 +627,7 @@ impl SystemInterface {
AppAction::Status(m) => status::update(&mut self.app.status, m.clone()),
AppAction::Storage(m) => storage::update(&mut self.app.storage, m.clone()),
AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
+ AppAction::Typeface(m) => typeface::update(&mut self.app.typeface, m.clone()),
AppAction::Backup(m) => match m {
pages::backup::BackupMessage::StartBackup => {
pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::StartBackup);
@@ -716,6 +716,18 @@ impl SystemInterface {
changed = true;
}
}
+ if self.app.current_page == Page::Typeface {
+ let s = self.scale_factor as f32;
+ if self.app.typeface.sans_box.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+ changed = true;
+ }
+ if self.app.typeface.serif_box.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+ changed = true;
+ }
+ if self.app.typeface.mono_box.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+ changed = true;
+ }
+ }
if changed { self.needs_rebuild = true; }
changed
}
@@ -840,6 +852,42 @@ impl SystemInterface {
return true;
}
}
+ if self.app.current_page == Page::Typeface {
+ let mut actions = Vec::new();
+ let mut consumed = false;
+
+ let tb = &mut self.app.typeface.sans_box;
+ if tb.keyboard_input(event) {
+ if tb.take_change() {
+ actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetSans(tb.text.clone())));
+ }
+ consumed = true;
+ }
+
+ let tb = &mut self.app.typeface.serif_box;
+ if tb.keyboard_input(event) {
+ if tb.take_change() {
+ actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetSerif(tb.text.clone())));
+ }
+ consumed = true;
+ }
+
+ let tb = &mut self.app.typeface.mono_box;
+ if tb.keyboard_input(event) {
+ if tb.take_change() {
+ actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetMono(tb.text.clone())));
+ }
+ consumed = true;
+ }
+
+ for a in &actions {
+ self.handle_action(a);
+ }
+ if consumed {
+ self.needs_rebuild = true;
+ return true;
+ }
+ }
false
}
WindowEvent::MouseInput { state, button, .. } => {
@@ -978,6 +1026,34 @@ impl SystemInterface {
actions.push(AppAction::Display(pages::display::DisplayMessage::BrightnessSet(sb.value as u32)));
}
}
+ if *state == ElementState::Pressed && self.app.current_page == Page::Typeface {
+ let tb = &mut self.app.typeface.sans_box;
+ if !tb.hit_test(lx, ly) { tb.unfocus(); }
+ if tb.mouse_input(*button, *state, lx, ly) {
+ self.needs_rebuild = true;
+ }
+ if tb.take_change() {
+ actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetSans(tb.text.clone())));
+ }
+
+ let tb = &mut self.app.typeface.serif_box;
+ if !tb.hit_test(lx, ly) { tb.unfocus(); }
+ if tb.mouse_input(*button, *state, lx, ly) {
+ self.needs_rebuild = true;
+ }
+ if tb.take_change() {
+ actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetSerif(tb.text.clone())));
+ }
+
+ let tb = &mut self.app.typeface.mono_box;
+ if !tb.hit_test(lx, ly) { tb.unfocus(); }
+ if tb.mouse_input(*button, *state, lx, ly) {
+ self.needs_rebuild = true;
+ }
+ if tb.take_change() {
+ actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetMono(tb.text.clone())));
+ }
+ }
for a in &actions {
self.handle_action(a);
}
@@ -1050,20 +1126,29 @@ impl SystemInterface {
}
}
-struct App { state: Option<SystemInterface> }
+struct App {
+ state: Option<SystemInterface>,
+ initial_page: Page,
+}
impl App {
- fn new() -> Self { Self { state: None } }
+ fn new(initial_page: Page) -> Self { Self { state: None, initial_page } }
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() { return; }
- let window = Arc::new(event_loop.create_window(
- WindowAttributes::default()
- .with_title("Clear System Interface")
- .with_inner_size(winit::dpi::LogicalSize::new(820, 680)),
- ).unwrap());
- let state = pollster::block_on(SystemInterface::new(window));
+ let mut attributes = WindowAttributes::default()
+ .with_title("Clear System Interface")
+ .with_decorations(false)
+ .with_inner_size(winit::dpi::LogicalSize::new(820, 680));
+ #[cfg(target_os = "linux")]
+ {
+ attributes = attributes.with_name("clear-system-interface", "clear-system-interface");
+ }
+ let window = Arc::new(event_loop.create_window(attributes).unwrap());
+ let mut state = pollster::block_on(SystemInterface::new(window));
+ state.app.current_page = self.initial_page;
+ state.needs_rebuild = true;
self.state = Some(state);
self.state.as_ref().unwrap().window.request_redraw();
}
@@ -1087,5 +1172,18 @@ fn main() {
let _guard = rt.enter();
let event_loop = EventLoop::new().unwrap();
event_loop.set_control_flow(ControlFlow::Poll);
- event_loop.run_app(&mut App::new()).unwrap();
+
+ let mut initial_page = Page::ALL[0];
+ let args: Vec<String> = std::env::args().collect();
+ if args.len() > 1 {
+ let arg = args.last().unwrap().to_lowercase();
+ for page in Page::ALL {
+ if page.label().to_lowercase() == arg {
+ initial_page = page;
+ break;
+ }
+ }
+ }
+
+ event_loop.run_app(&mut App::new(initial_page)).unwrap();
}
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index bfac176..7b32a46 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,6 +1,6 @@
use crate::app::{AppAction, PageContent};
use clear_ui::layout::{render_widget, Section};
-use clear_ui::widget::Spinbox;
+use clear_ui::widget::{Spinbox, Widget};
#[derive(Debug, Clone)]
pub struct AudioSink {
@@ -250,6 +250,7 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, _ch: f32) -> Page
let gap = 8.0;
state.sink_spinboxes[idx].value = (sink.volume * 100.0).round() as i32;
+ state.sink_spinboxes[idx].set_row_rect(sec.ax(8.0), cw - 16.0);
render_widget(&mut pc, &mut state.sink_spinboxes[idx], sec.ax(bar_x), row_y, sb_w, sb_h);
let mute_label = if sink.muted { "Unmute" } else { "Mute" };
@@ -306,6 +307,7 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, _ch: f32) -> Page
let gap = 8.0;
state.source_spinboxes[idx].value = (src.volume * 100.0).round() as i32;
+ state.source_spinboxes[idx].set_row_rect(sec.ax(8.0), cw - 16.0);
render_widget(&mut pc, &mut state.source_spinboxes[idx], sec.ax(bar_x), row_y, sb_w, sb_h);
let mute_label = if src.muted { "Unmute" } else { "Mute" };
diff --git a/src/pages/display.rs b/src/pages/display.rs
index aaeac19..dc350fb 100644
--- a/src/pages/display.rs
+++ b/src/pages/display.rs
@@ -1,6 +1,6 @@
use crate::app::PageContent;
use clear_ui::layout::{render_widget, Section};
-use clear_ui::widget::Spinbox;
+use clear_ui::widget::{Spinbox, Widget};
#[derive(Debug, Clone)]
pub struct DisplayOutput {
@@ -150,6 +150,7 @@ pub fn view(state: &mut DisplayState, cx: f32, cy: f32, cw: f32, _ch: f32) -> Pa
let sb_w = 100.0;
let sb_h = 26.0;
state.brightness_spinbox.value = bright_pct;
+ state.brightness_spinbox.set_row_rect(sec.ax(8.0), cw - 16.0);
render_widget(&mut pc, &mut state.brightness_spinbox, sec.ax(12.0), yt, sb_w, sb_h);
sec.content_y += sb_h + 12.0;
}
diff --git a/src/pages/input.rs b/src/pages/input.rs
index 116c73d..b9a82fd 100644
--- a/src/pages/input.rs
+++ b/src/pages/input.rs
@@ -3,7 +3,7 @@ use std::io::Write;
use crate::app::{AppAction, PageContent};
use clear_ui::layout::{render_widget, Section};
-use clear_ui::widget::{Spinbox, Toggle};
+use clear_ui::widget::{Spinbox, Toggle, Widget};
const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
@@ -33,8 +33,8 @@ impl Default for InputState {
tap_to_click: false,
repeat_rate: 50,
repeat_delay: 300,
- rate_spinbox: Spinbox::new(50, 1, 100, 1).with_label("Repeat Rate"),
- delay_spinbox: Spinbox::new(300, 100, 2000, 10).with_label("Repeat Delay"),
+ rate_spinbox: Spinbox::new(50, 1, 100, 1).with_label("Repeat Rate").with_unit("ms"),
+ delay_spinbox: Spinbox::new(300, 100, 2000, 10).with_label("Repeat Delay").with_unit("ms"),
tap_toggle: Toggle::new().with_label("Tap to Click"),
keybinds: Vec::new(),
}
@@ -57,8 +57,8 @@ pub fn read_input_config() -> InputState {
tap_to_click: tap,
repeat_rate: rate,
repeat_delay: delay,
- rate_spinbox: Spinbox::new(rate as i32, 1, 100, 1).with_label("Repeat Rate"),
- delay_spinbox: Spinbox::new(delay as i32, 100, 2000, 10).with_label("Repeat Delay"),
+ rate_spinbox: Spinbox::new(rate as i32, 1, 100, 1).with_label("Repeat Rate").with_unit("ms"),
+ delay_spinbox: Spinbox::new(delay as i32, 100, 2000, 10).with_label("Repeat Delay").with_unit("ms"),
tap_toggle: Toggle::new().with_label("Tap to Click"),
keybinds: parse_keybinds(&content),
}
@@ -179,6 +179,7 @@ pub fn view(state: &mut InputState, cx: f32, cy: f32, cw: f32, _ch: f32) -> Page
let toggle_w = 48.0;
let toggle_h = 24.0;
state.tap_toggle.set_toggled(state.tap_to_click);
+ state.tap_toggle.set_row_rect(sec.ax(8.0), cw - 16.0);
render_widget(&mut pc, &mut state.tap_toggle, sec.ax(100.0), yt, toggle_w, toggle_h);
sec.content_y += toggle_h + 12.0;
y = sec.finish(&mut pc);
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index c6f8378..64e232d 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -11,6 +11,7 @@ pub mod status;
pub mod processors;
pub mod notifications;
pub mod backup;
+pub mod typeface;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
@@ -26,10 +27,11 @@ pub enum Page {
Status,
Notifications,
Backup,
+ Typeface,
}
impl Page {
- pub const ALL: [Page; 12] = [
+ pub const ALL: [Page; 13] = [
Page::Audio,
Page::Backup,
Page::Display,
@@ -42,6 +44,7 @@ impl Page {
Page::Status,
Page::Storage,
Page::System,
+ Page::Typeface,
];
pub fn label(self) -> &'static str {
@@ -58,6 +61,7 @@ impl Page {
Page::Status => "Status",
Page::Notifications => "Notifications",
Page::Backup => "Backup",
+ Page::Typeface => "Typeface",
}
}
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 9fe2d49..81300b2 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -3,7 +3,7 @@ use std::io::Write;
use crate::app::{AppAction, PageContent};
use clear_ui::layout::{render_widget, Section};
-use clear_ui::widget::Toggle;
+use clear_ui::widget::{Toggle, Widget};
const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
@@ -150,6 +150,7 @@ pub fn view(state: &mut NotificationsState, cx: f32, cy: f32, cw: f32, _ch: f32)
let toggle_w = 48.0;
let toggle_h = 24.0;
state.enable_toggle.set_toggled(state.enable);
+ state.enable_toggle.set_row_rect(sec.ax(8.0), cw - 16.0);
render_widget(&mut pc, &mut state.enable_toggle, sec.ax(100.0), yt, toggle_w, toggle_h);
sec.content_y += toggle_h + 24.0;
diff --git a/src/pages/typeface.rs b/src/pages/typeface.rs
new file mode 100644
index 0000000..ad6b5c6
--- /dev/null
+++ b/src/pages/typeface.rs
@@ -0,0 +1,458 @@
+use std::fs;
+use crate::app::PageContent;
+use clear_ui::layout::Section;
+use clear_ui::widget::{Widget, TextLabel};
+use winit::event::{ElementState, KeyEvent, MouseButton};
+use winit::keyboard::{Key, NamedKey};
+
+const FONTS_CONF_PATH: &str = "/home/lsgalante/.config/fontconfig/fonts.conf";
+
+// ── TextBox Widget ──
+
+#[derive(Debug, Clone)]
+pub struct TextBox {
+ x: f32, y: f32, w: f32, h: f32,
+ pub text: String,
+ pub editing: bool,
+ pub edit_buffer: String,
+ hovered: bool,
+ just_changed: bool,
+}
+
+impl TextBox {
+ pub fn new(text: String) -> Self {
+ Self {
+ x: 0.0, y: 0.0, w: 0.0, h: 0.0,
+ text,
+ editing: false,
+ edit_buffer: String::new(),
+ hovered: false,
+ just_changed: false,
+ }
+ }
+
+ pub fn take_change(&mut self) -> bool {
+ let changed = self.just_changed;
+ self.just_changed = false;
+ changed
+ }
+}
+
+impl Default for TextBox {
+ fn default() -> Self {
+ Self::new(String::new())
+ }
+}
+
+impl Widget for TextBox {
+ fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn color(&self) -> [f32; 4] {
+ [0.10, 0.10, 0.16, 1.0]
+ }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was = self.hovered;
+ self.hovered = self.hit_test(px, py);
+ was != self.hovered
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left { return false; }
+ if state != ElementState::Pressed { return false; }
+ if !self.hit_test(px, py) { return false; }
+ self.focus();
+ true
+ }
+
+ fn focus(&mut self) {
+ if !self.editing {
+ self.editing = true;
+ self.edit_buffer = self.text.clone();
+ }
+ }
+
+ fn unfocus(&mut self) {
+ if self.editing {
+ self.editing = false;
+ if self.text != self.edit_buffer {
+ self.text = self.edit_buffer.clone();
+ self.just_changed = true;
+ }
+ }
+ }
+
+ fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+ if !self.editing { return false; }
+ if event.state != ElementState::Pressed { return false; }
+ match &event.logical_key {
+ Key::Named(NamedKey::Backspace) => {
+ self.edit_buffer.pop();
+ true
+ }
+ Key::Named(NamedKey::Enter) => {
+ self.text = self.edit_buffer.clone();
+ self.editing = false;
+ self.just_changed = true;
+ true
+ }
+ Key::Named(NamedKey::Escape) => {
+ self.editing = false;
+ true
+ }
+ _ => {
+ if let Some(text) = &event.text {
+ if !event.repeat {
+ for ch in text.chars() {
+ if ch.is_alphanumeric() || ch == ' ' || ch == '-' || ch == '_' || ch == '*' {
+ self.edit_buffer.push(ch);
+ }
+ }
+ }
+ }
+ true
+ }
+ }
+ }
+
+ fn hover_highlight(&self) -> Option<[f32; 4]> {
+ if self.editing {
+ Some([1.0, 1.0, 1.0, 0.12])
+ } else if self.hovered {
+ Some([1.0, 1.0, 1.0, 0.06])
+ } else {
+ None
+ }
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let bg_color = if self.editing {
+ [0.12, 0.12, 0.18, 1.0]
+ } else {
+ [0.08, 0.08, 0.12, 1.0]
+ };
+ let border_color = if self.editing {
+ [0.30, 0.50, 0.32, 1.0]
+ } else if self.hovered {
+ [0.25, 0.25, 0.35, 1.0]
+ } else {
+ [0.18, 0.18, 0.24, 1.0]
+ };
+ vec![
+ (self.x, self.y, self.w, self.h, border_color),
+ (self.x + 1.0, self.y + 1.0, self.w - 2.0, self.h - 2.0, bg_color),
+ ]
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ let val_text = if self.editing {
+ format!("{}|", self.edit_buffer)
+ } else {
+ self.text.clone()
+ };
+ vec![TextLabel {
+ text: val_text,
+ x: self.x + 8.0,
+ y: self.y + (self.h - 12.0) / 2.0,
+ font_size: 13.0,
+ color: if self.editing { [0xee, 0xee, 0xf5] } else { [0xcc, 0xcc, 0xd4] },
+ }]
+ }
+}
+
+// ── TypefaceState and TypefaceMessage ──
+
+#[derive(Debug, Clone)]
+pub struct TypefaceState {
+ pub loaded: bool,
+ pub sans_serif: String,
+ pub serif: String,
+ pub monospace: String,
+ pub all_fonts: Vec<String>,
+ pub mono_fonts: Vec<String>,
+ pub sans_box: TextBox,
+ pub serif_box: TextBox,
+ pub mono_box: TextBox,
+}
+
+impl Default for TypefaceState {
+ fn default() -> Self {
+ Self {
+ loaded: false,
+ sans_serif: String::new(),
+ serif: String::new(),
+ monospace: String::new(),
+ all_fonts: Vec::new(),
+ mono_fonts: Vec::new(),
+ sans_box: TextBox::default(),
+ serif_box: TextBox::default(),
+ mono_box: TextBox::default(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum TypefaceMessage {
+ Refreshed(TypefaceState),
+ SetSans(String),
+ SetSerif(String),
+ SetMono(String),
+}
+
+fn parse_font_for_alias(content: &str, alias: &str) -> Option<String> {
+ let lines: Vec<&str> = content.lines().collect();
+ for i in 0..lines.len() {
+ let line = lines[i].trim();
+ if line.contains("<test") && line.contains("name=\"family\"") && line.contains(&format!("<string>{}</string>", alias)) {
+ for j in (i + 1)..(i + 6).min(lines.len()) {
+ let next_line = lines[j].trim();
+ if next_line.contains("<edit") {
+ for k in (j + 1)..(j + 6).min(lines.len()) {
+ let str_line = lines[k].trim();
+ if str_line.contains("<string>") && str_line.contains("</string>") {
+ if let Some(start) = str_line.find("<string>") {
+ if let Some(end) = str_line.find("</string>") {
+ let font = &str_line[start + 8..end];
+ return Some(font.to_string());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ None
+}
+
+pub fn read_preferred_fonts() -> (String, String, String) {
+ let content = fs::read_to_string(FONTS_CONF_PATH).unwrap_or_default();
+
+ let sans = parse_font_for_alias(&content, "sans-serif").unwrap_or_else(|| "Noto Sans".to_string());
+ let serif = parse_font_for_alias(&content, "serif").unwrap_or_else(|| "Noto Serif".to_string());
+ let mono = parse_font_for_alias(&content, "monospace").unwrap_or_else(|| "Noto Sans Mono".to_string());
+
+ (sans, serif, mono)
+}
+
+pub fn save_preferred_fonts(sans: &str, serif: &str, mono: &str) {
+ let content = fs::read_to_string(FONTS_CONF_PATH).unwrap_or_default();
+
+ let mut dirs = Vec::new();
+ for line in content.lines() {
+ let trimmed = line.trim();
+ if trimmed.starts_with("<dir>") && trimmed.ends_with("</dir>") {
+ dirs.push(trimmed.to_string());
+ }
+ }
+ if dirs.is_empty() {
+ dirs.push("<dir>~/Dropbox/Fonts</dir>".to_string());
+ }
+
+ let mut new_content = String::new();
+ new_content.push_str("<?xml version=\"1.0\"?>\n");
+ new_content.push_str("<!DOCTYPE fontconfig SYSTEM \"fonts.dtd\">\n");
+ new_content.push_str("<fontconfig>\n");
+
+ for dir in dirs {
+ new_content.push_str(&format!(" {}\n", dir));
+ }
+
+ // Sans-Serif
+ new_content.push_str(" <match target=\"pattern\">\n");
+ new_content.push_str(" <test qual=\"any\" name=\"family\"><string>sans-serif</string></test>\n");
+ new_content.push_str(" <edit name=\"family\" mode=\"assign\" binding=\"same\">\n");
+ new_content.push_str(&format!(" <string>{}</string>\n", sans));
+ new_content.push_str(" </edit>\n");
+ new_content.push_str(" </match>\n");
+
+ // Serif
+ new_content.push_str(" <match target=\"pattern\">\n");
+ new_content.push_str(" <test qual=\"any\" name=\"family\"><string>serif</string></test>\n");
+ new_content.push_str(" <edit name=\"family\" mode=\"assign\" binding=\"same\">\n");
+ new_content.push_str(&format!(" <string>{}</string>\n", serif));
+ new_content.push_str(" </edit>\n");
+ new_content.push_str(" </match>\n");
+
+ // Monospace
+ new_content.push_str(" <match target=\"pattern\">\n");
+ new_content.push_str(" <test qual=\"any\" name=\"family\"><string>monospace</string></test>\n");
+ new_content.push_str(" <edit name=\"family\" mode=\"assign\" binding=\"same\">\n");
+ new_content.push_str(&format!(" <string>{}</string>\n", mono));
+ new_content.push_str(" </edit>\n");
+ new_content.push_str(" </match>\n");
+
+ new_content.push_str("</fontconfig>\n");
+
+ if let Some(parent) = std::path::Path::new(FONTS_CONF_PATH).parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+ let _ = fs::write(FONTS_CONF_PATH, new_content);
+
+ let _ = std::process::Command::new("fc-cache")
+ .arg("-f")
+ .spawn();
+}
+
+fn parse_families(output: Option<std::process::Output>) -> Vec<String> {
+ let mut families = Vec::new();
+ if let Some(o) = output {
+ let text = String::from_utf8_lossy(&o.stdout);
+ for line in text.lines() {
+ let trimmed = line.trim();
+ if !trimmed.is_empty() {
+ let family = trimmed.split(',').next().unwrap_or(trimmed).to_string();
+ if !family.is_empty() && !families.contains(&family) {
+ families.push(family);
+ }
+ }
+ }
+ }
+ families.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
+ families
+}
+
+pub async fn fetch_typeface_state() -> TypefaceState {
+ let (sans, serif, mono) = read_preferred_fonts();
+
+ let all_output = tokio::process::Command::new("fc-list")
+ .args([":", "family"])
+ .output().await.ok();
+ let all_fonts = parse_families(all_output);
+
+ let mono_output = tokio::process::Command::new("fc-list")
+ .args([":spacing=100", "family"])
+ .output().await.ok();
+ let mono_fonts = parse_families(mono_output);
+
+ TypefaceState {
+ loaded: true,
+ sans_serif: sans.clone(),
+ serif: serif.clone(),
+ monospace: mono.clone(),
+ all_fonts,
+ mono_fonts,
+ sans_box: TextBox::new(sans),
+ serif_box: TextBox::new(serif),
+ mono_box: TextBox::new(mono),
+ }
+}
+
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+
+pub fn view(state: &mut TypefaceState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+ let mut pc = PageContent::new();
+ let mut y = cy + 12.0;
+
+ let widget_w = cw - 24.0;
+ let widget_h = 26.0;
+
+ // ── Sans-Serif ──
+ let mut sec = Section::new(&mut pc, cx, y, cw, "Sans-Serif");
+ sec.spacing(8.0);
+ if !state.loaded {
+ sec.text(&mut pc, "Loading typefaces...", 12.0, 0.0, 12.0, TEXT_DIM);
+ sec.spacing(18.0);
+ } else {
+ sec.widget(&mut pc, &mut state.sans_box, 12.0, widget_w, widget_h);
+ sec.spacing(8.0);
+ }
+ y = sec.finish(&mut pc);
+
+ // ── Serif ──
+ let mut sec = Section::new(&mut pc, cx, y, cw, "Serif");
+ sec.spacing(8.0);
+ if !state.loaded {
+ sec.text(&mut pc, "Loading typefaces...", 12.0, 0.0, 12.0, TEXT_DIM);
+ sec.spacing(18.0);
+ } else {
+ sec.widget(&mut pc, &mut state.serif_box, 12.0, widget_w, widget_h);
+ sec.spacing(8.0);
+ }
+ y = sec.finish(&mut pc);
+
+ // ── Monospace ──
+ let mut sec = Section::new(&mut pc, cx, y, cw, "Monospace");
+ sec.spacing(8.0);
+ if !state.loaded {
+ sec.text(&mut pc, "Loading typefaces...", 12.0, 0.0, 12.0, TEXT_DIM);
+ sec.spacing(18.0);
+ } else {
+ sec.widget(&mut pc, &mut state.mono_box, 12.0, widget_w, widget_h);
+ sec.spacing(8.0);
+ }
+ sec.finish(&mut pc);
+
+ pc
+}
+
+pub fn update(state: &mut TypefaceState, msg: TypefaceMessage) {
+ match msg {
+ TypefaceMessage::Refreshed(new) => {
+ state.loaded = new.loaded;
+ state.all_fonts = new.all_fonts;
+ state.mono_fonts = new.mono_fonts;
+ if !state.sans_box.editing {
+ state.sans_serif = new.sans_serif.clone();
+ state.sans_box.text = new.sans_serif;
+ }
+ if !state.serif_box.editing {
+ state.serif = new.serif.clone();
+ state.serif_box.text = new.serif;
+ }
+ if !state.mono_box.editing {
+ state.monospace = new.monospace.clone();
+ state.mono_box.text = new.monospace;
+ }
+ }
+ TypefaceMessage::SetSans(sans) => {
+ state.sans_serif = sans.clone();
+ state.sans_box.text = sans;
+ save_preferred_fonts(&state.sans_serif, &state.serif, &state.monospace);
+ }
+ TypefaceMessage::SetSerif(serif) => {
+ state.serif = serif.clone();
+ state.serif_box.text = serif;
+ save_preferred_fonts(&state.sans_serif, &state.serif, &state.monospace);
+ }
+ TypefaceMessage::SetMono(mono) => {
+ state.monospace = mono.clone();
+ state.mono_box.text = mono;
+ save_preferred_fonts(&state.sans_serif, &state.serif, &state.monospace);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_font_for_alias() {
+ let content = r#"<?xml version="1.0"?>
+<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
+<fontconfig>
+ <dir>~/Dropbox/Fonts</dir>
+ <match target="pattern">
+ <test qual="any" name="family"><string>sans-serif</string></test>
+ <edit name="family" mode="assign" binding="same">
+ <string>Adwaita Sans</string>
+ </edit>
+ </match>
+ <match target="pattern">
+ <test qual="any" name="family"><string>monospace</string></test>
+ <edit name="family" mode="assign" binding="same">
+ <string>Berkeley Mono</string>
+ </edit>
+ </match>
+</fontconfig>
+"#;
+
+ assert_eq!(parse_font_for_alias(content, "sans-serif"), Some("Adwaita Sans".to_string()));
+ assert_eq!(parse_font_for_alias(content, "monospace"), Some("Berkeley Mono".to_string()));
+ assert_eq!(parse_font_for_alias(content, "serif"), None);
+ }
+}