Skip to main content

pagers_tui/
lib.rs

1mod app;
2mod event;
3mod state;
4mod stats;
5mod ui;
6
7use std::io::{self, Stdout};
8use std::sync::Arc;
9use std::sync::mpsc;
10use std::time::{Duration, Instant};
11
12use pagers_core::Cancellation;
13use pagers_core::events::Event as CoreEvent;
14use pagers_core::mincore::PageMap;
15use pagers_core::ops::Stats;
16use ratatui_core::buffer::Buffer;
17use ratatui_core::layout::Rect;
18use ratatui_core::terminal::{Terminal, TerminalOptions, Viewport};
19use ratatui_core::widgets::Widget;
20use ratatui_crossterm::{CrosstermBackend, crossterm};
21
22use app::App;
23use state::FileState;
24
25const MAX_DISPLAY_FILES: u16 = 8;
26const MAX_DISPLAY_PAGES: usize = 32;
27const FRAME_BUDGET: Duration = Duration::from_millis(100);
28
29struct TerminalGuard {
30    terminal: Terminal<CrosstermBackend<Stdout>>,
31    _state: TerminalStateGuard,
32}
33
34struct TerminalStateGuard;
35
36impl TerminalStateGuard {
37    fn new() -> io::Result<Self> {
38        crossterm::terminal::enable_raw_mode()?;
39        Ok(Self)
40    }
41}
42
43impl Drop for TerminalStateGuard {
44    fn drop(&mut self) {
45        let _ = crossterm::execute!(io::stdout(), crossterm::cursor::Show);
46        let _ = crossterm::terminal::disable_raw_mode();
47    }
48}
49
50impl TerminalGuard {
51    fn new(viewport_height: u16) -> io::Result<Self> {
52        let state = TerminalStateGuard::new()?;
53        crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
54        let backend = CrosstermBackend::new(io::stdout());
55        let terminal = Terminal::with_options(
56            backend,
57            TerminalOptions {
58                viewport: Viewport::Inline(viewport_height),
59            },
60        )?;
61        Ok(Self {
62            terminal,
63            _state: state,
64        })
65    }
66}
67
68struct RenderContext<'a> {
69    core_stats: &'a Stats,
70    label: &'a str,
71    action_sign: isize,
72}
73
74impl RenderContext<'_> {
75    fn render<PM: PageMap>(
76        &self,
77        files: &[&FileState<PM>],
78        file_rows_hwm: u16,
79        elapsed: f64,
80        area: Rect,
81        buf: &mut Buffer,
82    ) {
83        let [files_area, stats_area] = ui::layout(file_rows_hwm, area);
84        ui::FileListWidget {
85            files,
86            max_rows: file_rows_hwm,
87        }
88        .render(files_area, buf);
89        stats::SummaryWidget {
90            stats: self.core_stats,
91            elapsed,
92            label: self.label,
93            action_sign: self.action_sign,
94        }
95        .render(stats_area, buf);
96    }
97}
98
99fn drain_events<PM: PageMap>(
100    app: &mut App<PM>,
101    rx: &mpsc::Receiver<event::TuiEvent<PM>>,
102) -> app::ControlFlow {
103    while let Ok(evt) = rx.try_recv() {
104        match app.handle_event(evt) {
105            app::ControlFlow::Continue => {}
106            flow => return flow,
107        }
108    }
109    app::ControlFlow::Continue
110}
111
112pub fn run<PM: PageMap + Send + 'static>(
113    rx: mpsc::Receiver<CoreEvent<PM>>,
114    cancellation: Cancellation,
115    core_stats: Arc<Stats>,
116    label: &str,
117    action_sign: isize,
118    start: Instant,
119) -> io::Result<()> {
120    let viewport_height = MAX_DISPLAY_FILES + stats::SUMMARY_LINES;
121    let mut guard = TerminalGuard::new(viewport_height)?;
122
123    let tui_rx = event::spawn_event_threads(rx, cancellation.clone());
124    let mut app = App::new();
125    let mut file_rows_hwm: u16 = 0;
126    let ctx = RenderContext {
127        core_stats: &core_stats,
128        label,
129        action_sign,
130    };
131
132    let flow = loop {
133        let flow = match tui_rx.recv_timeout(FRAME_BUDGET) {
134            Ok(evt) => app.handle_event(evt),
135            Err(mpsc::RecvTimeoutError::Timeout) => app::ControlFlow::Continue,
136            Err(mpsc::RecvTimeoutError::Disconnected) => break app::ControlFlow::Quit,
137        };
138
139        let flow = match flow {
140            app::ControlFlow::Continue => drain_events(&mut app, &tui_rx),
141            other => other,
142        };
143
144        let elapsed = start.elapsed().as_secs_f64();
145        let files = app.visible_files(MAX_DISPLAY_FILES as usize);
146        file_rows_hwm = file_rows_hwm.max(files.len().min(MAX_DISPLAY_FILES as usize) as u16);
147        guard.terminal.draw(|frame| {
148            ctx.render(
149                &files,
150                file_rows_hwm,
151                elapsed,
152                frame.area(),
153                frame.buffer_mut(),
154            );
155        })?;
156
157        match flow {
158            app::ControlFlow::Continue => {}
159            other => break other,
160        }
161    };
162
163    if matches!(flow, app::ControlFlow::Quit) {
164        cancellation.cancel();
165    }
166
167    if matches!(flow, app::ControlFlow::Done) {
168        let elapsed = start.elapsed().as_secs_f64();
169        let files = app.visible_files(MAX_DISPLAY_FILES as usize);
170        file_rows_hwm = file_rows_hwm.max(files.len().min(MAX_DISPLAY_FILES as usize) as u16);
171        let total_lines = file_rows_hwm + stats::SUMMARY_LINES;
172
173        let _ = guard.terminal.insert_before(total_lines, |buf| {
174            ctx.render(&files, file_rows_hwm, elapsed, buf.area, buf);
175        });
176    }
177
178    Ok(())
179}