matchmaker/
tui.rs

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