Skip to main content

vissue_tui/
keys.rs

1//! Key dispatch. Bindings are listed on `?`.
2
3use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
4use vissue_core::keys::{ActionId, KeyMap};
5
6/// What the event loop does after a key.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Action {
9    /// Stay in the event loop.
10    Continue,
11    /// Leave the event loop.
12    Quit,
13}
14
15/// One of the five list surfaces.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Pane {
18    /// Actionable ready queue.
19    Ready,
20    /// Full filtered list.
21    List,
22    /// Open claims.
23    Claims,
24    /// Deadlines and scheduled dates.
25    Agenda,
26    /// Title and body search.
27    Search,
28}
29
30impl Pane {
31    /// Tab order, left to right.
32    pub const ALL: [Pane; 5] = [
33        Pane::Ready,
34        Pane::List,
35        Pane::Claims,
36        Pane::Agenda,
37        Pane::Search,
38    ];
39
40    /// Tab label drawn on the board.
41    pub fn title(self) -> &'static str {
42        match self {
43            Self::Ready => "Ready",
44            Self::List => "List",
45            Self::Claims => "Claims",
46            Self::Agenda => "Agenda",
47            Self::Search => "Search",
48        }
49    }
50
51    /// Index into [`Self::ALL`].
52    pub fn index(self) -> usize {
53        Self::ALL.iter().position(|p| *p == self).unwrap_or(0)
54    }
55
56    /// Pane at `i` modulo the tab count.
57    pub fn from_index(i: usize) -> Self {
58        Self::ALL[i % Self::ALL.len()]
59    }
60
61    /// Next pane in tab order.
62    pub fn next(self) -> Self {
63        Self::from_index(self.index() + 1)
64    }
65}
66
67/// Right-hand detail surface.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum DetailTab {
70    /// Metadata from `issue/get`.
71    Show,
72    /// On-disk heading range.
73    Excerpt,
74    /// Parent and child tree.
75    Tree,
76    /// Related-issue hits.
77    Related,
78    /// The working set: plan, declared inputs, and what they produced.
79    Recall,
80}
81
82impl DetailTab {
83    /// Tab order cycled by Enter in the detail pane.
84    pub const ALL: [DetailTab; 5] = [
85        DetailTab::Show,
86        DetailTab::Excerpt,
87        DetailTab::Tree,
88        DetailTab::Related,
89        DetailTab::Recall,
90    ];
91
92    /// Tab label drawn on the detail border.
93    pub fn title(self) -> &'static str {
94        match self {
95            Self::Show => "show",
96            Self::Excerpt => "excerpt",
97            Self::Tree => "tree",
98            Self::Related => "related",
99            Self::Recall => "recall",
100        }
101    }
102
103    /// Next tab in cycle order.
104    pub fn next(self) -> Self {
105        let i = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
106        Self::ALL[(i + 1) % Self::ALL.len()]
107    }
108}
109
110/// Which pane receives movement keys.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum Focus {
113    /// Row list on the left.
114    Rows,
115    /// Detail pane on the right.
116    Detail,
117}
118
119/// Line prompt opened by `/`, `n`, or `p`.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum PromptKind {
122    /// Search query for the Search pane.
123    Search,
124    /// Logbook note on the selected issue.
125    Note,
126    /// Deed accession to cite on the selected issue.
127    Deed,
128    /// Project filter. Empty clears it.
129    Project,
130}
131
132/// Destructive state change waiting for `y`.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ConfirmKind {
135    /// Set state to DONE.
136    Done,
137    /// Set state to CANCELLED.
138    Cancelled,
139}
140
141impl ConfirmKind {
142    /// Org TODO keyword this confirmation applies.
143    pub fn state(self) -> &'static str {
144        match self {
145            Self::Done => "DONE",
146            Self::Cancelled => "CANCELLED",
147        }
148    }
149}
150
151/// True for Press and Repeat; false for Release.
152pub fn is_press(key: KeyEvent) -> bool {
153    key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat
154}
155
156/// Printable character from `key`, including Shift. Other modifiers drop it.
157pub fn char_of(key: KeyEvent) -> Option<char> {
158    match key.code {
159        KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => {
160            Some(c)
161        }
162        _ => None,
163    }
164}
165
166/// The chord token a key press stands for, in the shared catalog's
167/// spelling: a printable character as itself, the named keys as
168/// `enter`, `esc`, `tab`, `space`. Arrows and modifiers are the board's
169/// own and yield nothing.
170pub fn chord_of(key: KeyEvent) -> Option<String> {
171    match key.code {
172        KeyCode::Char(' ') => Some("space".to_string()),
173        KeyCode::Char(_) => char_of(key).map(vissue_core::keys::chord_from_char),
174        KeyCode::Enter => Some("enter".to_string()),
175        KeyCode::Esc => Some("esc".to_string()),
176        KeyCode::Tab => Some("tab".to_string()),
177        _ => None,
178    }
179}
180
181/// The catalog actions this board performs; the rest are the HUD's.
182pub const TUI_ACTIONS: &[ActionId] = &[
183    ActionId::ListDown,
184    ActionId::ListUp,
185    ActionId::ListSelect,
186    ActionId::ListDone,
187    ActionId::PaneReady,
188    ActionId::PaneList,
189    ActionId::PaneClaims,
190    ActionId::PaneAgenda,
191    ActionId::PaneSearch,
192    ActionId::PaneNext,
193    ActionId::DetailCycle,
194    ActionId::ProjectCycle,
195    ActionId::Search,
196    ActionId::Claim,
197    ActionId::Note,
198    ActionId::Deed,
199    ActionId::StateCycle,
200    ActionId::ConfirmDone,
201    ActionId::ConfirmCancel,
202    ActionId::Open,
203    ActionId::CopyId,
204    ActionId::Reload,
205    ActionId::Help,
206];
207
208/// Overlay text shown on `?`, from the shared catalog as the keymap binds
209/// it, then the board's own keys, then the chords only the HUD answers.
210pub fn help_text(keymap: &KeyMap) -> String {
211    let mut out = String::from("vissue tui\n\n");
212    for row in KeyMap::catalog() {
213        if !TUI_ACTIONS.contains(&row.id) {
214            continue;
215        }
216        out.push_str(&format!(
217            "{:<13} {}\n",
218            keymap.chord_for(row.id),
219            row.id.title()
220        ));
221    }
222    out.push_str("arrows        move\nq / Esc       quit / back\n");
223    let hud_only: Vec<String> = KeyMap::catalog()
224        .iter()
225        .filter(|row| !TUI_ACTIONS.contains(&row.id))
226        .map(|row| format!("{} {}", keymap.chord_for(row.id), row.id.title()))
227        .collect();
228    if !hud_only.is_empty() {
229        out.push_str(&format!("\nHUD only: {}\n", hud_only.join(", ")));
230    }
231    out.push_str("\nBody edits stay in the file.\nbody lives in file; open the range above\n");
232    out
233}