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;
8use std::{io::{self, BufRead, Read, Write}, thread::sleep, time::Duration};
9
10#[allow(dead_code)]
11pub struct Tui<W>
12where
13W: Write,
14{
15    pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
16    layout: Option<TerminalLayoutSettings>,
17    pub area: Rect,
18    pub sleep : u64
19    // hacky state tracking below maybe
20}
21
22// given min height/desired percent
23
24impl<W> Tui<W>
25where
26W: Write,
27{
28    // waiting on https://github.com/ratatui/ratatui/issues/984 to implement growable inline, currently just tries to request max
29    // if max > than remainder, then scrolls up a bit
30    pub fn new_with_writer(writer: W, config: TerminalConfig) -> Result<Self> {
31        let mut backend = CrosstermBackend::new(writer);
32        let mut options = TerminalOptions::default();
33        
34        let (width, height) = Self::full_size().unwrap_or_default();
35        let area = if let Some(ref layout) = config.layout {
36            let request = layout.percentage.get_max(height, layout.max).min(height);
37            
38            let cursor_y= Self::get_cursor_y().unwrap_or_else(|e| {
39                warn!("Failed to read cursor: {e}");
40                height // overestimate
41            });
42            let initial_height = height
43            .saturating_sub(cursor_y);
44            
45            let scroll = request.saturating_sub(initial_height);
46            debug!("TUI dimensions: {width}, {height}. Cursor: {cursor_y}.", );
47            
48            // ensure available by scrolling
49            let cursor_y = match Self::scroll_up(&mut backend, scroll) {
50                Ok(_) => {
51                    cursor_y.saturating_sub(scroll) // the requested cursor doesn't seem updated so we assume it succeeded
52                }
53                Err(_) => {
54                    cursor_y
55                }
56            };
57            let available_height = height
58            .saturating_sub(cursor_y);
59            
60            debug!("TUI quantities: min: {}, initial: {initial_height}, requested: {request}, available: {available_height}, requested scroll: {scroll}", layout.min);
61            
62            if available_height < layout.min {
63                error!("Failed to allocate minimum height, falling back to fullscreen");
64                Rect::new(0, 0, width, height)
65            } else {
66                let area = Rect::new(
67                    0,
68                    cursor_y,
69                    width,
70                    available_height.min(request),
71                );
72                
73                // options.viewport = Viewport::Inline(available_height.min(request));
74                options.viewport = Viewport::Fixed(area.clone());
75                
76                area
77            }
78        } else {
79            Rect::new(0, 0, width, height)
80        };
81        
82        debug!("TUI area: {area}");
83        
84        let terminal = Terminal::with_options(backend, options)?;
85        Ok(Self {
86            terminal, 
87            layout: config.layout,
88            area,
89            sleep: if config.sleep == 0 { 100 } else { config.sleep as u64 }
90        })
91    }
92    
93    
94    
95    pub fn enter(&mut self) -> Result<()> {
96        let fullscreen = self.is_fullscreen();
97        let backend = self.terminal.backend_mut();
98        enable_raw_mode()?;
99        execute!(backend, EnableMouseCapture)?;
100        
101        if fullscreen {
102            self.enter_alternate()?;
103        }
104        Ok(())
105    }
106    
107    pub fn enter_alternate(&mut self) -> Result<()> {
108        let backend = self.terminal.backend_mut();
109        execute!(backend, EnterAlternateScreen)?;
110        execute!(
111            backend,
112            crossterm::terminal::Clear(ClearType::All)
113        )?;
114        self.terminal.clear()?;
115        debug!("Entered alternate screen");
116        Ok(())
117    }
118    
119    pub fn enter_execute(&mut self) {
120        self.exit();
121        sleep(Duration::from_millis(self.sleep)); // necessary to give resize some time
122        debug!("state: {:?}", crossterm::terminal::is_raw_mode_enabled());
123        
124        // do we ever need to scroll up?
125    }
126    
127    pub fn return_execute(&mut self) -> Result<()>
128    {
129        self.enter()?;
130        if !self.is_fullscreen() {
131            // altho we cannot resize the viewport, this is the best we can do
132            let _ = self.enter_alternate();
133        }
134        
135        sleep(Duration::from_millis(self.sleep));
136        
137        if let Err(e) = execute!(
138            self.terminal.backend_mut(),
139            crossterm::terminal::Clear(ClearType::All)
140        ) {
141            warn!("Failed to leave alternate screen: {:?}", e);
142        }
143        
144        if self.is_fullscreen() {
145            if let Some((width, height)) = Self::full_size() {
146                self.terminal.resize(Rect::new(0, 0, width, height))?;
147            } else {
148                error!("Failed to get terminal size")
149            }
150        } else {
151            self.terminal.resize(self.area)?;
152        }
153        
154        Ok(())
155    }
156    
157    pub fn exit(&mut self) {
158        let backend = self.terminal.backend_mut();
159        
160        // if !fullscreen {
161        if let Err(e) = execute!(backend, cursor::MoveTo(0, self.area.y)) {
162            warn!("Failed to move cursor: {:?}", e);
163        }
164        // } else {
165        //     if let Err(e) = execute!(backend, cursor::MoveTo(0, 0)) {
166        //         warn!("Failed to move cursor: {:?}", e);
167        //     }
168        // }
169        
170        
171        
172        if let Err(e) = execute!(
173            backend,
174            crossterm::terminal::Clear(ClearType::FromCursorDown)
175        ) {
176            warn!("Failed to clear screen: {:?}", e);
177        }
178        
179        if let Err(e) = execute!(backend, LeaveAlternateScreen, DisableMouseCapture) {
180            warn!("Failed to leave alternate screen: {:?}", e);
181        }
182        
183        if let Err(e) = self.terminal.show_cursor() {
184            warn!("Failed to show cursor: {:?}", e);
185        }
186        
187        if let Err(e) = disable_raw_mode() {
188            warn!("Failed to disable raw mode: {:?}", e);
189        }
190        
191        debug!("Terminal exited");
192    }
193    
194    // wrappers to hide impl
195    pub fn get_cursor_y() -> io::Result<u16> {
196        crossterm::cursor::position().map(|x| x.1)
197    }
198    
199    pub fn get_cursor() -> io::Result<(u16, u16)> {
200        crossterm::cursor::position()
201    }
202    
203    pub fn scroll_up(backend: &mut CrosstermBackend<W>, lines: u16) -> io::Result<u16> {
204        execute!(backend, crossterm::terminal::ScrollUp(lines))?;
205        Self::get_cursor_y() // note: do we want to skip this for speed
206    }
207    pub fn size() -> io::Result<(u16, u16)> {
208        crossterm::terminal::size()
209    }
210    pub fn full_size() -> Option<(u16, u16)> {
211        if let Ok((width, height)) = Self::size() {
212            Some((width, height))
213        } else {
214            error!("Failed to read terminal size");
215            None
216        }
217    }
218    pub fn is_fullscreen(&self) -> bool {
219        self.layout.is_none()
220    }
221    pub fn set_fullscreen(&mut self) {
222        self.layout = None;
223    }
224    pub fn layout(&self) -> &Option<TerminalLayoutSettings> {
225        &self.layout
226    }
227}
228
229impl Tui<Box<dyn Write + Send>> {
230    pub fn new(config: TerminalConfig) -> Result<Self> {
231        let writer = config.stream.to_stream();
232        let tui = Self::new_with_writer(writer, config)?;
233        Ok(tui)
234    }
235}
236
237impl<W> Drop for Tui<W>
238where
239W: Write,
240{
241    fn drop(&mut self) {
242        self.exit();
243    }
244}
245
246// ---------- IO ---------------
247
248#[derive(Debug, Clone, Deserialize, Default)]
249pub enum IoStream {
250    Stdout,
251    #[default]
252    BufferedStderr,
253}
254
255impl IoStream {
256    pub fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
257        match self {
258            IoStream::Stdout => Box::new(io::stdout()),
259            IoStream::BufferedStderr => Box::new(io::LineWriter::new(io::stderr())),
260        }
261    }
262}
263
264// -------------------- READER -----------------
265
266pub fn stdin_reader() -> Option<io::Stdin> {
267    if atty::is(atty::Stream::Stdin) {
268        error!("stdin is a TTY: picker requires piped input.");
269        return None
270    }
271    
272    Some(io::stdin())
273}
274
275pub fn read_to_chunks<R: Read>(reader: R, delim: char) -> std::io::Split<std::io::BufReader<R>> {
276    io::BufReader::new(reader).split(delim as u8)
277}
278
279// do not use for newlines as it doesn't handle \r!
280// todo: warn about this in config
281pub fn map_chunks<const INVALID_FAIL: bool>(iter: impl Iterator<Item = std::io::Result<Vec<u8>>>, mut f: impl FnMut(String) -> Result<()>)
282{
283    for (i, chunk_result) in iter.enumerate() {
284        if i == u32::MAX as usize {
285            warn!("Reached maximum segment limit, stopping input read");
286            break;
287        }
288        
289        let chunk = match chunk_result {
290            Ok(bytes) => bytes,
291            Err(e) => {
292                error!("Error reading from stdin: {e}");
293                break;
294            }
295        };
296        
297        match String::from_utf8(chunk) {
298            Ok(s) => {
299                debug!("Read: {s}");
300                if f(s).is_err() {
301                    break;
302                }
303            }
304            Err(e) => {
305                error!("Invalid UTF-8 in stdin at byte {}: {}", e.utf8_error().valid_up_to(), e);
306                // Skip but continue reading
307                if INVALID_FAIL {
308                    break
309                } else {
310                    continue
311                }
312            }
313        }
314    }
315}
316
317pub fn map_reader_lines<const INVALID_FAIL: bool>(reader: impl Read, mut f: impl FnMut(String) -> Result<()>) {
318    let buf_reader = io::BufReader::new(reader);
319    
320    for (i, line) in buf_reader.lines().enumerate() {
321        if i == u32::MAX as usize {
322            eprintln!("Reached maximum line limit, stopping input read");
323            break;
324        }
325        match line {
326            Ok(l) => {
327                if f(l).is_err() {
328                    break;
329                }
330            }
331            Err(e) => {
332                eprintln!("Error reading line: {}", e);
333                if INVALID_FAIL {
334                    break
335                } else {
336                    continue
337                }
338            }
339        }
340    }
341}