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