git.lucas.co / cce-system-interface
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 }