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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
//! The interactive loop: draw, read a key, act, redraw.
use std::io::{self, Write};
use super::terminal::{Key, RawTerminal, escape};
use super::{Menu, Outcome, SelectMode, Viewport};
use crate::color::Console;
/// Hides the cursor for as long as this value lives.
///
/// Separate from [`RawTerminal`] so that dropping them in order restores the
/// cursor before termios, and so a failure to hide never leaves raw mode set.
struct HiddenCursor;
impl HiddenCursor {
fn hide() -> Self {
let mut stderr = io::stderr();
let _ = write!(stderr, "{}", escape::HIDE_CURSOR);
let _ = stderr.flush();
Self
}
}
impl Drop for HiddenCursor {
fn drop(&mut self) {
let mut stderr = io::stderr();
let _ = write!(stderr, "{}", escape::SHOW_CURSOR);
let _ = stderr.flush();
}
}
impl Menu {
/// Runs the menu, returning what the user did.
///
/// Writes to stderr, so a caller's stdout stays clean for piping. Keys are
/// read from `/dev/tty`, so a redirected stdin does not disable navigation.
///
/// Returns [`Outcome::Unavailable`] without reading anything when `mode`
/// and `is_terminal` rule out interaction, when the menu has no entries, or
/// when the process has no controlling terminal to open.
/// It never blocks in that case — a menu in a pipeline or in CI must not
/// wait for a keypress that cannot come.
///
/// `is_terminal` is supplied by the caller rather than detected here,
/// matching the rest of the crate: the application owns the decision about
/// its own streams.
///
/// `Ctrl-C` arrives as a byte in raw mode and is reported as
/// [`Outcome::Cancelled`], so the terminal is restored normally.
///
/// A signal that kills the process outright — `SIGTERM`, `SIGHUP` — leaves
/// the terminal in raw mode, because no destructor runs. This crate
/// installs no signal handlers; an application that needs to survive that
/// must install its own.
pub fn run(
&self,
console: Console,
mode: SelectMode,
is_terminal: bool,
) -> io::Result<Outcome> {
if !mode.is_interactive(is_terminal) || self.is_empty() {
return Ok(Outcome::Unavailable);
}
let Some(terminal) = RawTerminal::acquire()? else {
return Ok(Outcome::Unavailable);
};
let cursor = HiddenCursor::hide();
let outcome = self.event_loop(console, &terminal);
drop(cursor);
drop(terminal);
// Leave the final frame behind rather than a half-erased one.
let _ = writeln!(io::stderr());
outcome
}
fn event_loop(&self, console: Console, terminal: &RawTerminal) -> io::Result<Outcome> {
let mut state = State::default();
let mut view = Scroll::default();
loop {
self.draw(console, terminal, &state, &mut view)?;
match self.act_on(terminal.read_key()?, &state) {
Action::Update(next) => state = next,
Action::Finish(outcome) => {
self.draw(console, terminal, &state, &mut view)?;
return Ok(outcome);
}
Action::Ignore => {}
}
}
}
fn act_on(&self, key: Key, state: &State) -> Action {
let matches = self.matching_items(state.query.as_deref());
// An empty result set still draws; it just has nothing to act on.
let last = matches.len().saturating_sub(1);
match key {
// Wrapping beats stopping at the ends: the list is short and a dead
// key at the bottom is the more annoying failure.
Key::Up => Action::Update(state.at(if state.index == 0 {
last
} else {
state.index - 1
})),
Key::Down => Action::Update(state.at(if state.index >= last {
0
} else {
state.index + 1
})),
Key::Enter => matches.get(state.index).map_or(Action::Ignore, |item| {
Action::Finish(Outcome::Selected(item.id.clone()))
}),
// Escape leaves the search before it leaves the menu, so a mistyped
// query costs one key rather than the whole selection.
Key::Escape => match state.query {
Some(_) => Action::Update(State::default()),
None => Action::Finish(Outcome::Cancelled),
},
Key::Interrupt => Action::Finish(Outcome::Cancelled),
Key::Backspace => match &state.query {
Some(query) if !query.is_empty() => {
let mut query = query.clone();
query.pop();
Action::Update(state.searching(query))
}
// Backspacing out of an empty query leaves the search.
Some(_) => Action::Update(State::default()),
None => Action::Ignore,
},
Key::Char(pressed) => self.act_on_char(pressed, state),
Key::Other => Action::Ignore,
}
}
fn act_on_char(&self, pressed: char, state: &State) -> Action {
// Inside a search every printable key is part of the query, so a
// script named "quality" can be typed without 'q' cancelling.
if let Some(query) = &state.query {
let mut query = query.clone();
query.push(pressed.to_ascii_lowercase());
return Action::Update(state.searching(query));
}
if pressed == '/' {
return Action::Update(state.searching(String::new()));
}
// Hint keys win over the built-in 'q', so a menu may bind 'q'.
if let Some(hint) = self
.hints
.iter()
.find(|hint| hint.key.eq_ignore_ascii_case(&pressed))
{
return Action::Finish(Outcome::Hotkey(hint.key));
}
if pressed == 'q' {
return Action::Finish(Outcome::Cancelled);
}
Action::Ignore
}
/// Draws the menu in place, overwriting the previous frame.
fn draw(
&self,
console: Console,
terminal: &RawTerminal,
state: &State,
view: &mut Scroll,
) -> io::Result<()> {
let query = state.query.as_deref();
let viewport = view.advance(self, terminal, state);
// Raw mode drops the implicit carriage return on newline, so the frame
// is rendered normally and then given explicit ones.
let frame = crate::internal::collect_to_string(|buf| {
self.write_frame(buf, console, Some(state.index), viewport, query)
});
let lines: Vec<&str> = frame.lines().collect();
let mut stderr = io::stderr();
if let Some(previous) = view.drawn {
write!(
stderr,
"{}{}",
escape::move_up(previous),
escape::CLEAR_TO_END
)?;
}
for line in &lines {
write!(stderr, "{line}\r\n")?;
}
stderr.flush()?;
// Counting what was actually written is what keeps the redraw exact.
// Deriving the height a second time invites the two to disagree, which
// shows up as a stale line or an eaten one.
view.drawn = Some(u16::try_from(lines.len()).unwrap_or(u16::MAX));
Ok(())
}
}
/// Where the body is scrolled to, and how tall the last frame was.
#[derive(Debug, Default)]
struct Scroll {
start: usize,
drawn: Option<u16>,
}
impl Scroll {
/// Scrolls the window the least amount that brings the cursor into view.
///
/// Only moving when the cursor would leave the window keeps the list still
/// under the cursor; recentring on every keypress makes short moves feel
/// like the whole screen is sliding.
fn advance(&mut self, menu: &Menu, terminal: &RawTerminal, state: &State) -> Option<Viewport> {
// A terminal that reports no size gets the whole menu, as before.
let (height, columns) = terminal.size()?;
let columns = (columns > 0).then_some(columns);
let query = state.query.as_deref();
let rows = menu.body_height(query);
// One line stays free so the frame does not push its own top off screen.
let body = height.saturating_sub(menu.chrome_height(state.query.is_some()) + 1);
if body == 0 || rows == 0 || rows <= body {
self.start = 0;
// Still bound the width: a short list can carry long descriptions.
return Some(Viewport::new(0, rows.max(1)).with_width(columns));
}
let cursor = menu.row_of_item(state.index, query);
let mut start = self.start.min(rows - 1);
if cursor < start {
start = cursor;
}
// Ask the viewport what it will actually draw rather than predicting
// it. How many rows fit depends on which scroll indicators appear,
// which depends on the start — a second calculation of that drifts,
// and the drift shows up as a cursor scrolled just off the bottom.
while start < rows - 1 && !Viewport::new(start, body).shows(rows, cursor) {
start += 1;
}
self.start = start;
Some(Viewport::new(start, body).with_width(columns))
}
}
/// Where the cursor is, and what is being searched for.
#[derive(Debug, Default, Clone)]
struct State {
index: usize,
/// `None` outside search mode; `Some("")` once `/` has been pressed.
query: Option<String>,
}
impl State {
fn at(&self, index: usize) -> Self {
Self {
index,
query: self.query.clone(),
}
}
/// Changing the query resets the cursor: the best match is now first, and
/// leaving the cursor where it was would land it on something unrelated.
fn searching(&self, query: String) -> Self {
Self {
index: 0,
query: Some(query),
}
}
}
enum Action {
Update(State),
Finish(Outcome),
Ignore,
}