git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commita58a0eb9edbab20dc5e75a2eea591655fd762e00
parente82ce42586
authorLucas Galante <[email protected]>
date2026-05-23 19:26
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);
+    }
+}