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