system settings
git clone https://git.lucas.co/cce-system-interface.git
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>>,