use std::num::NonZeroU64;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::backend::NvimBackend;
use super::snapshot::EditorMode;
use crate::components::events::{AppEvent, AppTx};
type Selection = ((usize, usize), (usize, usize));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuitKind {
WriteQuit,
DiscardQuit,
Command { save: bool },
}
impl QuitKind {
pub fn saves(self) -> bool {
match self {
QuitKind::WriteQuit => true,
QuitKind::DiscardQuit => false,
QuitKind::Command { save } => save,
}
}
pub fn needs_escape(self) -> bool {
matches!(self, QuitKind::Command { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NvimKeyDecision {
BufferZ,
Quit(QuitKind),
ReplayZThenForward,
Forward,
}
pub fn classify_nvim_key(
pending_z: bool,
key: &KeyEvent,
mode: &EditorMode,
cmdline: Option<&str>,
) -> NvimKeyDecision {
if pending_z {
return match key.code {
KeyCode::Char('Z') => NvimKeyDecision::Quit(QuitKind::WriteQuit),
KeyCode::Char('Q') => NvimKeyDecision::Quit(QuitKind::DiscardQuit),
_ => NvimKeyDecision::ReplayZThenForward,
};
}
if key.code == KeyCode::Char('Z') && *mode == EditorMode::Normal {
return NvimKeyDecision::BufferZ;
}
if key.code == KeyCode::Enter && *mode == EditorMode::Command {
let cmd = cmdline.unwrap_or("").trim_start_matches(':').trim();
let word = cmd.split([' ', '\t', '|']).next().unwrap_or("");
let saves = matches!(
word,
"w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
);
let quits = saves || matches!(word, "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
if quits {
return NvimKeyDecision::Quit(QuitKind::Command { save: saves });
}
}
NvimKeyDecision::Forward
}
fn needs_snapshot(pending_z: bool, key: &KeyEvent) -> bool {
!pending_z && matches!(key.code, KeyCode::Char('Z') | KeyCode::Enter)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NvimKeyResult {
Consumed,
Forwarded,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrameSync {
pub rev: Option<NonZeroU64>,
pub selection: Option<Selection>,
}
#[derive(Debug, Default)]
pub struct NvimHost {
pending_z: bool,
}
impl NvimHost {
pub fn new() -> Self {
Self::default()
}
pub fn handle_key(&mut self, nvim: &NvimBackend, key: &KeyEvent, tx: &AppTx) -> NvimKeyResult {
let decision = if needs_snapshot(self.pending_z, key) {
let snap = nvim.snapshot();
classify_nvim_key(self.pending_z, key, &snap.mode, snap.cmdline.as_deref())
} else {
classify_nvim_key(self.pending_z, key, &EditorMode::Normal, None)
};
self.pending_z = matches!(decision, NvimKeyDecision::BufferZ);
match decision {
NvimKeyDecision::BufferZ => NvimKeyResult::Consumed,
NvimKeyDecision::Quit(kind) => {
if kind.needs_escape() {
nvim.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), tx.clone());
}
if kind.saves() {
tx.send(AppEvent::Autosave).ok();
}
tx.send(AppEvent::FocusSidebar).ok();
NvimKeyResult::Consumed
}
NvimKeyDecision::ReplayZThenForward => {
nvim.handle_key(
&KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE),
tx.clone(),
);
nvim.handle_key(key, tx.clone());
NvimKeyResult::Forwarded
}
NvimKeyDecision::Forward => {
nvim.handle_key(key, tx.clone());
NvimKeyResult::Forwarded
}
}
}
pub fn frame_sync(&self, nvim: &NvimBackend, width: u16, height: u16) -> FrameSync {
nvim.maybe_resize(width, height);
let snap = nvim.snapshot();
let selection = snap.visual_selection;
let content_gen = snap.content_gen;
drop(snap);
FrameSync {
rev: NonZeroU64::new(content_gen.saturating_add(1)),
selection,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn enter() -> KeyEvent {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
#[test]
fn pending_z_then_z_is_write_quit_no_esc() {
assert_eq!(
classify_nvim_key(true, &key('Z'), &EditorMode::Normal, None),
NvimKeyDecision::Quit(QuitKind::WriteQuit)
);
}
#[test]
fn pending_z_then_q_is_quit_no_save() {
assert_eq!(
classify_nvim_key(true, &key('Q'), &EditorMode::Normal, None),
NvimKeyDecision::Quit(QuitKind::DiscardQuit)
);
}
#[test]
fn pending_z_then_other_replays() {
assert_eq!(
classify_nvim_key(true, &key('x'), &EditorMode::Normal, None),
NvimKeyDecision::ReplayZThenForward
);
}
#[test]
fn z_in_normal_buffers() {
assert_eq!(
classify_nvim_key(false, &key('Z'), &EditorMode::Normal, None),
NvimKeyDecision::BufferZ
);
}
#[test]
fn z_in_insert_forwards() {
assert_eq!(
classify_nvim_key(false, &key('Z'), &EditorMode::Insert, None),
NvimKeyDecision::Forward
);
}
#[test]
fn command_wq_saves_and_quits_with_esc() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":wq")),
NvimKeyDecision::Quit(QuitKind::Command { save: true })
);
}
#[test]
fn command_q_quits_no_save_with_esc() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q")),
NvimKeyDecision::Quit(QuitKind::Command { save: false })
);
}
#[test]
fn command_q_bang_quits() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q!")),
NvimKeyDecision::Quit(QuitKind::Command { save: false })
);
}
#[test]
fn command_bare_w_saves_and_quits() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":w")),
NvimKeyDecision::Quit(QuitKind::Command { save: true })
);
}
#[test]
fn command_write_with_filename_saves_and_quits() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":w report.md")),
NvimKeyDecision::Quit(QuitKind::Command { save: true })
);
}
#[test]
fn command_wq_with_bar_and_trailing_space() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":wq | echo hi")),
NvimKeyDecision::Quit(QuitKind::Command { save: true })
);
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":q ")),
NvimKeyDecision::Quit(QuitKind::Command { save: false })
);
}
#[test]
fn command_space_after_colon() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(": wq")),
NvimKeyDecision::Quit(QuitKind::Command { save: true })
);
}
#[test]
fn command_unknown_forwards() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Command, Some(":noh")),
NvimKeyDecision::Forward
);
}
#[test]
fn enter_in_normal_forwards() {
assert_eq!(
classify_nvim_key(false, &enter(), &EditorMode::Normal, None),
NvimKeyDecision::Forward
);
}
#[test]
fn needs_snapshot_only_for_z_and_enter_when_not_pending() {
assert!(needs_snapshot(false, &key('Z')));
assert!(needs_snapshot(false, &enter()));
assert!(!needs_snapshot(false, &key('a')));
assert!(!needs_snapshot(false, &key('Q')));
assert!(!needs_snapshot(true, &key('Z')));
assert!(!needs_snapshot(true, &enter()));
assert!(!needs_snapshot(true, &key('x')));
}
#[test]
fn regular_char_forwards() {
assert_eq!(
classify_nvim_key(false, &key('a'), &EditorMode::Insert, None),
NvimKeyDecision::Forward
);
}
}