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

commit59c60b333b71698b1a59a3d1cfa1eb181c774314
parent88c610d642
authorLucas Galante <[email protected]>
date2026-08-01 23:11
feat: Services moves to its own page; Processes and Services wells lose their labels

pages/services.rs now owns the services state, view, systemctl
update/fetch, and AppPage impl (extracted verbatim from processes.rs,
message names de-prefixed). New Page::Services entry in the dropdown
order; the services watcher gates on the new page index. Both pages
render as single label-less wells (tabless carve).

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

 src/app.rs             |   6 +
 src/main.rs            |   7 +-
 src/pages/mod.rs       |   6 +-
 src/pages/processes.rs | 395 ++-------------------------------------------
 src/pages/services.rs  | 426 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/renderer.rs        |   4 +-
 src/watchers.rs        |   6 +-
 7 files changed, 461 insertions(+), 389 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 88bb081..d9e625d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3,6 +3,7 @@ use cce_ui::layout::RenderTarget;
 use crate::pages::audio;
 use crate::pages::network;
 use crate::pages::processes;
+use crate::pages::services;
 use crate::pages::system_info;
 use crate::pages::storage;
 use crate::pages::fonts;
@@ -16,6 +17,7 @@ pub struct AppState {
     pub audio: audio::AudioState,
     pub network: network::NetworkState,
     pub processes: processes::ProcessesState,
+    pub services: services::ServicesState,
     pub system_info: system_info::SystemState,
     pub storage: storage::StorageState,
     pub fonts: fonts::FontsState,
@@ -31,6 +33,7 @@ impl Default for AppState {
             audio: audio::AudioState::default(),
             network: network::NetworkState::default(),
             processes: processes::ProcessesState::default(),
+            services: services::ServicesState::default(),
             system_info: system_info::SystemState::default(),
             storage: storage::StorageState::default(),
             fonts: fonts::FontsState::default(),
@@ -48,6 +51,7 @@ impl AppState {
             Page::Audio => &self.audio,
             Page::Packages => &self.packages,
             Page::Processes => &self.processes,
+            Page::Services => &self.services,
             Page::Radios => &self.network,
             Page::Storage => &self.storage,
             Page::System => &self.system_info,
@@ -62,6 +66,7 @@ impl AppState {
             Page::Audio => &mut self.audio,
             Page::Packages => &mut self.packages,
             Page::Processes => &mut self.processes,
+            Page::Services => &mut self.services,
             Page::Radios => &mut self.network,
             Page::Storage => &mut self.storage,
             Page::System => &mut self.system_info,
@@ -85,6 +90,7 @@ pub enum AppAction {
     Audio(audio::AudioMessage),
     Radios(network::NetworkMessage),
     Processes(processes::ProcessesMessage),
+    Services(services::ServicesMessage),
     SystemInfo(system_info::SystemMessage),
     Storage(storage::StorageMessage),
     Fonts(fonts::FontsMessage),
diff --git a/src/main.rs b/src/main.rs
index f500e25..6f1710a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -59,7 +59,7 @@ struct SystemInterface {
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsConfig>,
-    rx_services: std::sync::mpsc::Receiver<Vec<pages::processes::ServiceInfo>>,
+    rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
     rx_fonts: std::sync::mpsc::Receiver<pages::fonts::FontsState>,
     rx_accounts: std::sync::mpsc::Receiver<Vec<pages::accounts::AccountInfo>>,
     tx_backup: std::sync::mpsc::Sender<pages::storage::StorageMessage>,
@@ -518,8 +518,8 @@ impl SystemInterface {
             }
         }
         while let Ok(s) = self.rx_services.try_recv() {
-            processes::update(&mut self.app.processes, processes::ProcessesMessage::ServicesRefreshed(s));
-            if self.app.current_page == Page::Processes {
+            services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s));
+            if self.app.current_page == Page::Services {
                 self.needs_rebuild = true;
             }
         }
@@ -558,6 +558,7 @@ impl SystemInterface {
             AppAction::Radios(m) => network::update(&mut self.app.network, m.clone()),
             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
             AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
+            AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
             AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
             AppAction::Storage(m) => match m {
                 pages::storage::StorageMessage::StartBackup => {
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index f19e394..07e1ea7 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -3,6 +3,7 @@ pub mod network;
 pub mod storage;
 pub mod system_info;
 pub mod processes;
+pub mod services;
 pub mod fonts;
 pub mod accounts;
 pub mod packages;
@@ -17,12 +18,13 @@ pub enum Page {
     Storage,
     System,
     Processes,
+    Services,
     Fonts,
     Packages,
 }
 
 impl Page {
-    pub const ALL: [Page; 9] = [
+    pub const ALL: [Page; 10] = [
         Page::Accounts,
         Page::Audio,
         Page::Fonts,
@@ -30,6 +32,7 @@ impl Page {
         Page::Packages,
         Page::Processes,
         Page::Radios,
+        Page::Services,
         Page::Storage,
         Page::System,
     ];
@@ -43,6 +46,7 @@ impl Page {
             Page::Storage => "Storage",
             Page::System => "System",
             Page::Processes => "Processes",
+            Page::Services => "Services",
             Page::Fonts => "Fonts",
             Page::Packages => "Packages",
         }
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 11f421f..9571a21 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -1,42 +1,12 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
 use crate::scroll_region::ScrollRegion;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
-use cce_ui::widget::{TextBox, StatusDot, DotStatus, InteractiveListItem, WidgetHost};
-
-#[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
-    }
-}
+use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
 
 #[derive(Debug, Clone)]
 pub struct ProcessesState {
     pub loaded: bool,
     pub processes: Vec<(String, String, String)>, // (pid, cpu, comm)
     pub cpu_list: ScrollRegion,
-
-    // Services-related fields
-    pub services_loaded: bool,
-    pub services: Vec<ServiceInfo>,
-    pub services_active_tab: ServiceTab,
-    pub services_search_box: cce_ui::widget::Adapted<TextBox>,
-    pub services_list: ScrollRegion,
-    pub service_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
 }
 
 impl Default for ProcessesState {
@@ -45,13 +15,6 @@ impl Default for ProcessesState {
             loaded: false,
             processes: Vec::new(),
             cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
-
-            services_loaded: false,
-            services: Vec::new(),
-            services_active_tab: ServiceTab::System,
-            services_search_box: TextBox::new(String::new()).with_label("Filter Services"),
-            services_list: ScrollRegion::new(36.0, 6.0),
-            service_items: Vec::new(),
         }
     }
 }
@@ -60,13 +23,6 @@ impl Default for ProcessesState {
 pub enum ProcessesMessage {
     Refreshed(ProcessesState),
     None,
-
-    // Services-related variants
-    ServicesRefreshed(Vec<ServiceInfo>),
-    ServicesSetTab(ServiceTab),
-    ServicesStart(String, bool),
-    ServicesStop(String, bool),
-    ServicesRestart(String, bool),
 }
 
 pub async fn fetch_processes_state() -> ProcessesState {
@@ -94,25 +50,19 @@ pub async fn fetch_processes_state() -> ProcessesState {
         loaded: true,
         processes,
         cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
-        services_loaded: false,
-        services: Vec::new(),
-        services_active_tab: ServiceTab::System,
-        services_search_box: TextBox::new(String::new()).with_label("Filter Services"),
-        services_list: ScrollRegion::new(36.0, 6.0),
-        service_items: Vec::new(),
     }
 }
 
 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];
 
-pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, 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(2);
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
-    // ── Processes Section ──
-    builder.add_section_spanned(&mut final_pc, "Processes", 2, root_focused || sec_focused.first().copied().unwrap_or(false), |sec| {
+    // ── Processes Section (label-less well) ──
+    builder.add_section_spanned(&mut final_pc, "", 1, root_focused || sec_focused.first().copied().unwrap_or(false), |sec| {
         let rx = sec.left;
         if !state.loaded {
             sec.text("Loading processes...", 12.0, 0.0, 12.0, TEXT_FG);
@@ -176,195 +126,6 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
         }
     });
 
-    // ── Services Section ──
-    builder.add_section_spanned(&mut final_pc, "Services", 2, sec_focused.get(1).copied().unwrap_or(false), |sec| {
-        let sec_w = sec.cw;
-        if !state.services_loaded {
-            sec.text("Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
-        } else {
-            // Tab header buttons: System Services, User Services
-            let mut stack = sec.vstack(8.0);
-            let tab_h = 28.0;
-            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];
-
-            let label1 = if stack.context.cw < 250.0 { "System" } else { "System Services" };
-            let label2 = if stack.context.cw < 250.0 { "User" } else { "User Services" };
-
-            stack.add_row(2, 8.0, tab_h, |ctx, i, x, w| {
-                if i == 0 {
-                    ctx.button(
-                        label1,
-                        x,
-                        ctx.ay(),
-                        w,
-                        tab_h,
-                        if state.services_active_tab == ServiceTab::System { active_bg } else { inactive_bg },
-                        hover_bg,
-                        [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Processes(ProcessesMessage::ServicesSetTab(ServiceTab::System)),
-                    );
-                } else {
-                    ctx.button(
-                        label2,
-                        x,
-                        ctx.ay(),
-                        w,
-                        tab_h,
-                        if state.services_active_tab == ServiceTab::User { active_bg } else { inactive_bg },
-                        hover_bg,
-                        [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Processes(ProcessesMessage::ServicesSetTab(ServiceTab::User)),
-                    );
-                }
-            });
-
-            stack.context.spacing(4.0);
-
-            // Search textbox
-            let search_w = sec_w - 24.0;
-            let search_h = 46.0;
-            
-            state.services_search_box.set_row_rect(stack.context.left + 12.0, search_w);
-            stack.add_widget(&mut state.services_search_box, search_w, search_h, ctx);
-            stack.context.spacing(8.0);
-
-            // Scroll box list
-            let list_box_x = sec.left + 12.0;
-            let list_box_y = sec.ay();
-            let list_box_w = sec_w - 24.0;
-            let list_box_h = 360.0;
-            
-            // Filter services
-            let query = if state.services_search_box.editing {
-                state.services_search_box.edit_buffer.to_lowercase()
-            } else {
-                state.services_search_box.text.to_lowercase()
-            };
-            let filtered_services: Vec<&ServiceInfo> = state.services.iter()
-                .filter(|s| s.is_system == (state.services_active_tab == ServiceTab::System))
-                .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
-                .collect();
-
-            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
-            state.services_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            state.services_list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
-            state.services_list.push_prims(sec.pc);
-
-            let item_h = state.services_list.item_height;
-
-            if state.service_items.len() != filtered_services.len() {
-                state.service_items.clear();
-                for _ in 0..filtered_services.len() {
-                    state.service_items.push(InteractiveListItem::new(""));
-                }
-            }
-
-            sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            for (idx, service) in filtered_services.iter().enumerate() {
-                if let Some(draw_y) = state.services_list.get_item_draw_y(idx, 4.0) {
-                    let is_active = service.active_state == "active" || service.sub_state == "running";
-
-                    // Control buttons: Start, Stop, Restart on the right
-                    let is_small = sec_w < 350.0;
-                    let btn_w = if is_small { 24.0 } else { 46.0 };
-                    let r_btn_w = if is_small { 24.0 } else { 54.0 };
-                    let btn_gap = if is_small { 4.0 } else { 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;
-
-                    // Service Description (Truncate dynamically based on remaining space before Start button)
-                    let text_max_w = (start_x - 8.0) - (list_box_x + 32.0);
-                    let max_chars = ((text_max_w / 6.0) as usize).max(10);
-                    let desc = if service.description.is_empty() { "No description" } else { &service.description };
-                    let desc_truncated = if desc.len() > max_chars {
-                        format!("{}...", &desc[..max_chars.saturating_sub(3)])
-                    } else {
-                        desc.to_string()
-                    };
-
-                    // Render InteractiveListItem background and text labels
-                    // Rows dispatch as extra roots (the dissolved list is no parent).
-                    let item_btn = &mut state.service_items[idx];
-                    item_btn.title = service.name.clone();
-                    item_btn.subtitle = Some(desc_truncated);
-                    render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
-
-                    // Render StatusDot
-                    let status_dot_state = if service.active_state == "failed" {
-                        DotStatus::Error
-                    } else if is_active {
-                        DotStatus::Active
-                    } else {
-                        DotStatus::Inactive
-                    };
-                    let mut dot = StatusDot::new(status_dot_state);
-                    render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
-
-                    let active_txt = [0.90, 0.90, 0.95, 1.0];
-                    let disabled_txt = [0.40, 0.40, 0.45, 1.0];
-
-                    let start_lbl = if is_small { "▶" } else { "Start" };
-                    let stop_lbl = if is_small { "■" } else { "Stop" };
-                    let restart_lbl = if is_small { "⟳" } else { "Restart" };
-
-                    // Start button
-                    sec.pc.button(
-                        start_lbl,
-                        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::Processes(ProcessesMessage::ServicesStart(service.name.clone(), service.is_system)),
-                    );
-
-                    // Stop button
-                    sec.pc.button(
-                        stop_lbl,
-                        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::Processes(ProcessesMessage::ServicesStop(service.name.clone(), service.is_system)),
-                    );
-
-                    // Restart button
-                    sec.pc.button(
-                        restart_lbl,
-                        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::Processes(ProcessesMessage::ServicesRestart(service.name.clone(), service.is_system)),
-                    );
-                }
-            }
-            sec.pc.pop_clip_rect();
-
-            if filtered_services.is_empty() {
-                sec.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;
-        }
-    });
-
     final_pc
 }
 
@@ -374,123 +135,14 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
             state.loaded = new.loaded;
             state.processes = new.processes;
         }
-        ProcessesMessage::ServicesRefreshed(new_services) => {
-            state.services_loaded = true;
-            state.services = new_services;
-            state.service_items.clear();
-        }
-        ProcessesMessage::ServicesSetTab(tab) => {
-            state.services_active_tab = tab;
-            state.services_list.set_scroll_y(0.0);
-            state.service_items.clear();
-        }
-        ProcessesMessage::ServicesStart(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);
-        }
-        ProcessesMessage::ServicesStop(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);
-        }
-        ProcessesMessage::ServicesRestart(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);
-        }
         ProcessesMessage::None => {}
     }
 }
 
-// ── 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 {
-        let _ = tokio::process::Command::new("systemctl")
-            .args(["--user", action, name])
-            .spawn();
-    }
-}
-
 impl crate::pages::AppPage for ProcessesState {
-    // Sections: [Processes, Services]
+    // Sections: [Processes]
     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
-        vec![
-            Vec::new(),
-            vec![self.services_search_box.id()],
-        ]
+        vec![Vec::new()]
     }
 
     fn view(
@@ -509,17 +161,6 @@ impl crate::pages::AppPage for ProcessesState {
 
     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
 
-    fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
-        self.service_items.iter().map(|i| i.id()).collect()
-    }
-
-    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        for i in self.service_items.iter_mut() {
-            let (id, ptr) = (i.id(), i.as_ptr_mut());
-            ctx.register_widget(id, ptr);
-        }
-    }
-
     fn handle_pointer_move(
         &mut self,
         lx: f32,
@@ -527,31 +168,23 @@ impl crate::pages::AppPage for ProcessesState {
         _actions: &mut Vec<crate::app::AppAction>,
         _ctx: &mut cce_ui::context::UiContext,
     ) -> bool {
-        let cpu = self.loaded && self.cpu_list.cursor_moved(lx, ly);
-        let services = self.services_loaded && self.services_list.cursor_moved(lx, ly);
-        cpu || services
+        self.loaded && self.cpu_list.cursor_moved(lx, ly)
     }
 
     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
-        let cpu = self.loaded && self.cpu_list.press(lx, ly);
-        let services = self.services_loaded && self.services_list.press(lx, ly);
-        cpu || services
+        self.loaded && self.cpu_list.press(lx, ly)
     }
 
     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
-        let cpu = self.cpu_list.release();
-        let services = self.services_list.release();
-        cpu || services
+        self.cpu_list.release()
     }
 
     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
-        (self.loaded && self.cpu_list.wheel(delta, lx, ly))
-            || (self.services_loaded && self.services_list.wheel(delta, lx, ly))
+        self.loaded && self.cpu_list.wheel(delta, lx, ly)
     }
 
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
-        (self.loaded && self.cpu_list.keyboard(event))
-            || (self.services_loaded && self.services_list.keyboard(event))
+        self.loaded && self.cpu_list.keyboard(event)
     }
 }
 
@@ -563,7 +196,7 @@ mod tests {
     fn test_view_layout_grid() {
         let mut state = ProcessesState::default();
         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
-        let sec_focused = vec![false, false];
+        let sec_focused = vec![false];
         let mut ctx = cce_ui::context::UiContext::new();
         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
diff --git a/src/pages/services.rs b/src/pages/services.rs
new file mode 100644
index 0000000..978c360
--- /dev/null
+++ b/src/pages/services.rs
@@ -0,0 +1,426 @@
+use crate::app::{PageContent, SectionContextExt};
+use crate::scroll_region::ScrollRegion;
+use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
+use cce_ui::widget::{TextBox, StatusDot, DotStatus, InteractiveListItem, WidgetHost};
+
+#[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: cce_ui::widget::Adapted<TextBox>,
+    pub list: ScrollRegion,
+    pub items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
+}
+
+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: ScrollRegion::new(36.0, 6.0),
+            items: Vec::new(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum ServicesMessage {
+    Refreshed(Vec<ServiceInfo>),
+    SetTab(ServiceTab),
+    Start(String, bool),
+    Stop(String, bool),
+    Restart(String, bool),
+}
+
+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, _root_focused: bool, 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(1);
+
+    builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+        let sec_w = sec.cw;
+        if !state.loaded {
+            sec.text("Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
+        } else {
+            // Tab header buttons: System Services, User Services
+            let mut stack = sec.vstack(8.0);
+            let tab_h = 28.0;
+            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];
+
+            let label1 = if stack.context.cw < 250.0 { "System" } else { "System Services" };
+            let label2 = if stack.context.cw < 250.0 { "User" } else { "User Services" };
+
+            stack.add_row(2, 8.0, tab_h, |ctx, i, x, w| {
+                if i == 0 {
+                    ctx.button(
+                        label1,
+                        x,
+                        ctx.ay(),
+                        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)),
+                    );
+                } else {
+                    ctx.button(
+                        label2,
+                        x,
+                        ctx.ay(),
+                        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)),
+                    );
+                }
+            });
+
+            stack.context.spacing(4.0);
+
+            // Search textbox
+            let search_w = sec_w - 24.0;
+            let search_h = 46.0;
+
+            state.search_box.set_row_rect(stack.context.left + 12.0, search_w);
+            stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
+            stack.context.spacing(8.0);
+
+            // Scroll box list
+            let list_box_x = sec.left + 12.0;
+            let list_box_y = sec.ay();
+            let list_box_w = sec_w - 24.0;
+            let list_box_h = 360.0;
+
+            // 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();
+
+            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+            state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            state.list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
+            state.list.push_prims(sec.pc);
+
+            let item_h = state.list.item_height;
+
+            if state.items.len() != filtered_services.len() {
+                state.items.clear();
+                for _ in 0..filtered_services.len() {
+                    state.items.push(InteractiveListItem::new(""));
+                }
+            }
+
+            sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            for (idx, service) in filtered_services.iter().enumerate() {
+                if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
+                    let is_active = service.active_state == "active" || service.sub_state == "running";
+
+                    // Control buttons: Start, Stop, Restart on the right
+                    let is_small = sec_w < 350.0;
+                    let btn_w = if is_small { 24.0 } else { 46.0 };
+                    let r_btn_w = if is_small { 24.0 } else { 54.0 };
+                    let btn_gap = if is_small { 4.0 } else { 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;
+
+                    // Service Description (Truncate dynamically based on remaining space before Start button)
+                    let text_max_w = (start_x - 8.0) - (list_box_x + 32.0);
+                    let max_chars = ((text_max_w / 6.0) as usize).max(10);
+                    let desc = if service.description.is_empty() { "No description" } else { &service.description };
+                    let desc_truncated = if desc.len() > max_chars {
+                        format!("{}...", &desc[..max_chars.saturating_sub(3)])
+                    } else {
+                        desc.to_string()
+                    };
+
+                    // Render InteractiveListItem background and text labels
+                    // Rows dispatch as extra roots (the dissolved list is no parent).
+                    let item_btn = &mut state.items[idx];
+                    item_btn.title = service.name.clone();
+                    item_btn.subtitle = Some(desc_truncated);
+                    render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+
+                    // Render StatusDot
+                    let status_dot_state = if service.active_state == "failed" {
+                        DotStatus::Error
+                    } else if is_active {
+                        DotStatus::Active
+                    } else {
+                        DotStatus::Inactive
+                    };
+                    let mut dot = StatusDot::new(status_dot_state);
+                    render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
+
+                    let active_txt = [0.90, 0.90, 0.95, 1.0];
+                    let disabled_txt = [0.40, 0.40, 0.45, 1.0];
+
+                    let start_lbl = if is_small { "▶" } else { "Start" };
+                    let stop_lbl = if is_small { "■" } else { "Stop" };
+                    let restart_lbl = if is_small { "⟳" } else { "Restart" };
+
+                    // Start button
+                    sec.pc.button(
+                        start_lbl,
+                        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
+                    sec.pc.button(
+                        stop_lbl,
+                        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
+                    sec.pc.button(
+                        restart_lbl,
+                        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)),
+                    );
+                }
+            }
+            sec.pc.pop_clip_rect();
+
+            if filtered_services.is_empty() {
+                sec.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;
+        }
+    });
+
+    final_pc
+}
+
+pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
+    match msg {
+        ServicesMessage::Refreshed(new_services) => {
+            state.loaded = true;
+            state.services = new_services;
+            state.items.clear();
+        }
+        ServicesMessage::SetTab(tab) => {
+            state.active_tab = tab;
+            state.list.set_scroll_y(0.0);
+            state.items.clear();
+        }
+        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);
+        }
+    }
+}
+
+// ── 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 {
+        let _ = tokio::process::Command::new("systemctl")
+            .args(["--user", action, name])
+            .spawn();
+    }
+}
+
+impl crate::pages::AppPage for ServicesState {
+    // Sections: [Services]
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        vec![vec![self.search_box.id()]]
+    }
+
+    fn view(
+        &mut self,
+        cx: f32,
+        cy: f32,
+        cw: f32,
+        ch: f32,
+        root_focused: bool,
+        sec_focused: &[bool],
+        layout: &mut dyn LayoutStrategy,
+        ctx: &mut cce_ui::context::UiContext,
+    ) -> crate::app::PageContent {
+        view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
+    }
+
+    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+
+    fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
+        self.items.iter().map(|i| i.id()).collect()
+    }
+
+    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
+        for i in self.items.iter_mut() {
+            let (id, ptr) = (i.id(), i.as_ptr_mut());
+            ctx.register_widget(id, ptr);
+        }
+    }
+
+    fn handle_pointer_move(
+        &mut self,
+        lx: f32,
+        ly: f32,
+        _actions: &mut Vec<crate::app::AppAction>,
+        _ctx: &mut cce_ui::context::UiContext,
+    ) -> bool {
+        self.loaded && self.list.cursor_moved(lx, ly)
+    }
+
+    fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        self.loaded && self.list.press(lx, ly)
+    }
+
+    fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        self.list.release()
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
+        self.loaded && self.list.wheel(delta, lx, ly)
+    }
+
+    fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
+        self.loaded && self.list.keyboard(event)
+    }
+}
diff --git a/src/renderer.rs b/src/renderer.rs
index 53c4e1e..1ce259f 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -479,7 +479,9 @@ impl SystemInterface {
                        && base.y >= sb1.y - 1.0 && base.y + base.h <= sb1.y + sb1.h + 1.0 {
                         left_align = true;
                     }
-                    let sb2 = &self.app.processes.services_list;
+                }
+                if self.app.current_page == Page::Services {
+                    let sb2 = &self.app.services.list;
                     if base.x >= sb2.x - 1.0 && base.x + base.w <= sb2.x + sb2.w + 1.0
                        && base.y >= sb2.y - 1.0 && base.y + base.h <= sb2.y + sb2.h + 1.0 {
                         left_align = true;
diff --git a/src/watchers.rs b/src/watchers.rs
index a1cdf5d..eb8eaba 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,7 +1,7 @@
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU8, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
-use crate::pages::{Page, audio, network, fonts, processes, system_info, storage, packages, accounts, notifications};
+use crate::pages::{Page, audio, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
 
 pub struct Watchers {
     pub rx_audio: Receiver<audio::AudioState>,
@@ -10,7 +10,7 @@ pub struct Watchers {
     pub rx_system: Receiver<system_info::SystemInfo>,
     pub rx_storage: Receiver<storage::StorageState>,
     pub rx_notifications: Receiver<notifications::NotificationsConfig>,
-    pub rx_services: Receiver<Vec<processes::ServiceInfo>>,
+    pub rx_services: Receiver<Vec<services::ServiceInfo>>,
     pub rx_fonts: Receiver<fonts::FontsState>,
     pub rx_accounts: Receiver<Vec<accounts::AccountInfo>>,
     pub rx_packages: Receiver<packages::PackagesState>,
@@ -91,7 +91,7 @@ pub fn spawn_all(
     };
 
     let rx_fonts = spawn_bg_active(current_page_shared.clone(), Page::Fonts.index() as u8, 30, || fonts::fetch_typeface_state());
-    let rx_services = spawn_bg_active(current_page_shared.clone(), Page::Processes.index() as u8, 3, || processes::fetch_services());
+    let rx_services = spawn_bg_active(current_page_shared.clone(), Page::Services.index() as u8, 3, || services::fetch_services());
     let rx_accounts = spawn_bg_active(current_page_shared.clone(), Page::Accounts.index() as u8, 3, || accounts::fetch_accounts());
 
     let (tx_backup, rx_backup) = channel();