matchmaker/
tui.rs

1use crate::{Result, config::TerminalConfig};
2use cli_boilerplate_automation::bait::ResultExt;
3use crossterm::{
4    event::{
5        DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags,
6        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
7    },
8    execute,
9    terminal::{ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode},
10};
11use log::{debug, error};
12use ratatui::{Terminal, TerminalOptions, Viewport, layout::Rect, prelude::CrosstermBackend};
13use serde::{Deserialize, Serialize};
14use std::{
15    io::{self, Write},
16    thread::sleep,
17    time::Duration,
18};
19pub struct Tui<W>
20where
21    W: Write,
22{
23    pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
24    pub area: Rect,
25    pub config: TerminalConfig,
26}
27
28impl<W> Tui<W>
29where
30    W: Write,
31{
32    // waiting on https://github.com/ratatui/ratatui/issues/984 to implement growable inline, currently just tries to request max
33    // if max > than remainder, then scrolls up a bit
34    pub fn new_with_writer(writer: W, mut config: TerminalConfig) -> Result<Self> {
35        let mut backend = CrosstermBackend::new(writer);
36        let mut options = TerminalOptions::default();
37        if config.sleep_ms.is_zero() {
38            config.sleep_ms = Duration::from_millis(100)
39        };
40
41        // important for getting cursor
42        crossterm::terminal::enable_raw_mode()?;
43
44        let (width, height) = Self::full_size().unwrap_or_default();
45        let area = if let Some(ref layout) = config.layout {
46            let request = layout
47                .percentage
48                .compute_with_max(height, layout.max)
49                .min(height);
50
51            let cursor_y = Self::get_cursor_y(config.sleep_ms).unwrap_or_else(|e| {
52                error!("Failed to read cursor: {e}");
53                height - 1 // overestimate
54            });
55
56            let initial_height = height.saturating_sub(cursor_y);
57
58            let scroll = request.saturating_sub(initial_height);
59            debug!("TUI dimensions: {width}, {height}. Cursor_y: {cursor_y}.",);
60
61            // ensure available by scrolling
62            let cursor_y = match Self::scroll_up(&mut backend, scroll) {
63                Ok(_) => {
64                    cursor_y.saturating_sub(scroll) // the requested cursor doesn't seem updated so we assume it succeeded
65                }
66                Err(_) => cursor_y,
67            };
68            let available_height = height.saturating_sub(cursor_y);
69
70            debug!(
71                "TUI quantities: min: {}, initial_available: {initial_height}, requested: {request}, available: {available_height}, requested scroll: {scroll}",
72                layout.min
73            );
74
75            if available_height < layout.min {
76                error!("Failed to allocate minimum height, falling back to fullscreen");
77                Rect::new(0, 0, width, height)
78            } else {
79                let area = Rect::new(
80                    0,
81                    cursor_y,
82                    width,
83                    available_height.min(request).max(layout.min),
84                );
85
86                // options.viewport = Viewport::Inline(available_height.min(request));
87                options.viewport = Viewport::Fixed(area);
88
89                area
90            }
91        } else {
92            Rect::new(0, 0, width, height)
93        };
94
95        debug!("TUI area: {area}");
96
97        let terminal = Terminal::with_options(backend, options)?;
98        Ok(Self {
99            terminal,
100            config,
101            area,
102        })
103    }
104
105    pub fn enter(&mut self) -> Result<()> {
106        let fullscreen = self.is_fullscreen();
107        let backend = self.terminal.backend_mut();
108        crossterm::terminal::enable_raw_mode()?; // duplicate but crossterm checks this
109        execute!(backend, EnableMouseCapture)._elog();
110        #[cfg(feature = "bracketed-paste")]
111        execute!(backend, crossterm::event::EnableBracketedPaste)._elog();
112        if self.config.extended_keys {
113            execute!(
114                backend,
115                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
116            )
117            ._elog();
118        }
119
120        if fullscreen {
121            self.alternate_screen()?;
122        }
123        Ok(())
124    }
125
126    pub fn alternate_screen(&mut self) -> Result<()> {
127        let backend = self.terminal.backend_mut();
128        execute!(backend, EnterAlternateScreen)?;
129        execute!(backend, crossterm::terminal::Clear(ClearType::All))?;
130        self.terminal.clear()?;
131        debug!("Entered alternate screen");
132        Ok(())
133    }
134
135    pub fn enter_execute(&mut self) {
136        self.exit();
137        sleep(self.config.sleep_ms); // necessary to give resize some time
138        debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
139
140        // do we ever need to scroll up?
141    }
142
143    pub fn resize(&mut self, area: Rect) {
144        self.terminal.resize(area)._elog();
145        self.area = area
146    }
147
148    pub fn redraw(&mut self) {
149        self.terminal.resize(self.area)._elog();
150    }
151
152    pub fn return_execute(&mut self) -> Result<()> {
153        self.enter()?;
154        if !self.is_fullscreen() {
155            // altho we cannot resize the viewport, this is the best we can do
156            self.alternate_screen()._elog();
157        }
158        sleep(self.config.sleep_ms);
159        log::debug!("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        if self.is_fullscreen() || self.config.restore_fullscreen {
168            if let Some((width, height)) = Self::full_size() {
169                self.resize(Rect::new(0, 0, width, height));
170            } else {
171                error!("Failed to get terminal size");
172                self.resize(self.area);
173            }
174        } else {
175            self.resize(self.area);
176        }
177
178        Ok(())
179    }
180
181    pub fn exit(&mut self) {
182        let backend = self.terminal.backend_mut();
183
184        // if !fullscreen {
185        execute!(
186            backend,
187            crossterm::cursor::MoveTo(0, self.area.y),
188            crossterm::terminal::Clear(ClearType::FromCursorDown)
189        )
190        ._elog();
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
285mod utils {
286    use anyhow::{Result, bail};
287    use cli_boilerplate_automation::bait::{OptionExt, ResultExt};
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}