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