Skip to main content

scxtui/
app.rs

1// SPDX-License-Identifier: GPL-2.0
2
3//! Application state and event loop.
4//!
5//! Everything that reads or changes `scx.service` goes through the
6//! backend, which is `scxcli`. The app holds the last listing it got
7//! back, the kernel's own view read every tick, and the selections of
8//! the three views.
9
10use std::time::{Duration, Instant};
11
12use anyhow::Result;
13use ratatui::DefaultTerminal;
14use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
15
16use crate::backend::Backend;
17use crate::kernel::{self, KernelState};
18use crate::logs::{self, LogLine};
19use crate::model::{DEFAULT_ID, Installed, Listing, Pick, validate_arg};
20use crate::system::system_command;
21use crate::ui;
22use crate::watch::Watcher;
23
24/// How often the input poll wakes up to redraw / refresh.
25const TICK: Duration = Duration::from_millis(250);
26/// Slow poll for what inotify cannot see: the unit's state and the
27/// process behind it. The catalog and environment files are refreshed
28/// on inotify events instead.
29const REFRESH_EVERY: Duration = Duration::from_secs(5);
30/// Minimum spacing between two scheduler-affecting actions. Linux
31/// terminals deliver key autorepeat as plain `Press` events, so without
32/// this, holding Enter would fire one start per repeat.
33const ACTION_DEBOUNCE: Duration = Duration::from_millis(500);
34/// How long informational messages stay in the message bar. Errors are
35/// exempt: they are actionable and stay until replaced.
36const MESSAGE_TTL: Duration = Duration::from_secs(8);
37
38/// Deferred user actions: queued by key handlers, executed by the event
39/// loop right after the frame announcing them has been drawn. scxcli
40/// blocks (a restart waits for the unit to come up, pkexec may prompt),
41/// so the UI must show feedback before the call starts.
42#[derive(Clone, Debug, PartialEq)]
43enum PendingAction {
44    Start(String),
45    Run(String, Vec<String>),
46    Stop,
47    Monitor,
48}
49
50/// A single-line text field. The cursor is counted in characters, so
51/// editing multi-byte input stays sound; the UI derives its cursor
52/// block from the same count.
53#[derive(Default, Clone, Debug, PartialEq)]
54pub struct Input {
55    pub buffer: String,
56    pub cursor: usize,
57}
58
59impl Input {
60    fn byte_cursor(&self) -> usize {
61        self.buffer
62            .char_indices()
63            .nth(self.cursor)
64            .map_or(self.buffer.len(), |(idx, _)| idx)
65    }
66
67    fn len_chars(&self) -> usize {
68        self.buffer.chars().count()
69    }
70
71    fn insert(&mut self, c: char) {
72        let at = self.byte_cursor();
73        self.buffer.insert(at, c);
74        self.cursor += 1;
75    }
76
77    fn backspace(&mut self) {
78        if self.cursor > 0 {
79            self.cursor -= 1;
80            let at = self.byte_cursor();
81            self.buffer.remove(at);
82        }
83    }
84
85    fn delete(&mut self) {
86        let at = self.byte_cursor();
87        if at < self.buffer.len() {
88            self.buffer.remove(at);
89        }
90    }
91
92    fn edit(&mut self, key: KeyEvent) {
93        match key.code {
94            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => self.insert(c),
95            KeyCode::Backspace => self.backspace(),
96            KeyCode::Delete => self.delete(),
97            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
98            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len_chars()),
99            KeyCode::Home => self.cursor = 0,
100            KeyCode::End => self.cursor = self.len_chars(),
101            _ => {}
102        }
103    }
104}
105
106/// Which screen is shown.
107#[derive(Clone, Copy, PartialEq, Debug)]
108pub enum View {
109    Recommended,
110    All,
111    Logs,
112}
113
114pub struct Message {
115    pub text: String,
116    pub is_error: bool,
117    shown_at: Instant,
118}
119
120pub struct App {
121    backend: Box<dyn Backend>,
122    /// Last successful listing. Stays `None` after a failed read, with
123    /// the failure in `listing_error`, so the panel shows "unknown" plus
124    /// the reason instead of a stale snapshot.
125    pub listing: Option<Listing>,
126    pub listing_error: Option<String>,
127    /// The kernel's own view, re-read every tick.
128    pub kernel: Option<KernelState>,
129    read_kernel: Box<dyn Fn() -> Option<KernelState>>,
130    kernel_disputed: bool,
131    watcher: Option<Watcher>,
132    pub selected: usize,
133    pub sched_selected: usize,
134    /// The options field of the full list, when open. While it is open
135    /// it owns every key press, so global shortcuts cannot fire from
136    /// typed text.
137    pub args: Option<Input>,
138    /// The full list's warning is shown once per session.
139    pub all_warned: bool,
140    pub message: Option<Message>,
141    last_action: Option<Instant>,
142    pub view: View,
143    pub log_previous_boot: bool,
144    pub log_lines: Vec<LogLine>,
145    pub log_scroll: usize,
146    pub log_page: usize,
147    pending_action: Option<PendingAction>,
148    should_quit: bool,
149}
150
151impl App {
152    pub fn new(backend: Box<dyn Backend>) -> Self {
153        Self {
154            backend,
155            listing: None,
156            listing_error: None,
157            kernel: None,
158            read_kernel: Box::new(|| kernel::read().unwrap_or(None)),
159            kernel_disputed: false,
160            watcher: None,
161            selected: 0,
162            sched_selected: 0,
163            args: None,
164            all_warned: false,
165            message: None,
166            last_action: None,
167            view: View::Recommended,
168            log_previous_boot: false,
169            log_lines: Vec::new(),
170            log_scroll: 0,
171            log_page: 20,
172            pending_action: None,
173            should_quit: false,
174        }
175    }
176
177    pub fn backend_label(&self) -> String {
178        self.backend.label()
179    }
180
181    pub fn recommended(&self) -> &[Pick] {
182        self.listing
183            .as_ref()
184            .map_or(&[], |l| l.recommended.as_slice())
185    }
186
187    pub fn all(&self) -> &[Installed] {
188        self.listing.as_ref().map_or(&[], |l| l.all.as_slice())
189    }
190
191    pub fn selected_pick(&self) -> Option<&Pick> {
192        self.recommended().get(self.selected)
193    }
194
195    pub fn selected_scheduler(&self) -> Option<&Installed> {
196        self.all().get(self.sched_selected)
197    }
198
199    pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
200        let mut last_refresh = Instant::now();
201        let mut primed = false;
202        while !self.should_quit {
203            terminal.draw(|frame| ui::draw(frame, self))?;
204
205            // First frame is a skeleton; the initial fetch happens right
206            // after it is on screen and the immediate redraw fills it in.
207            if !primed {
208                primed = true;
209                self.refresh();
210                self.sync_selection_to_running();
211                self.start_watching();
212                last_refresh = Instant::now();
213                continue;
214            }
215
216            if self
217                .message
218                .as_ref()
219                .is_some_and(|m| !m.is_error && m.shown_at.elapsed() >= MESSAGE_TTL)
220            {
221                self.message = None;
222            }
223
224            if let Some(action) = self.pending_action.take() {
225                self.run_action(action, &mut terminal)?;
226                last_refresh = Instant::now();
227                continue;
228            }
229
230            if event::poll(TICK)?
231                && let Event::Key(key) = event::read()?
232                && key.kind == KeyEventKind::Press
233            {
234                self.on_key(key);
235            }
236
237            self.kernel = (self.read_kernel)();
238
239            let files_changed = self.watcher.as_mut().is_some_and(Watcher::changed);
240            if files_changed
241                || self.reconcile_kernel_dispute()
242                || last_refresh.elapsed() >= REFRESH_EVERY
243            {
244                self.refresh();
245                last_refresh = Instant::now();
246            }
247        }
248        Ok(())
249    }
250
251    fn start_watching(&mut self) {
252        let config = self.listing.as_ref().and_then(|l| l.config.clone());
253        match Watcher::new(&Watcher::files_for(config.as_deref())) {
254            Ok(watcher) => self.watcher = Some(watcher),
255            Err(err) => self.error(&format!("file watching unavailable ({err:#}); polling")),
256        }
257    }
258
259    /// Hands the terminal over to `scxtop` and takes it back afterwards.
260    fn run_monitor(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
261        let Ok(mut command) = system_command("scxtop") else {
262            self.error(
263                "scxtop not found in systemd's default search path; install it (cargo install \
264scxtop, or your distro's scx tools package)",
265            );
266            return Ok(());
267        };
268        ratatui::restore();
269        let result = command.status();
270        *terminal = ratatui::init();
271        terminal.clear()?;
272        match result {
273            Ok(status) if status.success() => self.info("scxtop exited"),
274            Ok(status) => self.error(&format!(
275                "scxtop exited with {status}; it needs root/CAP_BPF"
276            )),
277            Err(err) => self.error(&format!("failed to launch scxtop: {err}")),
278        }
279        Ok(())
280    }
281
282    fn queue(&mut self, action: PendingAction) {
283        self.info("working...");
284        self.pending_action = Some(action);
285    }
286
287    /// Runs a privileged scxcli command. When it goes through pkexec
288    /// the terminal is handed over for the duration, because pkexec may
289    /// prompt for a password on it (a text agent when the session has
290    /// no graphical one); with the shipped policy starting a scheduler
291    /// from the list prompts for nothing and the hand-over is a flicker.
292    fn privileged(
293        &mut self,
294        terminal: &mut DefaultTerminal,
295        call: impl FnOnce(&dyn Backend) -> Result<String>,
296    ) -> Result<Result<String>> {
297        if !self.backend.escalates() {
298            return Ok(call(self.backend.as_ref()));
299        }
300        ratatui::restore();
301        let result = call(self.backend.as_ref());
302        *terminal = ratatui::init();
303        terminal.clear()?;
304        Ok(result)
305    }
306
307    fn run_action(&mut self, action: PendingAction, terminal: &mut DefaultTerminal) -> Result<()> {
308        let outcome = match action {
309            PendingAction::Start(id) => self
310                .privileged(terminal, |b| b.start(&id))?
311                .map(|report| format!("{report}; see State and Kernel for what attached")),
312            PendingAction::Run(sched, args) => {
313                self.privileged(terminal, |b| b.run(&sched, &args))?
314            }
315            PendingAction::Stop => self.privileged(terminal, |b| b.stop())?,
316            PendingAction::Monitor => return self.run_monitor(terminal),
317        };
318        match outcome {
319            Ok(text) => self.info(&text),
320            Err(err) => self.error(&format!("{err:#}")),
321        }
322        self.refresh();
323        Ok(())
324    }
325
326    fn on_key(&mut self, key: KeyEvent) {
327        if self.args.is_some() {
328            self.on_key_args(key);
329            return;
330        }
331        match self.view {
332            View::Recommended => self.on_key_recommended(key),
333            View::All => self.on_key_all(key),
334            View::Logs => self.on_key_logs(key),
335        }
336    }
337
338    fn on_key_args(&mut self, key: KeyEvent) {
339        let Some(input) = self.args.as_mut() else {
340            return;
341        };
342        match key.code {
343            KeyCode::Esc => self.args = None,
344            KeyCode::Enter => self.submit_args(),
345            _ => input.edit(key),
346        }
347    }
348
349    fn submit_args(&mut self) {
350        let Some(input) = self.args.clone() else {
351            return;
352        };
353        let args: Vec<String> = input.buffer.split_whitespace().map(str::to_owned).collect();
354        if let Some(err) = args.iter().find_map(|arg| validate_arg(arg).err()) {
355            self.error(&format!("{err:#}"));
356            return;
357        }
358        let Some(sched) = self.selected_scheduler().map(|s| s.name.clone()) else {
359            return;
360        };
361        if self.action_allowed() {
362            self.args = None;
363            self.queue(PendingAction::Run(sched, args));
364        }
365    }
366
367    fn on_key_recommended(&mut self, key: KeyEvent) {
368        match key.code {
369            KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
370            KeyCode::Char('l') => self.open_logs(),
371            KeyCode::Char('A') => self.open_all(),
372            KeyCode::Char('t') => {
373                if self.action_allowed() {
374                    self.pending_action = Some(PendingAction::Monitor);
375                }
376            }
377            KeyCode::Down | KeyCode::Char('j') => {
378                self.selected = step(self.selected, self.recommended().len(), true);
379            }
380            KeyCode::Up | KeyCode::Char('k') => {
381                self.selected = step(self.selected, self.recommended().len(), false);
382            }
383            KeyCode::Enter => self.start_selected_pick(),
384            KeyCode::Char('s') => self.stop_running(),
385            KeyCode::Char('R') => {
386                self.refresh();
387                if self.listing.is_some() {
388                    self.info("refreshed");
389                }
390            }
391            _ => {}
392        }
393    }
394
395    fn on_key_all(&mut self, key: KeyEvent) {
396        match key.code {
397            KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('A') => {
398                self.view = View::Recommended;
399            }
400            KeyCode::Char('l') => self.open_logs(),
401            KeyCode::Char('t') => {
402                if self.action_allowed() {
403                    self.pending_action = Some(PendingAction::Monitor);
404                }
405            }
406            KeyCode::Down | KeyCode::Char('j') => {
407                self.sched_selected = step(self.sched_selected, self.all().len(), true);
408            }
409            KeyCode::Up | KeyCode::Char('k') => {
410                self.sched_selected = step(self.sched_selected, self.all().len(), false);
411            }
412            KeyCode::Char('a') => {
413                if self.selected_scheduler().is_some() {
414                    self.args = Some(Input::default());
415                }
416            }
417            KeyCode::Enter => {
418                if let Some(sched) = self.selected_scheduler().map(|s| s.name.clone())
419                    && self.action_allowed()
420                {
421                    self.queue(PendingAction::Start(sched));
422                }
423            }
424            KeyCode::Char('s') => self.stop_running(),
425            KeyCode::Char('R') => {
426                self.refresh();
427                if self.listing.is_some() {
428                    self.info("refreshed");
429                }
430            }
431            _ => {}
432        }
433    }
434
435    fn on_key_logs(&mut self, key: KeyEvent) {
436        match key.code {
437            KeyCode::Char('q' | 'l') | KeyCode::Esc => self.view = View::Recommended,
438            KeyCode::Up | KeyCode::Char('k') => self.log_scroll = self.log_scroll.saturating_add(1),
439            KeyCode::Down | KeyCode::Char('j') => {
440                self.log_scroll = self.log_scroll.saturating_sub(1);
441            }
442            KeyCode::PageUp => self.log_scroll = self.log_scroll.saturating_add(self.log_page),
443            KeyCode::PageDown => self.log_scroll = self.log_scroll.saturating_sub(self.log_page),
444            KeyCode::Char('g') => self.log_scroll = usize::MAX,
445            KeyCode::Char('G') => self.log_scroll = 0,
446            KeyCode::Char('b') => {
447                self.log_previous_boot = !self.log_previous_boot;
448                self.reload_logs();
449            }
450            KeyCode::Char('R') => self.reload_logs(),
451            _ => {}
452        }
453    }
454
455    fn open_all(&mut self) {
456        self.view = View::All;
457        if !self.all_warned {
458            self.all_warned = true;
459            self.info(
460                "all schedulers: one unsuited to the workload can perform worse than the \
461kernel's default; the recommended ones are marked",
462            );
463        }
464    }
465
466    fn open_logs(&mut self) {
467        self.view = View::Logs;
468        self.reload_logs();
469    }
470
471    fn reload_logs(&mut self) {
472        match logs::fetch(logs::UNIT, self.log_previous_boot) {
473            Ok(fetch) => {
474                let boot = if self.log_previous_boot { "-1" } else { "0" };
475                let tail_note = if fetch.truncated {
476                    "; older entries omitted, see journalctl for the full log"
477                } else {
478                    ""
479                };
480                self.info(&format!(
481                    "loaded {} lines from {} (boot {boot}){tail_note}",
482                    fetch.lines.len(),
483                    logs::UNIT
484                ));
485                self.log_lines = fetch.lines;
486                self.log_scroll = 0;
487            }
488            Err(err) => {
489                self.log_lines = Vec::new();
490                self.error(&format!("{err:#}"));
491            }
492        }
493    }
494
495    /// Aligns the selections with what runs, so the first Enter acts
496    /// on what the user sees. Only on startup, never on a refresh,
497    /// which must not fight the user's own selection.
498    fn sync_selection_to_running(&mut self) {
499        let Some(listing) = &self.listing else {
500            return;
501        };
502        let Some(name) = listing.current.scheduler.as_deref() else {
503            return;
504        };
505        if let Some(idx) = listing.recommended.iter().position(|p| p.id == name) {
506            self.selected = idx;
507        }
508        if let Some(idx) = listing.all.iter().position(|s| s.name == name) {
509            self.sched_selected = idx;
510        }
511    }
512
513    fn action_allowed(&mut self) -> bool {
514        let now = Instant::now();
515        if self
516            .last_action
517            .is_some_and(|last| now.duration_since(last) < ACTION_DEBOUNCE)
518        {
519            return false;
520        }
521        self.last_action = Some(now);
522        true
523    }
524
525    /// Enter in the recommended list: `scxcli start ID`. The same call
526    /// whether the entry is stopped, failed or running something else;
527    /// scxcli rewrites the file and restarts the unit either way.
528    fn start_selected_pick(&mut self) {
529        let Some(pick) = self.selected_pick() else {
530            return;
531        };
532        if !pick.available {
533            self.error(&format!("{} is not installed", pick.id));
534            return;
535        }
536        let id = pick.id.clone();
537        if self.action_allowed() {
538            self.queue(PendingAction::Start(id));
539        }
540    }
541
542    /// `s`: stop the unit and keep the selection, so it comes back on
543    /// the next start or boot. Not `start default`, which forgets it.
544    fn stop_running(&mut self) {
545        let active = self.listing.as_ref().is_some_and(|l| l.current.unit.active);
546        if !active {
547            self.info("nothing is running");
548            return;
549        }
550        if self.action_allowed() {
551            self.queue(PendingAction::Stop);
552        }
553    }
554
555    /// A failed read clears the snapshot and records why.
556    fn refresh(&mut self) {
557        match self.backend.list() {
558            Ok(listing) => {
559                self.selected = self
560                    .selected
561                    .min(listing.recommended.len().saturating_sub(1));
562                self.sched_selected = self.sched_selected.min(listing.all.len().saturating_sub(1));
563                self.listing = Some(listing);
564                self.listing_error = None;
565            }
566            Err(err) => {
567                self.listing = None;
568                self.listing_error = Some(format!("{err:#}"));
569            }
570        }
571        self.kernel = (self.read_kernel)();
572    }
573
574    /// The panel's warning; the event loop reuses it as a tripwire.
575    pub fn kernel_warning(&self) -> Option<String> {
576        let kernel = self.kernel.as_ref()?;
577        let current = &self.listing.as_ref()?.current;
578        let running = current.running.as_ref().map(|r| r.sched.as_str());
579        match (current.unit.active, running, kernel.enabled()) {
580            (false, _, true) => {
581                Some("a scheduler is attached outside of scx.service's control".to_owned())
582            }
583            (true, running, false) => Some(format!(
584                "scx.service is active ({}), but the kernel shows no attached scheduler",
585                running.unwrap_or("process unknown")
586            )),
587            (true, Some(sched), true) if !kernel.matches(sched) => {
588                let ops = kernel.ops.as_deref().unwrap_or("?");
589                Some(format!(
590                    "scx.service runs {sched}, but the kernel ops name is \"{ops}\""
591                ))
592            }
593            _ => None,
594        }
595    }
596
597    /// Edge-triggered: re-asks once per new dispute.
598    fn reconcile_kernel_dispute(&mut self) -> bool {
599        let disputed = self.kernel_warning().is_some();
600        let fire = disputed && !self.kernel_disputed;
601        self.kernel_disputed = disputed;
602        fire
603    }
604
605    /// An error-styled message that stays until replaced, for a
606    /// condition found before the loop starts.
607    pub fn warn(&mut self, text: &str) {
608        self.error(text);
609    }
610
611    fn info(&mut self, text: &str) {
612        self.message = Some(Message {
613            text: text.to_owned(),
614            is_error: false,
615            shown_at: Instant::now(),
616        });
617    }
618
619    fn error(&mut self, text: &str) {
620        self.message = Some(Message {
621            text: text.to_owned(),
622            is_error: true,
623            shown_at: Instant::now(),
624        });
625    }
626}
627
628fn step(idx: usize, len: usize, forward: bool) -> usize {
629    if len == 0 {
630        0
631    } else if forward {
632        (idx + 1) % len
633    } else {
634        (idx + len - 1) % len
635    }
636}
637
638/// Whether `id` is the entry that runs, for the lists' marker: the
639/// running scheduler's name, or `default` while the kernel's own runs.
640pub fn is_running(listing: &Listing, id: &str) -> bool {
641    listing.current.scheduler.as_deref() == Some(id)
642}
643
644/// Whether `id` is what the next start would run, for the lists'
645/// second marker: a selection that is stopped or failed.
646pub fn is_selected(listing: &Listing, id: &str) -> bool {
647    match &listing.current.next {
648        Some(next) => !next.overridden && next.args.is_empty() && next.name() == id,
649        None => id == DEFAULT_ID,
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use std::cell::RefCell;
656    use std::rc::Rc;
657
658    use crate::model::{Current, Running, Unit};
659    use anyhow::anyhow;
660
661    use super::*;
662
663    #[derive(Debug, PartialEq)]
664    enum Call {
665        Start(String),
666        Run(String, Vec<String>),
667        Stop,
668    }
669
670    struct Stub {
671        listing: Rc<RefCell<Listing>>,
672        broken: Rc<RefCell<bool>>,
673        calls: Rc<RefCell<Vec<Call>>>,
674    }
675
676    fn pick(id: &str, available: bool) -> Pick {
677        Pick {
678            id: id.into(),
679            label: id.into(),
680            description: String::new(),
681            path: (id != DEFAULT_ID && available).then(|| format!("/usr/bin/{id}")),
682            available,
683        }
684    }
685
686    fn installed(name: &str, recommended: bool) -> Installed {
687        Installed {
688            name: name.into(),
689            path: format!("/usr/bin/{name}"),
690            recommended,
691        }
692    }
693
694    fn listing() -> Listing {
695        Listing {
696            version: crate::model::OUTPUT_VERSION,
697            source: "/usr/share/scx/catalog.json".into(),
698            customized: false,
699            recommended: vec![
700                pick("default", true),
701                pick("scx_cosmos", true),
702                pick("scx_eevdf", false),
703            ],
704            all: vec![
705                installed("scx_bpfland", false),
706                installed("scx_cosmos", true),
707            ],
708            config: Some("/etc/default/scx".into()),
709            current: Current {
710                version: crate::model::OUTPUT_VERSION,
711                scheduler: Some("scx_cosmos".into()),
712                unit: Unit {
713                    active: true,
714                    enabled: true,
715                },
716                running: Some(Running {
717                    sched: "scx_cosmos".into(),
718                    args: Vec::new(),
719                }),
720                next: Some(crate::model::Next {
721                    sched: "/usr/bin/scx_cosmos".into(),
722                    args: Vec::new(),
723                    overridden: false,
724                }),
725                kernel: None,
726                failure: None,
727                config_error: None,
728            },
729        }
730    }
731
732    impl Backend for Stub {
733        fn label(&self) -> String {
734            "stub".into()
735        }
736        fn list(&self) -> Result<Listing> {
737            if *self.broken.borrow() {
738                return Err(anyhow!("list broken"));
739            }
740            Ok(self.listing.borrow().clone())
741        }
742        fn start(&self, id: &str) -> Result<String> {
743            self.calls.borrow_mut().push(Call::Start(id.into()));
744            let mut l = self.listing.borrow_mut();
745            l.current.unit.active = id != DEFAULT_ID;
746            l.current.scheduler = Some(id.into());
747            Ok(format!("started {id}"))
748        }
749        fn run(&self, sched: &str, args: &[String]) -> Result<String> {
750            self.calls
751                .borrow_mut()
752                .push(Call::Run(sched.into(), args.to_vec()));
753            Ok("running".into())
754        }
755        fn stop(&self) -> Result<String> {
756            self.calls.borrow_mut().push(Call::Stop);
757            let mut l = self.listing.borrow_mut();
758            l.current.unit.active = false;
759            l.current.running = None;
760            l.current.scheduler = Some(DEFAULT_ID.into());
761            Ok("stopped".into())
762        }
763        fn escalates(&self) -> bool {
764            false
765        }
766    }
767
768    struct Harness {
769        app: App,
770        calls: Rc<RefCell<Vec<Call>>>,
771        listing: Rc<RefCell<Listing>>,
772        broken: Rc<RefCell<bool>>,
773    }
774
775    fn app() -> Harness {
776        let listing = Rc::new(RefCell::new(listing()));
777        let calls = Rc::new(RefCell::new(Vec::new()));
778        let broken = Rc::new(RefCell::new(false));
779        let stub = Stub {
780            listing: listing.clone(),
781            broken: broken.clone(),
782            calls: calls.clone(),
783        };
784        let mut app = App::new(Box::new(stub));
785        app.read_kernel = Box::new(|| None);
786        app.refresh();
787        app.sync_selection_to_running();
788        Harness {
789            app,
790            calls,
791            listing,
792            broken,
793        }
794    }
795
796    fn key(code: KeyCode) -> KeyEvent {
797        KeyEvent::new(code, KeyModifiers::NONE)
798    }
799
800    fn drain(app: &mut App) {
801        // The event loop would run it after a draw; tests run the
802        // backend call directly.
803        if let Some(action) = app.pending_action.take() {
804            let outcome = match action {
805                PendingAction::Start(id) => app.backend.start(&id),
806                PendingAction::Run(s, a) => app.backend.run(&s, &a),
807                PendingAction::Stop => app.backend.stop(),
808                PendingAction::Monitor => Ok(String::new()),
809            };
810            assert!(outcome.is_ok());
811            app.refresh();
812        }
813    }
814
815    #[test]
816    fn startup_selects_the_running_scheduler_in_both_lists() {
817        let Harness { app, .. } = app();
818        assert_eq!(app.selected_pick().unwrap().id, "scx_cosmos");
819        assert_eq!(app.selected_scheduler().unwrap().name, "scx_cosmos");
820    }
821
822    #[test]
823    fn enter_starts_the_selected_entry_but_not_an_unavailable_one() {
824        let Harness { mut app, calls, .. } = app();
825        app.on_key(key(KeyCode::Enter));
826        drain(&mut app);
827        assert_eq!(*calls.borrow(), vec![Call::Start("scx_cosmos".into())]);
828
829        app.last_action = None;
830        app.on_key(key(KeyCode::Down));
831        assert_eq!(app.selected_pick().unwrap().id, "scx_eevdf");
832        app.on_key(key(KeyCode::Enter));
833        assert!(app.pending_action.is_none());
834        assert!(app.message.as_ref().unwrap().is_error);
835        assert_eq!(calls.borrow().len(), 1);
836
837        // Default is an entry like any other: `start default`.
838        app.last_action = None;
839        app.on_key(key(KeyCode::Down));
840        assert_eq!(app.selected_pick().unwrap().id, "default");
841        app.on_key(key(KeyCode::Enter));
842        drain(&mut app);
843        assert_eq!(calls.borrow().last(), Some(&Call::Start("default".into())));
844    }
845
846    #[test]
847    fn start_is_debounced() {
848        let Harness { mut app, calls, .. } = app();
849        app.on_key(key(KeyCode::Enter));
850        drain(&mut app);
851        app.on_key(key(KeyCode::Enter));
852        assert!(
853            app.pending_action.is_none(),
854            "second press within the window is swallowed"
855        );
856        assert_eq!(calls.borrow().len(), 1);
857    }
858
859    #[test]
860    fn stop_keeps_the_selection_and_marks_it() {
861        let Harness { mut app, calls, .. } = app();
862        app.on_key(key(KeyCode::Char('s')));
863        drain(&mut app);
864        assert_eq!(*calls.borrow(), vec![Call::Stop]);
865        let listing = app.listing.as_ref().unwrap();
866        assert!(!listing.current.unit.active);
867        assert!(is_running(listing, "default"));
868        assert!(is_selected(listing, "scx_cosmos"));
869        assert!(!is_selected(listing, "default"));
870
871        // Stopping with nothing running is a message, not a call.
872        app.last_action = None;
873        app.on_key(key(KeyCode::Char('s')));
874        assert!(app.pending_action.is_none());
875        assert!(!app.message.as_ref().unwrap().is_error);
876        assert_eq!(calls.borrow().len(), 1);
877    }
878
879    #[test]
880    fn the_selection_marker_ignores_runs_and_overrides() {
881        let Harness { listing, .. } = app();
882        let mut l = listing.borrow().clone();
883        assert!(is_selected(&l, "scx_cosmos"));
884        l.current.next.as_mut().unwrap().args = vec!["-v".into()];
885        assert!(!is_selected(&l, "scx_cosmos"), "a run is not a selection");
886        l.current.next.as_mut().unwrap().args.clear();
887        l.current.next.as_mut().unwrap().overridden = true;
888        assert!(!is_selected(&l, "scx_cosmos"));
889        l.current.next = None;
890        assert!(is_selected(&l, "default"));
891    }
892
893    #[test]
894    fn the_full_list_starts_with_defaults_or_runs_with_typed_options() {
895        let Harness { mut app, calls, .. } = app();
896        app.on_key(key(KeyCode::Char('A')));
897        assert_eq!(app.view, View::All);
898        app.on_key(key(KeyCode::Up));
899        assert_eq!(app.selected_scheduler().unwrap().name, "scx_bpfland");
900        app.on_key(key(KeyCode::Enter));
901        drain(&mut app);
902        assert_eq!(*calls.borrow(), vec![Call::Start("scx_bpfland".into())]);
903
904        app.last_action = None;
905        app.on_key(key(KeyCode::Char('a')));
906        for c in "-m performance".chars() {
907            app.on_key(key(KeyCode::Char(c)));
908        }
909        app.on_key(key(KeyCode::Enter));
910        drain(&mut app);
911        assert_eq!(
912            calls.borrow().last(),
913            Some(&Call::Run(
914                "scx_bpfland".into(),
915                vec!["-m".into(), "performance".into()]
916            ))
917        );
918        assert!(app.args.is_none());
919    }
920
921    #[test]
922    fn typed_options_are_validated_before_anything_runs() {
923        let Harness { mut app, calls, .. } = app();
924        app.on_key(key(KeyCode::Char('A')));
925        app.on_key(key(KeyCode::Char('a')));
926        for c in "$(id)".chars() {
927            app.on_key(key(KeyCode::Char(c)));
928        }
929        app.on_key(key(KeyCode::Enter));
930        assert!(app.args.is_some(), "the field stays open to fix");
931        assert!(app.message.as_ref().unwrap().is_error);
932        assert!(calls.borrow().is_empty());
933        // Global shortcuts do not fire from inside the field.
934        app.on_key(key(KeyCode::Char('q')));
935        assert!(!app.should_quit);
936        app.on_key(key(KeyCode::Esc));
937        assert!(app.args.is_none());
938    }
939
940    #[test]
941    fn a_failed_listing_clears_the_snapshot_and_keeps_selection_sane() {
942        let Harness {
943            mut app,
944            listing,
945            broken,
946            ..
947        } = app();
948        *broken.borrow_mut() = true;
949        app.refresh();
950        assert!(app.listing.is_none());
951        assert!(app.listing_error.is_some());
952        assert!(app.selected_pick().is_none());
953        app.on_key(key(KeyCode::Enter));
954        assert!(app.pending_action.is_none());
955
956        *broken.borrow_mut() = false;
957        listing.borrow_mut().recommended.truncate(1);
958        app.selected = 2;
959        app.refresh();
960        assert_eq!(app.selected, 0);
961    }
962
963    #[test]
964    fn kernel_warnings_follow_the_unit_and_the_kernel() {
965        let Harness { mut app, .. } = app();
966        app.kernel = Some(KernelState {
967            state: "enabled".into(),
968            ops: Some("rusty".into()),
969        });
970        assert!(app.kernel_warning().unwrap().contains("ops name"));
971        app.kernel = Some(KernelState {
972            state: "disabled".into(),
973            ops: None,
974        });
975        assert!(
976            app.kernel_warning()
977                .unwrap()
978                .contains("no attached scheduler")
979        );
980        assert!(app.reconcile_kernel_dispute());
981        assert!(!app.reconcile_kernel_dispute(), "edge-triggered");
982    }
983}