matchmaker/
tui.rs

1use crate::{Result, config::TerminalConfig};
2use crossterm::{
3    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    pub area: Rect,
16    pub config: TerminalConfig
17}
18
19impl<W> Tui<W>
20where
21W: Write,
22{
23    // waiting on https://github.com/ratatui/ratatui/issues/984 to implement growable inline, currently just tries to request max
24    // if max > than remainder, then scrolls up a bit
25    pub fn new_with_writer(writer: W, mut config: TerminalConfig) -> Result<Self> {
26        let mut backend = CrosstermBackend::new(writer);
27        let mut options = TerminalOptions::default();
28        
29        let (width, height) = Self::full_size().unwrap_or_default();
30        let area = if let Some(ref layout) = config.layout {
31            let request = layout.percentage.compute_with_clamp(height, layout.max).min(height);
32            
33            let cursor_y= Self::get_cursor_y().unwrap_or_else(|e| {
34                warn!("Failed to read cursor: {e}");
35                height // overestimate
36            });
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        if config.sleep == 0 { config.sleep = 100 };
82        Ok(Self {
83            terminal,
84            config,
85            area
86        })
87    }
88    
89    pub fn enter(&mut self) -> Result<()> {
90        let fullscreen = self.is_fullscreen();
91        let backend = self.terminal.backend_mut();
92        enable_raw_mode()?;
93        execute!(backend, EnableMouseCapture)?;
94        
95        if fullscreen {
96            self.enter_alternate()?;
97        }
98        Ok(())
99    }
100    
101    pub fn enter_alternate(&mut self) -> Result<()> {
102        let backend = self.terminal.backend_mut();
103        execute!(backend, EnterAlternateScreen)?;
104        execute!(
105            backend,
106            crossterm::terminal::Clear(ClearType::All)
107        )?;
108        self.terminal.clear()?;
109        debug!("Entered alternate screen");
110        Ok(())
111    }
112    
113    pub fn enter_execute(&mut self) {
114        self.exit();
115        sleep(Duration::from_millis(self.config.sleep)); // necessary to give resize some time
116        debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
117        
118        // do we ever need to scroll up?
119    }
120    
121    pub fn resize(&mut self, area: Rect) {
122        let _ = self
123        .terminal
124        .resize(area)
125        .map_err(|e| error!("{e}"));
126        self.area = area
127    }
128    
129    pub fn redraw(&mut self) {
130        let _ = self
131        .terminal
132        .resize(self.area)
133        .map_err(|e| error!("{e}"));
134    }
135    
136    pub fn return_execute(&mut self) -> Result<()> {
137        self.enter()?;
138        if !self.is_fullscreen() {
139            // altho we cannot resize the viewport, this is the best we can do
140            let _ = self.enter_alternate();
141        }
142        
143        sleep(Duration::from_millis(self.config.sleep));
144        
145        let _ = execute!(
146            self.terminal.backend_mut(),
147            crossterm::terminal::Clear(ClearType::All)
148        )
149        .map_err(|e| warn!("{e}"));
150        
151        if self.is_fullscreen() || self.config.restore_fullscreen {
152            if let Some((width, height)) = Self::full_size() {
153                self.resize(Rect::new(0, 0, width, height));
154            } else {
155                error!("Failed to get terminal size");
156                self.resize(self.area);
157            }
158        } else {
159            self.resize(self.area);
160        }
161        
162        Ok(())
163    }
164    
165    pub fn exit(&mut self) {
166        let backend = self.terminal.backend_mut();
167        
168        // if !fullscreen {
169        let _ = execute!(
170            backend,
171            crossterm::cursor::MoveTo(0, self.area.y),
172            crossterm::terminal::Clear(ClearType::FromCursorDown)
173        )
174        .map_err(|e| warn!("{e}"));
175        // } else {
176        //     if let Err(e) = execute!(backend, cursor::MoveTo(0, 0)) {
177        //         warn!("Failed to move cursor: {:?}", e);
178        //     }
179        // }
180        
181        let _ = execute!(backend, LeaveAlternateScreen, DisableMouseCapture)
182        .map_err(|e| warn!("{e}"));
183        
184        let _ = self
185        .terminal
186        .show_cursor()
187        .map_err(|e| warn!("{e}"));
188        
189        let _ = disable_raw_mode()
190        .map_err(|e| warn!("{e}"));
191        
192        
193        debug!("Terminal exited");
194    }
195    
196    // note: do not start before event stream
197    pub fn get_cursor_y() -> io::Result<u16> {
198        // crossterm uses stdout to determine cursor position
199        // todo: workarounds?
200        // #[cfg(not(target_os = "windows"))]
201        {
202            if !atty::is(atty::Stream::Stdout) {
203                return Err(io::Error::new(
204                    io::ErrorKind::NotConnected,
205                    "stdout is not a TTY",
206                ));
207            }
208        }
209        
210        crossterm::cursor::position().map(|u| u.1)
211    }
212    
213    pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
214        execute!(backend, crossterm::terminal::ScrollUp(lines))?;
215        Self::get_cursor_y() // note: do we want to skip this for speed
216    }
217    pub fn size() -> io::Result<(u16, u16)> {
218        crossterm::terminal::size()
219    }
220    pub fn full_size() -> Option<(u16, u16)> {
221        if let Ok((width, height)) = Self::size() {
222            Some((width, height))
223        } else {
224            error!("Failed to read terminal size");
225            None
226        }
227    }
228    pub fn is_fullscreen(&self) -> bool {
229        self.config.layout.is_none()
230    }
231    pub fn set_fullscreen(&mut self) {
232        self.config.layout = None;
233    }
234}
235
236impl Tui<Box<dyn Write + Send>> {
237    pub fn new(config: TerminalConfig) -> Result<Self> {
238        let writer = config.stream.to_stream();
239        let tui = Self::new_with_writer(writer, config)?;
240        Ok(tui)
241    }
242}
243
244impl<W> Drop for Tui<W>
245where
246W: Write,
247{
248    fn drop(&mut self) {
249        self.exit();
250    }
251}
252
253// ---------- IO ---------------
254
255#[derive(Debug, Clone, Deserialize, Default, Serialize, PartialEq)]
256pub enum IoStream {
257    Stdout,
258    #[default]
259    BufferedStderr,
260}
261
262impl IoStream {
263    pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
264        match self {
265            IoStream::Stdout => Box::new(io::stdout()),
266            IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
267        }
268    }
269}