ratada 0.4.0

A ratatui widget toolkit: driver, modals, forms, pickers, theming
Documentation
//! A read-only text pager modal: scroll long text with an incremental search.

use std::{cell::Cell, io};

use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::Modifier,
    text::{Line, Span},
    widgets::Paragraph,
};

use super::{
    chrome, input,
    layout::centered_fraction,
    modal::ModalSignal,
    nav,
    overlay::{self, PopupFlow, popup},
    scroll, style,
    terminal::Tui,
};
use crate::theme::Skin;

/// The scroll and search state of the pager.
struct Pager {
    lines: Vec<String>,
    offset: usize,
    query: String,
    searching: bool,
    matches: Vec<usize>,
    match_pos: usize,
    /// The visible line count, set during rendering and read by navigation.
    viewport: Cell<usize>,
}

impl Pager {
    /// The largest first-line offset that still fills the viewport.
    fn max_offset(&self) -> usize {
        self.lines.len().saturating_sub(self.viewport.get().max(1))
    }

    /// The scroll position shown in the frame's bottom border, e.g. `"42%"`.
    fn percent_badge(&self) -> String {
        let percent = nav::scroll_percent(nav::ScrollView {
            total: self.lines.len(),
            offset: self.offset,
            viewport: self.viewport.get().max(1),
        });
        format!("{percent}%")
    }

    fn handle_key(&mut self, key: KeyEvent) -> PopupFlow<()> {
        let view = self.viewport.get().max(1);
        let max_offset = self.max_offset();
        if self.searching {
            self.handle_search_key(key, max_offset);
            return PopupFlow::Continue;
        }
        // The pager binds no Ctrl chord of its own, so one must not reach the
        // bare keys below: Ctrl+N would jump to the next search match, and
        // Ctrl+J/Ctrl+H arrive as plain characters and would scroll.
        if input::is_command(key) {
            return PopupFlow::Continue;
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') => return PopupFlow::Done(()),
            KeyCode::Down | KeyCode::Char('j') => {
                self.offset = (self.offset + 1).min(max_offset);
            }
            KeyCode::Up | KeyCode::Char('k') => {
                self.offset = self.offset.saturating_sub(1);
            }
            KeyCode::PageDown => {
                self.offset = (self.offset + view).min(max_offset);
            }
            KeyCode::PageUp => self.offset = self.offset.saturating_sub(view),
            KeyCode::Home | KeyCode::Char('g') => self.offset = 0,
            KeyCode::End | KeyCode::Char('G') => self.offset = max_offset,
            KeyCode::Char('/') => {
                self.searching = true;
                self.query.clear();
                self.matches.clear();
            }
            KeyCode::Char('n') if !self.matches.is_empty() => {
                self.match_pos = (self.match_pos + 1) % self.matches.len();
                self.jump_to_match(max_offset);
            }
            KeyCode::Char('N') if !self.matches.is_empty() => {
                let len = self.matches.len();
                self.match_pos = (self.match_pos + len - 1) % len;
                self.jump_to_match(max_offset);
            }
            _ => {}
        }
        PopupFlow::Continue
    }

    fn handle_search_key(&mut self, key: KeyEvent, max_offset: usize) {
        match key.code {
            KeyCode::Enter => self.searching = false,
            KeyCode::Esc => {
                self.searching = false;
                self.query.clear();
                self.matches.clear();
            }
            KeyCode::Backspace => {
                self.query.pop();
                self.recompute();
                self.jump_to_match(max_offset);
            }
            // Anything that is not a command chord is query text: without this,
            // Ctrl+U would insert a `u` instead of being left for a line-clear.
            // Deliberately not `is_bare_character`, which would also reject
            // AltGr - `@`, `\` and `[` must reach the query, exactly as in
            // `input::apply_edit_key`.
            KeyCode::Char(ch) if !input::is_command(key) => {
                self.query.push(ch);
                self.recompute();
                self.jump_to_match(max_offset);
            }
            _ => {}
        }
    }

    fn recompute(&mut self) {
        self.matches = search_matches(&self.lines, &self.query);
        self.match_pos = 0;
    }

    /// Scrolls so the current match line is in view (placed at the top).
    fn jump_to_match(&mut self, max_offset: usize) {
        if let Some(&line) = self.matches.get(self.match_pos) {
            self.offset = line.min(max_offset);
        }
    }
}

/// Shows `text` in a scrollable, searchable viewer until the user closes it.
///
/// Scroll with `j`/`k`/arrows, `PageUp`/`PageDown`, `g`/`G` (top/bottom). `/`
/// starts a case-insensitive search; `n`/`N` jump between matches. `Esc` leaves
/// the search, then the viewer.
pub fn pager(
    tui: &mut Tui,
    skin: &Skin,
    title: &str,
    text: &str,
    render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<()>> {
    let mut state = Pager {
        lines: text.lines().map(str::to_string).collect(),
        offset: 0,
        query: String::new(),
        searching: false,
        matches: Vec::new(),
        match_pos: 0,
        viewport: Cell::new(1),
    };
    popup(
        tui,
        &mut state,
        |area, _| centered_fraction(area, 3, 4, 40, 8),
        |frame, _| render_bg(frame),
        |frame, rect, state: &Pager| {
            let inner = overlay::framed(frame, rect, skin, title);
            render_body(frame, inner, skin, state);
            // `render_body` has just set the viewport, so the percentage in the
            // border matches the rows actually on screen.
            chrome::render_badge(frame, rect, skin, &state.percent_badge());
        },
        Pager::handle_key,
    )
}

/// The line indices that contain `query`, case-insensitively. An empty query
/// matches nothing.
pub(crate) fn search_matches(lines: &[String], query: &str) -> Vec<usize> {
    if query.is_empty() {
        return Vec::new();
    }
    let needle = query.to_lowercase();
    lines
        .iter()
        .enumerate()
        .filter(|(_, line)| line.to_lowercase().contains(&needle))
        .map(|(index, _)| index)
        .collect()
}

fn render_body(frame: &mut Frame, inner: Rect, skin: &Skin, state: &Pager) {
    let palette = &skin.palette;
    let lines = &state.lines;
    let offset = state.offset;
    let query = &state.query;
    let searching = state.searching;
    let viewport = &state.viewport;

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);
    let text_area = rows[0];
    let view = text_area.height as usize;
    viewport.set(view);

    let visible: Vec<Line> = lines
        .iter()
        .skip(offset)
        .take(view)
        .map(|line| highlight_line(line, query, skin))
        .collect();
    frame.render_widget(Paragraph::new(visible), text_area);
    scroll::render_scrollbar(
        frame,
        text_area,
        skin,
        nav::ScrollView {
            total: lines.len(),
            offset,
            viewport: view,
        },
    );

    let footer = if searching {
        let mut spans = vec![Span::styled("/", style::fg(palette.accent))];
        spans.extend(input::query_spans(
            query,
            palette,
            (rows[1].width as usize).saturating_sub(1),
        ));
        Line::from(spans)
    } else {
        Line::from(Span::styled(
            " j/k scroll \u{b7} / search \u{b7} n/N next \u{b7} q close",
            style::secondary(palette),
        ))
    };
    frame.render_widget(Paragraph::new(footer), rows[1]);
}

/// Highlights case-insensitive occurrences of `query` within a single line.
fn highlight_line(line: &str, query: &str, skin: &Skin) -> Line<'static> {
    if query.is_empty() {
        return Line::from(line.to_string());
    }
    let needle = query.to_lowercase();
    let haystack = line.to_lowercase();
    let accent = style::fg(skin.palette.accent).add_modifier(Modifier::BOLD);
    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut start = 0usize;
    while let Some(found) = haystack[start..].find(&needle) {
        let at = start + found;
        if at > start {
            spans.push(Span::raw(line[start..at].to_string()));
        }
        let end = at + needle.len();
        spans.push(Span::styled(line[at..end].to_string(), accent));
        start = end;
    }
    if start < line.len() {
        spans.push(Span::raw(line[start..].to_string()));
    }
    Line::from(spans)
}

#[cfg(test)]
mod tests {
    use crossterm::event::KeyModifiers;

    use super::*;

    fn lines() -> Vec<String> {
        ["alpha", "Beta line", "gamma", "beta again"]
            .iter()
            .map(|s| (*s).to_string())
            .collect()
    }

    fn ctrl(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::CONTROL)
    }

    /// A pager over `lines()` with a one-row viewport, so every line scrolls.
    fn pager_state() -> Pager {
        Pager {
            lines: lines(),
            offset: 0,
            query: String::new(),
            searching: false,
            matches: Vec::new(),
            match_pos: 0,
            viewport: Cell::new(1),
        }
    }

    #[test]
    fn search_is_case_insensitive() {
        assert_eq!(search_matches(&lines(), "beta"), vec![1, 3]);
    }

    #[test]
    fn empty_query_matches_nothing() {
        assert!(search_matches(&lines(), "").is_empty());
    }

    #[test]
    fn ctrl_chords_do_not_scroll_or_jump_between_matches() {
        let mut state = pager_state();
        // Crossterm reports Ctrl+J as `Char('j') + CONTROL`, so a bare match on
        // the code alone would scroll.
        state.handle_key(ctrl(KeyCode::Char('j')));
        assert_eq!(state.offset, 0);
        state.handle_key(ctrl(KeyCode::Char('G')));
        assert_eq!(state.offset, 0);

        // With matches loaded, Ctrl+N must not step to the next one.
        state.query = "beta".to_string();
        state.recompute();
        state.handle_key(ctrl(KeyCode::Char('n')));
        assert_eq!(state.match_pos, 0);
        state.handle_key(ctrl(KeyCode::Char('N')));
        assert_eq!(state.match_pos, 0);
    }

    #[test]
    fn a_ctrl_chord_does_not_type_into_the_search_query() {
        let mut state = pager_state();
        state.searching = true;
        state.handle_key(ctrl(KeyCode::Char('u')));
        assert_eq!(state.query, "", "Ctrl+U typed a character");

        // A bare character still reaches the query.
        state.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
        assert_eq!(state.query, "u");
    }

    #[test]
    fn bare_keys_still_scroll_and_open_the_search() {
        let mut state = pager_state();
        state.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
        assert_eq!(state.offset, 1);
        state.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
        assert_eq!(state.offset, 0);
        state.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
        assert!(state.searching);
    }
}