pagers-tui 0.2.2

Terminal UI for pagers page cache diagnostics
Documentation
mod app;
mod event;
mod state;
mod stats;
mod ui;

use std::io::{self, Stdout};
use std::sync::Arc;
use std::sync::mpsc;
use std::time::{Duration, Instant};

use pagers_core::Cancellation;
use pagers_core::events::Event as CoreEvent;
use pagers_core::mincore::PageMap;
use pagers_core::ops::Stats;
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::terminal::{Terminal, TerminalOptions, Viewport};
use ratatui_core::widgets::Widget;
use ratatui_crossterm::{CrosstermBackend, crossterm};

use app::App;
use state::FileState;

const MAX_DISPLAY_FILES: u16 = 8;
const MAX_DISPLAY_PAGES: usize = 32;
const FRAME_BUDGET: Duration = Duration::from_millis(100);

struct TerminalGuard {
    terminal: Terminal<CrosstermBackend<Stdout>>,
    _state: TerminalStateGuard,
}

struct TerminalStateGuard;

impl TerminalStateGuard {
    fn new() -> io::Result<Self> {
        crossterm::terminal::enable_raw_mode()?;
        Ok(Self)
    }
}

impl Drop for TerminalStateGuard {
    fn drop(&mut self) {
        let _ = crossterm::execute!(io::stdout(), crossterm::cursor::Show);
        let _ = crossterm::terminal::disable_raw_mode();
    }
}

impl TerminalGuard {
    fn new(viewport_height: u16) -> io::Result<Self> {
        let state = TerminalStateGuard::new()?;
        crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
        let backend = CrosstermBackend::new(io::stdout());
        let terminal = Terminal::with_options(
            backend,
            TerminalOptions {
                viewport: Viewport::Inline(viewport_height),
            },
        )?;
        Ok(Self {
            terminal,
            _state: state,
        })
    }
}

struct RenderContext<'a> {
    core_stats: &'a Stats,
    label: &'a str,
    action_sign: isize,
}

impl RenderContext<'_> {
    fn render<PM: PageMap>(
        &self,
        files: &[&FileState<PM>],
        file_rows_hwm: u16,
        elapsed: f64,
        area: Rect,
        buf: &mut Buffer,
    ) {
        let [files_area, stats_area] = ui::layout(file_rows_hwm, area);
        ui::FileListWidget {
            files,
            max_rows: file_rows_hwm,
        }
        .render(files_area, buf);
        stats::SummaryWidget {
            stats: self.core_stats,
            elapsed,
            label: self.label,
            action_sign: self.action_sign,
        }
        .render(stats_area, buf);
    }
}

fn drain_events<PM: PageMap>(
    app: &mut App<PM>,
    rx: &mpsc::Receiver<event::TuiEvent<PM>>,
) -> app::ControlFlow {
    while let Ok(evt) = rx.try_recv() {
        match app.handle_event(evt) {
            app::ControlFlow::Continue => {}
            flow => return flow,
        }
    }
    app::ControlFlow::Continue
}

pub fn run<PM: PageMap + Send + 'static>(
    rx: mpsc::Receiver<CoreEvent<PM>>,
    cancellation: Cancellation,
    core_stats: Arc<Stats>,
    label: &str,
    action_sign: isize,
    start: Instant,
) -> io::Result<()> {
    let viewport_height = MAX_DISPLAY_FILES + stats::SUMMARY_LINES;
    let mut guard = TerminalGuard::new(viewport_height)?;

    let tui_rx = event::spawn_event_threads(rx, cancellation.clone());
    let mut app = App::new();
    let mut file_rows_hwm: u16 = 0;
    let ctx = RenderContext {
        core_stats: &core_stats,
        label,
        action_sign,
    };

    let flow = loop {
        let flow = match tui_rx.recv_timeout(FRAME_BUDGET) {
            Ok(evt) => app.handle_event(evt),
            Err(mpsc::RecvTimeoutError::Timeout) => app::ControlFlow::Continue,
            Err(mpsc::RecvTimeoutError::Disconnected) => break app::ControlFlow::Quit,
        };

        let flow = match flow {
            app::ControlFlow::Continue => drain_events(&mut app, &tui_rx),
            other => other,
        };

        let elapsed = start.elapsed().as_secs_f64();
        let files = app.visible_files(MAX_DISPLAY_FILES as usize);
        file_rows_hwm = file_rows_hwm.max(files.len().min(MAX_DISPLAY_FILES as usize) as u16);
        guard.terminal.draw(|frame| {
            ctx.render(
                &files,
                file_rows_hwm,
                elapsed,
                frame.area(),
                frame.buffer_mut(),
            );
        })?;

        match flow {
            app::ControlFlow::Continue => {}
            other => break other,
        }
    };

    if matches!(flow, app::ControlFlow::Quit) {
        cancellation.cancel();
    }

    if matches!(flow, app::ControlFlow::Done) {
        let elapsed = start.elapsed().as_secs_f64();
        let files = app.visible_files(MAX_DISPLAY_FILES as usize);
        file_rows_hwm = file_rows_hwm.max(files.len().min(MAX_DISPLAY_FILES as usize) as u16);
        let total_lines = file_rows_hwm + stats::SUMMARY_LINES;

        let _ = guard.terminal.insert_before(total_lines, |buf| {
            ctx.render(&files, file_rows_hwm, elapsed, buf.area, buf);
        });
    }

    Ok(())
}