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

commitfb49019043c28cfa85b6832ce883f22c2db53886
parent3605826180
authorLucas Galante <[email protected]>
date2026-05-25 20:36
Update system configuration and interface modules

 Cargo.lock            |   1 +
 Cargo.toml            |   1 +
 src/app.rs            |  17 ++
 src/main.rs           | 165 +++++++++++++--
 src/pages/input.rs    |  53 ++++-
 src/pages/mod.rs      |   6 +-
 src/pages/services.rs | 553 ++++++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 782 insertions(+), 14 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 812100a..ed8a1f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -317,6 +317,7 @@ dependencies = [
  "pollster",
  "raw-window-handle",
  "serde",
+ "serde_json",
  "smithay-client-toolkit",
  "tokio",
  "wayland-client",
diff --git a/Cargo.toml b/Cargo.toml
index deae035..4dbc9ef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ tokio = { version = "1", features = ["full"] }
 zbus = "5"
 futures = "0.3"
 serde = { version = "1", features = ["derive"] }
+serde_json = "1"
 
 [lib]
 name = "clear_system_interface"
diff --git a/src/app.rs b/src/app.rs
index 5a52a7d..353ea6c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -13,6 +13,7 @@ use crate::pages::storage;
 use crate::pages::system_info;
 use crate::pages::backup;
 use crate::pages::typeface;
+use crate::pages::services;
 use crate::pages::Page;
 
 pub struct AppState {
@@ -30,6 +31,7 @@ pub struct AppState {
     pub notifications: notifications::NotificationsState,
     pub backup: backup::BackupState,
     pub typeface: typeface::TypefaceState,
+    pub services: services::ServicesState,
 }
 
 impl Default for AppState {
@@ -49,6 +51,7 @@ impl Default for AppState {
             notifications: notifications::read_notifications_config(),
             backup: backup::BackupState::default(),
             typeface: typeface::TypefaceState::default(),
+            services: services::ServicesState::default(),
         }
     }
 }
@@ -68,6 +71,7 @@ pub enum AppAction {
     Notifications(notifications::NotificationsMessage),
     Backup(backup::BackupMessage),
     Typeface(typeface::TypefaceMessage),
+    Services(services::ServicesMessage),
 }
 
 pub struct PageContent {
@@ -85,6 +89,7 @@ pub struct ContentButton {
     pub label_size: f32,
     pub label_color: [f32; 4],
     pub action: AppAction,
+    pub left_align: bool,
 }
 
 impl PageContent {
@@ -111,6 +116,18 @@ impl PageContent {
             x, y, w, h, bg, hover_bg,
             label: label.to_string(), label_size: 12.0, label_color,
             action,
+            left_align: false,
+        });
+    }
+
+    pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
+                       bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
+                       action: AppAction) {
+        self.buttons.push(ContentButton {
+            x, y, w, h, bg, hover_bg,
+            label: label.to_string(), label_size: 12.0, label_color,
+            action,
+            left_align: true,
         });
     }
 }
diff --git a/src/main.rs b/src/main.rs
index 29b3a10..6d71f67 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -157,6 +157,7 @@ struct SystemInterface {
     rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
     rx_layout: std::sync::mpsc::Receiver<pages::layout::LayoutState>,
     rx_input: std::sync::mpsc::Receiver<pages::input::InputState>,
+    rx_fingers: std::sync::mpsc::Receiver<Vec<pages::input::Finger>>,
     rx_processors: std::sync::mpsc::Receiver<pages::processors::ProcessorsState>,
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemState>,
     rx_status: std::sync::mpsc::Receiver<pages::status::StatusState>,
@@ -164,6 +165,7 @@ struct SystemInterface {
     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>,
+    rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
     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>,
@@ -185,8 +187,10 @@ impl SystemInterface {
         xdg_shell_state: &XdgShell,
         width: u32,
         height: u32,
+        scale: f64,
     ) -> Self {
         let surface = compositor_state.create_surface(qh);
+        surface.set_buffer_scale(scale as i32);
         let window = xdg_shell_state.create_window(surface.clone(), WindowDecorations::None, qh);
         window.set_title("Clear System Interface");
         window.set_app_id("clear-system-interface");
@@ -325,6 +329,28 @@ impl SystemInterface {
             });
             rx
         };
+        let rx_fingers = {
+            let (tx, rx) = std::sync::mpsc::channel::<Vec<pages::input::Finger>>();
+            tokio::spawn(async move {
+                let socket_path = "/tmp/clear-input-coords.sock";
+                loop {
+                    if let Ok(stream) = tokio::net::UnixStream::connect(socket_path).await {
+                        use tokio::io::AsyncBufReadExt;
+                        let reader = tokio::io::BufReader::new(stream);
+                        let mut lines = reader.lines();
+                        while let Ok(Some(line)) = lines.next_line().await {
+                            if let Ok(fingers) = serde_json::from_str::<Vec<pages::input::Finger>>(&line) {
+                                if tx.send(fingers).is_err() {
+                                    return;
+                                }
+                            }
+                        }
+                    }
+                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+                }
+            });
+            rx
+        };
         let rx_system = spawn_bg(5, || pages::system_info::fetch_system_state());
         let rx_processors = spawn_bg(3, || pages::processors::fetch_processors_state());
         let rx_status = spawn_bg(10, || pages::status::fetch_status_state());
@@ -342,10 +368,11 @@ impl SystemInterface {
         };
         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());
         let (tx_backup, rx_backup) = std::sync::mpsc::channel();
 
         let (tx_color_selector, rx_color_selector) = std::sync::mpsc::channel();
-        let scale_factor = 2.0;
+        let scale_factor = scale;
 
         let mut this = Self {
             window, surface, wgpu_surface, device, queue, config, render_pipeline,
@@ -356,9 +383,9 @@ 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_power, rx_audio, rx_display, rx_network, rx_layout, rx_input, rx_fingers,
             rx_processors, rx_system, rx_status, rx_storage, rx_notifications,
-            rx_backup_state, rx_typeface, tx_backup, rx_backup,
+            rx_backup_state, rx_typeface, rx_services, tx_backup, rx_backup,
             tx_color_selector, rx_color_selector,
             width, height,
             needs_rebuild: true,
@@ -472,9 +499,43 @@ impl SystemInterface {
             let buf = make_text_buffer(&mut self.font_system, &btn.label, btn.label_size * s);
             let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
             let lh = btn.label_size * s * 1.4;
+            let mut left_align = btn.left_align;
+
+            // Auto-detect if inside a ScrollBox to apply left alignment by default
+            if !left_align && btn.w >= 60.0 {
+                if self.app.current_page == Page::Typeface {
+                    let sb = &self.app.typeface.list_box;
+                    let (sb_x, sb_y, sb_w, sb_h) = sb.rect();
+                    if btn.x >= sb_x - 1.0 && btn.x + btn.w <= sb_x + sb_w + 1.0
+                       && btn.y >= sb_y - 1.0 && btn.y + btn.h <= sb_y + sb_h + 1.0 {
+                        left_align = true;
+                    }
+                } else if self.app.current_page == Page::Processors {
+                    let sb = &self.app.processors.cpu_list_box;
+                    let (sb_x, sb_y, sb_w, sb_h) = sb.rect();
+                    if btn.x >= sb_x - 1.0 && btn.x + btn.w <= sb_x + sb_w + 1.0
+                       && btn.y >= sb_y - 1.0 && btn.y + btn.h <= sb_y + sb_h + 1.0 {
+                        left_align = true;
+                    }
+                } else if self.app.current_page == Page::Services {
+                    let sb = &self.app.services.list_box;
+                    let (sb_x, sb_y, sb_w, sb_h) = sb.rect();
+                    if btn.x >= sb_x - 1.0 && btn.x + btn.w <= sb_x + sb_w + 1.0
+                       && btn.y >= sb_y - 1.0 && btn.y + btn.h <= sb_y + sb_h + 1.0 {
+                        left_align = true;
+                    }
+                }
+            }
+
+            let text_x = if left_align {
+                btn.x * s + 8.0 * s
+            } else {
+                btn.x * s + (btn.w * s - tw) / 2.0
+            };
+
             text_items.push(TextItem {
                 buffer: buf,
-                x: btn.x * s + (btn.w * s - tw) / 2.0, y: (btn.y - scroll_offset_y) * s + (btn.h * s - lh) / 2.0,
+                x: text_x, y: (btn.y - scroll_offset_y) * s + (btn.h * s - lh) / 2.0,
                 color: glyphon::Color::rgb(
                     (btn.label_color[0] * 255.0) as u8,
                     (btn.label_color[1] * 255.0) as u8,
@@ -508,6 +569,7 @@ impl SystemInterface {
             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),
+            Page::Services => services::view(&mut self.app.services, cx, cy, cw, ch),
         }
     }
 
@@ -594,6 +656,14 @@ impl SystemInterface {
             input::update(&mut self.app.input, input::InputMessage::Refreshed(s));
             self.needs_rebuild = true;
         }
+        let mut got_fingers = None;
+        while let Ok(s) = self.rx_fingers.try_recv() {
+            got_fingers = Some(s);
+        }
+        if let Some(fingers) = got_fingers {
+            input::update(&mut self.app.input, input::InputMessage::UpdateFingers(fingers));
+            self.needs_rebuild = true;
+        }
         while let Ok(s) = self.rx_system.try_recv() {
             system_info::update(&mut self.app.system_info, system_info::SystemMessage::Refreshed(s));
             self.needs_rebuild = true;
@@ -622,6 +692,10 @@ impl SystemInterface {
             typeface::update(&mut self.app.typeface, typeface::TypefaceMessage::Refreshed(s));
             self.needs_rebuild = true;
         }
+        while let Ok(s) = self.rx_services.try_recv() {
+            pages::services::update(&mut self.app.services, pages::services::ServicesMessage::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;
@@ -654,6 +728,7 @@ impl SystemInterface {
             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::Services(m) => services::update(&mut self.app.services, m.clone()),
             AppAction::Backup(m) => match m {
                 pages::backup::BackupMessage::StartBackup => {
                     pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::StartBackup);
@@ -828,12 +903,21 @@ impl SystemInterface {
                 changed = true;
             }
         }
+        if self.app.current_page == Page::Services {
+            if self.app.services.search_box.cursor_moved(lx, ly) {
+                changed = true;
+            }
+            if self.app.services.list_box.cursor_moved(lx, ly) {
+                changed = true;
+            }
+        }
         if changed { self.needs_rebuild = true; }
         changed
     }
 
     fn handle_mouse_input(&mut self, button: clear_ui::widget::MouseButton, state: clear_ui::widget::ElementState) -> bool {
         if button != clear_ui::widget::MouseButton::Left { return false; }
+        let s = self.scale_factor as f32;
         if state == clear_ui::widget::ElementState::Released {
             let (px, py) = (self.cursor_x, self.cursor_y);
             for btn in &self.page_buttons.clone() {
@@ -856,7 +940,6 @@ impl SystemInterface {
                 }
             }
         }
-        let s = self.scale_factor as f32;
         let lx = self.cursor_x / s;
         let ly = self.cursor_y / s + self.scroll_y;
         let mut actions = Vec::new();
@@ -1115,6 +1198,13 @@ impl SystemInterface {
                 actions.push(AppAction::Typeface(pages::typeface::TypefaceMessage::SetSearch(tb.text.clone())));
             }
         }
+        if state == clear_ui::widget::ElementState::Pressed && self.app.current_page == Page::Services {
+            let tb = &mut self.app.services.search_box;
+            if !tb.hit_test(lx, ly) { tb.unfocus(); }
+            if tb.mouse_input(button, state, lx, ly) {
+                self.needs_rebuild = true;
+            }
+        }
         for a in &actions {
             self.handle_action(a);
         }
@@ -1139,6 +1229,13 @@ impl SystemInterface {
                     return true;
                 }
             }
+            if self.app.current_page == Page::Services {
+                let srv = &mut self.app.services;
+                if srv.list_box.mouse_wheel(delta, lx, ly) {
+                    self.needs_rebuild = true;
+                    return true;
+                }
+            }
             if self.app.current_page == Page::Processors {
                 let proc = &mut self.app.processors;
                 if proc.cpu_list_box.mouse_wheel(delta, lx, ly) {
@@ -1387,6 +1484,14 @@ impl SystemInterface {
                 return true;
             }
         }
+        if self.app.current_page == Page::Services {
+            let tb = &mut self.app.services.search_box;
+            if tb.keyboard_input(event) {
+                tb.take_change();
+                self.needs_rebuild = true;
+                return true;
+            }
+        }
         false
     }
 
@@ -1458,6 +1563,9 @@ struct App {
     pointer: Option<wl_pointer::WlPointer>,
     keyboard: Option<wl_keyboard::WlKeyboard>,
 
+    window: Option<XdgWindow>,
+    surface: Option<wl_surface::WlSurface>,
+
     state: Option<SystemInterface>,
     initial_page: Page,
     exit: bool,
@@ -1474,9 +1582,15 @@ impl CompositorHandler for App {
         _surface: &wl_surface::WlSurface,
         scale_factor: i32,
     ) {
+        _surface.set_buffer_scale(scale_factor);
         if let Some(state) = &mut self.state {
-            state.scale_factor = (scale_factor as f32).max(2.0) as f64;
-            state.resize(state.width, state.height);
+            let old_scale = state.scale_factor;
+            state.scale_factor = scale_factor as f64;
+            let logical_w = state.width as f64 / old_scale;
+            let logical_h = state.height as f64 / old_scale;
+            let pw = (logical_w * state.scale_factor) as u32;
+            let ph = (logical_h * state.scale_factor) as u32;
+            state.resize(pw, ph);
         }
         self.redraw = true;
     }
@@ -1586,11 +1700,17 @@ impl PointerHandler for App {
     ) {
         use smithay_client_toolkit::seat::pointer::PointerEventKind;
         for event in events {
-            let (x, y) = event.position;
+            if let Some(st) = &mut self.state {
+                let (cx, cy) = clear_ui::wayland::scale_pointer_pos(event.position, st.scale_factor);
+                st.cursor_x = cx;
+                st.cursor_y = cy;
+            }
             match &event.kind {
                 PointerEventKind::Motion { .. } => {
                     if let Some(st) = &mut self.state {
-                        if st.handle_cursor_moved(x as f32, y as f32) {
+                        let cx = st.cursor_x;
+                        let cy = st.cursor_y;
+                        if st.handle_cursor_moved(cx, cy) {
                             self.redraw = true;
                         }
                     }
@@ -1741,7 +1861,9 @@ impl WindowHandler for App {
             let width = w.get();
             let height = h.get();
             if let Some(state) = &mut self.state {
-                state.resize(width, height);
+                let pw = (width as f64 * state.scale_factor) as u32;
+                let ph = (height as f64 * state.scale_factor) as u32;
+                state.resize(pw, ph);
             }
         }
         self.redraw = true;
@@ -1821,20 +1943,33 @@ fn main() {
         seats: Vec::new(),
         pointer: None,
         keyboard: None,
+        window: None,
+        surface: None,
         state: None,
         initial_page,
         exit: false,
         redraw: true,
     };
 
+    // Perform a roundtrip to populate output_state with active output scales
+    event_queue.roundtrip(&mut app).unwrap();
+
+    let scale = clear_ui::wayland::detect_scale_factor(&app.output_state);
+
+    let pw = (820.0 * scale) as u32;
+    let ph = (680.0 * scale) as u32;
+
     let state = pollster::block_on(SystemInterface::new(
         &conn,
         &qh,
         &app.compositor_state,
         &app.xdg_shell_state,
-        820,
-        680,
+        pw,
+        ph,
+        scale,
     ));
+    app.window = Some(state.window.clone());
+    app.surface = Some(state.surface.clone());
     app.state = Some(state);
 
     let mut event_loop = EventLoop::try_new().unwrap();
@@ -1848,6 +1983,12 @@ fn main() {
         if app.exit {
             break;
         }
+        if let Some(state) = &mut app.state {
+            state.poll_background_updates();
+            if state.needs_rebuild {
+                app.redraw = true;
+            }
+        }
         if app.redraw {
             app.redraw = false;
             if let Some(state) = &mut app.state {
diff --git a/src/pages/input.rs b/src/pages/input.rs
index 3cade7f..47ca0d1 100644
--- a/src/pages/input.rs
+++ b/src/pages/input.rs
@@ -8,6 +8,13 @@ use clear_ui::widget::{Spinbox, Toggle};
 const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
 const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
 
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct Finger {
+    pub slot: usize,
+    pub x: f32,
+    pub y: f32,
+}
+
 #[derive(Debug, Clone)]
 pub struct Keybind {
     pub mods: String,
@@ -25,6 +32,7 @@ pub struct InputState {
     pub delay_spinbox: Spinbox,
     pub tap_toggle: Toggle,
     pub keybinds: Vec<Keybind>,
+    pub fingers: Vec<Finger>,
 
     // Inertial settings
     pub inertial_scroll: bool,
@@ -52,6 +60,7 @@ impl Default for InputState {
             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(),
+            fingers: Vec::new(),
 
             inertial_scroll: true,
             scroll_friction: 90,
@@ -75,6 +84,7 @@ pub enum InputMessage {
     ToggleTapToClick,
     ApplyRepeat,
     Refreshed(InputState),
+    UpdateFingers(Vec<Finger>),
 
     ToggleInertialScroll,
     ApplyScrollFriction,
@@ -105,6 +115,7 @@ pub fn read_input_config() -> InputState {
         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),
+        fingers: Vec::new(),
 
         inertial_scroll,
         scroll_friction,
@@ -248,7 +259,42 @@ pub fn view(state: &mut InputState, cx: f32, cy: f32, cw: f32, _ch: f32) -> Page
     let toggle_h = 24.0;
     state.tap_toggle.set_toggled(state.tap_to_click);
     sec.widget(&mut pc, &mut state.tap_toggle, 14.0, toggle_w, toggle_h);
-    sec.spacing(4.0);
+    sec.spacing(8.0);
+
+    // Centered trackpad visualizer box
+    let pad_w = 280.0;
+    let pad_h = 140.0;
+    let pad_x = sec.ax((cw - 16.0 - pad_w) / 2.0);
+    let pad_y = sec.ay();
+
+    // Background of trackpad: sleek dark translucent blue/grey
+    pc.rect([0.11, 0.11, 0.16, 0.85], pad_x, pad_y, pad_w, pad_h);
+
+    // Border: clean border
+    let border_color = [0.28, 0.28, 0.38, 1.0];
+    pc.rect(border_color, pad_x, pad_y, pad_w, 1.0);
+    pc.rect(border_color, pad_x, pad_y + pad_h - 1.0, pad_w, 1.0);
+    pc.rect(border_color, pad_x, pad_y, 1.0, pad_h);
+    pc.rect(border_color, pad_x + pad_w - 1.0, pad_y, 1.0, pad_h);
+
+    // Sleek label in the touchpad area
+    pc.text("Touchpad Area", pad_x + 12.0, pad_y + pad_h - 22.0, 11.0, [0.45, 0.45, 0.55, 1.0]);
+
+    // Active fingers visualizer
+    for finger in &state.fingers {
+        let rx = finger.x.clamp(0.0, 1.0);
+        let ry = finger.y.clamp(0.0, 1.0);
+        let fx = pad_x + rx * pad_w;
+        let fy = pad_y + ry * pad_h;
+        let dot_size = 12.0;
+
+        // Render glow (outer light blue rectangle)
+        pc.rect([0.35, 0.55, 0.95, 0.4], fx - (dot_size + 6.0) / 2.0, fy - (dot_size + 6.0) / 2.0, dot_size + 6.0, dot_size + 6.0);
+        // Render core (solid blue/purple rectangle)
+        pc.rect([0.45, 0.65, 1.0, 1.0], fx - dot_size / 2.0, fy - dot_size / 2.0, dot_size, dot_size);
+    }
+
+    sec.spacing(pad_h + 12.0);
     y = sec.finish(&mut pc);
 
     // ── Keyboard ──
@@ -352,7 +398,12 @@ pub fn update(state: &mut InputState, msg: InputMessage) {
             write_config_value("trackpad_friction", &friction.to_string());
         }
         InputMessage::Refreshed(new) => {
+            let fingers = state.fingers.clone();
             *state = new;
+            state.fingers = fingers;
+        }
+        InputMessage::UpdateFingers(fingers) => {
+            state.fingers = fingers;
         }
     }
 }
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 64e232d..80e3046 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -12,12 +12,14 @@ pub mod processors;
 pub mod notifications;
 pub mod backup;
 pub mod typeface;
+pub mod services;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Page {
     Power,
     Audio,
     Radios,
+    Services,
     Storage,
     Display,
     Layout,
@@ -31,7 +33,7 @@ pub enum Page {
 }
 
 impl Page {
-    pub const ALL: [Page; 13] = [
+    pub const ALL: [Page; 14] = [
         Page::Audio,
         Page::Backup,
         Page::Display,
@@ -41,6 +43,7 @@ impl Page {
         Page::Processors,
         Page::Power,
         Page::Radios,
+        Page::Services,
         Page::Status,
         Page::Storage,
         Page::System,
@@ -52,6 +55,7 @@ impl Page {
             Page::Power => "Power",
             Page::Audio => "Audio",
             Page::Radios => "Radios",
+            Page::Services => "Services",
             Page::Storage => "Storage",
             Page::Display => "Display",
             Page::Layout => "Layout",
diff --git a/src/pages/services.rs b/src/pages/services.rs
new file mode 100644
index 0000000..91f2f7a
--- /dev/null
+++ b/src/pages/services.rs
@@ -0,0 +1,553 @@
+use crate::app::PageContent;
+use clear_ui::layout::Section;
+use clear_ui::widget::{Widget, TextLabel, ScrollBox};
+use clear_ui::widget::{ElementState, KeyEvent, MouseButton, Key, NamedKey};
+
+// ── 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,
+    label: Option<String>,
+    row_x: f32,
+    row_w: f32,
+}
+
+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,
+            label: None,
+            row_x: 0.0,
+            row_w: 0.0,
+        }
+    }
+
+    pub fn with_label(mut self, label: &str) -> Self {
+        self.label = Some(label.to_string());
+        self
+    }
+
+    pub fn set_label(&mut self, label: &str) {
+        self.label = Some(label.to_string());
+    }
+
+    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_row_rect(&mut self, x: f32, w: f32) { self.row_x = x; self.row_w = w; }
+    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 hit_test(&self, px: f32, py: f32) -> bool {
+        let (x, y, w, h) = self.rect();
+        let hx = if self.row_w > 0.0 { self.row_x } else { x };
+        let hw = if self.row_w > 0.0 { self.row_w } else { w };
+        let (hy, hh) = if self.label.is_some() {
+            (y - 18.0, h + 18.0)
+        } else {
+            (y, h)
+        };
+        px >= hx && px <= hx + hw && py >= hy && py <= hy + hh
+    }
+
+    fn top_room(&self) -> f32 { if self.label.is_some() { 18.0 } else { 0.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 == '*' || ch == '.' || ch == '@' {
+                                self.edit_buffer.push(ch);
+                            }
+                        }
+                    }
+                }
+                true
+            }
+        }
+    }
+
+    fn hover_highlight(&self) -> Option<[f32; 4]> {
+        None
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let mut quads = Vec::new();
+        if self.hovered {
+            let (hy, hh) = if self.label.is_some() {
+                (self.y - 18.0, self.h + 18.0)
+            } else {
+                (self.y, self.h)
+            };
+            let hx = if self.row_w > 0.0 { self.row_x } else { self.x };
+            let hw = if self.row_w > 0.0 { self.row_w } else { self.w };
+            quads.push((hx, hy, hw, hh, [1.0, 1.0, 1.0, 0.06]));
+        }
+        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]
+        };
+        quads.push((self.x, self.y, self.w, self.h, border_color));
+        quads.push((self.x + 1.0, self.y + 1.0, self.w - 2.0, self.h - 2.0, bg_color));
+        quads
+    }
+
+    fn text_labels(&self) -> Vec<TextLabel> {
+        let mut labels = Vec::new();
+        if let Some(ref label) = self.label {
+            labels.push(TextLabel {
+                text: label.clone(),
+                x: self.x + 4.0,
+                y: self.y - 14.0,
+                font_size: 12.0,
+                color: [0x83, 0x83, 0x8a],
+            });
+        }
+        let val_text = if self.editing {
+            format!("{}|", self.edit_buffer)
+        } else {
+            self.text.clone()
+        };
+        labels.push(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] },
+        });
+        labels
+    }
+}
+
+// ── Service Types and Page State ──
+
+#[derive(Debug, Clone)]
+pub struct ServiceInfo {
+    pub name: String,
+    pub description: String,
+    pub active_state: String,
+    pub sub_state: String,
+    pub is_system: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ServiceTab {
+    System,
+    User,
+}
+
+impl Default for ServiceTab {
+    fn default() -> Self {
+        ServiceTab::System
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ServicesState {
+    pub loaded: bool,
+    pub services: Vec<ServiceInfo>,
+    pub active_tab: ServiceTab,
+    pub search_box: TextBox,
+    pub list_box: ScrollBox,
+}
+
+impl Default for ServicesState {
+    fn default() -> Self {
+        Self {
+            loaded: false,
+            services: Vec::new(),
+            active_tab: ServiceTab::System,
+            search_box: TextBox::new(String::new()).with_label("Filter Services"),
+            list_box: ScrollBox::new(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum ServicesMessage {
+    Refreshed(Vec<ServiceInfo>),
+    SetTab(ServiceTab),
+    Start(String, bool),
+    Stop(String, bool),
+    Restart(String, bool),
+}
+
+// ── Background Fetching ──
+
+pub async fn fetch_services() -> Vec<ServiceInfo> {
+    let mut services = Vec::new();
+
+    // 1. Fetch system-level services
+    if let Ok(output) = tokio::process::Command::new("systemctl")
+        .args(["list-units", "--type=service", "--all", "--no-legend"])
+        .output()
+        .await
+    {
+        let stdout = String::from_utf8_lossy(&output.stdout);
+        for line in stdout.lines() {
+            if let Some(info) = parse_service_line(line, true) {
+                services.push(info);
+            }
+        }
+    }
+
+    // 2. Fetch user-level services
+    if let Ok(output) = tokio::process::Command::new("systemctl")
+        .args(["--user", "list-units", "--type=service", "--all", "--no-legend"])
+        .output()
+        .await
+    {
+        let stdout = String::from_utf8_lossy(&output.stdout);
+        for line in stdout.lines() {
+            if let Some(info) = parse_service_line(line, false) {
+                services.push(info);
+            }
+        }
+    }
+
+    // Sort alphabetically by name
+    services.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+    services
+}
+
+fn parse_service_line(line: &str, is_system: bool) -> Option<ServiceInfo> {
+    let cleaned = line.trim_start_matches('●').trim();
+    if cleaned.is_empty() {
+        return None;
+    }
+    let parts: Vec<&str> = cleaned.split_whitespace().collect();
+    if parts.len() >= 4 && parts[0].ends_with(".service") {
+        let name = parts[0].to_string();
+        let _load = parts[1];
+        let active_state = parts[2].to_string();
+        let sub_state = parts[3].to_string();
+        let description = parts[4..].join(" ");
+        Some(ServiceInfo {
+            name,
+            description,
+            active_state,
+            sub_state,
+            is_system,
+        })
+    } else {
+        None
+    }
+}
+
+fn service_action(name: &str, action: &str, is_system: bool) {
+    if is_system {
+        // System service needs root privilege, spawn via pkexec
+        let _ = tokio::process::Command::new("pkexec")
+            .args(["systemctl", action, name])
+            .spawn();
+    } else {
+        // User service does not need root
+        let _ = tokio::process::Command::new("systemctl")
+            .args(["--user", action, name])
+            .spawn();
+    }
+}
+
+// ── View & Update ──
+
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+
+pub fn view(state: &mut ServicesState, 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, "Services");
+
+    if !state.loaded {
+        sec.text(&mut pc, "Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
+    } else {
+        // Tab header buttons: System Services, User Services
+        let tab_w = 140.0;
+        let tab_h = 28.0;
+        let tab_y = sec.ay();
+        let active_bg = [0.20, 0.40, 0.65, 0.4];
+        let inactive_bg = [0.10, 0.10, 0.16, 0.3];
+        let hover_bg = [0.20, 0.20, 0.25, 0.15];
+
+        pc.button(
+            "System Services",
+            cx + 12.0,
+            tab_y,
+            tab_w,
+            tab_h,
+            if state.active_tab == ServiceTab::System { active_bg } else { inactive_bg },
+            hover_bg,
+            [0.90, 0.90, 0.95, 1.0],
+            crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)),
+        );
+
+        pc.button(
+            "User Services",
+            cx + 12.0 + tab_w + 8.0,
+            tab_y,
+            tab_w,
+            tab_h,
+            if state.active_tab == ServiceTab::User { active_bg } else { inactive_bg },
+            hover_bg,
+            [0.90, 0.90, 0.95, 1.0],
+            crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)),
+        );
+        sec.content_y += tab_h + 12.0;
+
+        // Search textbox
+        let search_y = sec.ay() + state.search_box.top_room();
+        let search_w = cw - 24.0;
+        let search_h = 28.0;
+        
+        state.search_box.set_row_rect(cx + 12.0, search_w);
+        clear_ui::layout::render_widget(
+            &mut pc,
+            &mut state.search_box,
+            cx + 12.0,
+            search_y,
+            search_w,
+            search_h,
+        );
+        sec.content_y += search_h + state.search_box.top_room() + 16.0;
+
+        // Scroll box list
+        let list_box_x = cx + 12.0;
+        let list_box_y = sec.ay();
+        let list_box_w = cw - 24.0;
+        let list_box_h = 360.0;
+        
+        clear_ui::layout::render_widget(&mut pc, &mut state.list_box, list_box_x, list_box_y, list_box_w, list_box_h);
+
+        // Filter services
+        let query = if state.search_box.editing {
+            state.search_box.edit_buffer.to_lowercase()
+        } else {
+            state.search_box.text.to_lowercase()
+        };
+        let filtered_services: Vec<&ServiceInfo> = state.services.iter()
+            .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
+            .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
+            .collect();
+
+        let item_h = 36.0;
+        let item_gap = 6.0;
+        let item_height_full = item_h + item_gap;
+        let content_h = filtered_services.len() as f32 * item_height_full;
+
+        state.list_box.update_bounds(content_h, list_box_y, list_box_h);
+
+        for (idx, service) in filtered_services.iter().enumerate() {
+            let virtual_y = idx as f32 * item_height_full + 4.0;
+            if let Some(draw_y) = state.list_box.get_item_draw_y(virtual_y, item_h) {
+                // Item background
+                let bg_color = [0.08, 0.08, 0.12, 0.2];
+                pc.rect(bg_color, list_box_x + 4.0, draw_y, list_box_w - 24.0, item_h);
+
+                // Status indicator color
+                let is_active = service.active_state == "active" || service.sub_state == "running";
+                let status_color = if service.active_state == "failed" {
+                    [0.85, 0.25, 0.25, 1.0] // failed = red
+                } else if is_active {
+                    [0.25, 0.75, 0.35, 1.0] // active = green
+                } else {
+                    [0.55, 0.55, 0.60, 1.0] // inactive/dead = gray
+                };
+
+                // Render status dot (small square)
+                pc.rect(status_color, list_box_x + 14.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0);
+
+                // Service Name
+                pc.text(&service.name, list_box_x + 32.0, draw_y + 4.0, 13.0, [0.90, 0.90, 0.95, 1.0]);
+
+                // Service Description
+                let desc = if service.description.is_empty() { "No description" } else { &service.description };
+                let desc_truncated = if desc.len() > 65 { format!("{}...", &desc[..62]) } else { desc.to_string() };
+                pc.text(&desc_truncated, list_box_x + 32.0, draw_y + 19.0, 11.0, [0.55, 0.55, 0.60, 1.0]);
+
+                // Control buttons: Start, Stop, Restart on the right
+                let btn_w = 46.0;
+                let r_btn_w = 54.0;
+                let btn_gap = 6.0;
+                let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
+
+                let restart_x = right_edge - r_btn_w;
+                let stop_x = restart_x - btn_gap - btn_w;
+                let start_x = stop_x - btn_gap - btn_w;
+
+                let btn_y = draw_y + (item_h - 22.0) / 2.0;
+                let btn_h = 22.0;
+
+                let active_txt = [0.90, 0.90, 0.95, 1.0];
+                let disabled_txt = [0.40, 0.40, 0.45, 1.0];
+
+                // Start button
+                pc.button(
+                    "Start",
+                    start_x,
+                    btn_y,
+                    btn_w,
+                    btn_h,
+                    if !is_active { [0.16, 0.35, 0.18, 0.4] } else { [0.12, 0.12, 0.16, 0.1] },
+                    [0.22, 0.45, 0.25, 0.6],
+                    if !is_active { active_txt } else { disabled_txt },
+                    crate::app::AppAction::Services(ServicesMessage::Start(service.name.clone(), service.is_system)),
+                );
+
+                // Stop button
+                pc.button(
+                    "Stop",
+                    stop_x,
+                    btn_y,
+                    btn_w,
+                    btn_h,
+                    if is_active { [0.55, 0.16, 0.16, 0.3] } else { [0.12, 0.12, 0.16, 0.1] },
+                    [0.70, 0.22, 0.22, 0.5],
+                    if is_active { active_txt } else { disabled_txt },
+                    crate::app::AppAction::Services(ServicesMessage::Stop(service.name.clone(), service.is_system)),
+                );
+
+                // Restart button
+                pc.button(
+                    "Restart",
+                    restart_x,
+                    btn_y,
+                    r_btn_w,
+                    btn_h,
+                    [0.15, 0.28, 0.45, 0.3],
+                    [0.20, 0.38, 0.58, 0.5],
+                    active_txt,
+                    crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
+                );
+            }
+        }
+
+        if filtered_services.is_empty() {
+            pc.text("No services match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
+        }
+
+        sec.content_y += list_box_h;
+    }
+
+    sec.finish(&mut pc);
+    pc
+}
+
+pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
+    match msg {
+        ServicesMessage::Refreshed(new_services) => {
+            state.loaded = true;
+            state.services = new_services;
+        }
+        ServicesMessage::SetTab(tab) => {
+            state.active_tab = tab;
+            state.list_box.scroll_y = 0.0;
+        }
+        ServicesMessage::Start(name, is_system) => {
+            if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
+                srv.active_state = "activating".to_string();
+                srv.sub_state = "starting".to_string();
+            }
+            service_action(&name, "start", is_system);
+        }
+        ServicesMessage::Stop(name, is_system) => {
+            if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
+                srv.active_state = "deactivating".to_string();
+                srv.sub_state = "stopping".to_string();
+            }
+            service_action(&name, "stop", is_system);
+        }
+        ServicesMessage::Restart(name, is_system) => {
+            if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
+                srv.active_state = "activating".to_string();
+                srv.sub_state = "restarting".to_string();
+            }
+            service_action(&name, "restart", is_system);
+        }
+    }
+}