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

src/pages/timers.rs (32.6K)

  1 //! Systemd timers — the system's "cron". View system/user timers with their
  2 //! schedules, trigger the activated service immediately, and enable/disable
  3 //! timer units (system scope through pkexec).
  4 
  5 use crate::app::{PageContent, SectionContextExt};
  6 use cce_ui::widget::ScrollRegion;
  7 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
  8 use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox, WidgetHost};
  9 
 10 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 11 pub enum TimerTab {
 12     System,
 13     User,
 14 }
 15 
 16 #[derive(Debug, Clone)]
 17 pub struct TimerInfo {
 18     pub unit: String,
 19     pub activates: String,
 20     /// Absolute epoch microseconds; None when the timer has no scheduled run.
 21     pub next_usec: Option<u64>,
 22     pub last_usec: Option<u64>,
 23     pub active: bool,
 24     /// systemd UnitFileState: enabled / disabled / static / ...
 25     pub file_state: String,
 26     pub is_system: bool,
 27     /// The unit file lives in ~/.config/systemd/user — safe to edit in place.
 28     pub editable: bool,
 29 }
 30 
 31 #[derive(Debug, Clone)]
 32 pub struct TimersState {
 33     pub loaded: bool,
 34     pub timers: Vec<TimerInfo>,
 35     pub active_tab: TimerTab,
 36     pub list: ScrollRegion,
 37     pub items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
 38     pub creating: bool,
 39     /// Base unit name (without .timer) being edited, form shared with create.
 40     pub editing: Option<String>,
 41     pub name_box: cce_ui::widget::Adapted<TextBox>,
 42     pub command_box: cce_ui::widget::Adapted<TextBox>,
 43     pub schedule_box: cce_ui::widget::Adapted<TextBox>,
 44     pub status_msg: Option<String>,
 45 }
 46 
 47 impl Default for TimersState {
 48     fn default() -> Self {
 49         Self {
 50             loaded: false,
 51             timers: Vec::new(),
 52             active_tab: TimerTab::System,
 53             list: ScrollRegion::new(36.0, 6.0).with_frame(false),
 54             items: Vec::new(),
 55             creating: false,
 56             editing: None,
 57             name_box: TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
 58                 .with_placeholder("backup"),
 59             command_box: TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
 60                 .with_placeholder("/home/me/bin/backup.sh --fast"),
 61             schedule_box: TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
 62                 .with_placeholder("daily \u{2022} Mon 09:00 \u{2022} *-*-* 03:00:00"),
 63             status_msg: None,
 64         }
 65     }
 66 }
 67 
 68 #[derive(Debug, Clone)]
 69 pub enum TimersMessage {
 70     Refreshed(Vec<TimerInfo>),
 71     SetTab(TimerTab),
 72     /// Start the timer's activated service right now.
 73     RunNow(String, bool),
 74     Enable(String, bool),
 75     Disable(String, bool),
 76     CreateStart,
 77     CreateCancel,
 78     CreateSave,
 79     /// Open the form pre-filled from the unit files (user timers only).
 80     EditStart(String),
 81 }
 82 
 83 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 84 
 85 /// "46min" / "3h" / "5d 3h" — coarse two-unit humanization.
 86 fn humanize(secs: u64) -> String {
 87     let (d, h, m) = (secs / 86_400, (secs % 86_400) / 3600, (secs % 3600) / 60);
 88     if d > 0 {
 89         if h > 0 { format!("{}d {}h", d, h) } else { format!("{}d", d) }
 90     } else if h > 0 {
 91         if m > 0 { format!("{}h {}min", h, m) } else { format!("{}h", h) }
 92     } else if m > 0 {
 93         format!("{}min", m)
 94     } else {
 95         format!("{}s", secs)
 96     }
 97 }
 98 
 99 fn now_usec() -> u64 {
100     std::time::SystemTime::now()
101         .duration_since(std::time::UNIX_EPOCH)
102         .map(|d| d.as_micros() as u64)
103         .unwrap_or(0)
104 }
105 
106 fn schedule_line(t: &TimerInfo, now: u64) -> String {
107     let next = match t.next_usec {
108         Some(n) if n > now => format!("next in {}", humanize((n - now) / 1_000_000)),
109         Some(_) => "next imminent".to_string(),
110         None => "no run scheduled".to_string(),
111     };
112     let last = match t.last_usec {
113         Some(l) if l > 0 && l <= now => format!("last {} ago", humanize((now - l) / 1_000_000)),
114         _ => "never ran".to_string(),
115     };
116     format!("{} \u{2022} {} \u{2022} {}", t.activates, next, last)
117 }
118 
119 // ── Background fetching ──
120 
121 async fn fetch_scope(user: bool) -> Vec<TimerInfo> {
122     let mut args: Vec<&str> = Vec::new();
123     if user {
124         args.push("--user");
125     }
126     args.extend(["list-timers", "--all", "--output=json"]);
127     let out = match tokio::process::Command::new("systemctl").args(&args).output().await {
128         Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
129         Err(_) => return Vec::new(),
130     };
131     let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap_or_default();
132     let mut timers: Vec<TimerInfo> = parsed
133         .iter()
134         .filter_map(|v| {
135             Some(TimerInfo {
136                 unit: v.get("unit")?.as_str()?.to_string(),
137                 activates: v.get("activates").and_then(|a| a.as_str()).unwrap_or("").to_string(),
138                 next_usec: v.get("next").and_then(|n| n.as_u64()),
139                 last_usec: v.get("last").and_then(|n| n.as_u64()),
140                 active: false,
141                 file_state: String::new(),
142                 is_system: !user,
143                 editable: false,
144             })
145         })
146         .collect();
147     if timers.is_empty() {
148         return timers;
149     }
150 
151     // One batched `show` for active/enabled state, blocks split by blank lines.
152     let mut show_args: Vec<String> = Vec::new();
153     if user {
154         show_args.push("--user".into());
155     }
156     show_args.push("show".into());
157     show_args.extend(timers.iter().map(|t| t.unit.clone()));
158     show_args.extend(["-p".into(), "Id,ActiveState,UnitFileState".into()]);
159     if let Ok(o) = tokio::process::Command::new("systemctl").args(&show_args).output().await {
160         let text = String::from_utf8_lossy(&o.stdout).to_string();
161         for block in text.split("\n\n") {
162             let mut id = None;
163             let mut active = false;
164             let mut file_state = String::new();
165             for line in block.lines() {
166                 if let Some((k, v)) = line.split_once('=') {
167                     match k {
168                         "Id" => id = Some(v.to_string()),
169                         "ActiveState" => active = v == "active",
170                         "UnitFileState" => file_state = v.to_string(),
171                         _ => {}
172                     }
173                 }
174             }
175             if let Some(id) = id {
176                 if let Some(t) = timers.iter_mut().find(|t| t.unit == id) {
177                     t.active = active;
178                     t.file_state = file_state;
179                 }
180             }
181         }
182     }
183     if user {
184         if let Some(dir) = user_unit_dir() {
185             for t in timers.iter_mut() {
186                 t.editable = dir.join(&t.unit).exists();
187             }
188         }
189     }
190     timers.sort_by(|a, b| a.unit.to_lowercase().cmp(&b.unit.to_lowercase()));
191     timers
192 }
193 
194 fn user_unit_dir() -> Option<std::path::PathBuf> {
195     std::env::var("HOME").ok().map(|h| std::path::PathBuf::from(h).join(".config/systemd/user"))
196 }
197 
198 pub async fn fetch_timers() -> Vec<TimerInfo> {
199     let mut all = fetch_scope(false).await;
200     all.extend(fetch_scope(true).await);
201     all
202 }
203 
204 /// A TextBox's live contents: the in-progress edit buffer while focused, the
205 /// committed text otherwise (the recurring TextBox landmine).
206 fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
207     if tb.editing {
208         tb.edit_buffer.trim().to_string()
209     } else {
210         tb.text.trim().to_string()
211     }
212 }
213 
214 /// Create and enable a USER timer: `<name>.service` + `<name>.timer` under
215 /// ~/.config/systemd/user, schedule validated by `systemd-analyze calendar`.
216 fn create_user_timer(name: &str, command: &str, schedule: &str) -> Result<String, String> {
217     if name.is_empty() || command.is_empty() || schedule.is_empty() {
218         return Err("All fields are required".into());
219     }
220     if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
221         return Err("Name: letters, digits, - and _ only".into());
222     }
223     let valid = std::process::Command::new("systemd-analyze")
224         .args(["calendar", schedule])
225         .output()
226         .map(|o| o.status.success())
227         .unwrap_or(false);
228     if !valid {
229         return Err(format!("Invalid OnCalendar expression: {}", schedule));
230     }
231 
232     let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
233     let dir = std::path::PathBuf::from(home).join(".config/systemd/user");
234     std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
235     let timer_path = dir.join(format!("{}.timer", name));
236     if timer_path.exists() {
237         return Err(format!("{}.timer already exists", name));
238     }
239 
240     let service = format!(
241         "[Unit]\nDescription={name} (created by cce-system-interface)\n\n[Service]\nType=oneshot\nExecStart=/bin/sh -c '{command}'\n",
242         name = name,
243         command = command.replace('\'', "'\\''"),
244     );
245     let timer = format!(
246         "[Unit]\nDescription={name} schedule\n\n[Timer]\nOnCalendar={schedule}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",
247     );
248     std::fs::write(dir.join(format!("{}.service", name)), service).map_err(|e| e.to_string())?;
249     std::fs::write(&timer_path, timer).map_err(|e| e.to_string())?;
250 
251     let _ = tokio::process::Command::new("sh")
252         .args(["-c", &format!("systemctl --user daemon-reload && systemctl --user enable --now {}.timer", name)])
253         .spawn();
254     Ok(format!("Created and enabled {}.timer", name))
255 }
256 
257 /// First `Key=value` in a unit file, or None.
258 fn read_unit_field(path: &std::path::Path, key: &str) -> Option<String> {
259     let content = std::fs::read_to_string(path).ok()?;
260     content
261         .lines()
262         .find_map(|l| l.trim().strip_prefix(key).and_then(|r| r.strip_prefix('=')).map(|v| v.trim().to_string()))
263 }
264 
265 /// Replace the first `key=` line's value in-place, preserving everything else;
266 /// errors when the file has no such line (an unusual unit we shouldn't rewrite).
267 fn replace_unit_field(path: &std::path::Path, key: &str, value: &str) -> Result<(), String> {
268     let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
269     let mut replaced = false;
270     let out: Vec<String> = content
271         .lines()
272         .map(|l| {
273             if !replaced && l.trim_start().starts_with(key) && l.trim_start()[key.len()..].starts_with('=') {
274                 replaced = true;
275                 format!("{}={}", key, value)
276             } else {
277                 l.to_string()
278             }
279         })
280         .collect();
281     if !replaced {
282         return Err(format!("{} has no {}= line", path.display(), key));
283     }
284     std::fs::write(path, out.join("\n") + "\n").map_err(|e| e.to_string())
285 }
286 
287 /// Edit an existing USER timer in place: swap the .service ExecStart and the
288 /// .timer OnCalendar lines, keeping the rest of both files untouched.
289 fn update_user_timer(base: &str, command: &str, schedule: &str) -> Result<String, String> {
290     if command.is_empty() || schedule.is_empty() {
291         return Err("Command and schedule are required".into());
292     }
293     let valid = std::process::Command::new("systemd-analyze")
294         .args(["calendar", schedule])
295         .output()
296         .map(|o| o.status.success())
297         .unwrap_or(false);
298     if !valid {
299         return Err(format!("Invalid OnCalendar expression: {}", schedule));
300     }
301     let dir = user_unit_dir().ok_or("HOME not set")?;
302     replace_unit_field(&dir.join(format!("{}.timer", base)), "OnCalendar", schedule)?;
303     let service_path = dir.join(format!("{}.service", base));
304     if service_path.exists() {
305         replace_unit_field(&service_path, "ExecStart", command)?;
306     }
307     let _ = tokio::process::Command::new("sh")
308         .args(["-c", &format!("systemctl --user daemon-reload && systemctl --user try-restart {}.timer", base)])
309         .spawn();
310     Ok(format!("Updated {}.timer", base))
311 }
312 
313 fn systemctl_action(args: &[&str], is_system: bool) {
314     if is_system {
315         let mut full = vec!["systemctl"];
316         full.extend(args);
317         let _ = tokio::process::Command::new("pkexec").args(&full).spawn();
318     } else {
319         let mut full = vec!["--user"];
320         full.extend(args);
321         let _ = tokio::process::Command::new("systemctl").args(&full).spawn();
322     }
323 }
324 
325 pub fn view(state: &mut TimersState, 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 {
326     let m = crate::app::section_margin();
327     let mut final_pc = PageContent::new();
328     let sec_w = 320.0f32;
329     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
330 
331     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
332         let sec_w = sec.cw;
333         if !state.loaded {
334             sec.text("Loading systemd timers...", 12.0, 0.0, 12.0, TEXT_DIM);
335         } else {
336             // Tab header buttons: System Timers, User Timers
337             let mut stack = sec.vstack(cce_ui::layout::plate_gap());
338             let tab_h = 28.0;
339             let active_bg = [0.20, 0.40, 0.65, 0.4];
340             let inactive_bg = [0.10, 0.10, 0.16, 0.3];
341             let hover_bg = [0.20, 0.20, 0.25, 0.15];
342 
343             let label1 = if stack.context.cw < 250.0 { "System" } else { "System Timers" };
344             let label2 = if stack.context.cw < 250.0 { "User" } else { "User Timers" };
345 
346             stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
347                 if i == 0 {
348                     ctx.button(
349                         label1,
350                         x,
351                         ctx.ay(),
352                         w,
353                         tab_h,
354                         if state.active_tab == TimerTab::System { active_bg } else { inactive_bg },
355                         hover_bg,
356                         [0.90, 0.90, 0.95, 1.0],
357                         crate::app::AppAction::Timers(TimersMessage::SetTab(TimerTab::System)),
358                     );
359                 } else {
360                     ctx.button(
361                         label2,
362                         x,
363                         ctx.ay(),
364                         w,
365                         tab_h,
366                         if state.active_tab == TimerTab::User { active_bg } else { inactive_bg },
367                         hover_bg,
368                         [0.90, 0.90, 0.95, 1.0],
369                         crate::app::AppAction::Timers(TimersMessage::SetTab(TimerTab::User)),
370                     );
371                 }
372             });
373 
374             stack.context.spacing(4.0);
375 
376             // New Timer (user scope) — compact button; the form unfolds below.
377             let new_bg = if state.creating { active_bg } else { [0.13, 0.18, 0.14, 1.0] };
378             stack.add_row(3, cce_ui::layout::plate_gap(), 26.0, |c, i, x, w| {
379                 if i == 0 {
380                     c.button("New Timer", x, c.ay(), w, 26.0,
381                         new_bg, [0.25, 0.30, 0.26, 1.0], [0.90, 0.90, 0.95, 1.0],
382                         crate::app::AppAction::Timers(TimersMessage::CreateStart));
383                 }
384             });
385 
386             if state.creating || state.editing.is_some() {
387                 let item_w = sec_w - 2.0 * (stack.context.padding() + m);
388                 let rx = stack.context.left;
389                 let widget_h = cce_ui::layout::spinbox_height();
390 
391                 if let Some(base) = &state.editing {
392                     stack.context.text(&format!("Editing {}.timer \u{2014} command and schedule rewrite in place.", base), 12.0, 0.0, 11.0, TEXT_DIM);
393                 } else {
394                     stack.context.text("New user timer \u{2014} runs the command on the schedule.", 12.0, 0.0, 11.0, TEXT_DIM);
395                 }
396 
397                 if state.creating {
398                     state.name_box.set_row_rect(rx + m, item_w);
399                     stack.add_widget(&mut state.name_box, item_w, widget_h, ctx);
400                 }
401                 state.command_box.set_row_rect(rx + m, item_w);
402                 stack.add_widget(&mut state.command_box, item_w, widget_h, ctx);
403                 state.schedule_box.set_row_rect(rx + m, item_w);
404                 stack.add_widget(&mut state.schedule_box, item_w, widget_h, ctx);
405 
406                 stack.context.spacing(4.0);
407                 let save_label = if state.editing.is_some() { "Save" } else { "Create" };
408                 stack.add_row(3, cce_ui::layout::plate_gap(), 26.0, |c, i, x, w| {
409                     match i {
410                         0 => c.button(save_label, x, c.ay(), w, 26.0,
411                             [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], [0.90, 0.90, 0.95, 1.0],
412                             crate::app::AppAction::Timers(TimersMessage::CreateSave)),
413                         1 => c.button("Cancel", x, c.ay(), w, 26.0,
414                             [0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0], [0.90, 0.90, 0.95, 1.0],
415                             crate::app::AppAction::Timers(TimersMessage::CreateCancel)),
416                         _ => {}
417                     }
418                 });
419             }
420 
421             if let Some(ref msg) = state.status_msg {
422                 stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
423             }
424 
425             stack.context.spacing(cce_ui::layout::plate_gap());
426 
427             // Scroll box list, filling the page like the services list.
428             let list_box_x = sec.left + m;
429             let list_box_y = sec.ay();
430             let list_box_w = sec_w - 2.0 * m;
431             let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
432 
433             let now = now_usec();
434             let filtered: Vec<&TimerInfo> = state.timers.iter()
435                 .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
436                 .collect();
437 
438             state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
439             state.list.update_bounds(filtered.len(), list_box_y, list_box_h);
440             state.list.push_prims(sec.pc);
441 
442             let item_h = state.list.item_height;
443 
444             if state.items.len() != filtered.len() {
445                 state.items.clear();
446                 for _ in 0..filtered.len() {
447                     state.items.push(InteractiveListItem::new(""));
448                 }
449             }
450 
451             sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
452             for (idx, timer) in filtered.iter().enumerate() {
453                 if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
454                     let is_small = sec_w < 350.0;
455                     let run_w = if is_small { 40.0 } else { 76.0 };
456                     let en_w = if is_small { 40.0 } else { 66.0 };
457                     let edit_w = if is_small { 36.0 } else { 50.0 };
458                     let btn_gap = if is_small { 4.0 } else { 6.0 };
459                     // TODO(style): the row's button run, dot and text
460                     // column below are this list row's own layout.
461                     let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
462 
463                     let en_x = right_edge - en_w;
464                     let run_x = en_x - btn_gap - run_w;
465                     let edit_x = run_x - btn_gap - edit_w;
466 
467                     let btn_y = draw_y + (item_h - 22.0) / 2.0;
468                     let btn_h = 22.0;
469 
470                     // Title + schedule subtitle (truncated to the space before the buttons).
471                     let text_left_edge = if timer.editable { edit_x } else { run_x };
472                     let text_max_w = (text_left_edge - 8.0) - (list_box_x + 32.0);
473                     let max_chars = ((text_max_w / 6.0) as usize).max(10);
474                     let subtitle_full = schedule_line(timer, now);
475                     let subtitle = if subtitle_full.len() > max_chars {
476                         format!("{}...", &subtitle_full[..subtitle_full.char_indices().take(max_chars.saturating_sub(3)).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(0)])
477                     } else {
478                         subtitle_full
479                     };
480 
481                     let item_btn = &mut state.items[idx];
482                     item_btn.title = timer.unit.clone();
483                     item_btn.subtitle = Some(subtitle);
484                     render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
485 
486                     let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
487                     let mut dot = StatusDot::new(dot_state);
488                     render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
489 
490                     let active_txt = [0.90, 0.90, 0.95, 1.0];
491 
492                     // Edit: only for user units living in ~/.config/systemd/user.
493                     if timer.editable {
494                         let lbl = if is_small { "\u{270e}" } else { "Edit" };
495                         sec.pc.button(
496                             lbl,
497                             edit_x,
498                             btn_y,
499                             edit_w,
500                             btn_h,
501                             [0.15, 0.15, 0.20, 1.0],
502                             [0.22, 0.22, 0.28, 1.0],
503                             active_txt,
504                             crate::app::AppAction::Timers(TimersMessage::EditStart(timer.unit.clone())),
505                         );
506                     }
507 
508                     // Run Now: start the activated service immediately.
509                     let run_lbl = if is_small { "\u{25b6}" } else { "Run Now" };
510                     sec.pc.button(
511                         run_lbl,
512                         run_x,
513                         btn_y,
514                         run_w,
515                         btn_h,
516                         [0.16, 0.35, 0.18, 0.4],
517                         [0.22, 0.45, 0.25, 0.6],
518                         active_txt,
519                         crate::app::AppAction::Timers(TimersMessage::RunNow(timer.activates.clone(), timer.is_system)),
520                     );
521 
522                     // Enable/Disable the timer unit; static units have no toggle.
523                     match timer.file_state.as_str() {
524                         "enabled" | "enabled-runtime" => {
525                             let lbl = if is_small { "\u{25a0}" } else { "Disable" };
526                             sec.pc.button(
527                                 lbl,
528                                 en_x,
529                                 btn_y,
530                                 en_w,
531                                 btn_h,
532                                 [0.25, 0.14, 0.14, 1.0],
533                                 [0.40, 0.20, 0.20, 1.0],
534                                 [0.95, 0.55, 0.55, 1.0],
535                                 crate::app::AppAction::Timers(TimersMessage::Disable(timer.unit.clone(), timer.is_system)),
536                             );
537                         }
538                         "disabled" => {
539                             let lbl = if is_small { "\u{25cf}" } else { "Enable" };
540                             sec.pc.button(
541                                 lbl,
542                                 en_x,
543                                 btn_y,
544                                 en_w,
545                                 btn_h,
546                                 [0.15, 0.15, 0.20, 1.0],
547                                 [0.22, 0.22, 0.28, 1.0],
548                                 active_txt,
549                                 crate::app::AppAction::Timers(TimersMessage::Enable(timer.unit.clone(), timer.is_system)),
550                             );
551                         }
552                         _ => {
553                             sec.pc.text("static", en_x + 8.0, btn_y + 5.0, 11.0, TEXT_DIM);
554                         }
555                     }
556                 }
557             }
558             sec.pc.pop_clip_rect();
559 
560             if filtered.is_empty() {
561                 sec.pc.text("No timers in this scope", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
562             }
563 
564             // End the section so the well's bottom wall sits one margin below
565             // the list: finish() places the wall at content_y + padding +
566             // margin, so the list's own bottom margin and that one cancel.
567             sec.content_y = list_box_y + list_box_h - sec.padding();
568         }
569     });
570 
571     final_pc
572 }
573 
574 pub fn update(state: &mut TimersState, msg: TimersMessage) {
575     match msg {
576         TimersMessage::Refreshed(timers) => {
577             state.loaded = true;
578             state.timers = timers;
579             state.items.clear();
580         }
581         TimersMessage::SetTab(tab) => {
582             state.active_tab = tab;
583             state.list.set_scroll_y(0.0);
584             state.items.clear();
585         }
586         TimersMessage::RunNow(service, is_system) => {
587             if !service.is_empty() {
588                 systemctl_action(&["start", &service], is_system);
589             }
590         }
591         TimersMessage::Enable(unit, is_system) => {
592             if let Some(t) = state.timers.iter_mut().find(|t| t.unit == unit && t.is_system == is_system) {
593                 t.file_state = "enabled".to_string();
594             }
595             systemctl_action(&["enable", "--now", &unit], is_system);
596         }
597         TimersMessage::Disable(unit, is_system) => {
598             if let Some(t) = state.timers.iter_mut().find(|t| t.unit == unit && t.is_system == is_system) {
599                 t.file_state = "disabled".to_string();
600             }
601             systemctl_action(&["disable", "--now", &unit], is_system);
602         }
603         TimersMessage::CreateStart => {
604             state.creating = true;
605             state.editing = None;
606             state.status_msg = None;
607             for tb in [&mut state.name_box, &mut state.command_box, &mut state.schedule_box] {
608                 tb.text = String::new();
609                 tb.edit_buffer = String::new();
610             }
611         }
612         TimersMessage::CreateCancel => {
613             state.creating = false;
614             state.editing = None;
615             state.status_msg = None;
616         }
617         TimersMessage::EditStart(unit) => {
618             let base = unit.trim_end_matches(".timer").to_string();
619             let dir = user_unit_dir();
620             let schedule = dir.as_ref()
621                 .and_then(|d| read_unit_field(&d.join(format!("{}.timer", base)), "OnCalendar"))
622                 .unwrap_or_default();
623             let command = dir.as_ref()
624                 .and_then(|d| read_unit_field(&d.join(format!("{}.service", base)), "ExecStart"))
625                 .unwrap_or_default();
626             state.creating = false;
627             state.editing = Some(base.clone());
628             state.status_msg = None;
629             state.name_box.text = base.clone();
630             state.name_box.edit_buffer = base;
631             state.command_box.text = command.clone();
632             state.command_box.edit_buffer = command;
633             state.schedule_box.text = schedule.clone();
634             state.schedule_box.edit_buffer = schedule;
635         }
636         TimersMessage::CreateSave => {
637             let command = live_text(&state.command_box);
638             let schedule = live_text(&state.schedule_box);
639             let result = if let Some(base) = state.editing.clone() {
640                 update_user_timer(&base, &command, &schedule)
641             } else {
642                 create_user_timer(&live_text(&state.name_box), &command, &schedule)
643             };
644             match result {
645                 Ok(msg) => {
646                     state.creating = false;
647                     state.editing = None;
648                     state.status_msg = Some(msg);
649                     // Show the unit where it (re)appears on the next refresh.
650                     state.active_tab = TimerTab::User;
651                     state.items.clear();
652                 }
653                 Err(e) => {
654                     state.status_msg = Some(e);
655                 }
656             }
657         }
658     }
659 }
660 
661 impl crate::pages::AppPage for TimersState {
662     // Sections: [Timers] — the form boxes join the group while it is open
663     // (name box only on create; edits keep the unit name fixed).
664     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
665         if self.creating {
666             vec![vec![
667                 self.name_box.id(),
668                 self.command_box.id(),
669                 self.schedule_box.id(),
670             ]]
671         } else if self.editing.is_some() {
672             vec![vec![
673                 self.command_box.id(),
674                 self.schedule_box.id(),
675             ]]
676         } else {
677             vec![Vec::new()]
678         }
679     }
680 
681     fn view(
682         &mut self,
683         cx: f32,
684         cy: f32,
685         cw: f32,
686         ch: f32,
687         root_focused: bool,
688         sec_focused: &[bool],
689         layout: &mut dyn LayoutStrategy,
690         ctx: &mut cce_ui::context::UiContext,
691     ) -> crate::app::PageContent {
692         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
693     }
694 
695     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
696 
697     // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
698     // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
699     // hit-test against the row actually on screen. See the note in packages.rs.
700     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
701         let (list, items) = (&self.list, &self.items);
702         items
703             .iter()
704             .enumerate()
705             .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
706             .map(|(_, i)| i.id())
707             .collect()
708     }
709 
710     fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
711         let (list, items) = (&self.list, &mut self.items);
712         for (idx, i) in items.iter_mut().enumerate() {
713             if list.get_item_draw_y(idx, 4.0).is_none() {
714                 continue;
715             }
716             let (id, ptr) = (i.id(), i.as_ptr_mut());
717             ctx.register_widget(id, ptr);
718         }
719     }
720 
721     fn handle_pointer_move(
722         &mut self,
723         lx: f32,
724         ly: f32,
725         _actions: &mut Vec<crate::app::AppAction>,
726         _ctx: &mut cce_ui::context::UiContext,
727     ) -> bool {
728         self.loaded && self.list.cursor_moved(lx, ly)
729     }
730 
731     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
732         self.loaded && self.list.press(lx, ly)
733     }
734 
735     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
736         self.list.release()
737     }
738 
739     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
740         self.loaded && self.list.wheel(delta, lx, ly)
741     }
742 
743     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
744         self.loaded && self.list.keyboard(event)
745     }
746 
747     fn tick(&mut self, dt: f32) -> bool {
748         self.list.tick(dt)
749     }
750 }
751 
752 #[cfg(test)]
753 mod tests {
754     use super::*;
755 
756     #[test]
757     fn humanize_ranges() {
758         assert_eq!(humanize(30), "30s");
759         assert_eq!(humanize(46 * 60), "46min");
760         assert_eq!(humanize(3 * 3600), "3h");
761         assert_eq!(humanize(3 * 3600 + 20 * 60), "3h 20min");
762         assert_eq!(humanize(5 * 86_400 + 3 * 3600), "5d 3h");
763     }
764 
765     #[test]
766     fn unit_field_read_and_replace() {
767         let dir = std::env::temp_dir().join("cce-timer-edit-test");
768         std::fs::create_dir_all(&dir).unwrap();
769         let p = dir.join("t.timer");
770         std::fs::write(&p, "[Unit]\nDescription=x\n\n[Timer]\nOnCalendar=daily\nPersistent=true\n").unwrap();
771 
772         assert_eq!(read_unit_field(&p, "OnCalendar").as_deref(), Some("daily"));
773         replace_unit_field(&p, "OnCalendar", "Mon 09:00").unwrap();
774         let content = std::fs::read_to_string(&p).unwrap();
775         assert!(content.contains("OnCalendar=Mon 09:00"), "{content}");
776         assert!(content.contains("Persistent=true"), "rest preserved: {content}");
777         assert!(replace_unit_field(&p, "Nonexistent", "x").is_err());
778     }
779 
780     #[test]
781     fn create_timer_validation_rejects_before_side_effects() {
782         assert!(create_user_timer("", "echo hi", "daily").is_err());
783         assert!(create_user_timer("backup", "", "daily").is_err());
784         assert!(create_user_timer("backup", "echo hi", "").is_err());
785         assert!(create_user_timer("bad name!", "echo hi", "daily").is_err());
786     }
787 
788     #[test]
789     fn schedule_line_composes() {
790         let now = 1_000_000_000_000_000u64;
791         let t = TimerInfo {
792             unit: "x.timer".into(),
793             activates: "x.service".into(),
794             next_usec: Some(now + 46 * 60 * 1_000_000),
795             last_usec: Some(now - 16 * 3600 * 1_000_000),
796             active: true,
797             file_state: "enabled".into(),
798             is_system: true,
799             editable: false,
800         };
801         let line = schedule_line(&t, now);
802         assert!(line.contains("x.service"), "{line}");
803         assert!(line.contains("next in 46min"), "{line}");
804         assert!(line.contains("last 16h ago"), "{line}");
805 
806         let t2 = TimerInfo { next_usec: None, last_usec: None, ..t };
807         let line2 = schedule_line(&t2, now);
808         assert!(line2.contains("no run scheduled"), "{line2}");
809         assert!(line2.contains("never ran"), "{line2}");
810     }
811 }