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 }