Skip to main content

matchmaker/
tui.rs

1use crate::config::TerminalConfig;
2use anyhow::Result;
3use cli_boilerplate_automation::bait::ResultExt;
4use crossterm::{
5    event::{
6        DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags,
7        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
8    },
9    execute,
10    terminal::{ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode},
11};
12use log::{debug, error};
13use ratatui::{Terminal, TerminalOptions, Viewport, layout::Rect, prelude::CrosstermBackend};
14use serde::{Deserialize, Serialize};
15use std::{
16    io::{self, Write},
17    thread::sleep,
18    time::Duration,
19};
20pub struct Tui<W>
21where
22    W: Write,
23{
24    pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
25    pub area: Rect,
26    pub config: TerminalConfig,
27    pub cursor_y_offset: Option<u16>,
28    pub fullscreen: bool, // initially fullscreen
29}
30
31impl<W> Tui<W>
32where
33    W: Write,
34{
35    // waiting on https://github.com/ratatui/ratatui/issues/984 to implement growable inline, currently just tries to request max
36    // if max > than remainder, then scrolls up a bit
37    pub fn new_with_writer(writer: W, mut config: TerminalConfig) -> Result<Self> {
38        let mut backend = CrosstermBackend::new(writer);
39        let mut options = TerminalOptions::default();
40        if config.sleep_ms.is_zero() {
41            config.sleep_ms = Duration::from_millis(100)
42        };
43
44        // important for getting cursor
45        crossterm::terminal::enable_raw_mode()?;
46
47        let (width, height) = Self::full_size().unwrap_or_default();
48        let area = if let Some(ref layout) = config.layout {
49            let request = layout
50                .percentage
51                .compute_clamped(height, layout.min, layout.max);
52
53            let cursor_y = Self::get_cursor_y(config.sleep_ms).unwrap_or_else(|e| {
54                error!("Failed to read cursor: {e}");
55                height - 1 // overestimate
56            });
57
58            let initial_height = height.saturating_sub(cursor_y);
59
60            let scroll = request.saturating_sub(initial_height);
61            debug!("TUI dimensions: {width}, {height}. Cursor_y: {cursor_y}.",);
62
63            // ensure available by scrolling
64            let cursor_y = match Self::scroll_up(&mut backend, scroll) {
65                Ok(_) => {
66                    cursor_y.saturating_sub(scroll) // the requested cursor doesn't seem updated so we assume it succeeded
67                }
68                Err(_) => cursor_y,
69            };
70            let available_height = height.saturating_sub(cursor_y);
71
72            debug!(
73                "TUI quantities: min: {}, initial_available: {initial_height}, requested: {request}, available: {available_height}, requested scroll: {scroll}",
74                layout.min
75            );
76
77            if available_height < layout.min {
78                error!("Failed to allocate minimum height, falling back to fullscreen");
79                Rect::new(0, 0, width, height)
80            } else {
81                let area = Rect::new(
82                    0,
83                    cursor_y,
84                    width,
85                    available_height.min(request).max(layout.min),
86                );
87
88                // options.viewport = Viewport::Inline(available_height.min(request));
89                options.viewport = Viewport::Fixed(area);
90
91                area
92            }
93        } else {
94            Rect::new(0, 0, width, height)
95        };
96
97        debug!("TUI area: {area}");
98
99        let terminal = Terminal::with_options(backend, options)?;
100        Ok(Self {
101            terminal,
102            fullscreen: config.layout.is_none(),
103            cursor_y_offset: None,
104            config,
105            area,
106        })
107    }
108
109    pub fn enter(&mut self) -> Result<()> {
110        let fullscreen = self.is_fullscreen();
111
112        crossterm::terminal::enable_raw_mode()?;
113        if fullscreen {
114            self.enter_alternate_screen()?;
115        }
116
117        let backend = self.terminal.backend_mut();
118        execute!(backend, EnableMouseCapture)._elog();
119        #[cfg(feature = "bracketed-paste")]
120        {
121            execute!(backend, crossterm::event::EnableBracketedPaste)._elog();
122        }
123
124        if self.config.extended_keys {
125            execute!(
126                backend,
127                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
128            )
129            ._elog();
130            log::trace!("keyboard enhancement set");
131        }
132
133        Ok(())
134    }
135
136    // call iff self.fullscreen
137    pub fn enter_alternate_screen(&mut self) -> Result<()> {
138        let backend = self.terminal.backend_mut();
139        execute!(backend, EnterAlternateScreen)?;
140        execute!(backend, crossterm::terminal::Clear(ClearType::All))?;
141        self.terminal.clear()?;
142        debug!("Entered alternate screen");
143        Ok(())
144    }
145
146    pub fn enter_execute(&mut self) {
147        self.exit();
148        sleep(self.config.sleep_ms); // necessary to give resize some time
149        debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
150
151        // do we ever need to scroll up?
152    }
153
154    pub fn return_execute(&mut self) -> Result<()> {
155        self.config.layout = None; // force fullscreen
156        self.enter()?;
157
158        sleep(self.config.sleep_ms);
159        log::trace!("During return, slept {}", self.config.sleep_ms.as_millis());
160
161        execute!(
162            self.terminal.backend_mut(),
163            crossterm::terminal::Clear(ClearType::All)
164        )
165        ._wlog();
166
167        // resize
168        if self.is_fullscreen() || self.config.restore_fullscreen {
169            if let Some((width, height)) = Self::full_size() {
170                self.resize(Rect::new(0, 0, width, height));
171            } else {
172                error!("Failed to get terminal size");
173                self.resize(self.area);
174            }
175        } else {
176            self.resize(self.area);
177        }
178
179        Ok(())
180    }
181
182    pub fn exit(&mut self) {
183        let backend = self.terminal.backend_mut();
184
185        execute!(backend, LeaveAlternateScreen, DisableMouseCapture)._wlog();
186
187        if self.config.extended_keys {
188            execute!(backend, PopKeyboardEnhancementFlags)._elog();
189        }
190
191        let move_up = self.cursor_y_offset.unwrap_or(1);
192        log::debug!("Moving up by: {move_up}");
193
194        if self.config.clear_on_exit && !cfg!(debug_assertions) {
195            execute!(
196                backend,
197                crossterm::cursor::MoveUp(move_up),
198                crossterm::terminal::Clear(ClearType::FromCursorDown)
199            )
200            ._elog();
201        }
202
203        self.terminal.show_cursor()._wlog();
204
205        disable_raw_mode()._wlog();
206
207        debug!("Terminal exited");
208    }
209
210    pub fn resize(&mut self, area: Rect) {
211        self.terminal.resize(area)._elog();
212        self.area = area
213    }
214
215    pub fn redraw(&mut self) {
216        self.terminal.resize(self.area)._elog();
217    }
218
219    // note: do not start before event stream
220    pub fn get_cursor_y(timeout: Duration) -> io::Result<u16> {
221        // crossterm uses stdout to determine cursor position
222        // todo: workarounds?
223        // #[cfg(not(target_os = "windows"))]
224        Ok(if !atty::is(atty::Stream::Stdout) {
225            utils::query_cursor_position(timeout)
226                .map_err(io::Error::other)?
227                .1
228        } else {
229            crossterm::cursor::position()?.1
230        })
231    }
232
233    pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
234        execute!(backend, crossterm::terminal::ScrollUp(lines))?;
235        Ok(0) // not used
236        // Self::get_cursor_y() // note: do we want to skip this for speed
237    }
238    pub fn size() -> io::Result<(u16, u16)> {
239        crossterm::terminal::size()
240    }
241    pub fn full_size() -> Option<(u16, u16)> {
242        if let Ok((width, height)) = Self::size() {
243            Some((width, height))
244        } else {
245            error!("Failed to read terminal size");
246            None
247        }
248    }
249    pub fn is_fullscreen(&self) -> bool {
250        self.config.layout.is_none()
251    }
252    pub fn set_fullscreen(&mut self) {
253        self.config.layout = None;
254    }
255}
256
257impl Tui<Box<dyn Write + Send>> {
258    pub fn new(config: TerminalConfig) -> Result<Self> {
259        let writer = config.stream.to_stream();
260        let tui = Self::new_with_writer(writer, config)?;
261        Ok(tui)
262    }
263}
264
265impl<W> Drop for Tui<W>
266where
267    W: Write,
268{
269    fn drop(&mut self) {
270        self.exit();
271    }
272}
273
274// ---------- IO ---------------
275
276#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
277pub enum IoStream {
278    Stdout,
279    #[default]
280    BufferedStderr,
281}
282
283impl IoStream {
284    pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
285        match self {
286            IoStream::Stdout => Box::new(io::stdout()),
287            IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
288        }
289    }
290}
291
292// ------------------------------------------------------------
293
294#[cfg(unix)]
295mod utils {
296    use anyhow::{Context, Result, bail};
297    use std::{
298        fs::OpenOptions,
299        io::{Read, Write},
300        time::Duration,
301    };
302
303    /// Query the terminal for the current cursor position (col, row)
304    /// Needed because crossterm implementation fails when stdout is not connected.
305    /// Requires raw mode
306    pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
307        use nix::sys::{
308            select::{FdSet, select},
309            time::{TimeVal, TimeValLike},
310        };
311        use std::os::fd::AsFd;
312
313        let mut tty = OpenOptions::new()
314            .read(true)
315            .write(true)
316            .open("/dev/tty")
317            .context("Failed to open /dev/tty")?;
318
319        // Send the ANSI cursor position report query
320        tty.write_all(b"\x1b[6n")?;
321        tty.flush()?;
322
323        // Wait for input using select()
324        let fd = tty.as_fd();
325        let mut fds = FdSet::new();
326        fds.insert(fd);
327
328        let mut timeout = TimeVal::milliseconds(timeout.as_millis() as i64);
329
330        let ready =
331            select(None, &mut fds, None, None, Some(&mut timeout)).context("select() failed")?;
332
333        if ready == 0 {
334            bail!("Timed out waiting for cursor position response: {timeout:?}");
335        }
336
337        // Read the response
338        let mut buf = [0u8; 64];
339        let n = tty.read(&mut buf)?;
340        let s = String::from_utf8_lossy(&buf[..n]);
341
342        parse_cursor_response(&s).context(format!("Failed to parse terminal response: {s}"))
343    }
344
345    /// Parse the terminal response with format ESC [ row ; col R
346    /// and return (col, row) as 0-based coordinates.
347    fn parse_cursor_response(s: &str) -> Result<(u16, u16)> {
348        let coords = s
349            .strip_prefix("\x1b[")
350            .context("Missing ESC]")?
351            .strip_suffix('R')
352            .context("Missing R")?;
353
354        let mut parts = coords.split(';');
355
356        let row: u16 = parts.next().context("Missing row")?.parse()?;
357
358        let col: u16 = parts.next().context("Missing column")?.parse()?;
359
360        Ok((col - 1, row - 1)) // convert to 0-based
361    }
362}
363
364#[cfg(windows)]
365mod utils {
366    use anyhow::Result;
367    use std::time::Duration;
368    pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
369        let ret = crossterm::cursor::position()?;
370        Ok(ret)
371    }
372}