tsafe-tui 1.0.10

Terminal UI for tsafe secret vault — full-screen browser with keyboard navigation, history viewer, quick-unlock
Documentation
use crossterm::event::{
    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};

use crate::app::{App, Screen, SensitiveString};
use crate::state::{dispatch_input, AppInput};

/// Apply a sequence of crossterm [`Event`]s in order, the same way the interactive
/// [`crate::run_loop`] does **after** its login-screen Enter / OS keyring branch (i.e. each
/// event is passed to [`handle_event`]). Use this in tests to exercise multi-step flows without a TTY.
///
/// Returns `true` if any call returned quit, or if [`App::screen`] becomes [`Screen::Quitting`].
pub fn feed_events(
    app: &mut App,
    events: impl IntoIterator<Item = Event>,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    for event in events {
        if handle_event(app, event, password_store) {
            return true;
        }
        if app.screen == Screen::Quitting {
            return true;
        }
    }
    false
}

/// Process one terminal event. Returns `true` if the app should quit.
pub fn handle_event(
    app: &mut App,
    event: Event,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    match event {
        // Only act on key-press events; crossterm 0.28 also emits Release/Repeat on Windows.
        Event::Key(key) if key.kind == KeyEventKind::Press => match translate_key(key) {
            Some(input) => dispatch_input(app, input, password_store),
            None => false,
        },
        Event::Mouse(mouse) => handle_mouse(app, mouse, password_store),
        _ => false,
    }
}

/// Translate a crossterm `KeyEvent` into an abstract [`AppInput`].
/// Returns `None` for keys the app does not handle (F-keys, Ctrl+other, etc.).
fn translate_key(key: KeyEvent) -> Option<AppInput> {
    if key.modifiers.contains(KeyModifiers::CONTROL) {
        return if key.code == KeyCode::Char('c') {
            Some(AppInput::CtrlC)
        } else {
            None
        };
    }
    match key.code {
        KeyCode::Char(c) => Some(AppInput::Char(c)),
        KeyCode::Enter => Some(AppInput::Enter),
        KeyCode::Esc => Some(AppInput::Esc),
        KeyCode::Backspace => Some(AppInput::Backspace),
        KeyCode::Tab => Some(AppInput::Tab),
        KeyCode::Up => Some(AppInput::Up),
        KeyCode::Down => Some(AppInput::Down),
        _ => None,
    }
}

// ── Mouse events ──────────────────────────────────────────────────────────────
fn handle_mouse(
    app: &mut App,
    mouse: MouseEvent,
    _password_store: &mut Option<SensitiveString>,
) -> bool {
    let screen = app.screen.clone();
    let (term_cols, _) = crossterm::terminal::size().unwrap_or((80, 24));
    let profile_panel_width = term_cols / 4;

    match screen {
        Screen::Dashboard => match mouse.kind {
            MouseEventKind::ScrollDown => {
                if mouse.column < profile_panel_width {
                    if !app.profiles.is_empty() {
                        app.profile_cursor = (app.profile_cursor + 1) % app.profiles.len();
                    }
                } else {
                    let len = app.filtered_keys().len();
                    if len > 0 {
                        app.secret_cursor = (app.secret_cursor + 1) % len;
                    }
                }
            }
            MouseEventKind::ScrollUp => {
                if mouse.column < profile_panel_width {
                    if !app.profiles.is_empty() {
                        app.profile_cursor =
                            (app.profile_cursor + app.profiles.len() - 1) % app.profiles.len();
                    }
                } else {
                    let len = app.filtered_keys().len();
                    if len > 0 {
                        app.secret_cursor = (app.secret_cursor + len - 1) % len;
                    }
                }
            }
            MouseEventKind::Down(MouseButton::Left) => {
                if mouse.column >= profile_panel_width && mouse.row >= 2 {
                    let index = (mouse.row as usize).saturating_sub(2);
                    let len = app.filtered_keys().len();
                    if index < len {
                        app.secret_cursor = index;
                    }
                }
            }
            _ => {}
        },
        Screen::AuditLog => match mouse.kind {
            MouseEventKind::ScrollDown => {
                if app.audit_scroll + 1 < app.audit_lines.len() {
                    app.audit_scroll += 1;
                }
            }
            MouseEventKind::ScrollUp => {
                app.audit_scroll = app.audit_scroll.saturating_sub(1);
            }
            _ => {}
        },
        Screen::History { .. } => match mouse.kind {
            MouseEventKind::ScrollDown => {
                if app.history_scroll + 1 < app.history_entries.len() {
                    app.history_scroll += 1;
                }
            }
            MouseEventKind::ScrollUp => {
                app.history_scroll = app.history_scroll.saturating_sub(1);
            }
            _ => {}
        },
        Screen::MoveSecret { .. } | Screen::NsBulk { .. } => {} // keyboard-only modals
        _ => {}
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::AppInput;
    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

    #[test]
    fn translate_key_maps_printable_and_special_keys() {
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
            Some(AppInput::Char('z'))
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
            Some(AppInput::Enter)
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
            Some(AppInput::Esc)
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
            Some(AppInput::Backspace)
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
            Some(AppInput::Tab)
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
            Some(AppInput::Up)
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
            Some(AppInput::Down)
        );
    }

    #[test]
    fn translate_key_ctrl_c_only_control_sequence() {
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            Some(AppInput::CtrlC)
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
            None
        );
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL)),
            None
        );
    }

    #[test]
    fn translate_key_ignores_unhandled_codes() {
        assert_eq!(
            translate_key(KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE)),
            None
        );
    }

    #[test]
    fn handle_event_ignores_key_release_and_repeat() {
        let mut app = crate::app::App::new();
        app.update_rx = None;
        app.update_available = None;
        app.screen = crate::app::Screen::Login;
        let mut pw: Option<crate::app::SensitiveString> = None;
        let ev_release = Event::Key(KeyEvent::new_with_kind(
            KeyCode::Char('q'),
            KeyModifiers::NONE,
            KeyEventKind::Release,
        ));
        assert!(!handle_event(&mut app, ev_release, &mut pw));
        let ev_repeat = Event::Key(KeyEvent::new_with_kind(
            KeyCode::Char('q'),
            KeyModifiers::NONE,
            KeyEventKind::Repeat,
        ));
        assert!(!handle_event(&mut app, ev_repeat, &mut pw));
    }
}