lore/tui/mod.rs
1//! The picker: a panel drawn below the prompt, driven by key events.
2
3mod app;
4mod form;
5mod view;
6
7pub use app::{App, Outcome};
8
9use std::fs::File;
10use std::panic;
11use std::time::Duration;
12
13use anyhow::{Context, Result};
14use crossterm::event::{self, Event, KeyEventKind};
15use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
16use ratatui::backend::{Backend, ClearType, CrosstermBackend};
17use ratatui::{Terminal, TerminalOptions, Viewport};
18
19use crate::console;
20
21type Screen = Terminal<CrosstermBackend<File>>;
22
23/// Rows the picker reserves below the prompt.
24///
25/// It opens as a panel under the command line rather than taking over the
26/// screen, so the prompt and the scrollback above it stay where they were.
27/// Ratatui clamps this to the terminal height.
28///
29/// Seventeen is the hints, a blank row, the query, five for the detail pane and
30/// eight for the list. The hint line took one of the list's rows when it moved
31/// above the query; this buys it back, at the cost of one row of the user's
32/// screen every time the picker opens.
33const HEIGHT: u16 = 17;
34
35/// Runs the picker and returns what the shell should do.
36pub fn run(app: &mut App) -> Result<Outcome> {
37 let mut screen = enter()?;
38 let mut top = top_of(&mut screen);
39 let outcome = event_loop(&mut screen, app, &mut top);
40 leave(&mut screen, top)?;
41 outcome
42}
43
44fn event_loop(screen: &mut Screen, app: &mut App, top: &mut u16) -> Result<Outcome> {
45 loop {
46 screen.draw(|frame| view::draw(app, frame))?;
47 *top = (*top).min(top_of(screen));
48
49 match event::read()? {
50 // The next draw re-anchors the panel to wherever the cursor now is
51 // and clears only where it lands, so the rows it is sitting on have
52 // to be erased while the terminal still knows where they are.
53 Event::Resize(..) => screen.clear()?,
54 // Windows reports key releases as well as presses; acting on both
55 // would process every keystroke twice.
56 Event::Key(key) if key.kind == KeyEventKind::Press => {
57 if let Some(outcome) = app.on_key(key)? {
58 return Ok(outcome);
59 }
60 }
61 _ => {}
62 }
63 }
64}
65
66/// The screen row the panel currently starts on.
67fn top_of(screen: &mut Screen) -> u16 {
68 screen.get_frame().area().y
69}
70
71fn enter() -> Result<Screen> {
72 install_panic_hook();
73 // Before the terminal library decides which descriptor to read from.
74 console::adopt_terminal_as_stdin();
75 enable_raw_mode().context("failed to put the terminal into raw mode")?;
76
77 let mut screen = Terminal::with_options(
78 CrosstermBackend::new(console::device()?),
79 TerminalOptions {
80 viewport: Viewport::Inline(HEIGHT),
81 },
82 )
83 .context("failed to start the terminal backend")?;
84
85 // The reserved rows still hold whatever the shell last printed there, and a
86 // blank cell in the first frame matches a blank cell in the empty back
87 // buffer, so the diff would never write over it.
88 screen.clear().context("failed to clear the panel")?;
89 screen.hide_cursor().ok();
90
91 Ok(screen)
92}
93
94/// Erases the panel and leaves the cursor where it began, so the shell carries
95/// on as though the picker had never drawn anything.
96///
97/// `top` is the highest row the panel ever occupied rather than the one it ends
98/// on. A resize moves it, and the terminal's own clear only reaches the rows it
99/// holds now, so anything left behind by the move has to be erased from here.
100fn leave(screen: &mut Screen, top: u16) -> Result<()> {
101 drain_input();
102
103 let origin = screen.get_frame().area();
104 let top = top.min(origin.y);
105
106 screen.set_cursor_position((origin.x, top)).ok();
107 screen
108 .backend_mut()
109 .clear_region(ClearType::AfterCursor)
110 .ok();
111 screen.show_cursor().ok();
112
113 restore();
114 Ok(())
115}
116
117/// Throws away input the picker did not consume.
118///
119/// Windows queues a release record for every press, and a key held down repeats.
120/// Anything still queued when raw mode ends is handed to the shell, which reads
121/// it as if the user had typed it at the prompt.
122fn drain_input() {
123 while event::poll(Duration::ZERO).unwrap_or(false) {
124 if event::read().is_err() {
125 return;
126 }
127 }
128}
129
130/// Leaves the terminal as it was found. Safe to call more than once.
131fn restore() {
132 let _ = disable_raw_mode();
133}
134
135/// A panic inside raw mode would otherwise leave the user with an unusable
136/// terminal and no visible message.
137fn install_panic_hook() {
138 let previous = panic::take_hook();
139 panic::set_hook(Box::new(move |info| {
140 restore();
141 previous(info);
142 }));
143}