Skip to main content

matchmaker/
tui.rs

1use crate::config::TerminalConfig;
2use anyhow::Result;
3use cba::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        if self.config.move_up_on_exit {
192            let move_up = self.cursor_y_offset.unwrap_or(1);
193            log::debug!("Moving up by: {move_up}");
194            execute!(backend, crossterm::cursor::MoveUp(move_up))._elog();
195        }
196
197        if self.config.clear_on_exit && !cfg!(debug_assertions) {
198            execute!(
199                backend,
200                crossterm::cursor::MoveToColumn(0),
201                crossterm::terminal::Clear(ClearType::FromCursorDown)
202            )
203            ._elog();
204        }
205
206        self.terminal.show_cursor()._wlog();
207
208        disable_raw_mode()._wlog();
209
210        debug!("Terminal exited");
211    }
212
213    pub fn resize(&mut self, area: Rect) {
214        self.terminal.resize(area)._elog();
215        self.area = area
216    }
217
218    pub fn redraw(&mut self) {
219        self.terminal.resize(self.area)._elog();
220    }
221
222    // note: do not start before event stream
223    pub fn get_cursor_y(timeout: Duration) -> io::Result<u16> {
224        // crossterm uses stdout to determine cursor position
225        // todo: workarounds?
226        // #[cfg(not(target_os = "windows"))]
227        Ok(if !atty::is(atty::Stream::Stdout) {
228            utils::query_cursor_position(timeout)
229                .map_err(io::Error::other)?
230                .1
231        } else {
232            crossterm::cursor::position()?.1
233        })
234    }
235
236    pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
237        execute!(backend, crossterm::terminal::ScrollUp(lines))?;
238        Ok(0) // not used
239        // Self::get_cursor_y() // note: do we want to skip this for speed
240    }
241    pub fn size() -> io::Result<(u16, u16)> {
242        crossterm::terminal::size()
243    }
244    pub fn full_size() -> Option<(u16, u16)> {
245        if let Ok((width, height)) = Self::size() {
246            Some((width, height))
247        } else {
248            error!("Failed to read terminal size");
249            None
250        }
251    }
252    pub fn is_fullscreen(&self) -> bool {
253        self.config.layout.is_none()
254    }
255    pub fn set_fullscreen(&mut self) {
256        self.config.layout = None;
257    }
258}
259
260impl Tui<Box<dyn Write + Send>> {
261    pub fn new(config: TerminalConfig) -> Result<Self> {
262        let writer = config.stream.to_stream();
263        let tui = Self::new_with_writer(writer, config)?;
264        Ok(tui)
265    }
266}
267
268impl<W> Drop for Tui<W>
269where
270    W: Write,
271{
272    fn drop(&mut self) {
273        self.exit();
274    }
275}
276
277// ---------- IO ---------------
278
279#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
280pub enum IoStream {
281    Stdout,
282    #[default]
283    BufferedStderr,
284}
285
286impl IoStream {
287    pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
288        match self {
289            IoStream::Stdout => Box::new(io::stdout()),
290            IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
291        }
292    }
293}
294
295// ------------------------------------------------------------
296
297#[cfg(unix)]
298mod utils {
299    use anyhow::{Context, Result, bail};
300    use std::{
301        fs::OpenOptions,
302        io::{Read, Write},
303        time::Duration,
304    };
305
306    /// Query the terminal for the current cursor position (col, row)
307    /// Needed because crossterm implementation fails when stdout is not connected.
308    /// Requires raw mode
309    pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
310        use nix::sys::{
311            select::{FdSet, select},
312            time::{TimeVal, TimeValLike},
313        };
314        use std::os::fd::AsFd;
315
316        let mut tty = OpenOptions::new()
317            .read(true)
318            .write(true)
319            .open("/dev/tty")
320            .context("Failed to open /dev/tty")?;
321
322        // Send the ANSI cursor position report query
323        tty.write_all(b"\x1b[6n")?;
324        tty.flush()?;
325
326        // Wait for input using select()
327        let fd = tty.as_fd();
328        let mut fds = FdSet::new();
329        fds.insert(fd);
330
331        let mut timeout = TimeVal::milliseconds(timeout.as_millis() as i64);
332
333        let ready =
334            select(None, &mut fds, None, None, Some(&mut timeout)).context("select() failed")?;
335
336        if ready == 0 {
337            bail!("Timed out waiting for cursor position response: {timeout:?}");
338        }
339
340        // Read the response
341        let mut buf = [0u8; 64];
342        let n = tty.read(&mut buf)?;
343        let s = String::from_utf8_lossy(&buf[..n]);
344
345        parse_cursor_response(&s).context(format!("Failed to parse terminal response: {s}"))
346    }
347
348    /// Parse the terminal response with format ESC [ row ; col R
349    /// and return (col, row) as 0-based coordinates.
350    fn parse_cursor_response(s: &str) -> Result<(u16, u16)> {
351        let coords = s
352            .strip_prefix("\x1b[")
353            .context("Missing ESC]")?
354            .strip_suffix('R')
355            .context("Missing R")?;
356
357        let mut parts = coords.split(';');
358
359        let row: u16 = parts.next().context("Missing row")?.parse()?;
360
361        let col: u16 = parts.next().context("Missing column")?.parse()?;
362
363        Ok((col - 1, row - 1)) // convert to 0-based
364    }
365}
366
367#[cfg(windows)]
368mod utils {
369    use anyhow::Result;
370    use std::time::Duration;
371    pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
372        let ret = crossterm::cursor::position()?;
373        Ok(ret)
374    }
375}