matchmaker/
tui.rs

1use crate::{Result, config::{TerminalConfig, TerminalLayoutSettings}};
2use crossterm::{
3    cursor, event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}
4};
5use log::{debug, error, warn};
6use ratatui::{Terminal, TerminalOptions, Viewport, layout::Rect, prelude::CrosstermBackend};
7use serde::{Deserialize, Serialize};
8use std::{io::{self, Write}, thread::sleep, time::Duration};
9
10pub struct Tui<W>
11where
12W: Write,
13{
14    pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
15    layout: Option<TerminalLayoutSettings>,
16    pub area: Rect,
17    pub sleep : u64
18}
19
20impl<W> Tui<W>
21where
22W: Write,
23{
24    // waiting on https://github.com/ratatui/ratatui/issues/984 to implement growable inline, currently just tries to request max
25    // if max > than remainder, then scrolls up a bit
26    pub fn new_with_writer(writer: W, config: TerminalConfig) -> Result<Self> {
27        let mut backend = CrosstermBackend::new(writer);
28        let mut options = TerminalOptions::default();
29
30        let (width, height) = Self::full_size().unwrap_or_default();
31        let area = if let Some(ref layout) = config.layout {
32            let request = layout.percentage.get_max(height, layout.max).min(height);
33
34            let cursor_y= Self::get_cursor_y().unwrap_or_else(|e| {
35                warn!("Failed to read cursor: {e}");
36                height // overestimate
37            });
38            let initial_height = height
39            .saturating_sub(cursor_y);
40
41            let scroll = request.saturating_sub(initial_height);
42            debug!("TUI dimensions: {width}, {height}. Cursor: {cursor_y}.", );
43
44            // ensure available by scrolling
45            let cursor_y = match Self::scroll_up(&mut backend, scroll) {
46                Ok(_) => {
47                    cursor_y.saturating_sub(scroll) // the requested cursor doesn't seem updated so we assume it succeeded
48                }
49                Err(_) => {
50                    cursor_y
51                }
52            };
53            let available_height = height
54            .saturating_sub(cursor_y);
55
56            debug!("TUI quantities: min: {}, initial: {initial_height}, requested: {request}, available: {available_height}, requested scroll: {scroll}", layout.min);
57
58            if available_height < layout.min {
59                error!("Failed to allocate minimum height, falling back to fullscreen");
60                Rect::new(0, 0, width, height)
61            } else {
62                let area = Rect::new(
63                    0,
64                    cursor_y,
65                    width,
66                    available_height.min(request),
67                );
68
69                // options.viewport = Viewport::Inline(available_height.min(request));
70                options.viewport = Viewport::Fixed(area);
71
72                area
73            }
74        } else {
75            Rect::new(0, 0, width, height)
76        };
77
78        debug!("TUI area: {area}");
79
80        let terminal = Terminal::with_options(backend, options)?;
81        Ok(Self {
82            terminal,
83            layout: config.layout,
84            area,
85            sleep: if config.sleep == 0 { 100 } else { config.sleep as u64 }
86        })
87    }
88
89
90
91    pub fn enter(&mut self) -> Result<()> {
92        let fullscreen = self.is_fullscreen();
93        let backend = self.terminal.backend_mut();
94        enable_raw_mode()?;
95        execute!(backend, EnableMouseCapture)?;
96
97        if fullscreen {
98            self.enter_alternate()?;
99        }
100        Ok(())
101    }
102
103    pub fn enter_alternate(&mut self) -> Result<()> {
104        let backend = self.terminal.backend_mut();
105        execute!(backend, EnterAlternateScreen)?;
106        execute!(
107            backend,
108            crossterm::terminal::Clear(ClearType::All)
109        )?;
110        self.terminal.clear()?;
111        debug!("Entered alternate screen");
112        Ok(())
113    }
114
115    pub fn enter_execute(&mut self) {
116        self.exit();
117        sleep(Duration::from_millis(self.sleep)); // necessary to give resize some time
118        debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
119
120        // do we ever need to scroll up?
121    }
122
123    pub fn resize(&mut self, area: Rect) {
124        if let Err(e) = self.terminal.resize(area) {
125            error!("Failed to resize TUI to {area}: {e}")
126        }
127        self.area = area
128    }
129
130    pub fn redraw(&mut self) {
131        if let Err(e) = self.terminal.resize(self.area) {
132            error!("Failed to resize TUI to {}: {e}", self.area)
133        }
134    }
135
136    pub fn return_execute(&mut self) -> Result<()>
137    {
138        self.enter()?;
139        if !self.is_fullscreen() {
140            // altho we cannot resize the viewport, this is the best we can do
141            let _ = self.enter_alternate();
142        }
143
144        sleep(Duration::from_millis(self.sleep));
145
146        if let Err(e) = execute!(
147            self.terminal.backend_mut(),
148            crossterm::terminal::Clear(ClearType::All)
149        ) {
150            warn!("Failed to leave alternate screen: {:?}", e);
151        }
152
153        if self.is_fullscreen() {
154            if let Some((width, height)) = Self::full_size() {
155                self.resize(Rect::new(0, 0, width, height));
156            } else {
157                error!("Failed to get terminal size")
158            }
159        } else {
160            self.resize(self.area);
161        }
162
163        Ok(())
164    }
165
166    pub fn exit(&mut self) {
167        let backend = self.terminal.backend_mut();
168
169        // if !fullscreen {
170        if let Err(e) = execute!(backend, cursor::MoveTo(0, self.area.y)) {
171            warn!("Failed to move cursor: {:?}", e);
172        }
173        // } else {
174        //     if let Err(e) = execute!(backend, cursor::MoveTo(0, 0)) {
175        //         warn!("Failed to move cursor: {:?}", e);
176        //     }
177        // }
178
179
180
181        if let Err(e) = execute!(
182            backend,
183            crossterm::terminal::Clear(ClearType::FromCursorDown)
184        ) {
185            warn!("Failed to clear screen: {:?}", e);
186        }
187
188        if let Err(e) = execute!(backend, LeaveAlternateScreen, DisableMouseCapture) {
189            warn!("Failed to leave alternate screen: {:?}", e);
190        }
191
192        if let Err(e) = self.terminal.show_cursor() {
193            warn!("Failed to show cursor: {:?}", e);
194        }
195
196        if let Err(e) = disable_raw_mode() {
197            warn!("Failed to disable raw mode: {:?}", e);
198        }
199
200        debug!("Terminal exited");
201    }
202
203    // wrappers to hide impl
204    pub fn get_cursor_y() -> io::Result<u16> {
205        crossterm::cursor::position().map(|x| x.1)
206    }
207
208    pub fn get_cursor() -> io::Result<(u16, u16)> {
209        crossterm::cursor::position()
210    }
211
212    pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
213        execute!(backend, crossterm::terminal::ScrollUp(lines))?;
214        Self::get_cursor_y() // note: do we want to skip this for speed
215    }
216    pub fn size() -> io::Result<(u16, u16)> {
217        crossterm::terminal::size()
218    }
219    pub fn full_size() -> Option<(u16, u16)> {
220        if let Ok((width, height)) = Self::size() {
221            Some((width, height))
222        } else {
223            error!("Failed to read terminal size");
224            None
225        }
226    }
227    pub fn is_fullscreen(&self) -> bool {
228        self.layout.is_none()
229    }
230    pub fn set_fullscreen(&mut self) {
231        self.layout = None;
232    }
233    pub fn layout(&self) -> &Option<TerminalLayoutSettings> {
234        &self.layout
235    }
236}
237
238impl Tui<Box<dyn Write + Send>> {
239    pub fn new(config: TerminalConfig) -> Result<Self> {
240        let writer = config.stream.to_stream();
241        let tui = Self::new_with_writer(writer, config)?;
242        Ok(tui)
243    }
244}
245
246impl<W> Drop for Tui<W>
247where
248W: Write,
249{
250    fn drop(&mut self) {
251        self.exit();
252    }
253}
254
255// ---------- IO ---------------
256
257#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
258pub enum IoStream {
259    Stdout,
260    #[default]
261    BufferedStderr,
262}
263
264impl IoStream {
265    pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
266        match self {
267            IoStream::Stdout => Box::new(io::stdout()),
268            IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
269        }
270    }
271}