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        let backend = self.terminal.backend_mut();
106        crossterm::terminal::enable_raw_mode()?; // duplicate but crossterm checks this
107        execute!(backend, EnableMouseCapture)._elog();
108        #[cfg(feature = "bracketed-paste")]
109        execute!(backend, crossterm::event::EnableBracketedPaste)._elog();
110        if self.config.extended_keys {
111            execute!(
112                backend,
113                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
114            )
115            ._elog();
116        }
117
118        if fullscreen {
119            self.alternate_screen()?;
120        }
121        Ok(())
122    }
123
124    pub fn alternate_screen(&mut self) -> Result<()> {
125        let backend = self.terminal.backend_mut();
126        execute!(backend, EnterAlternateScreen)?;
127        execute!(backend, crossterm::terminal::Clear(ClearType::All))?;
128        self.terminal.clear()?;
129        debug!("Entered alternate screen");
130        Ok(())
131    }
132
133    pub fn enter_execute(&mut self) {
134        self.exit();
135        sleep(self.config.sleep_ms); // necessary to give resize some time
136        debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
137
138        // do we ever need to scroll up?
139    }
140
141    pub fn resize(&mut self, area: Rect) {
142        self.terminal.resize(area)._elog();
143        self.area = area
144    }
145
146    pub fn redraw(&mut self) {
147        self.terminal.resize(self.area)._elog();
148    }
149
150    pub fn return_execute(&mut self) -> Result<()> {
151        self.enter()?;
152        if !self.is_fullscreen() {
153            // altho we cannot resize the viewport, this is the best we can do
154            self.alternate_screen()._elog();
155        }
156        sleep(self.config.sleep_ms);
157        log::debug!("During return, slept {}", self.config.sleep_ms.as_millis());
158
159        execute!(
160            self.terminal.backend_mut(),
161            crossterm::terminal::Clear(ClearType::All)
162        )
163        ._wlog();
164
165        if self.is_fullscreen() || self.config.restore_fullscreen {
166            if let Some((width, height)) = Self::full_size() {
167                self.resize(Rect::new(0, 0, width, height));
168            } else {
169                error!("Failed to get terminal size");
170                self.resize(self.area);
171            }
172        } else {
173            self.resize(self.area);
174        }
175
176        Ok(())
177    }
178
179    pub fn exit(&mut self) {
180        let backend = self.terminal.backend_mut();
181
182        // if !fullscreen {
183        if self.config.clear_on_exit && !cfg!(debug_assertions) {
184            execute!(
185                backend,
186                crossterm::cursor::MoveTo(0, self.area.y),
187                crossterm::terminal::Clear(ClearType::FromCursorDown)
188            )
189            ._elog();
190        }
191
192        if self.config.extended_keys {
193            execute!(backend, PopKeyboardEnhancementFlags)._elog();
194        }
195        // } else {
196        //     if let Err(e) = execute!(backend, cursor::MoveTo(0, 0)) {
197        //         warn!("Failed to move cursor: {:?}", e);
198        //     }
199        // }
200
201        execute!(backend, LeaveAlternateScreen, DisableMouseCapture)._wlog();
202
203        self.terminal.show_cursor()._wlog();
204
205        disable_raw_mode()._wlog();
206
207        debug!("Terminal exited");
208    }
209
210    // note: do not start before event stream
211    pub fn get_cursor_y(timeout: Duration) -> io::Result<u16> {
212        // crossterm uses stdout to determine cursor position
213        // todo: workarounds?
214        // #[cfg(not(target_os = "windows"))]
215        Ok(if !atty::is(atty::Stream::Stdout) {
216            utils::query_cursor_position(timeout)
217                .map_err(io::Error::other)?
218                .1
219        } else {
220            crossterm::cursor::position()?.1
221        })
222    }
223
224    pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
225        execute!(backend, crossterm::terminal::ScrollUp(lines))?;
226        Ok(0) // not used
227        // Self::get_cursor_y() // note: do we want to skip this for speed
228    }
229    pub fn size() -> io::Result<(u16, u16)> {
230        crossterm::terminal::size()
231    }
232    pub fn full_size() -> Option<(u16, u16)> {
233        if let Ok((width, height)) = Self::size() {
234            Some((width, height))
235        } else {
236            error!("Failed to read terminal size");
237            None
238        }
239    }
240    pub fn is_fullscreen(&self) -> bool {
241        self.config.layout.is_none()
242    }
243    pub fn set_fullscreen(&mut self) {
244        self.config.layout = None;
245    }
246}
247
248impl Tui<Box<dyn Write + Send>> {
249    pub fn new(config: TerminalConfig) -> Result<Self> {
250        let writer = config.stream.to_stream();
251        let tui = Self::new_with_writer(writer, config)?;
252        Ok(tui)
253    }
254}
255
256impl<W> Drop for Tui<W>
257where
258    W: Write,
259{
260    fn drop(&mut self) {
261        self.exit();
262    }
263}
264
265// ---------- IO ---------------
266
267#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
268pub enum IoStream {
269    Stdout,
270    #[default]
271    BufferedStderr,
272}
273
274impl IoStream {
275    pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
276        match self {
277            IoStream::Stdout => Box::new(io::stdout()),
278            IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
279        }
280    }
281}
282
283// ------------------------------------------------------------
284
285#[cfg(unix)]
286mod utils {
287    use anyhow::{Context, Result, bail};
288    use std::{
289        fs::OpenOptions,
290        io::{Read, Write},
291        time::Duration,
292    };
293
294    /// Query the terminal for the current cursor position (col, row)
295    /// Needed because crossterm implementation fails when stdout is not connected.
296    /// Requires raw mode
297    pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
298        use nix::sys::{
299            select::{FdSet, select},
300            time::{TimeVal, TimeValLike},
301        };
302        use std::os::fd::AsFd;
303
304        let mut tty = OpenOptions::new()
305            .read(true)
306            .write(true)
307            .open("/dev/tty")
308            .context("Failed to open /dev/tty")?;
309
310        // Send the ANSI cursor position report query
311        tty.write_all(b"\x1b[6n")?;
312        tty.flush()?;
313
314        // Wait for input using select()
315        let fd = tty.as_fd();
316        let mut fds = FdSet::new();
317        fds.insert(fd);
318
319        let mut timeout = TimeVal::milliseconds(timeout.as_millis() as i64);
320
321        let ready =
322            select(None, &mut fds, None, None, Some(&mut timeout)).context("select() failed")?;
323
324        if ready == 0 {
325            bail!("Timed out waiting for cursor position response: {timeout:?}");
326        }
327
328        // Read the response
329        let mut buf = [0u8; 64];
330        let n = tty.read(&mut buf)?;
331        let s = String::from_utf8_lossy(&buf[..n]);
332
333        parse_cursor_response(&s).context(format!("Failed to parse terminal response: {s}"))
334    }
335
336    /// Parse the terminal response with format ESC [ row ; col R
337    /// and return (col, row) as 0-based coordinates.
338    fn parse_cursor_response(s: &str) -> Result<(u16, u16)> {
339        let coords = s
340            .strip_prefix("\x1b[")
341            .context("Missing ESC]")?
342            .strip_suffix('R')
343            .context("Missing R")?;
344
345        let mut parts = coords.split(';');
346
347        let row: u16 = parts.next().context("Missing row")?.parse()?;
348
349        let col: u16 = parts.next().context("Missing column")?.parse()?;
350
351        Ok((col - 1, row - 1)) // convert to 0-based
352    }
353}
354
355#[cfg(windows)]
356mod utils {
357    use anyhow::Result;
358    use std::time::Duration;
359    pub fn query_cursor_position(timeout: Duration) -> Result<(u16, u16)> {
360        let ret = crossterm::cursor::position()?;
361        Ok(ret)
362    }
363}