marver 0.0.26

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! `/` to narrow a list, on every screen that has one.
//!
//! One implementation, so the key means the same thing everywhere: `/` opens a
//! field, typing narrows the rows as you go, `↵` keeps the filter and closes
//! the field, `esc` backs out — the field first, then the filter, then the
//! screen.
//!
//! Matching is case-insensitive substring against whatever the screen decides
//! is searchable, which is usually more than it displays.

use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph};

/// How tall the field is when it is open, and 0 when it is not.
pub const FIELD_HEIGHT: u16 = 3;

/// What a key did to the filter, so the screen knows what to do next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Handled {
    /// Not the filter's key; the screen should deal with it.
    No,
    /// Taken, and nothing else changed.
    Yes,
    /// Taken, and the rows need rebuilding.
    Changed,
    /// `esc` with nothing left to back out of: the screen should leave.
    Leave,
}

#[derive(Debug, Default, Clone)]
pub struct Filter {
    query: String,
    /// What the query was when the field opened, to restore on `esc`.
    restore: Option<String>,
    typing: bool,
}

impl Filter {
    /// Whether the field is open and taking every key.
    pub fn is_typing(&self) -> bool {
        self.typing
    }

    /// Whether rows are being narrowed, field open or not.
    pub fn is_active(&self) -> bool {
        !self.query.is_empty()
    }

    pub fn query(&self) -> &str {
        &self.query
    }

    /// Whether `text` passes. An empty filter passes everything.
    pub fn matches(&self, text: &str) -> bool {
        if self.query.is_empty() {
            return true;
        }
        text.to_lowercase().contains(&self.query.to_lowercase())
    }

    /// Whether any of `fields` passes, for rows that are searchable by more
    /// than one thing.
    pub fn matches_any<'a>(&self, fields: impl IntoIterator<Item = &'a str>) -> bool {
        if self.query.is_empty() {
            return true;
        }
        fields.into_iter().any(|field| self.matches(field))
    }

    pub fn clear(&mut self) {
        self.query.clear();
        self.typing = false;
        self.restore = None;
    }

    /// Offer a key to the filter.
    ///
    /// Call this before the screen's own keys. `/` opens the field; while it is
    /// open every key belongs to the filter.
    pub fn handle_key(&mut self, key: KeyEvent) -> Handled {
        let modified = key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);

        if !self.typing {
            if key.code == KeyCode::Char('/') && !modified {
                self.restore = Some(self.query.clone());
                self.typing = true;
                return Handled::Yes;
            }
            // A filter is a layer over the screen, so `esc` takes it off before
            // it takes the screen off.
            if key.code == KeyCode::Esc && self.is_active() {
                self.clear();
                return Handled::Changed;
            }
            return Handled::No;
        }

        if modified {
            // ctrl-c drops the field, as it does in every other field.
            if key.code == KeyCode::Char('c') {
                return self.cancel();
            }
            return Handled::Yes;
        }

        match key.code {
            KeyCode::Esc => self.cancel(),
            // Kept, not applied: it is applied already. This only closes the
            // field so the screen's own keys work again.
            KeyCode::Enter => {
                self.typing = false;
                self.restore = None;
                Handled::Yes
            }
            KeyCode::Char(c) => {
                self.query.push(c);
                Handled::Changed
            }
            KeyCode::Backspace => {
                self.query.pop();
                Handled::Changed
            }
            _ => Handled::Yes,
        }
    }

    /// Put the query back to what it was when the field opened.
    fn cancel(&mut self) -> Handled {
        let restored = self.restore.take().unwrap_or_default();
        let changed = restored != self.query;
        self.query = restored;
        self.typing = false;
        if changed {
            Handled::Changed
        } else {
            Handled::Yes
        }
    }

    /// How much of the screen the field wants. Zero when it is closed.
    pub fn height(&self) -> u16 {
        if self.typing { FIELD_HEIGHT } else { 0 }
    }

    /// What to add to a screen's title while a filter is on.
    pub fn label(&self) -> String {
        if self.is_active() {
            format!(" · /{}", self.query)
        } else {
            String::new()
        }
    }

    /// Draw the field, if it is open.
    pub fn render(&self, frame: &mut Frame, area: Rect) {
        if !self.typing || area.height == 0 {
            return;
        }
        frame.render_widget(
            Paragraph::new(format!("{}", self.query))
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(Color::Magenta))
                        .title(" / — ↵ to keep, esc to drop "),
                )
                .style(Style::default().add_modifier(Modifier::BOLD)),
            area,
        );
    }

    /// What to say in the footer when a filter is hiding rows.
    pub fn count_label(shown: usize, total: usize) -> String {
        if shown == total {
            total.to_string()
        } else {
            format!("{shown} of {total}")
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn typed(filter: &mut Filter, text: &str) {
        for c in text.chars() {
            filter.handle_key(key(KeyCode::Char(c)));
        }
    }

    #[test]
    fn slash_opens_the_field_and_typing_narrows_as_it_goes() {
        let mut filter = Filter::default();
        assert_eq!(filter.handle_key(key(KeyCode::Char('/'))), Handled::Yes);
        assert!(filter.is_typing());

        assert_eq!(filter.handle_key(key(KeyCode::Char('a'))), Handled::Changed);
        typed(&mut filter, "uth");

        assert_eq!(filter.query(), "auth");
        assert!(filter.matches("Fix the AUTH flow"), "case-insensitive");
        assert!(!filter.matches("something else"));
    }

    #[test]
    fn an_empty_filter_passes_everything() {
        let filter = Filter::default();
        assert!(filter.matches("anything"));
        assert!(filter.matches(""));
        assert!(!filter.is_active());
    }

    #[test]
    fn enter_keeps_the_filter_and_gives_the_keys_back() {
        let mut filter = Filter::default();
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "api");

        filter.handle_key(key(KeyCode::Enter));

        assert!(!filter.is_typing(), "the field is closed");
        assert!(filter.is_active(), "and the rows stay narrowed");
        assert_eq!(filter.query(), "api");
    }

    #[test]
    fn esc_backs_out_one_layer_at_a_time() {
        let mut filter = Filter::default();
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "api");
        filter.handle_key(key(KeyCode::Enter));

        // Typing again, then thinking better of it, returns the old query.
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "-x");
        assert_eq!(filter.query(), "api-x");
        assert_eq!(filter.handle_key(key(KeyCode::Esc)), Handled::Changed);
        assert_eq!(filter.query(), "api", "back to what it was");

        // Now esc clears the filter itself.
        assert_eq!(filter.handle_key(key(KeyCode::Esc)), Handled::Changed);
        assert!(!filter.is_active());

        // And with nothing left, the screen deals with it.
        assert_eq!(filter.handle_key(key(KeyCode::Esc)), Handled::No);
    }

    #[test]
    fn ctrl_c_drops_the_field_like_every_other_field() {
        let mut filter = Filter::default();
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "half");

        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(filter.handle_key(ctrl_c), Handled::Changed);

        assert!(!filter.is_typing());
        assert!(!filter.is_active());
    }

    #[test]
    fn the_field_swallows_the_keys_that_are_bindings_outside_it() {
        // `x` deletes and `n` opens a screen, so a field that did not take
        // every key could not spell "next".
        let mut filter = Filter::default();
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "next");

        assert_eq!(filter.query(), "next");
    }

    #[test]
    fn a_slash_inside_a_query_is_typed_rather_than_reopening() {
        let mut filter = Filter::default();
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "marver/4");

        assert_eq!(filter.query(), "marver/4");
    }

    #[test]
    fn any_of_several_fields_can_match() {
        let mut filter = Filter::default();
        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "web");

        assert!(filter.matches_any(["Fix auth", "web", "running"]));
        assert!(!filter.matches_any(["Fix auth", "api", "running"]));
    }

    #[test]
    fn the_title_says_what_is_being_filtered_and_the_count_says_how_much() {
        let mut filter = Filter::default();
        assert_eq!(filter.label(), "");
        assert_eq!(Filter::count_label(9, 9), "9");

        filter.handle_key(key(KeyCode::Char('/')));
        typed(&mut filter, "auth");

        assert_eq!(filter.label(), " · /auth");
        assert_eq!(Filter::count_label(2, 9), "2 of 9");
    }
}