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

commita107294ecc80edb0e0afc2a9144d8cbec489e24c
parenta91926c984
authorLucas Galante <[email protected]>
date2026-06-04 02:03
Add Screensaver settings page and options tab to clear-system-interface

 src/app.rs               |   5 +
 src/main.rs              |  96 +++++++++++++-
 src/pages/mod.rs         |   7 +-
 src/pages/screensaver.rs | 327 +++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 432 insertions(+), 3 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index f6c97b2..d17cdeb 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -14,6 +14,7 @@ use crate::pages::backup;
 use crate::pages::typeface;
 use crate::pages::services;
 use crate::pages::colors;
+use crate::pages::screensaver;
 use crate::pages::Page;
 
 pub struct AppState {
@@ -32,6 +33,7 @@ pub struct AppState {
     pub typeface: typeface::TypefaceState,
     pub services: services::ServicesState,
     pub colors: colors::ColorsState,
+    pub screensaver: screensaver::ScreensaverState,
 }
 
 impl Default for AppState {
@@ -52,6 +54,7 @@ impl Default for AppState {
             typeface: typeface::TypefaceState::default(),
             services: services::ServicesState::default(),
             colors: colors::ColorsState::default(),
+            screensaver: screensaver::read_screensaver_config(),
         }
     }
 }
@@ -72,8 +75,10 @@ pub enum AppAction {
     Typeface(typeface::TypefaceMessage),
     Services(services::ServicesMessage),
     Colors(colors::ColorsMessage),
+    Screensaver(screensaver::ScreensaverMessage),
 }
 
+
 pub struct PageContent {
     pub rects: Vec<([f32; 4], f32, f32, f32, f32)>,
     pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>)>,
diff --git a/src/main.rs b/src/main.rs
index 08a10cb..c2dd1b7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -202,6 +202,7 @@ struct SystemInterface {
     rx_status: std::sync::mpsc::Receiver<pages::status::StatusState>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsState>,
+    rx_screensaver: std::sync::mpsc::Receiver<pages::screensaver::ScreensaverState>,
     rx_backup_state: std::sync::mpsc::Receiver<pages::backup::BackupState>,
     rx_typeface: std::sync::mpsc::Receiver<pages::typeface::TypefaceState>,
     rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
@@ -348,6 +349,17 @@ impl clear_ui::engine::Application for SystemInterface {
             });
             rx
         };
+        let rx_screensaver = {
+            let (tx, rx) = std::sync::mpsc::channel::<pages::screensaver::ScreensaverState>();
+            tokio::spawn(async move {
+                loop {
+                    let val = tokio::task::spawn_blocking(|| pages::screensaver::read_screensaver_config()).await;
+                    if let Ok(val) = val { if tx.send(val).is_err() { break; } }
+                    tokio::time::sleep(std::time::Duration::from_secs(30)).await;
+                }
+            });
+            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 rx_services = spawn_bg(3, || pages::services::fetch_services());
@@ -400,6 +412,7 @@ impl clear_ui::engine::Application for SystemInterface {
             rx_status,
             rx_storage,
             rx_notifications,
+            rx_screensaver,
             rx_backup_state,
             rx_typeface,
             rx_services,
@@ -461,6 +474,7 @@ impl clear_ui::engine::Application for SystemInterface {
             self.width = width as u32;
             self.height = height as u32;
             self.scale_factor = scale;
+            self.paginator.set_scale_factor(scale as f32);
             self.rebuild_layout(width, height);
         }
         for w in &self.widgets {
@@ -473,12 +487,16 @@ impl clear_ui::engine::Application for SystemInterface {
     }
 
     fn clear_color(&self) -> [f32; 4] {
-        [
+        let mut color = [
             self.app.colors.page_low_color[0] as f32 / 255.0,
             self.app.colors.page_low_color[1] as f32 / 255.0,
             self.app.colors.page_low_color[2] as f32 / 255.0,
             1.0,
-        ]
+        ];
+        if let Some(opacity) = clear_ui::color::read_opacity_if_configured() {
+            color[3] = opacity;
+        }
+        color
     }
 
     fn handle_pointer_move(&mut self, pos: clear_ui::engine::LogicalPosition, needs_rebuild: &mut bool) {
@@ -722,6 +740,12 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
                 link_parent_child(&mut self.page_root_container, &mut self.app.notifications.duration_spinbox);
                 link_parent_child(&mut self.page_root_container, &mut self.app.notifications.opacity_slider);
             }
+            Page::Screensaver => {
+                link_parent_child(&mut self.page_root_container, &mut self.app.screensaver.enable_toggle);
+                link_parent_child(&mut self.page_root_container, &mut self.app.screensaver.lock_screen_toggle);
+                link_parent_child(&mut self.page_root_container, &mut self.app.screensaver.timeout_spinbox);
+                link_parent_child(&mut self.page_root_container, &mut self.app.screensaver.style_menu);
+            }
             Page::Input => {
                 self.page_sec_containers.resize_with(5, clear_ui::widget::Container::new);
                 link_parent_child(&mut self.page_root_container, &mut self.page_sec_containers[0]);
@@ -972,6 +996,7 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
             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::Screensaver => screensaver::view(&mut self.app.screensaver, cx, cy, cw, ch),
             Page::Typefaces => typeface::view(&mut self.app.typeface, cx, cy, cw, ch, &sec_focused),
             Page::Services => services::view(&mut self.app.services, cx, cy, cw, ch, root_focused),
             Page::Colors => colors::view(&mut self.app.colors, cx, cy, cw, ch),
@@ -1104,6 +1129,10 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
             notifications::update(&mut self.app.notifications, notifications::NotificationsMessage::Refreshed(s));
             self.needs_rebuild = true;
         }
+        while let Ok(s) = self.rx_screensaver.try_recv() {
+            screensaver::update(&mut self.app.screensaver, pages::screensaver::ScreensaverMessage::Refreshed(s));
+            self.needs_rebuild = true;
+        }
         while let Ok(s) = self.rx_backup_state.try_recv() {
             pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::Refreshed(s));
             self.needs_rebuild = true;
@@ -1161,6 +1190,7 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
             }
             AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
             AppAction::Colors(m) => colors::update(&mut self.app.colors, m.clone()),
+            AppAction::Screensaver(m) => screensaver::update(&mut self.app.screensaver, m.clone()),
             AppAction::Backup(m) => match m {
                 pages::backup::BackupMessage::StartBackup => {
                     pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::StartBackup);
@@ -1332,6 +1362,20 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
                 changed = true;
             }
         }
+        if self.app.current_page == Page::Screensaver {
+            if self.app.screensaver.enable_toggle.cursor_moved(lx, ly) {
+                changed = true;
+            }
+            if self.app.screensaver.lock_screen_toggle.cursor_moved(lx, ly) {
+                changed = true;
+            }
+            if self.app.screensaver.timeout_spinbox.cursor_moved(lx, ly) {
+                changed = true;
+            }
+            if self.app.screensaver.style_menu.cursor_moved(lx, ly) {
+                changed = true;
+            }
+        }
         if self.app.current_page == Page::Hardware {
             if self.app.hardware.cpu_label.cursor_moved(lx, ly) {
                 changed = true;
@@ -1514,6 +1558,10 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
                     if self.app.notifications.duration_spinbox.hit_test(lx, ly) { clicked_any_focusable = true; }
                     if self.app.notifications.opacity_slider.hit_test(lx, ly) { clicked_any_focusable = true; }
                 }
+                Page::Screensaver => {
+                    if self.app.screensaver.timeout_spinbox.hit_test(lx, ly) { clicked_any_focusable = true; }
+                    if self.app.screensaver.style_menu.hit_test(lx, ly) { clicked_any_focusable = true; }
+                }
                 Page::Audio => {
                     for sb in &mut self.app.audio.sink_spinboxes {
                         if sb.hit_test(lx, ly) { clicked_any_focusable = true; }
@@ -1825,6 +1873,37 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
                 }
             }
         }
+        if self.app.current_page == Page::Screensaver {
+            let toggle = &mut self.app.screensaver.enable_toggle;
+            toggle.mouse_input(button, state, lx, ly);
+            if toggle.take_click() {
+                actions.push(AppAction::Screensaver(pages::screensaver::ScreensaverMessage::ToggleEnable));
+            }
+
+            let toggle = &mut self.app.screensaver.lock_screen_toggle;
+            toggle.mouse_input(button, state, lx, ly);
+            if toggle.take_click() {
+                actions.push(AppAction::Screensaver(pages::screensaver::ScreensaverMessage::ToggleLockScreen));
+            }
+
+            if state == clear_ui::widget::ElementState::Pressed {
+                let sb = &mut self.app.screensaver.timeout_spinbox;
+                if !sb.hit_test(lx, ly) { sb.unfocus(); }
+                let old = sb.value;
+                if sb.mouse_input(button, state, lx, ly) && sb.value != old {
+                    actions.push(AppAction::Screensaver(pages::screensaver::ScreensaverMessage::SetTimeout(sb.value)));
+                }
+            }
+
+            let menu = &mut self.app.screensaver.style_menu;
+            if state == clear_ui::widget::ElementState::Pressed && !menu.hit_test(lx, ly) { menu.unfocus(); }
+            if menu.mouse_input(button, state, lx, ly) {
+                self.needs_rebuild = true;
+            }
+            if state == clear_ui::widget::ElementState::Pressed && menu.take_change() {
+                actions.push(AppAction::Screensaver(pages::screensaver::ScreensaverMessage::SetStyle(menu.selected)));
+            }
+        }
         if state == clear_ui::widget::ElementState::Pressed && self.app.current_page == Page::Audio {
             for (i, sb) in self.app.audio.sink_spinboxes.iter_mut().enumerate() {
                 if !sb.hit_test(lx, ly) { sb.unfocus(); }
@@ -2353,6 +2432,19 @@ fn collect_popover_rects(w: &dyn clear_ui::widget::Widget, popovers: &mut Vec<(f
                 return true;
             }
         }
+        if self.app.current_page == Page::Screensaver {
+            let sb = &mut self.app.screensaver.timeout_spinbox;
+            let old = sb.value;
+            if sb.keyboard_input(event) {
+                let new_val = sb.value;
+                drop(sb);
+                if new_val != old {
+                    self.handle_action(&AppAction::Screensaver(pages::screensaver::ScreensaverMessage::SetTimeout(new_val)));
+                }
+                self.needs_rebuild = true;
+                return true;
+            }
+        }
         if self.app.current_page == Page::Notifications {
             let sb = &mut self.app.notifications.duration_spinbox;
             let old = sb.value;
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index c1c82e4..5921d4c 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -13,6 +13,7 @@ pub mod backup;
 pub mod typeface;
 pub mod services;
 pub mod colors;
+pub mod screensaver;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Page {
@@ -30,10 +31,11 @@ pub enum Page {
     Backup,
     Typefaces,
     Colors,
+    Screensaver,
 }
 
 impl Page {
-    pub const ALL: [Page; 14] = [
+    pub const ALL: [Page; 15] = [
         Page::Audio,
         Page::Backup,
         Page::Colors,
@@ -48,6 +50,7 @@ impl Page {
         Page::Storage,
         Page::System,
         Page::Typefaces,
+        Page::Screensaver,
     ];
 
     pub fn label(self) -> &'static str {
@@ -66,6 +69,7 @@ impl Page {
             Page::Backup => "Backup",
             Page::Typefaces => "Typefaces",
             Page::Colors => "Colors",
+            Page::Screensaver => "Screensaver",
         }
     }
 
@@ -73,3 +77,4 @@ impl Page {
         ""
     }
 }
+
diff --git a/src/pages/screensaver.rs b/src/pages/screensaver.rs
new file mode 100644
index 0000000..6f14b46
--- /dev/null
+++ b/src/pages/screensaver.rs
@@ -0,0 +1,327 @@
+use std::fs;
+use crate::app::{AppAction, PageContent};
+use clear_ui::layout::Section;
+use clear_ui::widget::{Toggle, Spinbox, Dropdown};
+
+const CONFIG_PATH: &str = "/home/lsgalante/.config/ccec/config.toml";
+
+#[derive(Debug, Clone)]
+pub struct ScreensaverState {
+    pub enable: bool,
+    pub enable_toggle: Toggle,
+    pub timeout: i32,
+    pub timeout_spinbox: Spinbox,
+    pub lock_screen: bool,
+    pub lock_screen_toggle: Toggle,
+    pub style: String,
+    pub style_menu: Dropdown,
+}
+
+impl Default for ScreensaverState {
+    fn default() -> Self {
+        Self {
+            enable: true,
+            enable_toggle: Toggle::new().with_label("Enable Screensaver"),
+            timeout: 10,
+            timeout_spinbox: Spinbox::new(10, 1, 120, 1)
+                .with_label("Screensaver Timeout")
+                .with_unit("m"),
+            lock_screen: true,
+            lock_screen_toggle: Toggle::new().with_label("Lock Screen on Activation"),
+            style: "starfield".to_string(),
+            style_menu: Dropdown::new(
+                vec!["Blank".to_string(), "Starfield".to_string(), "Matrix Rain".to_string()],
+                1,
+            ).with_label("Screensaver Style"),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum ScreensaverMessage {
+    ToggleEnable,
+    ToggleLockScreen,
+    SetTimeout(i32),
+    SetStyle(usize),
+    StartPreview,
+    Refreshed(ScreensaverState),
+}
+
+pub fn read_screensaver_config() -> ScreensaverState {
+    let content = fs::read_to_string(CONFIG_PATH).unwrap_or_default();
+    let enable = parse_screensaver_enable(&content);
+    let timeout = parse_screensaver_timeout(&content);
+    let lock_screen = parse_screensaver_lock_screen(&content);
+    let style = parse_screensaver_style(&content);
+
+    let style_idx = match style.to_lowercase().as_str() {
+        "blank" => 0,
+        "starfield" => 1,
+        "matrix" => 2,
+        _ => 1, // default to Starfield
+    };
+
+    ScreensaverState {
+        enable,
+        enable_toggle: Toggle::new().with_label("Enable Screensaver"),
+        timeout,
+        timeout_spinbox: Spinbox::new(timeout, 1, 120, 1)
+            .with_label("Screensaver Timeout")
+            .with_unit("m"),
+        lock_screen,
+        lock_screen_toggle: Toggle::new().with_label("Lock Screen on Activation"),
+        style: style.clone(),
+        style_menu: Dropdown::new(
+            vec!["Blank".to_string(), "Starfield".to_string(), "Matrix Rain".to_string()],
+            style_idx,
+        ).with_label("Screensaver Style"),
+    }
+}
+
+fn parse_screensaver_enable(content: &str) -> bool {
+    let mut in_section = false;
+    for line in content.lines() {
+        let trimmed = line.trim();
+        if trimmed == "[screensaver]" {
+            in_section = true;
+            continue;
+        }
+        if trimmed.starts_with('[') && in_section {
+            break;
+        }
+        if in_section && trimmed.starts_with("enable") {
+            if let Some(val) = trimmed.split('=').nth(1) {
+                return val.trim() == "true";
+            }
+        }
+    }
+    true // default to true
+}
+
+fn parse_screensaver_lock_screen(content: &str) -> bool {
+    let mut in_section = false;
+    for line in content.lines() {
+        let trimmed = line.trim();
+        if trimmed == "[screensaver]" {
+            in_section = true;
+            continue;
+        }
+        if trimmed.starts_with('[') && in_section {
+            break;
+        }
+        if in_section && trimmed.starts_with("lock_screen") {
+            if let Some(val) = trimmed.split('=').nth(1) {
+                return val.trim() == "true";
+            }
+        }
+    }
+    true // default to true
+}
+
+fn parse_screensaver_timeout(content: &str) -> i32 {
+    let mut in_section = false;
+    for line in content.lines() {
+        let trimmed = line.trim();
+        if trimmed == "[screensaver]" {
+            in_section = true;
+            continue;
+        }
+        if trimmed.starts_with('[') && in_section {
+            break;
+        }
+        if in_section && trimmed.starts_with("timeout") {
+            if let Some(val) = trimmed.split('=').nth(1) {
+                if let Ok(t) = val.trim().parse::<i32>() {
+                    return t;
+                }
+            }
+        }
+    }
+    10 // default to 10 minutes
+}
+
+fn parse_screensaver_style(content: &str) -> String {
+    let mut in_section = false;
+    for line in content.lines() {
+        let trimmed = line.trim();
+        if trimmed == "[screensaver]" {
+            in_section = true;
+            continue;
+        }
+        if trimmed.starts_with('[') && in_section {
+            break;
+        }
+        if in_section && trimmed.starts_with("style") {
+            if let Some(val) = trimmed.split('=').nth(1) {
+                return val.trim().trim_matches('"').to_string();
+            }
+        }
+    }
+    "starfield".to_string() // default to starfield
+}
+
+fn write_config_value(key: &str, value: &str) {
+    let content = fs::read_to_string(CONFIG_PATH).unwrap_or_default();
+    let new_line = format!("{} = {}", key, value);
+
+    let mut found = false;
+    let mut updated_lines = Vec::new();
+    let mut in_section = false;
+
+    for line in content.lines() {
+        let trimmed = line.trim();
+        if trimmed == "[screensaver]" {
+            in_section = true;
+            updated_lines.push(line.to_string());
+            continue;
+        }
+        if trimmed.starts_with('[') && in_section {
+            in_section = false;
+        }
+        if in_section && trimmed.starts_with(key) {
+            found = true;
+            updated_lines.push(new_line.clone());
+        } else {
+            updated_lines.push(line.to_string());
+        }
+    }
+
+    let mut updated = updated_lines.join("\n");
+
+    if !found {
+        let mut result = String::new();
+        let has_section = content.lines().any(|l| l.trim() == "[screensaver]");
+        if has_section {
+            let mut in_section = false;
+            let mut inserted = false;
+            for line in updated.lines() {
+                if line.trim() == "[screensaver]" {
+                    in_section = true;
+                    result.push_str(line);
+                    result.push('\n');
+                    continue;
+                }
+                if line.trim().starts_with('[') && in_section {
+                    if !inserted {
+                        result.push_str(&new_line);
+                        result.push('\n');
+                        inserted = true;
+                    }
+                    in_section = false;
+                }
+                result.push_str(line);
+                result.push('\n');
+            }
+            if !inserted {
+                result.push_str(&new_line);
+                result.push('\n');
+            }
+            updated = result;
+        } else {
+            updated.push_str("\n[screensaver]\n");
+            updated.push_str(&new_line);
+            updated.push_str("\n");
+        }
+    }
+    let _ = fs::write(CONFIG_PATH, updated);
+}
+
+const BTN_BG: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
+const BTN_HOVER: [f32; 4] = [0.28, 0.50, 0.78, 1.0];
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+
+pub fn view(state: &mut ScreensaverState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+    let mut pc = PageContent::new();
+    let y = cy + 12.0;
+
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Screensaver Settings");
+
+    let toggle_w = 48.0;
+    let toggle_h = 24.0;
+    
+    state.enable_toggle.set_toggled(state.enable);
+    sec.widget(&mut pc, &mut state.enable_toggle, 14.0, toggle_w, toggle_h);
+    sec.spacing(8.0);
+
+    state.lock_screen_toggle.set_toggled(state.lock_screen);
+    sec.widget(&mut pc, &mut state.lock_screen_toggle, 14.0, toggle_w, toggle_h);
+    sec.spacing(16.0);
+
+    state.timeout_spinbox.value = state.timeout;
+    sec.widget(&mut pc, &mut state.timeout_spinbox, 14.0, 200.0, 26.0);
+    sec.spacing(16.0);
+
+    sec.widget(&mut pc, &mut state.style_menu, 14.0, 200.0, 26.0);
+    sec.spacing(24.0);
+
+    let btn_w = 160.0;
+    let btn_h = 32.0;
+    let btn_y = sec.ay();
+    sec.row(1, 0.0, btn_h, |_, x, _| {
+        pc.button(
+            "Preview Screensaver",
+            x,
+            btn_y,
+            btn_w,
+            btn_h,
+            BTN_BG,
+            BTN_HOVER,
+            WHITE,
+            AppAction::Screensaver(ScreensaverMessage::StartPreview),
+        );
+    });
+    sec.spacing(12.0);
+    sec.finish(&mut pc);
+
+    pc
+}
+
+pub fn update(state: &mut ScreensaverState, msg: ScreensaverMessage) {
+    match msg {
+        ScreensaverMessage::ToggleEnable => {
+            state.enable = !state.enable;
+            write_config_value("enable", &state.enable.to_string());
+        }
+        ScreensaverMessage::ToggleLockScreen => {
+            state.lock_screen = !state.lock_screen;
+            write_config_value("lock_screen", &state.lock_screen.to_string());
+        }
+        ScreensaverMessage::SetTimeout(t) => {
+            state.timeout = t;
+            write_config_value("timeout", &state.timeout.to_string());
+        }
+        ScreensaverMessage::SetStyle(idx) => {
+            state.style_menu.selected = idx;
+            let val = match idx {
+                0 => "blank",
+                1 => "starfield",
+                2 => "matrix",
+                _ => "starfield",
+            };
+            state.style = val.to_string();
+            write_config_value("style", &format!("\"{}\"", val));
+        }
+        ScreensaverMessage::StartPreview => {
+            let style_flag = match state.style_menu.selected {
+                0 => "blank",
+                1 => "starfield",
+                2 => "matrix",
+                _ => "starfield",
+            };
+            
+            // Spawn screensaver tool from PATH or local directory
+            std::process::Command::new("/home/lsgalante/Dropbox/Clear/cce-screenaver/target/debug/cce-screenaver")
+                .arg(style_flag)
+                .spawn()
+                .or_else(|_| {
+                    std::process::Command::new("cce-screenaver")
+                        .arg(style_flag)
+                        .spawn()
+                })
+                .ok();
+        }
+        ScreensaverMessage::Refreshed(new) => {
+            *state = new;
+        }
+    }
+}