Skip to main content

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}