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