Skip to main content

pma_core/
keys.rs

1//! The one key table every front end reads: which key opens which tab, moves
2//! the selection, or does which action. Keys are DOM `KeyboardEvent.key`
3//! names (`j`, `Enter`, `Escape`, `ArrowDown`), so the web uses the table as
4//! it is and the TUI maps its key codes to the same names. Plain keys only:
5//! a browser or screen reader reserves most combinations with a modifier.
6
7use crate::api::{Action, Query, Selection};
8use crate::report_runs::By;
9
10/// A screen: one query, rendered as a list or as text.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum Tab {
13    Next,
14    Matrix,
15    Runs,
16    Jobs,
17    Agents,
18    Workflows,
19    Campaigns,
20    Report,
21    Notes,
22    Status,
23    /// One project, opened from a row that names it. It has no key of its
24    /// own: which project is the front end's to remember.
25    Project,
26}
27
28impl Tab {
29    pub const ALL: [Tab; 11] = [
30        Tab::Next,
31        Tab::Matrix,
32        Tab::Runs,
33        Tab::Jobs,
34        Tab::Agents,
35        Tab::Workflows,
36        Tab::Campaigns,
37        Tab::Report,
38        Tab::Notes,
39        Tab::Status,
40        Tab::Project,
41    ];
42
43    pub fn title(self) -> &'static str {
44        match self {
45            Tab::Next => "next",
46            Tab::Matrix => "matrix",
47            Tab::Runs => "runs",
48            Tab::Jobs => "jobs",
49            Tab::Agents => "agents",
50            Tab::Workflows => "workflows",
51            Tab::Campaigns => "campaigns",
52            Tab::Report => "report",
53            Tab::Notes => "notes",
54            Tab::Status => "status",
55            Tab::Project => "project",
56        }
57    }
58
59    /// What the tab shows. Lists are not cut short: a front end scrolls.
60    /// `project` names the project the project tab shows.
61    pub fn query(self, by: By, project: &str) -> Query {
62        let of = Selection::default();
63        match self {
64            Tab::Next => Query::Next { of, all: true },
65            Tab::Matrix => Query::Matrix {
66                of,
67                quadrant: None,
68                all: true,
69            },
70            Tab::Runs => Query::Runs,
71            Tab::Jobs => Query::Jobs,
72            Tab::Agents => Query::Agents,
73            Tab::Workflows => Query::Workflows,
74            Tab::Campaigns => Query::Campaigns,
75            Tab::Report => Query::Report { by },
76            Tab::Notes => Query::Notes,
77            // Every project, untiered ones too: it is the list a project is
78            // opened from.
79            Tab::Status => Query::Status {
80                of,
81                explain: false,
82                all: true,
83            },
84            Tab::Project => Query::Project {
85                name: project.to_string(),
86            },
87        }
88    }
89}
90
91/// An action by kind, without its arguments: what a key names.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub enum Act {
94    ApproveRun,
95    ApproveRuns,
96    RejectRun,
97    AddReviewTime,
98    SettlePrs,
99    StopJob,
100    ApproveJob,
101    StopWorkflow,
102    AddNote,
103    EditNote,
104    RemoveNote,
105    Scan,
106    /// Scans the project the project view shows, and nothing else.
107    ScanProject,
108    Dispatch,
109    /// Resumes the selected instance.
110    ResumeWorkflow,
111    /// Starts an instance from a typed `<workflow> <target> [flags]`.
112    StartWorkflow,
113    ReworkRun,
114    Pr,
115    Push,
116    RunCampaign,
117    Sync,
118    Verify,
119}
120
121impl Act {
122    pub const ALL: [Act; 22] = [
123        Act::ApproveRun,
124        Act::ApproveRuns,
125        Act::RejectRun,
126        Act::AddReviewTime,
127        Act::SettlePrs,
128        Act::StopJob,
129        Act::ApproveJob,
130        Act::StopWorkflow,
131        Act::AddNote,
132        Act::EditNote,
133        Act::RemoveNote,
134        Act::Scan,
135        Act::ScanProject,
136        Act::Dispatch,
137        Act::ResumeWorkflow,
138        Act::StartWorkflow,
139        Act::ReworkRun,
140        Act::Pr,
141        Act::Push,
142        Act::RunCampaign,
143        Act::Sync,
144        Act::Verify,
145    ];
146
147    pub fn name(self) -> String {
148        format!("{self:?}")
149    }
150
151    /// Whether the act needs a selected row; one that does not acts on the
152    /// whole tab or on nothing in particular.
153    pub fn on_row(self) -> bool {
154        !matches!(
155            self,
156            Act::Scan
157                | Act::ScanProject
158                | Act::Sync
159                | Act::AddNote
160                | Act::SettlePrs
161                | Act::ApproveRuns
162                | Act::StartWorkflow
163        )
164    }
165
166    /// How much an act weighs: `primary` moves work forward, `danger`
167    /// discards or publishes something that cannot be called back.
168    pub fn weight(self) -> &'static str {
169        match self {
170            Act::ApproveRun | Act::ApproveJob | Act::Dispatch => "primary",
171            Act::RejectRun | Act::StopJob | Act::StopWorkflow | Act::RemoveNote | Act::Push => {
172                "danger"
173            }
174            _ => "default",
175        }
176    }
177
178    /// The kinds an action can be. A new `Action` variant fails to compile
179    /// here until it has a kind, and the tests then require it a key.
180    pub fn of(a: &Action) -> &'static [Act] {
181        match a {
182            Action::ApproveRun { .. } => &[Act::ApproveRun],
183            Action::ApproveRuns { .. } => &[Act::ApproveRuns],
184            Action::RejectRun { .. } => &[Act::RejectRun],
185            Action::AddReviewTime { .. } => &[Act::AddReviewTime],
186            Action::SettlePrs => &[Act::SettlePrs],
187            Action::StopJob { .. } => &[Act::StopJob],
188            Action::ApproveJob { .. } => &[Act::ApproveJob],
189            Action::StopWorkflow { .. } => &[Act::StopWorkflow],
190            Action::AddNote { .. } => &[Act::AddNote],
191            Action::EditNote { .. } => &[Act::EditNote],
192            Action::RemoveNote { .. } => &[Act::RemoveNote],
193            Action::Scan { .. } => &[Act::Scan, Act::ScanProject],
194            Action::Dispatch { .. } => &[Act::Dispatch],
195            Action::WorkflowRun { .. } => &[Act::ResumeWorkflow, Act::StartWorkflow],
196            Action::ReworkRun { .. } => &[Act::ReworkRun],
197            Action::Publish { .. } => &[Act::Pr, Act::Push],
198            Action::RunCampaign { .. } => &[Act::RunCampaign],
199            Action::Sync { .. } => &[Act::Sync],
200            Action::Verify { .. } => &[Act::Verify],
201        }
202    }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum Command {
207    Tab(Tab),
208    Act(Act),
209    Down,
210    Up,
211    First,
212    Last,
213    /// The next list on a tab with several.
214    NextPane,
215    PrevPane,
216    /// The selected item in full: a run's diff, a job's log.
217    Open,
218    /// Closes what `Open` or a prompt opened.
219    Back,
220    /// The report's next dimension.
221    ReportBy,
222    /// The project of the selected row.
223    OpenProject,
224    Help,
225    /// The TUI only; a browser tab is closed by the browser.
226    Quit,
227}
228
229/// What a front end asks before it acts.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum Ask {
232    Nothing,
233    /// Yes or no: the action cannot be undone from here.
234    Confirm,
235    /// A line of text, with this label.
236    Text(&'static str),
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct Binding {
241    pub key: &'static str,
242    pub command: Command,
243    /// Where the key applies; empty is every tab.
244    pub tabs: &'static [Tab],
245    pub ask: Ask,
246    pub label: &'static str,
247}
248
249const fn bind(key: &'static str, command: Command, label: &'static str) -> Binding {
250    Binding {
251        key,
252        command,
253        tabs: &[],
254        ask: Ask::Nothing,
255        label,
256    }
257}
258
259const fn on(
260    key: &'static str,
261    tabs: &'static [Tab],
262    act: Act,
263    ask: Ask,
264    label: &'static str,
265) -> Binding {
266    Binding {
267        key,
268        command: Command::Act(act),
269        tabs,
270        ask,
271        label,
272    }
273}
274
275const TASKS: &[Tab] = &[Tab::Next, Tab::Matrix, Tab::Project];
276const PANES: &[Tab] = &[
277    Tab::Next,
278    Tab::Matrix,
279    Tab::Agents,
280    Tab::Workflows,
281    Tab::Project,
282];
283const RUNS: &[Tab] = &[Tab::Runs, Tab::Project];
284
285pub const BINDINGS: &[Binding] = &[
286    bind("1", Command::Tab(Tab::Next), "next"),
287    bind("2", Command::Tab(Tab::Matrix), "matrix"),
288    bind("3", Command::Tab(Tab::Runs), "runs"),
289    bind("4", Command::Tab(Tab::Jobs), "jobs"),
290    bind("5", Command::Tab(Tab::Agents), "agents"),
291    bind("6", Command::Tab(Tab::Workflows), "workflows"),
292    bind("7", Command::Tab(Tab::Campaigns), "campaigns"),
293    bind("8", Command::Tab(Tab::Report), "report"),
294    bind("9", Command::Tab(Tab::Notes), "notes"),
295    bind("0", Command::Tab(Tab::Status), "status"),
296    bind("j", Command::Down, "down"),
297    bind("ArrowDown", Command::Down, "down"),
298    bind("k", Command::Up, "up"),
299    bind("ArrowUp", Command::Up, "up"),
300    bind("g", Command::First, "first"),
301    bind("G", Command::Last, "last"),
302    Binding {
303        tabs: PANES,
304        ..bind("Tab", Command::NextPane, "next list")
305    },
306    Binding {
307        tabs: PANES,
308        ..bind("l", Command::NextPane, "next list")
309    },
310    Binding {
311        tabs: PANES,
312        ..bind("h", Command::PrevPane, "previous list")
313    },
314    Binding {
315        tabs: &[Tab::Runs, Tab::Jobs, Tab::Project],
316        ..bind("Enter", Command::Open, "open")
317    },
318    Binding {
319        tabs: &[Tab::Next, Tab::Matrix, Tab::Runs, Tab::Agents, Tab::Status],
320        ..bind("o", Command::OpenProject, "open project")
321    },
322    bind("Escape", Command::Back, "back"),
323    Binding {
324        tabs: &[Tab::Report],
325        ..bind("b", Command::ReportBy, "by")
326    },
327    bind("?", Command::Help, "keys"),
328    bind("q", Command::Quit, "quit"),
329    // Anywhere.
330    on("c", &[], Act::Scan, Ask::Nothing, "scan"),
331    on("i", &[], Act::Sync, Ask::Confirm, "sync issues"),
332    on("n", &[], Act::AddNote, Ask::Text("note"), "add note"),
333    on(
334        "r",
335        &[Tab::Project],
336        Act::ScanProject,
337        Ask::Nothing,
338        "rescan project",
339    ),
340    // A task.
341    on("d", TASKS, Act::Dispatch, Ask::Confirm, "dispatch"),
342    on("v", TASKS, Act::Verify, Ask::Nothing, "verify project"),
343    // A run.
344    on("a", RUNS, Act::ApproveRun, Ask::Nothing, "approve"),
345    on("b", RUNS, Act::ApproveRuns, Ask::Confirm, "approve clean"),
346    on("x", RUNS, Act::RejectRun, Ask::Confirm, "reject"),
347    on("w", RUNS, Act::ReworkRun, Ask::Text("feedback"), "rework"),
348    on(
349        "t",
350        RUNS,
351        Act::AddReviewTime,
352        Ask::Text("minutes"),
353        "review time",
354    ),
355    on("p", RUNS, Act::Pr, Ask::Confirm, "pull request"),
356    on("u", RUNS, Act::Push, Ask::Confirm, "push"),
357    on("y", RUNS, Act::SettlePrs, Ask::Nothing, "check PRs"),
358    // A job.
359    on(
360        "a",
361        &[Tab::Jobs],
362        Act::ApproveJob,
363        Ask::Nothing,
364        "approve plan",
365    ),
366    on("s", &[Tab::Jobs], Act::StopJob, Ask::Confirm, "stop"),
367    // A workflow instance.
368    on(
369        "r",
370        &[Tab::Workflows],
371        Act::ResumeWorkflow,
372        Ask::Nothing,
373        "resume",
374    ),
375    on(
376        "f",
377        &[Tab::Workflows],
378        Act::StartWorkflow,
379        Ask::Text("workflow target [--local] [--unattended]"),
380        "start",
381    ),
382    on(
383        "s",
384        &[Tab::Workflows],
385        Act::StopWorkflow,
386        Ask::Confirm,
387        "stop",
388    ),
389    // A campaign.
390    on(
391        "r",
392        &[Tab::Campaigns],
393        Act::RunCampaign,
394        Ask::Confirm,
395        "run",
396    ),
397    // A note.
398    on("e", &[Tab::Notes], Act::EditNote, Ask::Text("note"), "edit"),
399    on("x", &[Tab::Notes], Act::RemoveNote, Ask::Confirm, "remove"),
400];
401
402/// The binding `key` has on `tab`. One applying to that tab alone wins over
403/// one applying everywhere.
404pub fn lookup(key: &str, tab: Tab) -> Option<&'static Binding> {
405    let fits = |b: &&Binding| b.key == key;
406    BINDINGS
407        .iter()
408        .filter(fits)
409        .find(|b| b.tabs.contains(&tab))
410        .or_else(|| BINDINGS.iter().filter(fits).find(|b| b.tabs.is_empty()))
411}
412
413/// The bindings that apply on `tab`, for a help screen.
414pub fn on_tab(tab: Tab) -> Vec<&'static Binding> {
415    BINDINGS
416        .iter()
417        .filter(|b| b.tabs.is_empty() || b.tabs.contains(&tab))
418        .collect()
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    /// Every action a front end may take has a key, so no front end can do
426    /// what the other has no key for.
427    #[test]
428    fn every_act_has_a_key() {
429        for act in Act::ALL {
430            assert!(
431                BINDINGS.iter().any(|b| b.command == Command::Act(act)),
432                "{act:?} has no key"
433            );
434        }
435        for tab in Tab::ALL.into_iter().filter(|t| *t != Tab::Project) {
436            assert!(
437                BINDINGS.iter().any(|b| b.command == Command::Tab(tab)),
438                "{tab:?} has no key"
439            );
440        }
441        assert!(
442            BINDINGS.iter().any(|b| b.command == Command::OpenProject),
443            "a project opens from a row"
444        );
445    }
446
447    /// One key means one thing on a tab: two bindings for the same tab, or a
448    /// tab binding that hides an everywhere binding, would make it ambiguous.
449    #[test]
450    fn no_key_means_two_things_on_one_tab() {
451        for tab in Tab::ALL {
452            let keys: Vec<&str> = on_tab(tab).iter().map(|b| b.key).collect();
453            for k in &keys {
454                assert_eq!(
455                    keys.iter().filter(|x| *x == k).count(),
456                    1,
457                    "`{k}` is bound twice on {tab:?}"
458                );
459            }
460        }
461    }
462
463    #[test]
464    fn keys_are_plain() {
465        for b in BINDINGS {
466            assert!(
467                b.key.chars().count() == 1 || b.key.chars().all(|c| c.is_ascii_alphabetic()),
468                "{}",
469                b.key
470            );
471        }
472    }
473
474    #[test]
475    fn lookup_prefers_the_tab() {
476        assert_eq!(
477            lookup("a", Tab::Jobs).map(|b| b.command),
478            Some(Command::Act(Act::ApproveJob))
479        );
480        assert_eq!(
481            lookup("a", Tab::Runs).map(|b| b.command),
482            Some(Command::Act(Act::ApproveRun))
483        );
484        assert!(lookup("a", Tab::Next).is_none());
485        assert_eq!(
486            lookup("c", Tab::Notes).map(|b| b.command),
487            Some(Command::Act(Act::Scan))
488        );
489    }
490}