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

commitec96fd85acb078ba4dfe0ea3e46340ea6ec8e1d7
parent1978605180
authorLucas Galante <[email protected]>
date2026-08-13 01:14
feat: Storage's Run Backup button is focusable

ctrl+i on the Full System Backup section had nothing to land on: the button was
an immediate-mode PageContent button, not a registered widget, so
section_widgets() reported three empty groups.

Makes it a retained Adapted<Button> on StorageState, returned as the third
section's group, with clicks drained in propagate_widget_changes — mouse and
Enter/Space both arrive through the same take_click().

Two things this required:

Refreshed now carries a StorageInfo payload (the fetched numbers only) instead
of a whole StorageState, following the notifications page. The old arm did
`*state = new`, which with a widget on the struct would have swapped the live
button for a freshly-allocated one with a different id, mid-frame, every ten
seconds — the same class of bug as d56a573's package-selection clobber. Fields
are assigned individually now, which also retires the backup_in_progress
save/restore dance.

The button is built `.with_raised(false)`. A raised button paints an SDF bevel,
and PageContent's RenderTarget has no `bevel` — so the plate silently vanished
and only the label drew. The flat fill+border path is also what carries the
focus ring.

Verified live: the plate renders, and ctrl+j x3 then ctrl+i moves focus onto the
button (border near-black -> focus colour, a clean rectangle in the pixel diff).
Activation itself is deliberately NOT exercised — StartBackup spawns pkexec on
backup-system.sh, i.e. a real privileged whole-filesystem backup.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/main.rs          |  2 +-
 src/pages/storage.rs | 86 ++++++++++++++++++++++++++++++++++++++++------------
 src/watchers.rs      |  2 +-
 3 files changed, 69 insertions(+), 21 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 1f5fa7a..b09cc5b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -58,7 +58,7 @@ struct SystemInterface {
     rx_bluetooth: std::sync::mpsc::Receiver<pages::bluetooth::BluetoothState>,
     pub rx_processes: std::sync::mpsc::Receiver<pages::processes::ProcessesState>,
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
-    rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
+    rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageInfo>,
     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsConfig>,
     rx_browser: std::sync::mpsc::Receiver<pages::browser::BrowserConfig>,
     rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index fa90e6d..c5aaa7c 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,7 +1,23 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
 use std::fs;
 
+/// What the background poll produces — the fetched numbers only, never the
+/// widgets. `Refreshed` carries this rather than a whole `StorageState` so a
+/// refresh cannot clobber `backup_button` (the notifications-page pattern; the
+/// old `*state = new` would have swapped the live widget for a fresh one with a
+/// different id, mid-frame, every ten seconds).
+#[derive(Debug, Clone)]
+pub struct StorageInfo {
+    pub disk_total: f64,
+    pub disk_used: f64,
+    pub ram_total: f64,
+    pub ram_used: f64,
+    pub last_backup_time: String,
+    pub backup_size: String,
+    pub error_message: Option<String>,
+}
+
 #[derive(Debug, Clone)]
 pub struct StorageState {
     pub disk_total: f64,
@@ -16,6 +32,9 @@ pub struct StorageState {
     pub last_backup_time: String,
     pub backup_size: String,
     pub error_message: Option<String>,
+    /// Retained so it can hold keyboard focus: ctrl+i descends into the Full
+    /// System Backup section and lands here, and Enter/Space runs the backup.
+    pub backup_button: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 }
 
 impl Default for StorageState {
@@ -31,13 +50,23 @@ impl Default for StorageState {
             last_backup_time: "Never".to_string(),
             backup_size: "0 B".to_string(),
             error_message: None,
+            backup_button: cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
+                // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
+                // has no `bevel`, so a raised plate silently draws nothing here
+                // (the label renders, the plate does not). The border path is also
+                // what carries the keyboard focus ring.
+                .with_raised(false)
+                .with_label("Run Backup")
+                .with_bg(BTN_BG)
+                .with_hover_bg(BTN_HOVER)
+                .with_label_color(WHITE),
         }
     }
 }
 
 #[derive(Debug, Clone)]
 pub enum StorageMessage {
-    Refreshed(StorageState),
+    Refreshed(StorageInfo),
     StartBackup,
     BackupFinished(Result<(String, String), String>),
 }
@@ -81,7 +110,7 @@ pub fn read_backup_status() -> (String, String, Option<String>) {
     (last_backup, size, err_msg)
 }
 
-pub async fn fetch_storage_state() -> StorageState {
+pub async fn fetch_storage_state() -> StorageInfo {
     let disk_output = tokio::process::Command::new("df")
         .args(["-BG", "/"])
         .output().await.ok()
@@ -98,14 +127,11 @@ pub async fn fetch_storage_state() -> StorageState {
 
     let (last_backup, size, err) = read_backup_status();
 
-    StorageState {
+    StorageInfo {
         disk_total,
         disk_used,
         ram_total,
         ram_used,
-        loaded: true,
-        backup_loaded: true,
-        backup_in_progress: false,
         last_backup_time: last_backup,
         backup_size: size,
         error_message: err,
@@ -170,7 +196,7 @@ const BTN_HOVER: [f32; 4] = [0.28, 0.50, 0.78, 1.0];
 const BTN_DISABLED: [f32; 4] = [0.15, 0.18, 0.22, 1.0];
 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 
-pub fn view(state: &StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(3);
@@ -266,14 +292,22 @@ pub fn view(state: &StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focuse
             let mut stack = sec.vstack(8.0);
             let btn_h = 32.0;
             
-            let (btn_label, bg, hover, action) = if state.backup_in_progress {
-                ("Backing up...", BTN_DISABLED, BTN_DISABLED, AppAction::Storage(StorageMessage::StartBackup))
+            // Retained widget rather than an immediate `sec.button`, so it can hold
+            // keyboard focus. Its label/colours are re-synced each frame from the
+            // backup state, the way cce-email drives its retained btn_unread.
+            let (btn_label, bg, hover) = if state.backup_in_progress {
+                ("Backing up...", BTN_DISABLED, BTN_DISABLED)
             } else {
-                ("Run Backup", BTN_BG, BTN_HOVER, AppAction::Storage(StorageMessage::StartBackup))
+                ("Run Backup", BTN_BG, BTN_HOVER)
             };
-            
-            stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
-                ctx.button(btn_label, x, ctx.ay(), w, btn_h, bg, hover, WHITE, action.clone());
+            state.backup_button.set_label(btn_label);
+            state.backup_button.bg = Some(bg);
+            state.backup_button.hover_bg = Some(hover);
+
+            let btn = &mut state.backup_button;
+            stack.add_row(1, 0.0, btn_h, |sctx, _, x, w| {
+                let y = sctx.ay();
+                render_widget(sctx.pc, btn, x, y, w, btn_h, ctx);
             });
         }
     });
@@ -284,9 +318,16 @@ pub fn view(state: &StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focuse
 pub fn update(state: &mut StorageState, msg: StorageMessage) {
     match msg {
         StorageMessage::Refreshed(new) => {
-            let in_prog = state.backup_in_progress;
-            *state = new;
-            state.backup_in_progress = in_prog;
+            // Field-wise, so the retained button and an in-flight backup survive.
+            state.disk_total = new.disk_total;
+            state.disk_used = new.disk_used;
+            state.ram_total = new.ram_total;
+            state.ram_used = new.ram_used;
+            state.last_backup_time = new.last_backup_time;
+            state.backup_size = new.backup_size;
+            state.error_message = new.error_message;
+            state.loaded = true;
+            state.backup_loaded = true;
         }
         StorageMessage::StartBackup => {
             state.backup_in_progress = true;
@@ -311,7 +352,9 @@ pub fn update(state: &mut StorageState, msg: StorageMessage) {
 impl crate::pages::AppPage for StorageState {
     // Sections: [Local Storage, Memory, Full System Backup] — no evented widgets.
     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
-        vec![Vec::new(), Vec::new(), Vec::new()]
+        // Only the third section (Full System Backup) has anything focusable;
+        // the first two are read-only readouts, so ctrl+i there has no target.
+        vec![Vec::new(), Vec::new(), vec![self.backup_button.id()]]
     }
 
     fn view(
@@ -328,5 +371,10 @@ impl crate::pages::AppPage for StorageState {
         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
+        // Mouse click and Enter/Space on the focused button both land here.
+        if self.backup_button.take_click() && !self.backup_in_progress {
+            actions.push(AppAction::Storage(StorageMessage::StartBackup));
+        }
+    }
 }
diff --git a/src/watchers.rs b/src/watchers.rs
index 6b5853f..5c8c276 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -9,7 +9,7 @@ pub struct Watchers {
     pub rx_bluetooth: Receiver<bluetooth::BluetoothState>,
     pub rx_processes: Receiver<processes::ProcessesState>,
     pub rx_system: Receiver<system_info::SystemInfo>,
-    pub rx_storage: Receiver<storage::StorageState>,
+    pub rx_storage: Receiver<storage::StorageInfo>,
     pub rx_notifications: Receiver<notifications::NotificationsConfig>,
     pub rx_browser: Receiver<browser::BrowserConfig>,
     pub rx_services: Receiver<Vec<services::ServiceInfo>>,