system settings
git clone https://git.lucas.co/cce-system-interface.git
src/pages/services.rs (24K)
1 use crate::app::{PageContent, SectionContextExt};
2 use cce_ui::widget::ScrollRegion;
3 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
4 use cce_ui::widget::{TextBox, InteractiveListItem, WidgetHost};
5
6 #[derive(Debug, Clone)]
7 pub struct ServiceInfo {
8 pub name: String,
9 pub description: String,
10 pub active_state: String,
11 pub sub_state: String,
12 pub is_system: bool,
13 }
14
15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 pub enum ServiceTab {
17 System,
18 User,
19 }
20
21 impl Default for ServiceTab {
22 fn default() -> Self {
23 ServiceTab::System
24 }
25 }
26
27 #[derive(Debug, Clone)]
28 pub struct ServicesState {
29 pub loaded: bool,
30 pub services: Vec<ServiceInfo>,
31 pub active_tab: ServiceTab,
32 pub search_box: cce_ui::widget::Adapted<TextBox>,
33 pub list: ScrollRegion,
34 pub items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
35 }
36
37 impl Default for ServicesState {
38 fn default() -> Self {
39 Self {
40 loaded: false,
41 services: Vec::new(),
42 active_tab: ServiceTab::System,
43 search_box: TextBox::new(String::new()).with_label("Filter Services"),
44 list: ScrollRegion::new(36.0, 6.0).with_frame(false),
45 items: Vec::new(),
46 }
47 }
48 }
49
50 #[derive(Debug, Clone)]
51 pub enum ServicesMessage {
52 Refreshed(Vec<ServiceInfo>),
53 SetTab(ServiceTab),
54 Start(String, bool),
55 Stop(String, bool),
56 Restart(String, bool),
57 }
58
59 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
60
61 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 {
62 let m = crate::app::section_margin();
63 let mut final_pc = PageContent::new();
64 let sec_w = 320.0f32;
65 let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
66
67 builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
68 let sec_w = sec.cw;
69 if !state.loaded {
70 sec.text("Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
71 } else {
72 // Tab header buttons: System Services, User Services
73 let mut stack = sec.vstack(cce_ui::layout::plate_gap());
74 let tab_h = 28.0;
75 let active_bg = [0.20, 0.40, 0.65, 0.4];
76 let inactive_bg = [0.10, 0.10, 0.16, 0.3];
77 let hover_bg = [0.20, 0.20, 0.25, 0.15];
78
79 let label1 = if stack.context.cw < 250.0 { "System" } else { "System Services" };
80 let label2 = if stack.context.cw < 250.0 { "User" } else { "User Services" };
81
82 stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
83 if i == 0 {
84 ctx.button(
85 label1,
86 x,
87 ctx.ay(),
88 w,
89 tab_h,
90 if state.active_tab == ServiceTab::System { active_bg } else { inactive_bg },
91 hover_bg,
92 [0.90, 0.90, 0.95, 1.0],
93 crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)),
94 );
95 } else {
96 ctx.button(
97 label2,
98 x,
99 ctx.ay(),
100 w,
101 tab_h,
102 if state.active_tab == ServiceTab::User { active_bg } else { inactive_bg },
103 hover_bg,
104 [0.90, 0.90, 0.95, 1.0],
105 crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)),
106 );
107 }
108 });
109
110 stack.context.spacing(4.0);
111
112 // Search textbox
113 let search_w = sec_w - 2.0 * m;
114 let search_h = 46.0;
115
116 state.search_box.set_row_rect(stack.context.left + m, search_w);
117 stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
118 stack.context.spacing(cce_ui::layout::plate_gap());
119
120 // Scroll box list
121 let list_box_x = sec.left + m;
122 let list_box_y = sec.ay();
123 let list_box_w = sec_w - 2.0 * m;
124 // Fill the page: the well's bottom wall lands at the page bottom,
125 // the list keeps one margin above it.
126 let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
127
128 // Filter services
129 let query = if state.search_box.editing {
130 state.search_box.edit_buffer.to_lowercase()
131 } else {
132 state.search_box.text.to_lowercase()
133 };
134 let filtered_services: Vec<&ServiceInfo> = state.services.iter()
135 .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
136 .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
137 .collect();
138
139 // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
140 state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
141 state.list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
142 state.list.push_prims(sec.pc);
143
144 let item_h = state.list.item_height;
145
146 if state.items.len() != filtered_services.len() {
147 state.items.clear();
148 for _ in 0..filtered_services.len() {
149 state.items.push(InteractiveListItem::new(""));
150 }
151 }
152
153 sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
154 for (idx, service) in filtered_services.iter().enumerate() {
155 if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
156 // Transport + Restart, on the LEFT where the status dot
157 // used to be: the dot was reporting what the transport icon
158 // already says (play = stopped, stop = running), so the
159 // controls take the column it was using and the name/
160 // description run from there to the scrollbar gutter.
161 //
162 // Sized on whether the icon set is actually THERE:
163 // `button_icon` falls back to the labels when it isn't, and
164 // a 24px button doesn't clip a label so much as replace it
165 // — the text centers, so both ends cut and "Restart" reads
166 // "sta". `upload_icon` caches per (name, px), so asking
167 // every row costs one hash lookup.
168 let icons_ok = cce_ui::upload_icon("play", 32).is_some();
169 let is_small = sec_w < 350.0;
170 let (btn_w, r_btn_w) = match (icons_ok, is_small) {
171 (true, _) => (24.0, 24.0),
172 (false, true) => (24.0, 24.0),
173 (false, false) => (46.0, 54.0),
174 };
175 let btn_gap = if is_small { 4.0 } else { 6.0 };
176
177 // TODO(style): the row's control run and text column
178 // below are this list row's own layout.
179 let toggle_x = list_box_x + 10.0;
180 let restart_x = toggle_x + btn_w + btn_gap;
181 // The row's text starts after the controls and still ends
182 // short of the scrollbar gutter, exactly where it did when
183 // the controls were on the right.
184 let item_x = restart_x + r_btn_w + 10.0;
185 let item_w = (list_box_x + list_box_w - 20.0) - item_x;
186
187 let btn_y = draw_y + (item_h - 22.0) / 2.0;
188 let btn_h = 22.0;
189
190 // Service description, truncated to the room the row's text
191 // column actually has (the item insets its labels by 8px).
192 let text_max_w = item_w - 16.0;
193 let max_chars = ((text_max_w / 6.0) as usize).max(10);
194 // `failed` was the status dot's third state and has nowhere
195 // else to show: a failed unit offers the same play button a
196 // cleanly stopped one does, so without this the row gives no
197 // sign it died. It leads the line, and it goes in BEFORE the
198 // truncation so a long description can't be what pushes the
199 // marker off the end.
200 let failed = service.active_state == "failed" || service.sub_state == "failed";
201 // Char-boundary safe: the byte slice this replaced would
202 // panic whenever the cut landed inside a multi-byte
203 // character, and unit descriptions are free text.
204 let desc_truncated = cce_ui::widget::display::truncate_tail(
205 &description_line(failed, &service.description),
206 max_chars,
207 );
208
209 // Render InteractiveListItem background and text labels
210 // Rows dispatch as extra roots (the dissolved list is no parent).
211 let item_btn = &mut state.items[idx];
212 item_btn.title = service.name.clone();
213 item_btn.subtitle = Some(desc_truncated);
214 render_widget(sec.pc, item_btn, item_x, draw_y, item_w, item_h, ctx);
215
216 let active_txt = [0.90, 0.90, 0.95, 1.0];
217 // No per-action tints: both controls wear the DE's themed
218 // button face, and what they DO is carried by the icon.
219 // (The toolkit picks these itself for a `Button` with no
220 // override; this host paints buttons from its own collected
221 // colours, so it has to ask for them.)
222 let face = cce_ui::colors::button_background_color();
223 let face_hover = cce_ui::colors::button_hover_color();
224
225 // ONE transport button, showing the action it will take:
226 // play on a stopped service, stop on a running one. The two
227 // dimmed half-buttons this replaced were never both live —
228 // exactly one of them did anything on any given row.
229 //
230 // `running` is NOT the `is_active` the status dot reads.
231 // `update` sets a transitional state the instant the button
232 // is clicked (activating/starting, deactivating/stopping),
233 // and counting those as the state they are heading for is
234 // what flips this icon under the pointer instead of leaving
235 // it stale until the next refresh lands. The dot keeps
236 // reporting the confirmed state: the button says what it
237 // will do, the dot says what is true.
238 let running = transport_running(&service.active_state, &service.sub_state);
239
240 // Fallback labels only — an icon face never draws them.
241 // Without the icons a narrow row is back to needing the
242 // one-glyph words it used before.
243 let (start_lbl, stop_lbl, restart_lbl) = if icons_ok || !is_small {
244 ("Start", "Stop", "Restart")
245 } else {
246 ("\u{25b6}", "\u{25a0}", "\u{27f3}")
247 };
248
249 // Start/Stop, collapsed
250 sec.pc.button_icon(
251 if running { "stop" } else { "play" },
252 if running { stop_lbl } else { start_lbl },
253 toggle_x,
254 btn_y,
255 btn_w,
256 btn_h,
257 face,
258 face_hover,
259 active_txt,
260 1.0,
261 crate::app::AppAction::Services(if running {
262 ServicesMessage::Stop(service.name.clone(), service.is_system)
263 } else {
264 ServicesMessage::Start(service.name.clone(), service.is_system)
265 }),
266 );
267
268 // Restart button
269 sec.pc.button_icon(
270 "refresh",
271 restart_lbl,
272 restart_x,
273 btn_y,
274 r_btn_w,
275 btn_h,
276 face,
277 face_hover,
278 active_txt,
279 1.0,
280 crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
281 );
282 }
283 }
284 sec.pc.pop_clip_rect();
285
286 if filtered_services.is_empty() {
287 sec.pc.text("No services match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
288 }
289
290 // End the section so the well's bottom wall sits one margin below
291 // the list: finish() places the wall at content_y + padding +
292 // margin, so the list's own bottom margin and that one cancel.
293 sec.content_y = list_box_y + list_box_h - sec.padding();
294 }
295 });
296
297 final_pc
298 }
299
300 /// The optimistic states [`update`] writes the instant a transport button is
301 /// clicked, before systemd has said anything back. Named because
302 /// [`transport_running`] has to agree with them: the whole point of writing
303 /// them is that the button's icon flips under the pointer instead of staying
304 /// stale until the next refresh lands.
305 const STARTING: (&str, &str) = ("activating", "starting");
306 const STOPPING: (&str, &str) = ("deactivating", "stopping");
307 const RESTARTING: (&str, &str) = ("activating", "restarting");
308
309 /// The row's second line: the unit description, led by `failed` when it is.
310 ///
311 /// `failed` was the status dot's third state and has nowhere else to show — a
312 /// failed unit offers the same play button a cleanly stopped one does, so
313 /// without this the row gives no sign it died. The marker LEADS, and is
314 /// composed before the caller truncates, so a long description can never be
315 /// what pushes it off the end.
316 pub fn description_line(failed: bool, description: &str) -> String {
317 match (failed, description.is_empty()) {
318 (true, true) => "failed".to_string(),
319 (true, false) => format!("failed \u{2014} {description}"),
320 (false, true) => "No description".to_string(),
321 (false, false) => description.to_string(),
322 }
323 }
324
325 /// Whether the row's single transport button offers Stop (`true`) or Start
326 /// (`false`) — which is also which icon it wears.
327 ///
328 /// A transitional state counts as the state it is heading FOR, not the one it
329 /// is leaving. That is what makes the click feel like a toggle: `update` marks
330 /// the unit `activating` the moment Start is pressed, and this reads that as
331 /// running, so the icon becomes Stop immediately.
332 ///
333 /// This is deliberately NOT the `is_active` the status dot reads. The dot
334 /// reports what is confirmed true; the button reports what it will do.
335 pub fn transport_running(active_state: &str, sub_state: &str) -> bool {
336 match active_state {
337 "active" | "activating" | "reloading" => true,
338 "deactivating" => false,
339 _ => sub_state == "running",
340 }
341 }
342
343 pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
344 match msg {
345 ServicesMessage::Refreshed(new_services) => {
346 state.loaded = true;
347 state.services = new_services;
348 state.items.clear();
349 }
350 ServicesMessage::SetTab(tab) => {
351 state.active_tab = tab;
352 state.list.set_scroll_y(0.0);
353 state.items.clear();
354 }
355 ServicesMessage::Start(name, is_system) => {
356 if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
357 srv.active_state = STARTING.0.to_string();
358 srv.sub_state = STARTING.1.to_string();
359 }
360 service_action(&name, "start", is_system);
361 }
362 ServicesMessage::Stop(name, is_system) => {
363 if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
364 srv.active_state = STOPPING.0.to_string();
365 srv.sub_state = STOPPING.1.to_string();
366 }
367 service_action(&name, "stop", is_system);
368 }
369 ServicesMessage::Restart(name, is_system) => {
370 if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
371 srv.active_state = RESTARTING.0.to_string();
372 srv.sub_state = RESTARTING.1.to_string();
373 }
374 service_action(&name, "restart", is_system);
375 }
376 }
377 }
378
379 // ── Background Fetching ──
380
381 pub async fn fetch_services() -> Vec<ServiceInfo> {
382 let mut services = Vec::new();
383
384 // 1. Fetch system-level services
385 if let Ok(output) = tokio::process::Command::new("systemctl")
386 .args(["list-units", "--type=service", "--all", "--no-legend"])
387 .output()
388 .await
389 {
390 let stdout = String::from_utf8_lossy(&output.stdout);
391 for line in stdout.lines() {
392 if let Some(info) = parse_service_line(line, true) {
393 services.push(info);
394 }
395 }
396 }
397
398 // 2. Fetch user-level services
399 if let Ok(output) = tokio::process::Command::new("systemctl")
400 .args(["--user", "list-units", "--type=service", "--all", "--no-legend"])
401 .output()
402 .await
403 {
404 let stdout = String::from_utf8_lossy(&output.stdout);
405 for line in stdout.lines() {
406 if let Some(info) = parse_service_line(line, false) {
407 services.push(info);
408 }
409 }
410 }
411
412 // Sort alphabetically by name
413 services.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
414 services
415 }
416
417 fn parse_service_line(line: &str, is_system: bool) -> Option<ServiceInfo> {
418 let cleaned = line.trim_start_matches('●').trim();
419 if cleaned.is_empty() {
420 return None;
421 }
422 let parts: Vec<&str> = cleaned.split_whitespace().collect();
423 if parts.len() >= 4 && parts[0].ends_with(".service") {
424 let name = parts[0].to_string();
425 let _load = parts[1];
426 let active_state = parts[2].to_string();
427 let sub_state = parts[3].to_string();
428 let description = parts[4..].join(" ");
429 Some(ServiceInfo {
430 name,
431 description,
432 active_state,
433 sub_state,
434 is_system,
435 })
436 } else {
437 None
438 }
439 }
440
441 fn service_action(name: &str, action: &str, is_system: bool) {
442 if is_system {
443 // System service needs root privilege, spawn via pkexec
444 let _ = tokio::process::Command::new("pkexec")
445 .args(["systemctl", action, name])
446 .spawn();
447 } else {
448 let _ = tokio::process::Command::new("systemctl")
449 .args(["--user", action, name])
450 .spawn();
451 }
452 }
453
454 impl crate::pages::AppPage for ServicesState {
455 // Sections: [Services]
456 fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
457 // Mirrors the view's `!loaded` branch: the search box is only painted (and
458 // so only registered) once the unit list has landed. The group count stays
459 // 1 either way — an empty outer Vec would kill the ctrl-nav entry point.
460 if self.loaded {
461 vec![vec![self.search_box.id()]]
462 } else {
463 vec![Vec::new()]
464 }
465 }
466
467 fn view(
468 &mut self,
469 cx: f32,
470 cy: f32,
471 cw: f32,
472 ch: f32,
473 root_focused: bool,
474 sec_focused: &[bool],
475 layout: &mut dyn LayoutStrategy,
476 ctx: &mut cce_ui::context::UiContext,
477 ) -> crate::app::PageContent {
478 view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
479 }
480
481 fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
482
483 // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
484 // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
485 // hit-test against the row actually on screen. See the note in packages.rs.
486 fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
487 let (list, items) = (&self.list, &self.items);
488 items
489 .iter()
490 .enumerate()
491 .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
492 .map(|(_, i)| i.id())
493 .collect()
494 }
495
496 fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
497 let (list, items) = (&self.list, &mut self.items);
498 for (idx, i) in items.iter_mut().enumerate() {
499 if list.get_item_draw_y(idx, 4.0).is_none() {
500 continue;
501 }
502 let (id, ptr) = (i.id(), i.as_ptr_mut());
503 ctx.register_widget(id, ptr);
504 }
505 }
506
507 fn handle_pointer_move(
508 &mut self,
509 lx: f32,
510 ly: f32,
511 _actions: &mut Vec<crate::app::AppAction>,
512 _ctx: &mut cce_ui::context::UiContext,
513 ) -> bool {
514 self.loaded && self.list.cursor_moved(lx, ly)
515 }
516
517 fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
518 self.loaded && self.list.press(lx, ly)
519 }
520
521 fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
522 self.list.release()
523 }
524
525 fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
526 self.loaded && self.list.wheel(delta, lx, ly)
527 }
528
529 fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
530 self.loaded && self.list.keyboard(event)
531 }
532
533 fn tick(&mut self, dt: f32) -> bool {
534 self.list.tick(dt)
535 }
536 }
537
538 #[cfg(test)]
539 mod tests {
540 use super::{description_line, transport_running, RESTARTING, STARTING, STOPPING};
541
542 #[test]
543 fn settled_states_pick_the_opposite_action() {
544 assert!(transport_running("active", "running"));
545 assert!(!transport_running("inactive", "dead"));
546 assert!(!transport_running("failed", "failed"));
547 // sub_state alone is enough — some units report it without
548 // active_state catching up.
549 assert!(transport_running("something-else", "running"));
550 }
551
552 #[test]
553 fn a_click_flips_the_icon_before_any_refresh() {
554 // The exact states `update` writes optimistically. If these two ever
555 // disagree, the button stops feeling like a toggle: the icon would sit
556 // on the old action until systemd's next list lands, and a second
557 // click would re-send the action already in flight.
558 assert!(transport_running(STARTING.0, STARTING.1), "Start must show Stop immediately");
559 assert!(!transport_running(STOPPING.0, STOPPING.1), "Stop must show Start immediately");
560 // Restart keeps the unit running throughout, so the transport button
561 // must not flicker to Start while it cycles.
562 assert!(transport_running(RESTARTING.0, RESTARTING.1), "Restart must keep showing Stop");
563 }
564
565 #[test]
566 fn the_failed_marker_leads_and_survives_truncation() {
567 assert_eq!(description_line(false, "Bluetooth service"), "Bluetooth service");
568 assert_eq!(description_line(false, ""), "No description");
569 assert_eq!(description_line(true, ""), "failed");
570 assert!(description_line(true, "Bluetooth service").starts_with("failed"));
571
572 // The row truncates whatever this returns, so the marker is only
573 // guaranteed visible while it stays at the FRONT.
574 let long = description_line(true, &"x".repeat(500));
575 let shown = cce_ui::widget::display::truncate_tail(&long, 20);
576 assert!(shown.starts_with("failed"), "got {shown:?}");
577 assert!(shown.chars().count() <= 20);
578 }
579 }