Skip to main content

tix/app/
help.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    pub(in crate::app) fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
6        // tix #1496: while the `/` line has the keyboard, every key belongs to the
7        // query. The list underneath filters as you type, so Enter only *commits* the
8        // input (the query stays and the list keeps its filter) and Esc clears it — the
9        // same bargain `/` makes on the board, so one gesture does not mean two things
10        // in the same tool. Closing the overlay is one Esc away in both states.
11        if self.help_searching {
12            match key.code {
13                KeyCode::Enter => self.help_searching = false,
14                KeyCode::Esc => {
15                    self.help_query.clear();
16                    self.help_searching = false;
17                }
18                KeyCode::Backspace => {
19                    self.help_query.pop();
20                }
21                KeyCode::Char(c) => self.help_query.push(c),
22                _ => {}
23            }
24            // A narrower list starts at the top: keeping an offset from the wider list
25            // would scroll the matches away before the reader saw them.
26            self.help_scroll = 0;
27            return Ok(Action::None);
28        }
29        match key.code {
30            KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
31                self.close_help();
32            }
33            // Not a toggle: `/` starts the input line, and the query it leaves behind is
34            // what filters the list. Reopening it with the query still there is how you
35            // extend a search, and Esc inside it is how you clear one.
36            KeyCode::Char('/') => self.help_searching = true,
37            // tix #1441/#1442: the overlay scrolls — one row for the cursor keys its
38            // context already binds (`next`/`previous`, which until now the help
39            // advertised and this handler ignored), one page for PgUp/PgDn, one wheel
40            // notch for the wheel. The same three steps the detail pane uses, because
41            // "scroll" meaning something different per surface is how a keyboard stops
42            // being learnable.
43            KeyCode::Down | KeyCode::Char('j') => self.scroll_help(1),
44            KeyCode::Up | KeyCode::Char('k') => self.scroll_help(-1),
45            KeyCode::PageDown => self.scroll_help(i32::from(self.help_page())),
46            KeyCode::PageUp => self.scroll_help(-i32::from(self.help_page())),
47            _ => {}
48        }
49        Ok(Action::None)
50    }
51
52    /// Rows one page step moves in the overlay: what fits, less a row of overlap.
53    pub(in crate::app) fn help_page(&self) -> u16 {
54        self.help_metrics.get().visible.saturating_sub(1).max(1)
55    }
56
57    /// Open the keybindings overlay, always at the top and unfiltered (tix #1441).
58    ///
59    /// A help screen that reappeared halfway down — from a scroll the user made some
60    /// minutes ago — would hide the first bindings from exactly the person who opened
61    /// it to look them up. The same goes for a `/` query left over from last time
62    /// (tix #1496).
63    pub(in crate::app) fn open_help(&mut self, return_to: Mode) {
64        self.help_scroll = 0;
65        self.help_query.clear();
66        self.help_searching = false;
67        self.mode = Mode::Help {
68            return_to: Box::new(return_to),
69        };
70    }
71
72    /// Scroll the overlay by `rows` (negative scrolls up), clamped to the list.
73    ///
74    /// The clamp is what makes the last row reachable *and* keeps the list from being
75    /// scrolled into emptiness — a bound the old truncating overlay did not need
76    /// because it could not move at all.
77    pub(in crate::app) fn scroll_help(&mut self, rows: i32) {
78        let max = i32::from(self.help_metrics.get().max_scroll);
79        let next = i32::from(self.help_scroll) + rows;
80        self.help_scroll = next.clamp(0, max) as u16;
81    }
82
83    pub(in crate::app) fn close_help(&mut self) {
84        if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
85            self.mode = *return_to;
86        }
87    }
88
89    /// Open the startup tips modal over whatever the session opened on (tix
90    /// #1632), starting at a random tip so the same advice does not wear out.
91    ///
92    /// A no-op when the embedded list is empty, so a build whose embedded tips
93    /// failed to parse simply has no dialog rather than an empty one.
94    pub fn open_tips(&mut self) {
95        let tips = crate::tips::all();
96        if tips.is_empty() {
97            return;
98        }
99        let return_to = std::mem::replace(&mut self.mode, Mode::Board);
100        self.tips = Some(crate::ui::tips::TipsState::new(crate::tips::random_start(
101            tips.len(),
102        )));
103        self.mode = Mode::Tips {
104            return_to: Box::new(return_to),
105        };
106    }
107
108    /// Close the tips modal, landing back on the mode it was opened from.
109    pub(in crate::app) fn close_tips(&mut self) {
110        self.tips = None;
111        if let Mode::Tips { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
112            self.mode = *return_to;
113        }
114        // The raw key path returns before the binding layer's `sync_top_mode`, so
115        // the rail would keep highlighting whatever it did before the modal opened.
116        // Close by re-deriving it.
117        self.sync_top_mode();
118    }
119
120    /// The tips modal's keys (tix #1632), read raw — see `handle_key`.
121    ///
122    /// `Left`/`h`/`p` and `Right`/`l`/`n` walk the list, `r` jumps to a random
123    /// tip (never the one already shown), `Enter`/`Esc`/`q` closes.
124    /// `d` turns the tips off and persists the setting, then the modal shows the
125    /// one line telling the reader how to turn them back on; the keys stay raw so
126    /// `d` here means "disable", not the board's "delete".
127    pub(in crate::app) fn handle_tips_key(&mut self, key: KeyEvent) -> Result<Action> {
128        match key.code {
129            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('Q') => {
130                self.close_tips();
131            }
132            KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('p') => {
133                let len = crate::tips::all().len();
134                if let Some(state) = self.tips.as_mut() {
135                    state.previous(len);
136                }
137            }
138            KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('n') => {
139                let len = crate::tips::all().len();
140                if let Some(state) = self.tips.as_mut() {
141                    state.next(len);
142                }
143            }
144            KeyCode::Char('r') | KeyCode::Char('R') => {
145                let len = crate::tips::all().len();
146                if let Some(state) = self.tips.as_mut() {
147                    // Inert on the "tips are off" notice: there is no tip on
148                    // screen to replace.
149                    if !state.disabled {
150                        state.randomize(len);
151                    }
152                }
153            }
154            KeyCode::Char('d') | KeyCode::Char('D') => self.disable_tips(),
155            _ => {}
156        }
157        Ok(Action::None)
158    }
159
160    /// Turn the startup tips off and write the setting to `tix.toml` (tix #1632).
161    ///
162    /// The write goes through [`App::persist_config`], the same writer the settings
163    /// surface uses, so the value lands in the file the next session reads. The
164    /// modal stays open showing the "set `tips_on_startup = true` to bring it
165    /// back" line — the reader just made a permanent choice and is told so before
166    /// the dialog goes away.
167    pub(in crate::app) fn disable_tips(&mut self) {
168        self.store.config.tips_on_startup = false;
169        let saved = self.persist_config();
170        if let Some(state) = self.tips.as_mut() {
171            state.disabled = true;
172        }
173        if !saved {
174            // `persist_config` already put the reason in the status line; the
175            // modal's notice would otherwise promise a persistence that did not
176            // happen.
177            self.status_msg =
178                Some("tips disabled for this session — tix.toml could not be written".to_string());
179        }
180    }
181}