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

commit65c78832fff578f21c05010672f46f4e261655f8
parent5c134bca73
authorLucas Galante <[email protected]>
date2026-05-26 20:25
Merge power page into hardware page and move system actions to system page

 src/app.rs               |   4 -
 src/main.rs              |  15 +-
 src/pages/hardware.rs    | 300 ++++++++++++++++++++++++++++++++++++++-
 src/pages/mod.rs         |   6 +-
 src/pages/power.rs       | 357 -----------------------------------------------
 src/pages/system_info.rs |  56 +++++++-
 6 files changed, 350 insertions(+), 388 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 0647030..f6c97b2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6,7 +6,6 @@ use crate::pages::input;
 use crate::pages::layout;
 use crate::pages::network;
 use crate::pages::notifications;
-use crate::pages::power;
 use crate::pages::hardware;
 use crate::pages::status;
 use crate::pages::storage;
@@ -19,7 +18,6 @@ use crate::pages::Page;
 
 pub struct AppState {
     pub current_page: Page,
-    pub power: power::PowerState,
     pub audio: audio::AudioState,
     pub display: display::DisplayState,
     pub network: network::NetworkState,
@@ -40,7 +38,6 @@ impl Default for AppState {
     fn default() -> Self {
         Self {
             current_page: Page::ALL[0],
-            power: power::PowerState::default(),
             audio: audio::AudioState::default(),
             display: display::DisplayState::default(),
             network: network::NetworkState::default(),
@@ -61,7 +58,6 @@ impl Default for AppState {
 
 #[derive(Debug, Clone)]
 pub enum AppAction {
-    Power(power::PowerMessage),
     Audio(audio::AudioMessage),
     Display(display::DisplayMessage),
     Radios(network::NetworkMessage),
diff --git a/src/main.rs b/src/main.rs
index c457c19..cc16a3a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -211,7 +211,6 @@ struct SystemInterface {
     cursor_x: f32,
     cursor_y: f32,
 
-    rx_power: std::sync::mpsc::Receiver<pages::power::PowerState>,
     rx_audio: std::sync::mpsc::Receiver<pages::audio::AudioState>,
     rx_display: std::sync::mpsc::Receiver<pages::display::DisplayState>,
     rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
@@ -369,7 +368,6 @@ impl SystemInterface {
             rx
         }
 
-        let rx_power = spawn_bg(5, || pages::power::fetch_power_state());
         let rx_audio = spawn_bg(3, || pages::audio::fetch_audio_state());
         let rx_display = spawn_bg(10, || pages::display::fetch_display_state());
         let rx_network = spawn_bg(5, || pages::network::fetch_network_state());
@@ -462,7 +460,7 @@ impl SystemInterface {
             sidebar_width: 140.0, header_height: 0.0, status_height: 0.0,
             cursor_x: 0.0, cursor_y: 0.0,
             scale_factor,
-            rx_power, rx_audio, rx_display, rx_network, rx_layout, rx_input, rx_fingers,
+            rx_audio, rx_display, rx_network, rx_layout, rx_input, rx_fingers,
             rx_hardware, rx_system, rx_status, rx_storage, rx_notifications,
             rx_backup_state, rx_typeface, rx_services, rx_colors, tx_backup, rx_backup,
             tx_color_selector, rx_color_selector,
@@ -861,7 +859,6 @@ impl SystemInterface {
             .map(|c| clear_ui::widget::focus::is_focused(c))
             .collect();
         match self.app.current_page {
-            Page::Power => power::view(&self.app.power, cx, cy, cw, ch),
             Page::Audio => audio::view(&mut self.app.audio, cx, cy, cw, ch, &sec_focused),
             Page::Display => display::view(&mut self.app.display, cx, cy, cw, ch),
             Page::Radios => network::view(&mut self.app.network, cx, cy, cw, ch, root_focused),
@@ -938,10 +935,6 @@ impl SystemInterface {
 
     fn poll_background_updates(&mut self) {
         use pages::*;
-        while let Ok(s) = self.rx_power.try_recv() {
-            power::update(&mut self.app.power, power::PowerMessage::Refreshed(s));
-            self.needs_rebuild = true;
-        }
         while let Ok(s) = self.rx_audio.try_recv() {
             audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s));
             self.needs_rebuild = true;
@@ -1029,7 +1022,6 @@ impl SystemInterface {
     fn handle_action(&mut self, action: &AppAction) {
         use pages::*;
         match action {
-            AppAction::Power(m) => power::update(&mut self.app.power, m.clone()),
             AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
             AppAction::Display(m) => display::update(&mut self.app.display, m.clone()),
             AppAction::Radios(m) => network::update(&mut self.app.network, m.clone()),
@@ -2344,7 +2336,6 @@ struct App {
     surface: Option<wl_surface::WlSurface>,
 
     state: Option<SystemInterface>,
-    initial_page: Page,
     exit: bool,
     redraw: bool,
     ctrl_pressed: bool,
@@ -2756,7 +2747,6 @@ fn main() {
         window: None,
         surface: None,
         state: None,
-        initial_page,
         exit: false,
         redraw: true,
         ctrl_pressed: false,
@@ -2772,7 +2762,7 @@ fn main() {
     let pw = (820.0 * scale) as u32;
     let ph = (680.0 * scale) as u32;
 
-    let state = pollster::block_on(SystemInterface::new(
+    let mut state = pollster::block_on(SystemInterface::new(
         &conn,
         &qh,
         &app.compositor_state,
@@ -2781,6 +2771,7 @@ fn main() {
         ph,
         scale,
     ));
+    state.app.current_page = initial_page;
     app.window = Some(state.window.clone());
     app.surface = Some(state.surface.clone());
     app.state = Some(state);
diff --git a/src/pages/hardware.rs b/src/pages/hardware.rs
index 7b0d1f4..53d73d6 100644
--- a/src/pages/hardware.rs
+++ b/src/pages/hardware.rs
@@ -1,7 +1,20 @@
-use crate::app::PageContent;
+use crate::app::{AppAction, PageContent};
 use clear_ui::layout::Section;
 use clear_ui::widget::{Label, ScrollingList};
 
+#[derive(Debug, Clone, Default)]
+pub struct BatteryInfo {
+    pub percentage: f32,
+    pub state: String,
+    pub energy: f64,
+    pub energy_full: f64,
+    pub energy_rate: f64,
+    pub time_to_empty: i64,
+    pub time_to_full: i64,
+    pub vendor: String,
+    pub model: String,
+}
+
 #[derive(Debug, Clone)]
 pub struct HardwareState {
     pub cpu_model: String,
@@ -13,6 +26,12 @@ pub struct HardwareState {
     pub gpu_labels: Vec<Label>,
     pub processes: Vec<(String, String, String)>, // (pid, cpu, comm)
     pub cpu_list_box: ScrollingList,
+
+    // Power-related fields
+    pub battery: BatteryInfo,
+    pub on_ac: bool,
+    pub cpu_powersave: bool,
+    pub gpu_powersave: bool,
 }
 
 impl Default for HardwareState {
@@ -27,6 +46,11 @@ impl Default for HardwareState {
             gpu_labels: Vec::new(),
             processes: Vec::new(),
             cpu_list_box: ScrollingList::new(24.0, 2.0),
+
+            battery: BatteryInfo::default(),
+            on_ac: true,
+            cpu_powersave: false,
+            gpu_powersave: false,
         }
     }
 }
@@ -34,9 +58,118 @@ impl Default for HardwareState {
 #[derive(Debug, Clone)]
 pub enum HardwareMessage {
     Refreshed(HardwareState),
+    SetCpuPerformance,
+    SetCpuPowersave,
+    SetGpuDefault,
+    SetGpuPowersave,
     None,
 }
 
+// ── zbus proxies ────────────────────────────────────────────────────
+
+#[zbus::proxy(
+    interface = "org.freedesktop.UPower.Device",
+    default_service = "org.freedesktop.UPower",
+    default_path = "/org/freedesktop/UPower/devices/battery_BAT0"
+)]
+trait UpowerBattery {
+    #[zbus(property)]
+    fn percentage(&self) -> zbus::Result<f64>;
+    #[zbus(property)]
+    fn state(&self) -> zbus::Result<u32>;
+    #[zbus(property)]
+    fn energy(&self) -> zbus::Result<f64>;
+    #[zbus(property)]
+    fn energy_full(&self) -> zbus::Result<f64>;
+    #[zbus(property)]
+    fn energy_rate(&self) -> zbus::Result<f64>;
+    #[zbus(property)]
+    fn time_to_empty(&self) -> zbus::Result<i64>;
+    #[zbus(property)]
+    fn time_to_full(&self) -> zbus::Result<i64>;
+    #[zbus(property)]
+    fn vendor(&self) -> zbus::Result<String>;
+    #[zbus(property)]
+    fn model(&self) -> zbus::Result<String>;
+}
+
+#[zbus::proxy(
+    interface = "org.freedesktop.UPower",
+    default_service = "org.freedesktop.UPower",
+    default_path = "/org/freedesktop/UPower"
+)]
+trait UpowerDaemon {
+    #[zbus(property, name = "OnBattery")]
+    fn on_battery(&self) -> zbus::Result<bool>;
+}
+
+// ── Helpers ─────────────────────────────────────────────────────────
+
+fn format_duration(secs: i64) -> String {
+    let h = secs / 3600;
+    let m = (secs % 3600) / 60;
+    if h > 0 { format!("{}h {}m", h, m) } else { format!("{}m", m) }
+}
+
+fn spawn_cpu_power(powersave: bool) {
+    let script = if powersave { "cpu-powersave-on" } else { "cpu-powersave-off" };
+    let _ = tokio::process::Command::new("pkexec")
+        .arg(format!("/home/lsgalante/.local/share/clear-system-interface/helpers/{}", script))
+        .spawn();
+}
+
+fn spawn_gpu_power(powersave: bool) {
+    let script = if powersave { "gpu-powersave-on" } else { "gpu-powersave-off" };
+    let _ = tokio::process::Command::new("pkexec")
+        .arg(format!("/home/lsgalante/.local/share/clear-system-interface/helpers/{}", script))
+        .spawn();
+}
+
+fn current_cpu_governor() -> String {
+    std::fs::read_to_string("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
+        .unwrap_or_default().trim().to_string()
+}
+
+async fn current_gpu_power_cap() -> bool {
+    tokio::process::Command::new("nvidia-smi")
+        .args(["--query-gpu=power.limit", "--format=csv,noheader,nounits"])
+        .output().await.ok()
+        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f32>().ok())
+        .map(|w| w <= 10.0).unwrap_or(false)
+}
+
+async fn fetch_upower() -> (BatteryInfo, bool) {
+    let conn = match zbus::Connection::system().await {
+        Ok(c) => c,
+        Err(_) => return (BatteryInfo::default(), true),
+    };
+
+    let battery = match UpowerBatteryProxy::new(&conn).await {
+        Ok(proxy) => BatteryInfo {
+            percentage: proxy.percentage().await.unwrap_or(0.0) as f32,
+            state: {
+                let s = proxy.state().await.unwrap_or(0);
+                match s { 1 => "charging", 2 => "discharging", 4 => "fully-charged", _ => "unknown" }.into()
+            },
+            energy: proxy.energy().await.unwrap_or(0.0),
+            energy_full: proxy.energy_full().await.unwrap_or(0.0),
+            energy_rate: proxy.energy_rate().await.unwrap_or(0.0),
+            time_to_empty: proxy.time_to_empty().await.unwrap_or(0),
+            time_to_full: proxy.time_to_full().await.unwrap_or(0),
+            vendor: proxy.vendor().await.unwrap_or_default(),
+            model: proxy.model().await.unwrap_or_default(),
+        },
+        Err(_) => BatteryInfo::default(),
+    };
+
+    let on_ac = match UpowerDaemonProxy::new(&conn).await {
+        Ok(proxy) => !proxy.on_battery().await.unwrap_or(false),
+        Err(_) => true,
+    };
+
+    (battery, on_ac)
+}
+
 fn read_cpu_temp() -> Option<f32> {
     if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") {
         for entry in entries.filter_map(|e| e.ok()) {
@@ -188,6 +321,10 @@ pub async fn fetch_hardware_state() -> HardwareState {
         Label::new(&text).with_font_size(12.0).with_color([212, 212, 212])
     }).collect();
 
+    let (battery, on_ac) = fetch_upower().await;
+    let cpu_powersave = current_cpu_governor() == "powersave";
+    let gpu_powersave = current_gpu_power_cap().await;
+
     HardwareState {
         cpu_model,
         cpu_usage,
@@ -198,11 +335,22 @@ pub async fn fetch_hardware_state() -> HardwareState {
         gpu_labels,
         processes,
         cpu_list_box: ScrollingList::new(24.0, 2.0),
+        battery,
+        on_ac,
+        cpu_powersave,
+        gpu_powersave,
     }
 }
 
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
+const BTN_ACTIVE: [f32; 4] = [0.20, 0.40, 0.22, 1.0];
+const BTN_INACTIVE: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
+const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
+const ORANGE: [f32; 4] = [1.0, 0.73, 0.20, 1.0];
 
 pub fn view(state: &mut HardwareState, cx: f32, cy: f32, cw: f32, _ch: f32, root_focused: bool) -> PageContent {
     let mut pc = PageContent::new();
@@ -237,12 +385,10 @@ pub fn view(state: &mut HardwareState, cx: f32, cy: f32, cw: f32, _ch: f32, root
         pc.text("CPU %", list_box_x + list_box_w - 60.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
 
         let row_h = 24.0;
-        let row_gap = 2.0;
         // Update ScrollingList bounds for the scrollable viewport (which starts below the header)
         state.cpu_list_box.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - 6.0);
 
         // Visible process rows rendering (virtualized/clipped)
-        
         for (idx, (pid, cpu, comm)) in state.processes.iter().enumerate() {
             if let Some(draw_y) = state.cpu_list_box.get_item_draw_y(idx, 4.0) {
                 // Standard row action button (transparent background, highlights on hover)
@@ -255,7 +401,7 @@ pub fn view(state: &mut HardwareState, cx: f32, cy: f32, cw: f32, _ch: f32, root
                     [0.0, 0.0, 0.0, 0.0],
                     [1.0, 1.0, 1.0, 0.06],
                     [0.0, 0.0, 0.0, 0.0],
-                    crate::app::AppAction::Hardware(HardwareMessage::None),
+                    AppAction::Hardware(HardwareMessage::None),
                 );
                 
                 pc.text(pid, list_box_x + 12.0, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
@@ -283,7 +429,130 @@ pub fn view(state: &mut HardwareState, cx: f32, cy: f32, cw: f32, _ch: f32, root
             sec_gpu.widget(&mut pc, gpu_lbl, 12.0, cw - 24.0, 26.0);
         }
     }
-    sec_gpu.finish(&mut pc);
+    y = sec_gpu.finish(&mut pc);
+
+    // ── Battery Section ──
+    let mut sec_bat = Section::new(&mut pc, cx, y, cw, "Battery");
+    if !state.loaded {
+        sec_bat.text(&mut pc, "Loading battery status...", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec_bat.spacing(18.0);
+    } else {
+        let bat = &state.battery;
+        let bat_icon = match bat.state.as_str() {
+            "charging" => "+",
+            "fully-charged" => "=",
+            _ => "",
+        };
+
+        let pct_color = if bat.percentage < 20.0 { RED }
+            else if bat.percentage < 50.0 { ORANGE }
+            else { ACCENT };
+
+        let pct_str = format!("{} {:.0}%", bat_icon, bat.percentage);
+        sec_bat.text(&mut pc, &pct_str, 12.0, 0.0, 24.0, pct_color);
+        sec_bat.spacing(30.0);
+
+        let state_str = format!("{}  •  {:.1}W  •  {:.1}/{:.1} Wh",
+            bat.state, bat.energy_rate, bat.energy, bat.energy_full);
+        sec_bat.text(&mut pc, &state_str, 12.0, 0.0, 12.0, TEXT_DIM);
+        sec_bat.spacing(18.0);
+
+        let time_str = if bat.time_to_empty > 0 {
+            format!("Time remaining: {}", format_duration(bat.time_to_empty))
+        } else if bat.time_to_full > 0 {
+            format!("Time to full: {}", format_duration(bat.time_to_full))
+        } else { String::new() };
+        if !time_str.is_empty() {
+            sec_bat.text(&mut pc, &time_str, 12.0, 0.0, 12.0, TEXT_DIM);
+            sec_bat.spacing(18.0);
+        }
+
+        let detail_str = format!("{}  {}", bat.vendor, bat.model);
+        sec_bat.text(&mut pc, &detail_str, 12.0, 0.0, 11.0, TEXT_DIM);
+        sec_bat.spacing(20.0);
+
+        let ac_str = if state.on_ac { "On AC Power" } else { "On Battery" };
+        sec_bat.text(&mut pc, ac_str, 12.0, 0.0, 14.0, TEXT_FG);
+    }
+    y = sec_bat.finish(&mut pc);
+
+    // ── CPU Governor section ──
+    let mut sec_gov = Section::new(&mut pc, cx, y, cw, "CPU Governor");
+    if !state.loaded {
+        sec_gov.text(&mut pc, "Loading CPU governor...", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec_gov.spacing(18.0);
+    } else {
+        let btn_h = 44.0;
+        let yt = sec_gov.ay();
+
+        sec_gov.row(2, 8.0, btn_h, |i, x, w| {
+            if i == 0 {
+                let perf_active = !state.cpu_powersave;
+                let (perf_bg, perf_desc, perf_desc_color) = if perf_active {
+                    (BTN_ACTIVE, "Governor set to performance", ACCENT)
+                } else {
+                    (BTN_INACTIVE, "Switch to performance governor", TEXT_DIM)
+                };
+
+                pc.button("Performance", x, yt, w, btn_h,
+                    perf_bg, BTN_HOVER, WHITE,
+                    AppAction::Hardware(HardwareMessage::SetCpuPerformance));
+                pc.text(perf_desc, x + 4.0, yt + 26.0, 10.0, perf_desc_color);
+            } else {
+                let (save_bg, save_desc, save_desc_color) = if state.cpu_powersave {
+                    (BTN_ACTIVE, "Governor set to powersave — lower power, slower burst", ACCENT)
+                } else {
+                    (BTN_INACTIVE, "Switch to powersave governor (requires auth)", TEXT_DIM)
+                };
+
+                pc.button("Powersave", x, yt, w, btn_h,
+                    save_bg, BTN_HOVER, WHITE,
+                    AppAction::Hardware(HardwareMessage::SetCpuPowersave));
+                pc.text(save_desc, x + 4.0, yt + 26.0, 10.0, save_desc_color);
+            }
+        });
+        sec_gov.spacing(12.0);
+    }
+    y = sec_gov.finish(&mut pc);
+
+    // ── GPU Power section ──
+    let mut sec_gpow = Section::new(&mut pc, cx, y, cw, "GPU Power");
+    if !state.loaded {
+        sec_gpow.text(&mut pc, "Loading GPU power status...", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec_gpow.spacing(18.0);
+    } else {
+        let btn_h = 44.0;
+        let yt = sec_gpow.ay();
+
+        sec_gpow.row(2, 8.0, btn_h, |i, x, w| {
+            if i == 0 {
+                let gpu_def_active = !state.gpu_powersave;
+                let (gpu_def_bg, gpu_def_desc, gpu_def_desc_c) = if gpu_def_active {
+                    (BTN_ACTIVE, "NVIDIA running at default power limit", ACCENT)
+                } else {
+                    (BTN_INACTIVE, "Restore default power limit (requires auth)", TEXT_DIM)
+                };
+
+                pc.button("80W Default", x, yt, w, btn_h,
+                    gpu_def_bg, BTN_HOVER, WHITE,
+                    AppAction::Hardware(HardwareMessage::SetGpuDefault));
+                pc.text(gpu_def_desc, x + 4.0, yt + 26.0, 10.0, gpu_def_desc_c);
+            } else {
+                let (gpu_cap_bg, gpu_cap_desc, gpu_cap_desc_c) = if state.gpu_powersave {
+                    (BTN_ACTIVE, "NVIDIA power limit capped at 5W — minimal draw", ACCENT)
+                } else {
+                    (BTN_INACTIVE, "Cap NVIDIA to 5W power limit (requires auth)", TEXT_DIM)
+                };
+
+                pc.button("5W Cap", x, yt, w, btn_h,
+                    gpu_cap_bg, BTN_HOVER, WHITE,
+                    AppAction::Hardware(HardwareMessage::SetGpuPowersave));
+                pc.text(gpu_cap_desc, x + 4.0, yt + 26.0, 10.0, gpu_cap_desc_c);
+            }
+        });
+        sec_gpow.spacing(12.0);
+    }
+    sec_gpow.finish(&mut pc);
 
     pc
 }
@@ -302,6 +571,27 @@ pub fn update(state: &mut HardwareState, msg: HardwareMessage) {
             let old_scroll = state.cpu_list_box.scroll_y();
             state.cpu_list_box = new.cpu_list_box;
             state.cpu_list_box.set_scroll_y(old_scroll);
+
+            state.battery = new.battery;
+            state.on_ac = new.on_ac;
+            state.cpu_powersave = new.cpu_powersave;
+            state.gpu_powersave = new.gpu_powersave;
+        }
+        HardwareMessage::SetCpuPerformance => {
+            state.cpu_powersave = false;
+            spawn_cpu_power(false);
+        }
+        HardwareMessage::SetCpuPowersave => {
+            state.cpu_powersave = true;
+            spawn_cpu_power(true);
+        }
+        HardwareMessage::SetGpuDefault => {
+            state.gpu_powersave = false;
+            spawn_gpu_power(false);
+        }
+        HardwareMessage::SetGpuPowersave => {
+            state.gpu_powersave = true;
+            spawn_gpu_power(true);
         }
         HardwareMessage::None => {}
     }
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index ce677a1..c1c82e4 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,4 +1,3 @@
-pub mod power;
 pub mod audio;
 pub mod network;
 pub mod display;
@@ -17,7 +16,6 @@ pub mod colors;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Page {
-    Power,
     Audio,
     Radios,
     Services,
@@ -35,7 +33,7 @@ pub enum Page {
 }
 
 impl Page {
-    pub const ALL: [Page; 15] = [
+    pub const ALL: [Page; 14] = [
         Page::Audio,
         Page::Backup,
         Page::Colors,
@@ -44,7 +42,6 @@ impl Page {
         Page::Layout,
         Page::Notifications,
         Page::Hardware,
-        Page::Power,
         Page::Radios,
         Page::Services,
         Page::Status,
@@ -55,7 +52,6 @@ impl Page {
 
     pub fn label(self) -> &'static str {
         match self {
-            Page::Power => "Power",
             Page::Audio => "Audio",
             Page::Radios => "Radios",
             Page::Services => "Services",
diff --git a/src/pages/power.rs b/src/pages/power.rs
deleted file mode 100644
index 0f37df6..0000000
--- a/src/pages/power.rs
+++ /dev/null
@@ -1,357 +0,0 @@
-use crate::app::{AppAction, PageContent};
-use clear_ui::layout::Section;
-
-#[derive(Debug, Clone, Default)]
-pub struct BatteryInfo {
-    pub percentage: f32,
-    pub state: String,
-    pub energy: f64,
-    pub energy_full: f64,
-    pub energy_rate: f64,
-    pub time_to_empty: i64,
-    pub time_to_full: i64,
-    pub vendor: String,
-    pub model: String,
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct PowerState {
-    pub loaded: bool,
-    pub battery: BatteryInfo,
-    pub on_ac: bool,
-    pub cpu_powersave: bool,
-    pub gpu_powersave: bool,
-}
-
-#[derive(Debug, Clone)]
-pub enum PowerMessage {
-    Refreshed(PowerState),
-    SetCpuPerformance,
-    SetCpuPowersave,
-    SetGpuDefault,
-    SetGpuPowersave,
-    Suspend,
-    Hibernate,
-    Reboot,
-    PowerOff,
-    Tick,
-}
-
-// ── zbus proxies ────────────────────────────────────────────────────
-
-#[zbus::proxy(
-    interface = "org.freedesktop.UPower.Device",
-    default_service = "org.freedesktop.UPower",
-    default_path = "/org/freedesktop/UPower/devices/battery_BAT0"
-)]
-trait UpowerBattery {
-    #[zbus(property)]
-    fn percentage(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn state(&self) -> zbus::Result<u32>;
-    #[zbus(property)]
-    fn energy(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn energy_full(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn energy_rate(&self) -> zbus::Result<f64>;
-    #[zbus(property)]
-    fn time_to_empty(&self) -> zbus::Result<i64>;
-    #[zbus(property)]
-    fn time_to_full(&self) -> zbus::Result<i64>;
-    #[zbus(property)]
-    fn vendor(&self) -> zbus::Result<String>;
-    #[zbus(property)]
-    fn model(&self) -> zbus::Result<String>;
-}
-
-#[zbus::proxy(
-    interface = "org.freedesktop.UPower",
-    default_service = "org.freedesktop.UPower",
-    default_path = "/org/freedesktop/UPower"
-)]
-trait UpowerDaemon {
-    #[zbus(property, name = "OnBattery")]
-    fn on_battery(&self) -> zbus::Result<bool>;
-}
-
-// ── Helpers ─────────────────────────────────────────────────────────
-
-fn format_duration(secs: i64) -> String {
-    let h = secs / 3600;
-    let m = (secs % 3600) / 60;
-    if h > 0 { format!("{}h {}m", h, m) } else { format!("{}m", m) }
-}
-
-fn spawn_systemctl(action: &str) {
-    let _ = tokio::process::Command::new("systemctl").arg(action).spawn();
-}
-
-fn spawn_cpu_power(powersave: bool) {
-    let script = if powersave { "cpu-powersave-on" } else { "cpu-powersave-off" };
-    let _ = tokio::process::Command::new("pkexec")
-        .arg(format!("/home/lsgalante/.local/share/clear-system-interface/helpers/{}", script))
-        .spawn();
-}
-
-fn spawn_gpu_power(powersave: bool) {
-    let script = if powersave { "gpu-powersave-on" } else { "gpu-powersave-off" };
-    let _ = tokio::process::Command::new("pkexec")
-        .arg(format!("/home/lsgalante/.local/share/clear-system-interface/helpers/{}", script))
-        .spawn();
-}
-
-fn current_cpu_governor() -> String {
-    std::fs::read_to_string("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
-        .unwrap_or_default().trim().to_string()
-}
-
-async fn current_gpu_power_cap() -> bool {
-    tokio::process::Command::new("nvidia-smi")
-        .args(["--query-gpu=power.limit", "--format=csv,noheader,nounits"])
-        .output().await.ok()
-        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f32>().ok())
-        .map(|w| w <= 10.0).unwrap_or(false)
-}
-
-pub async fn fetch_power_state() -> PowerState {
-    let (battery, on_ac) = fetch_upower().await;
-    let cpu_powersave = current_cpu_governor() == "powersave";
-    let gpu_powersave = current_gpu_power_cap().await;
-    PowerState { loaded: true, battery, on_ac, cpu_powersave, gpu_powersave }
-}
-
-async fn fetch_upower() -> (BatteryInfo, bool) {
-    let conn = match zbus::Connection::system().await {
-        Ok(c) => c,
-        Err(_) => return (BatteryInfo::default(), true),
-    };
-
-    let battery = match UpowerBatteryProxy::new(&conn).await {
-        Ok(proxy) => BatteryInfo {
-            percentage: proxy.percentage().await.unwrap_or(0.0) as f32,
-            state: {
-                let s = proxy.state().await.unwrap_or(0);
-                match s { 1 => "charging", 2 => "discharging", 4 => "fully-charged", _ => "unknown" }.into()
-            },
-            energy: proxy.energy().await.unwrap_or(0.0),
-            energy_full: proxy.energy_full().await.unwrap_or(0.0),
-            energy_rate: proxy.energy_rate().await.unwrap_or(0.0),
-            time_to_empty: proxy.time_to_empty().await.unwrap_or(0),
-            time_to_full: proxy.time_to_full().await.unwrap_or(0),
-            vendor: proxy.vendor().await.unwrap_or_default(),
-            model: proxy.model().await.unwrap_or_default(),
-        },
-        Err(_) => BatteryInfo::default(),
-    };
-
-    let on_ac = match UpowerDaemonProxy::new(&conn).await {
-        Ok(proxy) => !proxy.on_battery().await.unwrap_or(false),
-        Err(_) => true,
-    };
-
-    (battery, on_ac)
-}
-
-const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
-const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
-const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
-const BTN_ACTIVE: [f32; 4] = [0.20, 0.40, 0.22, 1.0];
-const BTN_INACTIVE: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
-const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
-const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
-const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
-const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
-const ORANGE: [f32; 4] = [1.0, 0.73, 0.20, 1.0];
-
-pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
-    let mut pc = PageContent::new();
-    let mut y = cy + 12.0;
-
-    // ── Battery section ──
-    let mut sec = Section::new(&mut pc, cx, y, cw, "Battery");
-
-    if !state.loaded {
-        sec.text(&mut pc, "Loading battery status...", 12.0, 0.0, 12.0, TEXT_DIM);
-        sec.spacing(18.0);
-    } else {
-        let bat = &state.battery;
-        let bat_icon = match bat.state.as_str() {
-            "charging" => "+",
-            "fully-charged" => "=",
-            _ => "",
-        };
-
-        let pct_color = if bat.percentage < 20.0 { RED }
-            else if bat.percentage < 50.0 { ORANGE }
-            else { ACCENT };
-
-        let pct_str = format!("{} {:.0}%", bat_icon, bat.percentage);
-        sec.text(&mut pc, &pct_str, 12.0, 0.0, 24.0, pct_color);
-        sec.spacing(30.0);
-
-        let state_str = format!("{}  •  {:.1}W  •  {:.1}/{:.1} Wh",
-            bat.state, bat.energy_rate, bat.energy, bat.energy_full);
-        sec.text(&mut pc, &state_str, 12.0, 0.0, 12.0, TEXT_DIM);
-        sec.spacing(18.0);
-
-        let time_str = if bat.time_to_empty > 0 {
-            format!("Time remaining: {}", format_duration(bat.time_to_empty))
-        } else if bat.time_to_full > 0 {
-            format!("Time to full: {}", format_duration(bat.time_to_full))
-        } else { String::new() };
-        if !time_str.is_empty() {
-            sec.text(&mut pc, &time_str, 12.0, 0.0, 12.0, TEXT_DIM);
-            sec.spacing(18.0);
-        }
-
-        let detail_str = format!("{}  {}", bat.vendor, bat.model);
-        sec.text(&mut pc, &detail_str, 12.0, 0.0, 11.0, TEXT_DIM);
-        sec.spacing(20.0);
-
-        let ac_str = if state.on_ac { "On AC Power" } else { "On Battery" };
-        sec.text(&mut pc, ac_str, 12.0, 0.0, 14.0, TEXT_FG);
-    }
-
-    y = sec.finish(&mut pc);
-
-    // ── CPU Governor section ──
-    let mut sec = Section::new(&mut pc, cx, y, cw, "CPU Governor");
-
-    if !state.loaded {
-        sec.text(&mut pc, "Loading CPU governor...", 12.0, 0.0, 12.0, TEXT_DIM);
-        sec.spacing(18.0);
-    } else {
-        let btn_h = 44.0;
-        let yt = sec.ay();
-
-        sec.row(2, 8.0, btn_h, |i, x, w| {
-            if i == 0 {
-                let perf_active = !state.cpu_powersave;
-                let (perf_bg, perf_desc, perf_desc_color) = if perf_active {
-                    (BTN_ACTIVE, "Governor set to performance", ACCENT)
-                } else {
-                    (BTN_INACTIVE, "Switch to performance governor", TEXT_DIM)
-                };
-
-                pc.button("Performance", x, yt, w, btn_h,
-                    perf_bg, BTN_HOVER, WHITE,
-                    AppAction::Power(PowerMessage::SetCpuPerformance));
-                pc.text(perf_desc, x + 4.0, yt + 26.0, 10.0, perf_desc_color);
-            } else {
-                let (save_bg, save_desc, save_desc_color) = if state.cpu_powersave {
-                    (BTN_ACTIVE, "Governor set to powersave — lower power, slower burst", ACCENT)
-                } else {
-                    (BTN_INACTIVE, "Switch to powersave governor (requires auth)", TEXT_DIM)
-                };
-
-                pc.button("Powersave", x, yt, w, btn_h,
-                    save_bg, BTN_HOVER, WHITE,
-                    AppAction::Power(PowerMessage::SetCpuPowersave));
-                pc.text(save_desc, x + 4.0, yt + 26.0, 10.0, save_desc_color);
-            }
-        });
-        sec.spacing(12.0);
-    }
-    y = sec.finish(&mut pc);
-
-    // ── GPU Power section ──
-    let mut sec = Section::new(&mut pc, cx, y, cw, "GPU Power");
-
-    if !state.loaded {
-        sec.text(&mut pc, "Loading GPU power status...", 12.0, 0.0, 12.0, TEXT_DIM);
-        sec.spacing(18.0);
-    } else {
-        let btn_h = 44.0;
-        let yt = sec.ay();
-
-        sec.row(2, 8.0, btn_h, |i, x, w| {
-            if i == 0 {
-                let gpu_def_active = !state.gpu_powersave;
-                let (gpu_def_bg, gpu_def_desc, gpu_def_desc_c) = if gpu_def_active {
-                    (BTN_ACTIVE, "NVIDIA running at default power limit", ACCENT)
-                } else {
-                    (BTN_INACTIVE, "Restore default power limit (requires auth)", TEXT_DIM)
-                };
-
-                pc.button("80W Default", x, yt, w, btn_h,
-                    gpu_def_bg, BTN_HOVER, WHITE,
-                    AppAction::Power(PowerMessage::SetGpuDefault));
-                pc.text(gpu_def_desc, x + 4.0, yt + 26.0, 10.0, gpu_def_desc_c);
-            } else {
-                let (gpu_cap_bg, gpu_cap_desc, gpu_cap_desc_c) = if state.gpu_powersave {
-                    (BTN_ACTIVE, "NVIDIA power limit capped at 5W — minimal draw", ACCENT)
-                } else {
-                    (BTN_INACTIVE, "Cap NVIDIA to 5W power limit (requires auth)", TEXT_DIM)
-                };
-
-                pc.button("5W Cap", x, yt, w, btn_h,
-                    gpu_cap_bg, BTN_HOVER, WHITE,
-                    AppAction::Power(PowerMessage::SetGpuPowersave));
-                pc.text(gpu_cap_desc, x + 4.0, yt + 26.0, 10.0, gpu_cap_desc_c);
-            }
-        });
-        sec.spacing(12.0);
-    }
-    y = sec.finish(&mut pc);
-
-    // ── System Actions section ──
-    let mut sec = Section::new(&mut pc, cx, y, cw, "System Actions");
-
-    let yt = sec.ay();
-    let act_btn_h = 32.0;
-
-    sec.row(4, 8.0, act_btn_h, |i, x, w| {
-        match i {
-            0 => {
-                pc.button("Suspend", x, yt, w, act_btn_h,
-                    SAFE_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::Suspend));
-            }
-            1 => {
-                pc.button("Hibernate", x, yt, w, act_btn_h,
-                    SAFE_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::Hibernate));
-            }
-            2 => {
-                pc.button("Reboot", x, yt, w, act_btn_h,
-                    DANGER_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::Reboot));
-            }
-            3 => {
-                pc.button("Power Off", x, yt, w, act_btn_h,
-                    DANGER_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::PowerOff));
-            }
-            _ => {}
-        }
-    });
-    sec.spacing(12.0);
-    sec.finish(&mut pc);
-
-    pc
-}
-
-pub fn update(state: &mut PowerState, msg: PowerMessage) {
-    match msg {
-        PowerMessage::Refreshed(new) => { *state = new; }
-        PowerMessage::SetCpuPerformance => {
-            state.cpu_powersave = false;
-            spawn_cpu_power(false);
-        }
-        PowerMessage::SetCpuPowersave => {
-            state.cpu_powersave = true;
-            spawn_cpu_power(true);
-        }
-        PowerMessage::SetGpuDefault => {
-            state.gpu_powersave = false;
-            spawn_gpu_power(false);
-        }
-        PowerMessage::SetGpuPowersave => {
-            state.gpu_powersave = true;
-            spawn_gpu_power(true);
-        }
-        PowerMessage::Suspend => spawn_systemctl("suspend"),
-        PowerMessage::Hibernate => spawn_systemctl("hibernate"),
-        PowerMessage::Reboot => spawn_systemctl("reboot"),
-        PowerMessage::PowerOff => spawn_systemctl("poweroff"),
-        PowerMessage::Tick => {} // handled externally
-    }
-}
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index 3e7929e..1b9e1cf 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,4 +1,4 @@
-use crate::app::PageContent;
+use crate::app::{AppAction, PageContent};
 use clear_ui::layout::Section;
 
 #[derive(Debug, Clone, Default)]
@@ -12,6 +12,10 @@ pub struct SystemState {
 #[derive(Debug, Clone)]
 pub enum SystemMessage {
     Refreshed(SystemState),
+    Suspend,
+    Hibernate,
+    Reboot,
+    PowerOff,
 }
 
 pub async fn fetch_system_state() -> SystemState {
@@ -35,10 +39,18 @@ pub async fn fetch_system_state() -> SystemState {
 
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
+const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
+const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+
+fn spawn_systemctl(action: &str) {
+    let _ = tokio::process::Command::new("systemctl").arg(action).spawn();
+}
 
 pub fn view(state: &SystemState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
-    let y = cy + 12.0;
+    let mut y = cy + 12.0;
 
     let mut sec = Section::new(&mut pc, cx, y, cw, "System");
     if !state.loaded {
@@ -49,13 +61,47 @@ pub fn view(state: &SystemState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCon
         sec.spacing(10.0);
         sec.text(&mut pc, &format!("Uptime: {}", state.uptime), 12.0, 0.0, 12.0, TEXT_DIM);
     }
-    sec.finish(&mut pc);
+    y = sec.finish(&mut pc);
+
+    // ── System Actions section ──
+    let mut sec_act = Section::new(&mut pc, cx, y, cw, "System Actions");
+
+    let yt = sec_act.ay();
+    let act_btn_h = 32.0;
+
+    sec_act.row(4, 8.0, act_btn_h, |i, x, w| {
+        match i {
+            0 => {
+                pc.button("Suspend", x, yt, w, act_btn_h,
+                    SAFE_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Suspend));
+            }
+            1 => {
+                pc.button("Hibernate", x, yt, w, act_btn_h,
+                    SAFE_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Hibernate));
+            }
+            2 => {
+                pc.button("Reboot", x, yt, w, act_btn_h,
+                    DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Reboot));
+            }
+            3 => {
+                pc.button("Power Off", x, yt, w, act_btn_h,
+                    DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::PowerOff));
+            }
+            _ => {}
+        }
+    });
+    sec_act.spacing(12.0);
+    sec_act.finish(&mut pc);
 
     pc
 }
 
-pub fn update(state: &mut SystemState, msg: SystemMessage) {
+pub fn update(_state: &mut SystemState, msg: SystemMessage) {
     match msg {
-        SystemMessage::Refreshed(new) => { *state = new; }
+        SystemMessage::Refreshed(new) => { *_state = new; }
+        SystemMessage::Suspend => spawn_systemctl("suspend"),
+        SystemMessage::Hibernate => spawn_systemctl("hibernate"),
+        SystemMessage::Reboot => spawn_systemctl("reboot"),
+        SystemMessage::PowerOff => spawn_systemctl("poweroff"),
     }
 }