use std::io; use std::sync::Arc; use std::time::Duration; use crossterm::{ event::{self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind}, execute, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}, }; use ratatui::{Terminal, layout::Rect, style::Style, widgets::Paragraph}; use tui_textarea::CursorMove; use yame::app::{App, FileMode, get_selection_text, resolve_file_mode}; use yame::config::{LayoutConfig, Theme, load_config, write_config}; use yame::decoration::{ DecorationMap, block_highlights_to_decoration_map, build_decoration_map, count_words_plain, }; use yame::highlighting::HighlightCache; use yame::layout::{DEFAULT_MIN_COLS, compute_layout}; use yame::renderer; use yame::search::SearchState; use yame::settings::{SettingsModal, SettingsOutcome, handle_settings_key}; use yame::status::StatusMode; use super::commands::{center_scroll, clamp_scroll, handle_exit, handle_save, handle_save_to_path}; use super::picker; // --------------------------------------------------------------------------- // Key-event outcome // --------------------------------------------------------------------------- /// Signals the event loop needs to act on after `handle_key_event` returns. /// /// Keeping I/O (file saves, config reloads) out of `handle_key_event` makes /// that function fully unit-testable without a real terminal or filesystem. #[derive(Debug, PartialEq)] pub(super) enum KeyOutcome { /// Normal dispatch — state mutation complete, keep running. Continue, /// Ctrl+S / Super+S: persist buffer to disk, then keep running. Save, /// ExitPrompt Y: persist buffer to disk, then exit the loop. SaveAndExit, /// ExitPrompt N / Ctrl+Q on a clean buffer: exit without saving. Exit, /// Ctrl+R: reload config from disk and redisplay a confirmation banner. ReloadConfig, /// User confirmed a filename in the save-as prompt. Carries the typed path /// and whether to exit after saving (triggered by the exit-prompt Y path). SaveAs { filename: String, then_exit: bool }, /// Ctrl+O: suspend TUI, run the file picker, load the selected file. OpenPicker, /// F2: open the settings modal, loading current config from disk. OpenSettings, /// Settings field committed — write config to disk and apply it. CommitSettings, } // --------------------------------------------------------------------------- // Background decoration worker // --------------------------------------------------------------------------- /// Parameters shipped to the decoration worker for one decoration pass. /// /// All fields are cheaply cloneable or `Arc`-wrapped so the main thread can /// keep its own copies while the worker operates on its snapshot. struct DecorateRequest { text: String, mode: FileMode, theme: Theme, italic_support: bool, cache: Option>, } /// Run the appropriate decoration pass for the given request. fn run_decorate(req: &DecorateRequest) -> (DecorationMap, usize) { match &req.mode { FileMode::Markdown => build_decoration_map( &req.text, &req.theme, req.italic_support, req.cache.as_deref(), ), FileMode::PlainHighlight(lang) => { let map = req .cache .as_deref() .and_then(|cache| cache.highlight_block(lang, &req.text)) .map(|hl| block_highlights_to_decoration_map(&hl, 0)) .unwrap_or_default(); let wc = count_words_plain(&req.text); (map, wc) } FileMode::PlainText => (DecorationMap::default(), count_words_plain(&req.text)), } } // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- /// Map a screen-absolute (row, col) mouse position to a logical document /// (row, col) position, accounting for the editor gutter, scroll offset, and /// soft-wrapped lines. Returns `None` if the click is outside the editor area. /// /// `decoration_map` is required to look up each line's `continuation_indent` /// so that list items and blockquotes (whose continuation rows are wrapped at a /// narrower width and rendered with a visual indent) count the correct number of /// visual rows and map column positions correctly. // Timeouts under cargo-mutants because the 91 binary-crate tests run in parallel // and a slow integration test reliably pushes the suite past the budget even when // a mutation-specific unit test would fail immediately. The function is already // covered by 10+ targeted unit tests (lines 930–1164) with inline mutation notes; // mutation verification here adds no safety signal beyond those tests. #[mutants::skip] pub(super) fn screen_to_doc( screen_row: u16, screen_col: u16, editor_area: &Rect, scroll_top: usize, lines: &[String], decoration_map: &yame::decoration::DecorationMap, show_line_numbers: bool, ) -> Option<(u16, u16)> { if screen_row < editor_area.y || screen_col < editor_area.x || screen_row >= editor_area.y + editor_area.height || screen_col >= editor_area.x + editor_area.width { return None; } let left_gutter = renderer::left_gutter_width(lines.len(), show_line_numbers); let cw = (editor_area.width as usize) .saturating_sub(left_gutter as usize + renderer::GUTTER as usize) .max(1); let click_vis_row = (screen_row - editor_area.y) as usize; let click_col = screen_col.saturating_sub(editor_area.x + left_gutter) as usize; let mut vis = 0usize; for (li, line) in lines.iter().enumerate().skip(scroll_top) { // Continuation indent for this line (0 for plain paragraphs, ≥2 for // list items and blockquotes). Must match the renderer exactly so that // visual row counts are identical. let line_ci = decoration_map .get(&li) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let cont_width = cw.saturating_sub(line_ci).max(1); let wrapped = renderer::wrap_line_indented(line, cw, cont_width); let seg_count = wrapped.len().max(1); if vis + seg_count > click_vis_row { let si = click_vis_row - vis; let char_ranges = renderer::wrap_char_ranges(line, &wrapped); let seg_char_start = char_ranges.get(si).map_or(0, |&(start, _)| start); let row_str = wrapped.get(si).copied().unwrap_or(""); // Continuation rows (si > 0) are rendered with `line_ci` visual // columns of indent before the text. Subtract that offset so the // click column maps to the correct position within `row_str`. let col_in_row = if si > 0 { click_col.saturating_sub(line_ci) } else { click_col }; let chars_into_row = renderer::chars_for_display_cols(row_str, col_in_row); let doc_col = (seg_char_start + chars_into_row).min(line.chars().count()); return Some((li as u16, doc_col as u16)); } vis += seg_count; } // Click fell below all rendered lines — land at the end of the last logical // line so that clicking below-and-right of the text behaves like clicking past // the end of the last line, not the beginning of it. let last = lines.len().saturating_sub(1); let end_col = lines.get(last).map_or(0, |l| l.chars().count()); Some((last as u16, end_col as u16)) } /// Returns `true` if the key is a pure cursor-movement key that cannot change /// document content. Used to skip the decoration debounce timer on nav presses. pub(super) fn is_navigation_key(k: &crossterm::event::KeyEvent) -> bool { // Ctrl+Up/Down are handled in their own explicit arm in handle_key_event // (they scroll the viewport rather than edit), so they never reach the `_` // arm where is_navigation_key is called. Nonetheless, matching purely on // k.code (ignoring modifiers) means Ctrl+Up is still classified nav here, // which is the correct policy if the arm ordering ever changes. matches!( k.code, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown ) } /// If there is an active selection and `k` is a pair-opener, wrap the /// selection with the corresponding pair and return `true`. /// Returns `true` for key events that are safe to process in read-only mode: /// navigation, selection, copy, search, mode toggles, viewport scroll, and exit. /// Everything else (character input, backspace, paste, undo/redo, save, …) returns /// `false` and will be suppressed with a status-bar reminder. pub(super) fn is_ro_allowed(mods: KeyModifiers, code: KeyCode) -> bool { matches!( (mods, code), // ── Navigation & selection ─────────────────────────────────────────── ( _, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown, ) // ── Exit ───────────────────────────────────────────────────────────── | (KeyModifiers::NONE, KeyCode::Esc) | (KeyModifiers::CONTROL, KeyCode::Char('q')) // ── Copy (not paste) ───────────────────────────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('c')) | (KeyModifiers::SUPER, KeyCode::Char('c')) // ── Search (read-only — replace is blocked inside handle_search_key) ─ | (KeyModifiers::CONTROL, KeyCode::Char('f')) // ── Go-to-line ─────────────────────────────────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('g')) // ── Config reload ──────────────────────────────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('r')) // ── Mode toggles (typewriter, focus, read-only) ────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('t')) | (KeyModifiers::CONTROL, KeyCode::Char('d')) | (KeyModifiers::CONTROL, KeyCode::Char('e')) // ── Select all (non-destructive selection) ─────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('a')) | (KeyModifiers::SUPER, KeyCode::Char('a')) // ── Shortcuts / settings modals ──────────────────────────────────────── | (KeyModifiers::NONE, KeyCode::F(1)) | (KeyModifiers::NONE, KeyCode::F(2)) ) } pub(super) fn handle_pair_wrap(app: &mut App, k: crossterm::event::KeyEvent) -> bool { if k.modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { return false; } let (open, close) = match k.code { KeyCode::Char('(') => ('(', ')'), KeyCode::Char('[') => ('[', ']'), KeyCode::Char('{') => ('{', '}'), KeyCode::Char('"') => ('"', '"'), KeyCode::Char('\'') => ('\'', '\''), KeyCode::Char('`') => ('`', '`'), KeyCode::Char('*') => ('*', '*'), KeyCode::Char('_') => ('_', '_'), _ => return false, }; let selected = match get_selection_text(app) { Some(s) => s, None => return false, }; // Single insert_str while selection is active: delete selection (1 undo entry) + // insert wrapped text (1 undo entry) = 2 steps, down from 3 with input() + insert_str(). app.textarea.insert_str(format!("{open}{selected}{close}")); true } /// When `app.auto_close_pairs` is enabled and there is no active selection, /// handle a key event as an auto-close pair action: /// /// - Asymmetric openers `( [ {` always insert the open+close pair and place /// the cursor between them (one `Back` step). /// - Asymmetric closers `) ] }` skip over the next char if it matches; otherwise /// return `false` so the caller falls through to normal `textarea.input`. /// - Symmetric chars `" ' \` * _` skip over if the next char matches; otherwise /// insert the pair and place the cursor between them. /// /// Returns `true` when the event was fully handled, `false` when the caller /// should fall through to the normal `textarea.input` path. pub(super) fn handle_auto_close(app: &mut App, k: crossterm::event::KeyEvent) -> bool { if !app.auto_close_pairs { return false; } if k.modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { return false; } if app.textarea.selection_range().is_some() { return false; } let KeyCode::Char(c) = k.code else { return false; }; let (row, col) = app.textarea.cursor(); let next_char = app .textarea .lines() .get(row) .and_then(|line| line.chars().nth(col)); match c { // Asymmetric openers: always insert pair. '(' => { app.textarea.insert_str("()"); app.textarea.move_cursor(CursorMove::Back); true } '[' => { app.textarea.insert_str("[]"); app.textarea.move_cursor(CursorMove::Back); true } '{' => { app.textarea.insert_str("{}"); app.textarea.move_cursor(CursorMove::Back); true } // Asymmetric closers: skip-over only. ')' => { if next_char == Some(')') { app.textarea.move_cursor(CursorMove::Forward); true } else { false } } ']' => { if next_char == Some(']') { app.textarea.move_cursor(CursorMove::Forward); true } else { false } } '}' => { if next_char == Some('}') { app.textarea.move_cursor(CursorMove::Forward); true } else { false } } // Symmetric chars: skip-over if next matches, else insert pair. '"' | '\'' | '`' | '*' | '_' => { if next_char == Some(c) { app.textarea.move_cursor(CursorMove::Forward); true } else { app.textarea.insert_str(format!("{c}{c}")); app.textarea.move_cursor(CursorMove::Back); true } } _ => false, } } // --------------------------------------------------------------------------- // Search helpers (called from handle_search_key) // --------------------------------------------------------------------------- /// Replace the current match with `search.replace`, then refresh matches. fn do_replace_current(app: &mut App) { let Some((ml, ms, me)) = app.search.as_ref().and_then(|s| s.current_match()) else { return; }; let replace_text = app .search .as_ref() .map(|s| s.replace.clone()) .unwrap_or_default(); let match_len = me - ms; app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); for _ in 0..match_len { app.textarea.delete_next_char(); } app.textarea.insert_str(&replace_text); app.force_redecorate = true; app.recompute_dirty(); app.last_keystroke = Some(std::time::Instant::now()); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.update_matches(&lines); // current index now points to the next match (positions shifted after replacement). } } /// Replace every match with `search.replace`, processing from bottom to top /// so earlier char offsets stay valid as later spans are overwritten. fn do_replace_all(app: &mut App) { let Some(search) = &app.search else { return }; if search.matches.is_empty() { return; } let matches = search.matches.clone(); let replace_text = search.replace.clone(); for &(ml, ms, me) in matches.iter().rev() { app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); for _ in 0..(me - ms) { app.textarea.delete_next_char(); } app.textarea.insert_str(&replace_text); } app.force_redecorate = true; app.recompute_dirty(); app.last_keystroke = Some(std::time::Instant::now()); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.update_matches(&lines); } } // --------------------------------------------------------------------------- // Search-mode key dispatcher // --------------------------------------------------------------------------- /// Handle a key event while the search bar is active. /// /// Printable characters feed the active field. Action keys navigate matches /// or perform replacements. Ctrl+S still saves; Escape closes the bar. pub(super) fn handle_search_key(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { // Dismiss the shortcut cheatsheet on the first keypress inside search mode. if let Some(s) = &mut app.search { s.show_help = false; } match (k.modifiers, k.code) { // Close search. (KeyModifiers::NONE, KeyCode::Esc) => { app.search = None; app.search_last_typed = None; } // Save still works inside search mode. (KeyModifiers::CONTROL, KeyCode::Char('s')) | (KeyModifiers::SUPER, KeyCode::Char('s')) => { return KeyOutcome::Save; } // Next match: Ctrl+F or Enter while search field is focused. // Enter while replace field is focused: replace current then advance. (KeyModifiers::CONTROL, KeyCode::Char('f')) | (KeyModifiers::NONE, KeyCode::Enter) => { let replace_enter = k.code == KeyCode::Enter && app .search .as_ref() .is_some_and(|s| !s.focus_search && s.show_replace); if replace_enter { do_replace_current(app); } else { if let Some(s) = &mut app.search { s.next_match(); } let jump = app.search.as_ref().and_then(|s| s.current_match()); if let Some((ml, ms, _)) = jump { app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); } } } // Prev match: Shift+Enter. (KeyModifiers::SHIFT, KeyCode::Enter) => { if let Some(s) = &mut app.search { s.prev_match(); } let jump = app.search.as_ref().and_then(|s| s.current_match()); if let Some((ml, ms, _)) = jump { app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); } } // Delete last char of the active field. (KeyModifiers::NONE, KeyCode::Backspace) => { if let Some(s) = &mut app.search { let was_search = s.focus_search; s.pop_char(); if was_search { app.search_last_typed = Some(std::time::Instant::now()); } } } // Toggle regex mode — takes effect immediately (not debounced). (KeyModifiers::ALT, KeyCode::Char('r')) => { let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.regex_mode = !s.regex_mode; s.update_matches(&lines); app.search_last_typed = None; } } // Ctrl+H: toggle the replace row (blocked in read-only mode). (KeyModifiers::CONTROL, KeyCode::Char('h')) if !app.read_only => { if let Some(s) = &mut app.search { s.show_replace = !s.show_replace; s.focus_search = true; // return focus to search field } } // Tab: switch focus between search / replace fields. (KeyModifiers::NONE, KeyCode::Tab) => { if let Some(s) = &mut app.search && s.show_replace { s.focus_search = !s.focus_search; } } // Ctrl+A: replace all (only active when the replace row is visible). (KeyModifiers::CONTROL, KeyCode::Char('a')) if app.search.as_ref().is_some_and(|s| s.show_replace) => { do_replace_all(app); } // Printable character with no control modifier → feed active field. _ if !has_ctrl_alt_super(k.modifiers) && matches!(k.code, KeyCode::Char(_)) => { if let KeyCode::Char(c) = k.code && let Some(s) = &mut app.search { let was_search = s.focus_search; s.push_char(c); if was_search { app.search_last_typed = Some(std::time::Instant::now()); } } } // Swallow everything else so stray keys don't reach the editor. _ => {} } KeyOutcome::Continue } // --------------------------------------------------------------------------- // Go-to-line key dispatcher // --------------------------------------------------------------------------- /// Handle a key event while the go-to-line prompt is active. /// /// Only digit characters are accepted into the input buffer; Backspace edits it; /// Enter commits the jump; Esc cancels without moving the cursor. pub(super) fn handle_goto_line_key(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { match (k.modifiers, k.code) { (KeyModifiers::NONE, KeyCode::Esc) => { app.status.mode = StatusMode::Normal; } (KeyModifiers::NONE, KeyCode::Enter) => { // Parse the buffered digits and jump, then always close the prompt. // Scroll clamping is handled automatically on the next frame because // `free_scroll` is always false after a key press. if let Some(raw) = app.status.goto_input() && let Ok(n) = raw.parse::() && n >= 1 { let max_line = app.textarea.lines().len().saturating_sub(1); let target = (n - 1).min(max_line); app.textarea.move_cursor(CursorMove::Jump(target as u16, 0)); } app.status.mode = StatusMode::Normal; } (KeyModifiers::NONE, KeyCode::Backspace) => { app.status.goto_pop(); } // Accept digits only; silently ignore everything else. (KeyModifiers::NONE, KeyCode::Char(c)) if c.is_ascii_digit() => { app.status.goto_push(c); } _ => {} } KeyOutcome::Continue } // --------------------------------------------------------------------------- // Save-as key dispatcher // --------------------------------------------------------------------------- /// Handle a key event while the save-as prompt is active (untitled buffer). /// /// Printable characters are appended to the filename buffer; Backspace edits /// it; Enter confirms (emitting `KeyOutcome::SaveAs`); Esc cancels and returns /// to `Normal` mode without saving. pub(super) fn handle_save_as_key(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { match (k.modifiers, k.code) { (KeyModifiers::NONE, KeyCode::Esc) => { app.status.mode = StatusMode::Normal; } (KeyModifiers::NONE, KeyCode::Enter) => { if let Some((input, then_exit)) = app.status.save_as_state() { let filename = input.trim().to_string(); if filename.is_empty() { // Nothing typed — stay in the prompt. return KeyOutcome::Continue; } app.status.mode = StatusMode::Normal; return KeyOutcome::SaveAs { filename, then_exit, }; } } (KeyModifiers::NONE, KeyCode::Backspace) => { app.status.save_as_pop(); } // Accept any printable character. (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => { app.status.save_as_push(c); } _ => {} } KeyOutcome::Continue } // --------------------------------------------------------------------------- // Key-event dispatcher (pure — no file I/O, no terminal I/O) // --------------------------------------------------------------------------- /// Dispatch a single key event, mutating `app` state. /// /// Returns `true` when `mods` contains any of CONTROL, ALT, or SUPER. /// /// Extracted so that `#[mutants::skip]` can suppress the undetectable `| → ^` /// mutations: for distinct-bit flags `a | b | c == a ^ b ^ c`, so no test can /// distinguish them. #[mutants::skip] fn has_ctrl_alt_super(mods: KeyModifiers) -> bool { mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) } /// Select every character in the buffer and return the total line count. fn select_all(app: &mut App) -> usize { let n = app.textarea.lines().len(); app.textarea.cancel_selection(); app.textarea.move_cursor(CursorMove::Top); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::Bottom); app.textarea.move_cursor(CursorMove::End); n } /// Returns a [`KeyOutcome`] telling the caller what (if any) I/O action to /// perform next. File writes, config reloads, and loop termination are the /// responsibility of the caller (`event_loop`). This separation makes the /// entire key-dispatch path unit-testable without a real terminal or filesystem. pub(super) fn handle_key_event(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { // Any key press re-engages cursor-clamping scroll. // Ctrl+Up/Down immediately override this below by setting free_scroll = true again. app.free_scroll = false; // ── Exit-prompt mode ──────────────────────────────────────────────────── if matches!(app.status.mode, StatusMode::ExitPrompt) { // Guard the destructive y/n responses: Ctrl+Y must not trigger SaveAndExit // and Ctrl+N must not trigger Exit. Other modifier+char combos (Ctrl+C, // Ctrl+Q …) still pass through so they continue to act as cancel shortcuts. if has_ctrl_alt_super(k.modifiers) && matches!(k.code, KeyCode::Char('y') | KeyCode::Char('n')) { return KeyOutcome::Continue; } return match k.code { KeyCode::Char('y') | KeyCode::Char('Y') => { if app.file_path.is_none() { // Untitled buffer — prompt for a filename; exit after save. app.status.start_save_as(true); KeyOutcome::Continue } else { KeyOutcome::SaveAndExit } } KeyCode::Char('n') | KeyCode::Char('N') => KeyOutcome::Exit, KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => { app.status.mode = StatusMode::Normal; KeyOutcome::Continue } _ => KeyOutcome::Continue, }; } // ── Shortcuts modal — swallow all keys; Esc or F1 closes it ───────────── if app.show_shortcuts { match (k.modifiers, k.code) { (KeyModifiers::NONE, KeyCode::Esc) | (KeyModifiers::NONE, KeyCode::F(1)) => { app.show_shortcuts = false; } _ => {} } return KeyOutcome::Continue; } // ── Settings modal — delegate to settings key handler ──────────────────── if app.settings.is_some() { use yame::settings::VISIBLE_FIELDS; let outcome = { let modal = app.settings.as_mut().unwrap(); handle_settings_key(modal, k, VISIBLE_FIELDS) }; return match outcome { SettingsOutcome::Continue => KeyOutcome::Continue, SettingsOutcome::Close => { app.settings = None; KeyOutcome::Continue } SettingsOutcome::Committed => KeyOutcome::CommitSettings, }; } // ── Save-as mode — intercepts all keys while the prompt is open ───── if matches!(app.status.mode, StatusMode::SaveAs { .. }) { return handle_save_as_key(app, k); } // ── Switch-file prompt — dirty-buffer confirmation for Ctrl+O ──────── if matches!(app.status.mode, StatusMode::SwitchFilePrompt) { return match k.code { KeyCode::Char('y') | KeyCode::Char('Y') => { app.status.mode = StatusMode::Normal; KeyOutcome::OpenPicker } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => { app.status.mode = StatusMode::Normal; KeyOutcome::Continue } _ => KeyOutcome::Continue, }; } // ── Go-to-line mode — intercepts all keys while the prompt is open ──── if matches!(app.status.mode, StatusMode::GoToLine { .. }) { return handle_goto_line_key(app, k); } // ── Search mode — intercepts all keys while the bar is open ──────────── if app.search.is_some() { return handle_search_key(app, k); } // ── Read-only guard ───────────────────────────────────────────────────── if app.read_only && !is_ro_allowed(k.modifiers, k.code) { app.status .set_timed("Read-only — ^Q to quit", Duration::from_secs(2)); return KeyOutcome::Continue; } // ── Normal editing mode ───────────────────────────────────────────────── match (k.modifiers, k.code) { (KeyModifiers::CONTROL, KeyCode::Char('s')) | (KeyModifiers::SUPER, KeyCode::Char('s')) => { if app.file_path.is_none() { // Untitled buffer — open the save-as prompt instead. app.status.start_save_as(false); KeyOutcome::Continue } else { KeyOutcome::Save } } // Ctrl+O / Cmd+O: open file picker. If the current buffer is dirty, // ask the user to confirm discarding changes before suspending the TUI. (KeyModifiers::CONTROL, KeyCode::Char('o')) | (KeyModifiers::SUPER, KeyCode::Char('o')) => { if app.is_dirty { app.status.mode = StatusMode::SwitchFilePrompt; KeyOutcome::Continue } else { KeyOutcome::OpenPicker } } // Open search bar. (KeyModifiers::CONTROL, KeyCode::Char('f')) => { let (cur_line, cur_col) = app.textarea.cursor(); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = SearchState::new(false); s.update_matches(&lines); s.snap_to_cursor(cur_line, cur_col); app.search = Some(s); KeyOutcome::Continue } // Open search-and-replace bar. (KeyModifiers::CONTROL, KeyCode::Char('h')) => { let (cur_line, cur_col) = app.textarea.cursor(); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = SearchState::new(true); s.update_matches(&lines); s.snap_to_cursor(cur_line, cur_col); app.search = Some(s); KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Char('q')) | (KeyModifiers::NONE, KeyCode::Esc) => { if handle_exit(app) { KeyOutcome::Exit } else { KeyOutcome::Continue } } (KeyModifiers::CONTROL, KeyCode::Char('a')) | (KeyModifiers::SUPER, KeyCode::Char('a')) => { let n = select_all(app); app.status.set_timed( yame::clipboard::lines_msg("Selected", n), Duration::from_secs(2), ); KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Char('c')) | (KeyModifiers::SUPER, KeyCode::Char('c')) => { yame::clipboard::handle_copy(app); KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Char('x')) | (KeyModifiers::SUPER, KeyCode::Char('x')) => { yame::clipboard::handle_cut(app); app.force_redecorate = true; app.recompute_dirty(); KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Char('v')) | (KeyModifiers::SUPER, KeyCode::Char('v')) => { yame::clipboard::handle_paste(app); app.force_redecorate = true; KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Char('z')) | (KeyModifiers::SUPER, KeyCode::Char('z')) => { app.status.dismiss(); app.config_warnings.clear(); app.textarea.undo(); app.force_redecorate = true; app.last_keystroke = Some(std::time::Instant::now()); app.recompute_dirty(); KeyOutcome::Continue } // Ctrl+Y / Cmd+Y / Cmd+Shift+Z (macOS convention) → redo. (KeyModifiers::CONTROL, KeyCode::Char('y')) | (KeyModifiers::SUPER, KeyCode::Char('y')) => { app.status.dismiss(); app.config_warnings.clear(); app.textarea.redo(); app.force_redecorate = true; app.last_keystroke = Some(std::time::Instant::now()); app.recompute_dirty(); KeyOutcome::Continue } // Cmd+Shift+Z → redo (macOS standard; SUPER|SHIFT can't be an or-pattern). (mods, KeyCode::Char('z')) if mods == KeyModifiers::SUPER | KeyModifiers::SHIFT => { app.status.dismiss(); app.config_warnings.clear(); app.textarea.redo(); app.force_redecorate = true; app.last_keystroke = Some(std::time::Instant::now()); app.recompute_dirty(); KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Char('g')) => { app.status.start_goto_line(); KeyOutcome::Continue } // Ctrl+T: toggle typewriter mode (keep cursor line centred in viewport). (KeyModifiers::CONTROL, KeyCode::Char('t')) => { app.typewriter_mode = !app.typewriter_mode; KeyOutcome::Continue } // Ctrl+D: toggle focus mode (dim lines outside the current paragraph). (KeyModifiers::CONTROL, KeyCode::Char('d')) => { app.focus_mode = !app.focus_mode; KeyOutcome::Continue } // Ctrl+E: toggle read-only mode on/off without exiting. (KeyModifiers::CONTROL, KeyCode::Char('e')) => { app.read_only = !app.read_only; let msg = if app.read_only { "Read-only on — editing disabled" } else { "Editing enabled" }; app.status.set_timed(msg, Duration::from_secs(2)); KeyOutcome::Continue } // F1: open the full keybindings reference modal. (KeyModifiers::NONE, KeyCode::F(1)) => { app.show_shortcuts = true; KeyOutcome::Continue } // F2: open the settings modal. (KeyModifiers::NONE, KeyCode::F(2)) => KeyOutcome::OpenSettings, (KeyModifiers::CONTROL, KeyCode::Char('r')) => KeyOutcome::ReloadConfig, // Alt+T / Option+T: reflow the GFM table under the cursor to uniform column widths. // On macOS, most terminals (Ghostty, Terminal.app) send '†' (U+2020, DAGGER) when // Option+T is pressed instead of a proper Alt sequence. Both forms are caught here // so the binding works regardless of whether the terminal is configured to send Meta. (KeyModifiers::ALT, KeyCode::Char('t')) | (KeyModifiers::NONE, KeyCode::Char('†')) => { yame::table_format::handle_format_table(app); KeyOutcome::Continue } // Ctrl+K: insert a fenced code block (```\n```) and position the cursor // right after the opening ``` so the language tag can be typed immediately. // // Note: Ctrl+I cannot be used — terminals send it as ASCII 0x09 (Tab). // Alt+` is unreliable on macOS because Option produces special characters // unless the terminal is configured to use it as Meta. Ctrl+K is a plain // letter combo that every terminal delivers reliably. (KeyModifiers::CONTROL, KeyCode::Char('k')) => { let (row, col) = app.textarea.cursor(); app.textarea.insert_str("```\n```"); app.textarea .move_cursor(CursorMove::Jump(row as u16, (col + 3) as u16)); app.force_redecorate = true; app.mark_keystroke(); KeyOutcome::Continue } // Ctrl+Up/Down: scroll viewport without moving cursor. (KeyModifiers::CONTROL, KeyCode::Up) => { app.scroll_top = app.scroll_top.saturating_sub(1); app.free_scroll = true; KeyOutcome::Continue } (KeyModifiers::CONTROL, KeyCode::Down) => { let max = app.textarea.lines().len().saturating_sub(1); app.scroll_top = (app.scroll_top + 1).min(max); app.free_scroll = true; KeyOutcome::Continue } // Visual-line Up/Down: step by displayed row, not by logical line. (KeyModifiers::NONE, KeyCode::Down) => handle_visual_move(app, true, false), (KeyModifiers::NONE, KeyCode::Up) => handle_visual_move(app, false, false), (KeyModifiers::SHIFT, KeyCode::Down) => handle_visual_move(app, true, true), (KeyModifiers::SHIFT, KeyCode::Up) => handle_visual_move(app, false, true), // Tab: expand to spaces aligned to the next tab stop rather than inserting // a raw '\t'. Raw tabs break cursor positioning, selection, and soft-wrap // because the layout engine assumes every character is 1 display column wide. (KeyModifiers::NONE, KeyCode::Tab) => { let (_, col) = app.textarea.cursor(); let tw = app.tab_width; let spaces = tw - (col % tw); for _ in 0..spaces { app.textarea.input(crossterm::event::KeyEvent::new( KeyCode::Char(' '), KeyModifiers::NONE, )); } app.mark_keystroke(); KeyOutcome::Continue } (KeyModifiers::NONE, KeyCode::Enter) => { app.sticky_col = None; app.status.dismiss(); app.config_warnings.clear(); handle_enter(app) } _ => { // Any non-vertical-nav key ends the sticky-column gesture. app.sticky_col = None; app.status.dismiss(); app.config_warnings.clear(); let is_nav = is_navigation_key(&k); if !is_nav && (handle_pair_wrap(app, k) || handle_auto_close(app, k)) { app.force_redecorate = true; app.mark_keystroke(); } else { let prev_line_count = app.textarea.lines().len(); app.textarea.input(k); if app.textarea.lines().len() != prev_line_count { app.force_redecorate = true; } if !is_nav { app.mark_keystroke(); } // Navigation keys cannot mutate content, so is_dirty cannot change; // skipping recompute_dirty() here avoids an O(N) line comparison on // every arrow-key press for large documents. } KeyOutcome::Continue } } } // --------------------------------------------------------------------------- // List continuation // --------------------------------------------------------------------------- /// Detect the list item prefix at the start of `line`. /// /// Returns `(current_prefix, next_prefix)` where `current_prefix` is the /// exact prefix characters on this line (e.g., `"- "`, `" 1. "`, `"- [ ] "`) /// and `next_prefix` is the prefix to insert on the continuation line (same /// for unordered; incremented number for ordered; always unchecked for tasks). /// /// Returns `None` if the line does not start with a list item marker. pub(super) fn detect_list_prefix(line: &str) -> Option<(String, String)> { let indent_len = line.len() - line.trim_start_matches(' ').len(); let indent = &line[..indent_len]; let rest = &line[indent_len..]; // Task list (check before plain unordered to avoid `- [ ]` matching as `- `). // Pattern: `[-*+] [ ] `, `[-*+] [x] `, `[-*+] [X] ` if let Some(bullet) = rest.chars().next().filter(|c| matches!(c, '-' | '*' | '+')) { let after_bullet = &rest[1..]; // all are ASCII (len_utf8 == 1) for mark in &[" [ ] ", " [x] ", " [X] "] { if after_bullet.starts_with(mark) { let current = format!("{indent}{bullet}{mark}"); let next = format!("{indent}{bullet} [ ] "); return Some((current, next)); } } // Plain unordered. if after_bullet.starts_with(' ') { let prefix = format!("{indent}{bullet} "); return Some((prefix.clone(), prefix)); } } // Ordered list: one or more digits, then '.' or ')', then a space. let digit_end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(0); if digit_end > 0 { let digits = &rest[..digit_end]; let after_digits = &rest[digit_end..]; if let Some(punct) = after_digits .chars() .next() .filter(|p| matches!(p, '.' | ')') && after_digits[1..].starts_with(' ')) { let num: u64 = digits.parse().unwrap_or(1); let current = format!("{indent}{digits}{punct} "); let next = format!("{indent}{}{punct} ", num + 1); return Some((current, next)); } } None } /// Handle the Enter key in normal editing mode, with list continuation. /// /// - If the cursor is on a list item whose content after the prefix is empty, /// the prefix is stripped from the current line (exit list). /// - If the cursor is on a list item with content, a newline is inserted /// followed by the continuation prefix (ordered numbers increment). /// - Otherwise falls through to a plain newline. fn handle_enter(app: &mut App) -> KeyOutcome { // Don't intercept Enter while a selection is active — let tui-textarea // handle the delete-selection-then-newline sequence normally. if app.textarea.selection_range().is_some() { app.textarea.insert_newline(); app.force_redecorate = true; app.mark_keystroke(); return KeyOutcome::Continue; } let (row, _col) = app.textarea.cursor(); let line = app .textarea .lines() .get(row) .map(|s| s.as_str()) .unwrap_or("") .to_owned(); if let Some((current_prefix, next_prefix)) = detect_list_prefix(&line) { let content_after = &line[current_prefix.len()..]; if content_after.trim().is_empty() { // Empty list item → exit list: remove the prefix, leave a blank line. let prefix_char_count = current_prefix.chars().count(); app.textarea .move_cursor(CursorMove::Jump(row as u16, prefix_char_count as u16)); app.textarea.delete_line_by_head(); } else { // List item with content → insert newline then continuation prefix. app.textarea.insert_newline(); app.textarea.insert_str(&next_prefix); } app.force_redecorate = true; app.mark_keystroke(); return KeyOutcome::Continue; } // Plain Enter — inherit leading whitespace from the current line, then newline. let indent: String = line .chars() .take_while(|c| *c == ' ' || *c == '\t') .collect(); // "Exit indent" — Enter on a pure-whitespace line strips one indent level // instead of producing another indented blank line (mirrors list exit). if !indent.is_empty() && indent.len() == line.len() { let strip = if indent.starts_with('\t') { 1 } else { app.tab_width.min(indent.len()) }; app.textarea .move_cursor(CursorMove::Jump(row as u16, strip as u16)); app.textarea.delete_line_by_head(); app.force_redecorate = true; app.mark_keystroke(); return KeyOutcome::Continue; } let prev_line_count = app.textarea.lines().len(); app.textarea.insert_newline(); if app.textarea.lines().len() != prev_line_count { app.force_redecorate = true; } if !indent.is_empty() { app.textarea.insert_str(&indent); } app.mark_keystroke(); KeyOutcome::Continue } // --------------------------------------------------------------------------- // Visual-line navigation // --------------------------------------------------------------------------- /// Move the cursor one visual row up (`go_down = false`) or down (`go_down = /// true`), honouring soft-wrap so the cursor steps through displayed rows /// rather than jumping over wrapped text to the next logical line. /// /// Uses `app.content_width` (kept current by the event loop) and /// `app.decoration_map` (for continuation-indent widths on list/blockquote /// lines) to use exactly the same wrapping as the renderer. /// /// `app.sticky_col` is set on the first call of a vertical gesture and /// preserved on subsequent Up/Down presses; any other key clears it (see the /// `_` arm of `handle_key_event`). fn handle_visual_move(app: &mut App, go_down: bool, selecting: bool) -> KeyOutcome { let cw = app.content_width; if cw == 0 { // Geometry not yet known (before first render); fall back to native. let code = if go_down { KeyCode::Down } else { KeyCode::Up }; let mods = if selecting { KeyModifiers::SHIFT } else { KeyModifiers::NONE }; app.textarea .input(crossterm::event::KeyEvent::new(code, mods)); return KeyOutcome::Continue; } let (cur_row, cur_col) = app.textarea.cursor(); let lines = app.textarea.lines(); // Wrap widths for the current logical line (matches renderer). let cur_ci = app .decoration_map .get(&cur_row) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let cur_cont = cw.saturating_sub(cur_ci).max(1); let cur_line = lines.get(cur_row).map_or("", |s| s.as_str()); let (cur_subrow, _cur_char_start, cur_total) = renderer::cursor_subrow_info(cur_line, cur_col, cw, cur_cont); // Establish (or recover) the sticky column for this gesture. // Stored in display columns (not char count) so wide chars are handled correctly. let vcol = *app .sticky_col .get_or_insert_with(|| renderer::cursor_vcol(cur_line, cur_col, cw, cur_cont)); // Determine target (logical row, subrow-within-that-row). let (tgt_row, tgt_subrow) = if go_down { if cur_subrow + 1 < cur_total { (cur_row, cur_subrow + 1) } else if cur_row + 1 < lines.len() { (cur_row + 1, 0) } else { return KeyOutcome::Continue; // already at last visual row } } else { if cur_subrow > 0 { (cur_row, cur_subrow - 1) } else if cur_row > 0 { let prev = cur_row - 1; let prev_ci = app .decoration_map .get(&prev) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let prev_cont = cw.saturating_sub(prev_ci).max(1); let prev_line = lines.get(prev).map_or("", |s| s.as_str()); let prev_total = renderer::wrap_line_indented(prev_line, cw, prev_cont) .len() .max(1); // Equivalent-mutant note: `- → +` yields prev_total + 1 and `- → /` // yields prev_total / 1 = prev_total. Both are clamped to the last // valid subrow index by `char_col_at_visual` (which does // `target_subrow.min(last_idx)`), so no test can distinguish them from // `prev_total - 1`. (prev, prev_total - 1) } else { return KeyOutcome::Continue; // already at first visual row } }; // Convert (tgt_subrow, vcol) → logical char column in the target line. let tgt_line = lines.get(tgt_row).map_or("", |s| s.as_str()); let tgt_ci = app .decoration_map .get(&tgt_row) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let tgt_cont = cw.saturating_sub(tgt_ci).max(1); let tgt_col = renderer::char_col_at_visual(tgt_line, tgt_subrow, vcol, cw, tgt_cont); // Apply or extend selection. if selecting { if app.textarea.selection_range().is_none() { app.textarea.start_selection(); } } else { app.textarea.cancel_selection(); } app.textarea .move_cursor(CursorMove::Jump(tgt_row as u16, tgt_col as u16)); KeyOutcome::Continue } // --------------------------------------------------------------------------- // Event loop // --------------------------------------------------------------------------- /// Apply a committed settings change: write config to disk, rebuild theme, and send a fresh /// decoration request. Shared by the keyboard CommitSettings path and mouse click activation. #[mutants::skip] // Performs disk I/O (write_config) and terminal-state mutation. fn do_commit_settings(app: &mut App, deco_tx: &std::sync::mpsc::Sender) { let (cfg, new_tw, new_fm, new_ro) = match &app.settings { Some(m) => ( m.config.clone(), m.typewriter_mode, m.focus_mode, m.read_only, ), None => return, }; match write_config(&cfg) { Ok(()) => { let mut warnings = Vec::new(); app.theme = Theme::from_config(&cfg.palette, &cfg.theme, &cfg.headings, &mut warnings); app.highlight_cache = cfg.highlighting.enabled.then(|| { let palette_theme = cfg .highlighting .use_palette_colors .then(|| yame::highlighting::build_palette_theme(&app.theme)); Arc::new(yame::highlighting::HighlightCache::new( true, cfg.highlighting.syntect_theme.clone(), palette_theme, )) }); if let Some(p) = &app.file_path { app.file_mode = resolve_file_mode(p, &cfg.filetype); } app.show_line_numbers = cfg.layout.line_numbers.unwrap_or(false); app.powerline_glyphs = cfg.layout.powerline_glyphs.unwrap_or(true); app.tab_width = cfg.layout.tab_width.unwrap_or(4) as usize; app.auto_close_pairs = cfg.layout.auto_close_pairs.unwrap_or(false); app.autosave_secs = cfg.layout.autosave_seconds.map(|n| n as u64).unwrap_or(0); app.typewriter_mode = new_tw; app.focus_mode = new_fm; app.read_only = new_ro; app.config_warnings = warnings; // Send a fresh decoration request immediately so the new theme is applied without // waiting for the DEBOUNCE timer. We do NOT clear decoration_map or drain deco_rx // — the worker's own "latest wins" drain handles rapid cycling, and clearing would // cause a visible flash to unstyled text. let _ = deco_tx.send(DecorateRequest { text: app.textarea.lines().join("\n"), mode: app.file_mode.clone(), theme: app.theme.clone(), italic_support: app.italic_support, cache: app.highlight_cache.clone(), }); // Refresh the modal's resolved theme so derived defaults update immediately. if let Some(modal) = app.settings.as_mut() { modal.resolved = app.theme.clone(); } } Err(e) => { app.status .set_timed(format!("⚠ Settings error: {e}"), Duration::from_secs(3)); } } } #[mutants::skip] // Terminal I/O loop — requires a real terminal backend + live event stream; not unit-testable. pub(super) fn event_loop( terminal: &mut Terminal, app: &mut App, layout_config: &LayoutConfig, ) -> io::Result<()> where io::Error: From, { const POLL_TIMEOUT: Duration = Duration::from_millis(16); const DEBOUNCE: Duration = Duration::from_millis(50); const SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); const BOTTOM_PADDING: usize = 3; const SCROLL_LINES: usize = 3; let min_cols = layout_config.min_cols.unwrap_or(DEFAULT_MIN_COLS); let max_cols = layout_config.max_cols; // Spawn a persistent background decoration worker. // // The worker receives `DecorateRequest` messages and sends back // `(DecorationMap, usize)` results. Before starting each job it drains // any superseded requests ("latest wins") so rapid typing never queues up // stale work. The thread exits automatically when `deco_tx` is dropped // (i.e. when the event loop returns). let (deco_tx, deco_work_rx) = std::sync::mpsc::channel::(); let (deco_result_tx, deco_rx) = std::sync::mpsc::channel::<(DecorationMap, usize)>(); std::thread::Builder::new() .name("deco-worker".into()) .spawn(move || { while let Ok(req) = deco_work_rx.recv() { // Drain any superseded requests — only the latest matters. let req = std::iter::once(req) .chain(deco_work_rx.try_iter()) .last() .unwrap(); // always at least one element let result = run_decorate(&req); if deco_result_tx.send(result).is_err() { break; // main thread gone, exit cleanly } } }) .expect("failed to spawn decoration worker"); // force_redecorate=true (set in App::new) triggers an immediate background // decoration request on the first loop iteration. Text is visible from the // first draw; decorations arrive within one or two frames. let mut last_editor_area = Rect::default(); let mut drag_selecting = false; let mut last_autosave = std::time::Instant::now(); loop { // Apply the latest completed decoration result from the background worker. // Drain all pending results and keep only the freshest one. let latest = std::iter::from_fn(|| deco_rx.try_recv().ok()).last(); if let Some((map, wc)) = latest { app.decoration_map = map; app.word_count = wc; } if app.force_redecorate || app.last_keystroke.is_some_and(|t| t.elapsed() >= DEBOUNCE) { let _ = deco_tx.send(DecorateRequest { text: app.textarea.lines().join("\n"), mode: app.file_mode.clone(), theme: app.theme.clone(), italic_support: app.italic_support, cache: app.highlight_cache.clone(), }); app.last_keystroke = None; app.force_redecorate = false; } if app .search_last_typed .is_some_and(|t| t.elapsed() >= SEARCH_DEBOUNCE) { let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.update_matches(&lines); } app.search_last_typed = None; } // Autosave: fire when enabled, dirty, file known, and interval elapsed. if app.autosave_secs > 0 && app.is_dirty && app.file_path.is_some() && last_autosave.elapsed() >= Duration::from_secs(app.autosave_secs) { handle_save(app)?; last_autosave = std::time::Instant::now(); } app.status.tick(); // Pre-draw scroll clamp { let term_size = terminal.size()?; let term_area = Rect::new(0, 0, term_size.width, term_size.height); let pre_layout = compute_layout(term_area, min_cols, max_cols); // Account for warning banner and search bar when computing the // editor area so scroll clamping uses the same geometry as the draw. let warn_rows = u16::from(!app.config_warnings.is_empty() && pre_layout.column.height > 0); let sb_rows = renderer::search_bar_height(app); let top_rows = warn_rows + sb_rows; let pre_editor_area = Rect { y: pre_layout.column.y + top_rows, height: pre_layout.column.height.saturating_sub(top_rows), ..pre_layout.column }; // Keep content_width current so handle_visual_move wraps identically // to the renderer. Computed here (pre-draw) so it is valid before // the first key event arrives. let left_gutter = renderer::left_gutter_width(app.textarea.lines().len(), app.show_line_numbers); app.content_width = (pre_editor_area.width as usize) .saturating_sub(left_gutter as usize + renderer::GUTTER as usize) .max(1); // Clamp is skipped while the user is free-scrolling (mouse wheel or // Ctrl+Up/Down). free_scroll persists until a key press, mouse click, // drag, or terminal resize clears it (scroll and hover events do not). // In typewriter mode the cursor line is kept vertically centred instead // of merely kept in-viewport. Free-scroll still works; the view // re-centres on the next non-scroll event. if !app.free_scroll { if app.typewriter_mode { center_scroll(app, pre_editor_area); } else { clamp_scroll(app, pre_editor_area, BOTTOM_PADDING); } } } execute!(io::stdout(), BeginSynchronizedUpdate)?; terminal.draw(|f| { let layout = compute_layout(f.area(), min_cols, max_cols); let content_bg_area = Rect { x: layout.full.x, y: layout.full.y, width: layout.full.width, height: layout.column.height, }; f.render_widget( Paragraph::new("").style(Style::default().bg(app.theme.bg)), content_bg_area, ); // Warning banner (if any) — always the topmost row of the column. let after_warn = if !app.config_warnings.is_empty() && layout.column.height > 0 { let warn_area = Rect { height: 1, ..layout.column }; let msg = format!(" ⚠ {} [any key to dismiss]", app.config_warnings[0]); f.render_widget( Paragraph::new(msg) .style(Style::default().fg(app.theme.warning).bg(app.theme.ui_bar)), warn_area, ); Rect { y: layout.column.y + 1, height: layout.column.height.saturating_sub(1), ..layout.column } } else { layout.column }; // Search bar — rendered below any warning banner, above the editor. let sb_rows = renderer::search_bar_height(app); let editor_area = if sb_rows > 0 && after_warn.height > sb_rows { let sb_area = Rect { x: layout.full.x, y: after_warn.y, width: layout.full.width, height: sb_rows, }; renderer::render_search_bar(f, sb_area, app); Rect { y: after_warn.y + sb_rows, height: after_warn.height.saturating_sub(sb_rows), ..after_warn } } else { after_warn }; let (search_matches, search_current) = app .search .as_ref() .map(|s| (s.matches.as_slice(), s.current)) .unwrap_or((&[], 0)); let focus_mode_paragraph = if app.focus_mode { Some(renderer::focus_paragraph_bounds( app.textarea.lines(), app.textarea.cursor().0, )) } else { None }; let view = renderer::MarkdownView { lines: app.textarea.lines(), decoration_map: &app.decoration_map, scroll_top: app.scroll_top, cursor: app.textarea.cursor(), selection: app.textarea.selection_range(), theme: &app.theme, column_width: layout.column.width, show_line_numbers: app.show_line_numbers, search_matches, search_current, focus_mode_paragraph, }; f.render_widget(view, editor_area); if app.search.as_ref().is_some_and(|s| s.show_help) { renderer::render_search_help_modal(f, editor_area, app); } if app.show_shortcuts { renderer::render_shortcuts_modal(f, editor_area, app); } if app.settings.is_some() { renderer::render_settings_modal(f, editor_area, app); } renderer::render_status_bar(f, layout.status_bar, app); renderer::render_info_line(f, layout.info_line, app); last_editor_area = editor_area; })?; execute!(io::stdout(), EndSynchronizedUpdate)?; if event::poll(POLL_TIMEOUT)? { match event::read()? { // Only process key-press and key-repeat events. When the Kitty // keyboard protocol is active (e.g. leaked from a previous app // such as neovim that did not restore terminal state on exit), // the terminal also sends Release events. Letting those through // causes spurious characters — e.g. releasing Ctrl before Z after // a Ctrl+Z produces a bare `(NONE, Char('z'), Release)` that // bypasses the CONTROL match arm and inserts 'z'. Event::Key(k) if k.kind != crossterm::event::KeyEventKind::Release => { let prev_free_scroll = app.free_scroll; match handle_key_event(app, k) { KeyOutcome::Continue => {} KeyOutcome::Save => { // Save doesn't move the cursor, so keep the viewport where it was. app.free_scroll = prev_free_scroll; handle_save(app)?; last_autosave = std::time::Instant::now(); } KeyOutcome::SaveAndExit => { handle_save(app)?; break; } KeyOutcome::Exit => break, KeyOutcome::ReloadConfig => { let (new_config, new_warnings) = load_config(); let mut warnings = new_warnings; app.theme = Theme::from_config( &new_config.palette, &new_config.theme, &new_config.headings, &mut warnings, ); // Rebuild the highlight cache so fenced code blocks pick up // any theme or palette changes immediately. app.highlight_cache = new_config.highlighting.enabled.then(|| { let palette_theme = new_config .highlighting .use_palette_colors .then(|| yame::highlighting::build_palette_theme(&app.theme)); Arc::new(yame::highlighting::HighlightCache::new( true, new_config.highlighting.syntect_theme.clone(), palette_theme, )) }); // Re-resolve file mode in case [filetype] config changed. // Untitled buffers stay in Markdown mode until saved. if let Some(p) = &app.file_path { app.file_mode = resolve_file_mode(p, &new_config.filetype); } app.show_line_numbers = new_config.layout.line_numbers.unwrap_or(false); app.auto_close_pairs = new_config.layout.auto_close_pairs.unwrap_or(false); app.autosave_secs = new_config .layout .autosave_seconds .map(|n| n as u64) .unwrap_or(0); app.config_warnings = warnings; app.status .set_timed("Config reloaded.", Duration::from_millis(1500)); app.last_keystroke = Some(std::time::Instant::now()); } KeyOutcome::SaveAs { filename, then_exit, } => { let path = std::path::PathBuf::from(&filename); app.shortened_path = renderer::shorten_path(&path, 3); app.file_mode = resolve_file_mode(&path, &yame::config::FiletypeConfig::default()); app.file_path = Some(path); handle_save_to_path(app)?; if then_exit { break; } } KeyOutcome::OpenSettings => { let (config, _) = load_config(); let mut warnings = Vec::new(); let resolved = Theme::from_config( &config.palette, &config.theme, &config.headings, &mut warnings, ); app.settings = Some(SettingsModal::new( config, resolved, app.typewriter_mode, app.focus_mode, app.read_only, )); } KeyOutcome::CommitSettings => { do_commit_settings(app, &deco_tx); } KeyOutcome::OpenPicker => { match picker::pick_file() { Ok(Some(path)) => { // No-op if the user picked the file already open. let same = app.file_path.as_deref() == Some(path.as_path()); if !same { match app.load_new_file(path, app.tab_width) { Ok(()) => { if let Some(p) = &app.file_path { app.file_mode = resolve_file_mode( p, &yame::config::FiletypeConfig::default(), ); } } Err(e) => { app.status .set_dismissible(format!("⚠ Open failed: {e}")); } } } } Ok(None) => {} // user cancelled Err(e) => { app.status.set_dismissible(format!("⚠ Picker: {e}")); } } // Always force a full repaint after leaving/re-entering // alternate screen — the terminal may have been dirtied. terminal.clear()?; } } } Event::Mouse(mouse) => { use yame::settings::{ModalHit, VISIBLE_FIELDS}; // Compute hit while the borrow of app.settings is scoped separately. let modal_hit = app .settings .as_ref() .map(|m| m.hit_test(mouse.row, mouse.column, last_editor_area)); match mouse.kind { MouseEventKind::ScrollDown => { // Scroll inside modal → move field cursor; outside → scroll editor. if matches!(modal_hit, Some(ModalHit::Field(_) | ModalHit::Tab(_))) { app.settings.as_mut().unwrap().move_down(VISIBLE_FIELDS); } else { let max = app.textarea.lines().len().saturating_sub(1); app.scroll_top = (app.scroll_top + SCROLL_LINES).min(max); app.free_scroll = true; } } MouseEventKind::ScrollUp => { if matches!(modal_hit, Some(ModalHit::Field(_) | ModalHit::Tab(_))) { app.settings.as_mut().unwrap().move_up(); } else { app.scroll_top = app.scroll_top.saturating_sub(SCROLL_LINES); app.free_scroll = true; } } MouseEventKind::Down(MouseButton::Left) => { use yame::settings::SettingsOutcome; match modal_hit { Some(ModalHit::Tab(t)) => { app.settings.as_mut().unwrap().switch_to_tab(t); } Some(ModalHit::Field(f)) => { // Navigate to the clicked field, then activate it if it's a // toggle, cycler, or expand row (text/color fields need Enter). let outcome = { let modal = app.settings.as_mut().unwrap(); modal.jump_to_field(f); modal.activate_clicked_field() }; if matches!(outcome, SettingsOutcome::Committed) { do_commit_settings(app, &deco_tx); } } Some(ModalHit::Outside) => { // Click truly outside the modal bounds — dismiss. app.settings = None; } Some(ModalHit::Inert) => { // Click on non-interactive interior surface (border, separator, // hint row) — keep the modal open, do nothing. } None => { // No modal open — standard click behaviour. app.free_scroll = false; drag_selecting = false; if let Some((doc_row, doc_col)) = screen_to_doc( mouse.row, mouse.column, &last_editor_area, app.scroll_top, app.textarea.lines(), &app.decoration_map, app.show_line_numbers, ) { app.textarea.cancel_selection(); app.textarea .move_cursor(CursorMove::Jump(doc_row, doc_col)); } } } } MouseEventKind::Drag(MouseButton::Left) if modal_hit.is_none() => { // Drag moves the cursor, so re-engage cursor-clamping scroll. app.free_scroll = false; if let Some((doc_row, doc_col)) = screen_to_doc( mouse.row, mouse.column, &last_editor_area, app.scroll_top, app.textarea.lines(), &app.decoration_map, app.show_line_numbers, ) { if !drag_selecting { app.textarea.start_selection(); drag_selecting = true; } app.textarea.move_cursor(CursorMove::Jump(doc_row, doc_col)); } } _ => {} } } Event::Paste(text) => { // Bracketed paste: the terminal has wrapped the pasted content // in escape sequences so crossterm delivers the entire payload // as a single event. Insert it directly — bypassing // handle_enter so list-continuation logic never fires on the // embedded newlines. app.textarea.insert_str(&text); app.force_redecorate = true; app.mark_keystroke(); } Event::Resize(_, _) => { // Viewport geometry changed — re-engage cursor-clamping scroll // so the cursor is guaranteed visible after the resize. app.free_scroll = false; } _ => { // Unknown events (FocusGained, FocusLost, mouse hover, …) do // NOT clear free_scroll — they are background events that should // not interrupt an explicit scroll the user initiated. } } } } Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::path::PathBuf; use tui_textarea::TextArea; use yame::app::{App, ClipboardState, FileMode}; use yame::config::Theme; use yame::decoration::DecorationMap; use yame::status::StatusLine; fn make_app() -> App { App { textarea: TextArea::default(), file_path: Some(PathBuf::from("test.md")), shortened_path: "test.md".to_string(), is_dirty: false, saved_content: None, theme: Theme::default_theme(), italic_support: false, powerline_glyphs: false, last_keystroke: None, force_redecorate: false, decoration_map: DecorationMap::default(), word_count: 0, status: StatusLine::default(), config_warnings: vec![], scroll_top: 0, free_scroll: false, sticky_col: None, content_width: 0, clipboard: ClipboardState::Uninitialized, tab_width: 4, highlight_cache: None, file_mode: FileMode::Markdown, show_line_numbers: false, search: None, search_last_typed: None, typewriter_mode: false, focus_mode: false, show_shortcuts: false, read_only: false, auto_close_pairs: false, autosave_secs: 0, settings: None, } } fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } // Kills: input.rs:269:24 replace && with || in handle_key_event. // With ||: !is_nav || handle_pair_wrap short-circuits to true for any non-nav key // (handle_pair_wrap is never evaluated), so the if-branch is entered without // calling textarea.input → the textarea stays empty instead of receiving 'a'. #[test] fn typing_char_reaches_textarea() { let mut app = make_app(); handle_key_event(&mut app, key(KeyCode::Char('a'))); assert_eq!( app.textarea.lines()[0], "a", "typed char must reach textarea" ); } // Kills: input.rs:269:16 delete ! in handle_key_event. // Without !: is_nav && handle_pair_wrap short-circuits to false for all non-nav // keys (is_nav=false), so pair-wrap is never called; instead textarea.input('(') // is called → just "(" instead of the wrapped "(hello)". #[test] fn pair_wrap_with_selection_wraps() { let mut app = make_app(); app.textarea.insert_str("hello"); app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::End); handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!( app.textarea.lines()[0], "(hello)", "pair-wrap must wrap the selection" ); } // Kills: the insert_str(open+selected+close) line being split back into two calls. // Two undo steps restore the buffer fully; a third undo would over-shoot into the // pre-"hello" state, proving the wrap is recorded as exactly 2 history entries. #[test] fn pair_wrap_undoes_in_two_steps() { let mut app = make_app(); app.textarea.insert_str("hello"); app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::End); handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "(hello)"); app.textarea.undo(); // remove "(hello)" insertion app.textarea.undo(); // restore "hello" by un-deleting the selection assert_eq!( app.textarea.lines()[0], "hello", "two undos must fully restore the pre-wrap state" ); // A third undo would overshoot into blank — verify it does nothing more. app.textarea.undo(); assert_eq!( app.textarea.lines()[0], "", "third undo removes the original 'hello' insert, not some phantom entry" ); } // ── Auto-close pairs ───────────────────────────────────────────────────── // Kills: `delete !app.auto_close_pairs` guard — with the guard removed, // typing `(` would insert `()` even when the feature is off. #[test] fn auto_close_off_typing_open_inserts_single_char() { let mut app = make_app(); // auto_close_pairs = false handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "("); } // Kills: `delete insert_str("()")` branch — the opener would do nothing. #[test] fn auto_close_on_typing_open_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "()"); assert_eq!( app.textarea.cursor(), (0, 1), "cursor must sit between the pair" ); } // Kills: `delete move_cursor(Back)` — cursor would land after the pair, not between. #[test] fn auto_close_on_cursor_placed_between_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('['))); assert_eq!(app.textarea.cursor(), (0, 1)); } // Kills: `delete move_cursor(Forward)` in skip-over branch — closer would be // inserted as a duplicate instead of consumed. #[test] fn auto_close_on_typing_closer_skips_over() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('('))); // cursor is at col 1 inside "()" handle_key_event(&mut app, key(KeyCode::Char(')'))); assert_eq!(app.textarea.lines()[0], "()", "no duplicate close inserted"); assert_eq!( app.textarea.cursor(), (0, 2), "cursor must be past the closer" ); } // Kills: `delete Some(c) ==` guard — symmetric char would always insert a pair // instead of skipping over the existing one. #[test] fn auto_close_on_symmetric_char_skips_over_existing() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('"'))); // cursor between the two quotes: `"|"` handle_key_event(&mut app, key(KeyCode::Char('"'))); assert_eq!(app.textarea.lines()[0], "\"\"", "no triple-quote produced"); assert_eq!(app.textarea.cursor(), (0, 2)); } // Kills: `delete selection_range().is_some()` guard — with the guard removed, // auto_close would fire on top of pair_wrap (selection case). #[test] fn auto_close_on_with_selection_still_wraps() { let mut app = make_app(); app.auto_close_pairs = true; app.textarea.insert_str("hello"); app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::End); handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "(hello)"); } // Kills: input.rs:294:43 replace | with & in handle_auto_close. // With |→&, CONTROL & ALT = 0 (non-overlapping bits), so intersects(0) is always // false — Ctrl+( bypasses the modifier guard and auto-closes to "()". #[test] fn auto_close_ctrl_char_not_handled() { let mut app = make_app(); app.auto_close_pairs = true; let handled = handle_auto_close( &mut app, KeyEvent::new(KeyCode::Char('('), KeyModifiers::CONTROL), ); assert!(!handled, "Ctrl+( must not trigger auto-close"); assert_eq!( app.textarea.lines()[0], "", "no characters must be inserted" ); } // Kills: input.rs:319:9 delete match arm '['. // With arm deleted [ falls to _ → returns false; caller inserts just "[" not "[]". #[test] fn auto_close_square_bracket_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('['))); assert_eq!(app.textarea.lines()[0], "[]"); assert_eq!(app.textarea.cursor(), (0, 1)); } // Kills: input.rs:324:9 delete match arm '{'. #[test] fn auto_close_curly_brace_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('{'))); assert_eq!(app.textarea.lines()[0], "{}"); assert_eq!(app.textarea.cursor(), (0, 1)); } // Kills: input.rs:338:9 delete match arm ']' and 339:26 replace == with !=. // Arm deleted: ] falls to _ → inserts ], giving "[]" → "]" = "[]]". // ==→!=: next_char IS ] → condition false → inserts ], same "[]]". #[test] fn auto_close_square_closer_skips_over() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('['))); handle_key_event(&mut app, key(KeyCode::Char(']'))); assert_eq!(app.textarea.lines()[0], "[]", "no duplicate ] inserted"); assert_eq!( app.textarea.cursor(), (0, 2), "cursor must be past the closer" ); } // Kills: input.rs:346:9 delete match arm '}' and 347:26 replace == with !=. #[test] fn auto_close_curly_closer_skips_over() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('{'))); handle_key_event(&mut app, key(KeyCode::Char('}'))); assert_eq!(app.textarea.lines()[0], "{}", "no duplicate }} inserted"); assert_eq!( app.textarea.cursor(), (0, 2), "cursor must be past the closer" ); } // Kills: input.rs:355:9 delete match arm '"' | '\'' | '`' | '*' | '_'. // With arm deleted, " falls to _ → false → caller inserts single " not "". // The existing skip-over test types " twice, so both mutations produce "" // at cursor col 2 — that test does NOT distinguish. This one types " once. #[test] fn auto_close_symmetric_char_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('"'))); assert_eq!( app.textarea.lines()[0], "\"\"", "symmetric char must insert pair" ); assert_eq!( app.textarea.cursor(), (0, 1), "cursor must sit between the pair" ); } // Kills: input.rs:275:47 replace != with == in handle_key_event. // With ==: force_redecorate is set only when line count has NOT changed; // pressing Enter adds a new line, so with the mutation force_redecorate is NOT set. #[test] fn enter_sets_force_redecorate() { let mut app = make_app(); app.force_redecorate = false; handle_key_event(&mut app, key(KeyCode::Enter)); assert!( app.force_redecorate, "Enter adds a line — force_redecorate must be true" ); } // ── Visual-line navigation ─────────────────────────────────────────────── fn nav_app(lines: Vec<&str>, content_width: usize) -> App { let mut app = make_app(); app.content_width = content_width; app.textarea = TextArea::new(lines.into_iter().map(String::from).collect()); app } // Down stays within the same logical line when it wraps. // "abcde fghij" at width 8 wraps: first row "abcde" (cols 0-4), // second row "fghij" (cols 6-10). Cursor at col 0 → Down → col 6. #[test] fn visual_down_within_wrapped_line() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 0)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (0, 6), "Down must land on second visual row of same logical line" ); } // Up reverses the within-line move. #[test] fn visual_up_within_wrapped_line() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 6)); handle_key_event(&mut app, key(KeyCode::Up)); assert_eq!( app.textarea.cursor(), (0, 0), "Up must return to first visual row of same logical line" ); } // Down on the last visual row of line 0 crosses to line 1. #[test] fn visual_down_crosses_logical_line() { let mut app = nav_app(vec!["abc", "def"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 2)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (1, 2), "Down from last visual row must cross to next logical line" ); } // Up on the first visual row of line 1 crosses back to line 0. #[test] fn visual_up_crosses_logical_line() { let mut app = nav_app(vec!["abc", "def"], 20); app.textarea.move_cursor(CursorMove::Jump(1, 2)); handle_key_event(&mut app, key(KeyCode::Up)); assert_eq!( app.textarea.cursor(), (0, 2), "Up from first visual row must cross back to previous logical line" ); } // Sticky col is set on the first Down and preserved on the second, so // moving through a short middle line restores the column on a longer line. // Lines: ["abcde", "ab", "abcde"], width 20 (no wrapping). // Cursor at (0, 4): Down → (1, 2) [clamped]; Down → (2, 4) [restored]. #[test] fn sticky_col_preserved_through_short_line() { let mut app = nav_app(vec!["abcde", "ab", "abcde"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 4)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!(app.textarea.cursor(), (1, 2), "clamped to short line"); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (2, 4), "sticky col must restore original column on longer line" ); } // Any non-vertical-nav key must clear sticky_col. #[test] fn sticky_col_cleared_by_non_vertical_key() { let mut app = nav_app(vec!["abcde", "ab", "abcde"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 4)); handle_key_event(&mut app, key(KeyCode::Down)); // sets sticky_col = 4 assert!(app.sticky_col.is_some(), "sticky_col set after Down"); handle_key_event(&mut app, key(KeyCode::Right)); // non-vertical → clears assert!( app.sticky_col.is_none(), "sticky_col must be cleared by Right" ); } // Down at the last line/row is a no-op (cursor stays put). #[test] fn visual_down_at_last_row_is_noop() { let mut app = nav_app(vec!["abc"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 1)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (0, 1), "Down at last row must not move cursor" ); } // Up at the first row is a no-op. #[test] fn visual_up_at_first_row_is_noop() { let mut app = nav_app(vec!["abc"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 1)); handle_key_event(&mut app, key(KeyCode::Up)); assert_eq!( app.textarea.cursor(), (0, 1), "Up at first row must not move cursor" ); } // ── decorate() ────────────────────────────────────────────────────────── // // Kills input.rs:55:5 `replace decorate → … with (Default::default(), 0)` and // `replace decorate → … with (Default::default(), 1)`. // // Both stubs return an empty DecorationMap AND word_count=0 or 1. A real // markdown decoration pass on multi-word text with a heading returns a // non-empty map AND word_count > 1. // Helper: build a DecorateRequest from an App snapshot (for tests). fn make_req(text: &str, app: &App) -> DecorateRequest { DecorateRequest { text: text.to_string(), mode: app.file_mode.clone(), theme: app.theme.clone(), italic_support: app.italic_support, cache: app.highlight_cache.clone(), } } // FileMode::Markdown: heading + body text → non-empty map, word_count > 1. #[test] fn decorate_markdown_returns_nonempty_map_and_correct_word_count() { let mut app = make_app(); app.file_mode = FileMode::Markdown; let text = "# Hello World\nThis is some text.\n"; let (map, wc) = run_decorate(&make_req(text, &app)); assert!( !map.is_empty(), "Markdown decoration must produce a non-empty DecorationMap" ); // "Hello World This is some text" = 7 words; stubs return 0 or 1. assert!( wc > 1, "Markdown decoration word count must be > 1 for multi-word text (got {wc})" ); } // FileMode::PlainText: no decoration, word count only. #[test] fn decorate_plain_text_returns_empty_map_with_word_count() { let mut app = make_app(); app.file_mode = FileMode::PlainText; let (map, wc) = run_decorate(&make_req("hello world\n", &app)); assert!( map.is_empty(), "PlainText mode must return an empty DecorationMap" ); assert_eq!(wc, 2, "PlainText mode must still count words correctly"); } // ── screen_to_doc ──────────────────────────────────────────────────────── // Helper: editor area spanning the full terminal at (0,0), width includes // two GUTTER columns (1 each side), so content_width = area.width - 2. fn editor_rect(width: u16, height: u16) -> Rect { Rect { x: 0, y: 0, width, height, } } // Helper: build a DecorationMap with a single span on logical line `li` // whose only non-default field is `continuation_indent`. fn dec_map_with_ci(li: usize, ci: u8) -> DecorationMap { use yame::decoration::StyledSpan; let mut map = DecorationMap::default(); map.insert( li, vec![StyledSpan { continuation_indent: ci, ..StyledSpan::default() }], ); map } // Click outside the editor area returns None. #[test] fn screen_to_doc_outside_area_returns_none() { let area = editor_rect(12, 5); let lines: Vec = vec!["hello".into()]; let map = DecorationMap::default(); // Row above area assert!( screen_to_doc( 0, 0, &Rect { x: 0, y: 2, width: 12, height: 5 }, 0, &lines, &map, false, ) .is_none() ); // Col outside area assert!(screen_to_doc(0, 20, &area, 0, &lines, &map, false).is_none()); } // Plain (no continuation indent) line: click at gutter+2 → doc col 2. #[test] fn screen_to_doc_plain_line_click() { // area width=12 → GUTTER=1 each side → cw=10 let area = editor_rect(12, 5); let lines: Vec = vec!["hello world".into()]; let map = DecorationMap::default(); // screen_col = GUTTER + 2 = 3 → click_col = 2 → char 2 = 'l' let result = screen_to_doc(0, 3, &area, 0, &lines, &map, false); assert_eq!(result, Some((0, 2)), "plain click must map col correctly"); } // Regression: click on the *third* visual row of a wrapped list item must // map to the same logical line (0), not the next logical line (1). // // Setup: cw=10, ci=2 → cont_width=8 // line 0: "- abc defgh ijk" // wrap_line_indented → ["- abc", "defgh", "ijk"] (3 visual rows) // line 1: "next line" // // Old bug: wrap_line (ignoring ci) gave ["- abc", "defgh ijk"] (2 rows), // so vis row 2 was counted as the start of line 1. #[test] fn screen_to_doc_list_item_third_wrap_row_lands_on_correct_logical_line() { let area = editor_rect(12, 10); // cw = 10 let lines: Vec = vec!["- abc defgh ijk".into(), "next line".into()]; let map = dec_map_with_ci(0, 2); // Visual row 2 is the "ijk" continuation row of line 0. // screen_col = GUTTER(1) + ci(2) = 3 → clicking at the first char of "ijk". let result = screen_to_doc(2, 3, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(0), "third visual row of wrapped list item must map to logical line 0" ); } // Column mapping on a continuation row must subtract the continuation // indent before computing the char position. // // Continuation row "defgh" of "- abc defgh ijk" at ci=2: // screen_col = GUTTER(1) + ci(2) + 3 = 6 → click_col=5, col_in_row=5-2=3 // "defgh"[0..3] = "def" → char index 3 within "defgh" → char 8 in original // ("- abc " = 6 chars, "defgh" starts at 6, char 3 within it → global 9) #[test] fn screen_to_doc_list_item_continuation_column_adjusted() { let area = editor_rect(12, 10); // cw = 10 // "- abc defgh ijk": '- abc ' = 6 chars, 'defgh' starts at char 6 let lines: Vec = vec!["- abc defgh ijk".into(), "next line".into()]; let map = dec_map_with_ci(0, 2); // Visual row 1 = "defgh" continuation row. // screen_col = 1 (GUTTER) + 2 (ci) + 3 = 6 → click_col=5, col_in_row=3 // → chars_for_display_cols("defgh", 3) = 3 → doc_col = 6 + 3 = 9 let result = screen_to_doc(1, 6, &area, 0, &lines, &map, false); assert_eq!( result, Some((0, 9)), "continuation row column must be adjusted by continuation_indent" ); } // Click where screen_col < editor_area.x (not 0) must return None. // // The bounds check is `screen_col < editor_area.x`. When area.x == 0, that // condition is unreachable (u16 < 0 never holds), so these mutants: // · `< → ==` (col 23): `screen_col == area.x` is false when col < x // · `< → <=` (col 23): admits clicks where col == x (inside area) as None // · `|| → &&` (col 9 of the joined condition): the combined guard collapses // to a conjunction — only returns None when ALL four conditions are true // …are only observable with area.x > 0. #[test] fn screen_to_doc_outside_area_nonzero_x_returns_none() { // area starts at x=3, y=1; any click with screen_col < 3 or screen_row < 1 // (while the other coordinate is inside) must return None. let area = Rect { x: 3, y: 1, width: 10, height: 5, }; let lines: Vec = vec!["hello".into()]; let map = DecorationMap::default(); // screen_col=2 < area.x=3; screen_row=2 is in [1,6) — only col violates bounds. // With `||→&&` mutation the conjunction fires only if ALL four hold, which is // false here (row is valid), so the mutant would NOT return None. assert!( screen_to_doc(2, 2, &area, 0, &lines, &map, false).is_none(), "col below area.x must return None even when row is in-bounds" ); // screen_col=2 < area.x=3 and screen_row=0 < area.y=1 — both out of bounds. assert!( screen_to_doc(0, 2, &area, 0, &lines, &map, false).is_none(), "both row and col below area must return None" ); } // A click exactly AT screen_col == editor_area.x is INSIDE the area (it // lands on the first gutter column). With the `< → <=` mutation this click // would incorrectly return None. #[test] fn screen_to_doc_click_at_area_x_edge_returns_some() { // area.x = 2, so the valid column range is [2, 12). screen_col = 2 (= area.x) // is the first valid column. click_col = 2 - (x=2 + GUTTER=1) saturating = 0. let area = Rect { x: 2, y: 0, width: 10, height: 5, }; let lines: Vec = vec!["hello".into()]; let map = DecorationMap::default(); let result = screen_to_doc(0, 2, &area, 0, &lines, &map, false); assert!( result.is_some(), "screen_col == area.x is inside the area and must return Some" ); } // When editor_area.y > 0, click_vis_row must subtract area.y, not add it. // With the `- → +` mutation click_vis_row = screen_row + area.y, which maps // clicks to the wrong (much-further-down) visual row. #[test] fn screen_to_doc_area_y_offset_subtracted() { // area.y = 1 — editor starts one row down from the terminal top. // Lines: ["aaa", "bbb", "ccc"] — each one visual row. // Click at screen_row=2: correct click_vis_row = 2-1 = 1 → logical line 1 ("bbb"). // With `+ mutation: click_vis_row = 2+1 = 3 → falls off the 3-row doc → line 2. let area = Rect { x: 0, y: 1, width: 12, height: 5, }; let lines: Vec = vec!["aaa".into(), "bbb".into(), "ccc".into()]; let map = DecorationMap::default(); let result = screen_to_doc(2, 1, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(1), "area.y must be subtracted (not added) from screen_row to get click_vis_row" ); } // Clicking on the FIRST visual row of the SECOND logical line must map to // logical line 1, not logical line 0. // // Kills `> → >=` (line 88): with `>=`, vis+seg_count == click_vis_row enters // line 0's block one row early and returns (0, …) instead of (1, …). // // Also kills `+= → -=` (line 105): vis -= seg_count underflows to usize::MAX // in debug mode → panic → test failure. #[test] fn screen_to_doc_click_first_row_of_second_line() { // Two single-row lines; visual row 1 is the sole row of "world". let area = editor_rect(12, 5); let lines: Vec = vec!["hello".into(), "world".into()]; let map = DecorationMap::default(); let result = screen_to_doc(1, 1, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(1), "vis-row 1 (= seg_count of line 0) must map to logical line 1" ); } // When the click is on a line after the first, `si = click_vis_row - vis` // must use subtraction so it points to the correct sub-row within the line. // // Kills `- → +` (line 89): si = click_vis_row + vis → huge index → row_str="" // → chars_into_row=0 → doc_col=0 regardless of click_col. #[test] fn screen_to_doc_click_column_on_second_line() { // Click at screen_col=4 → click_col = 4-GUTTER=3. "world"[0..3]="wor" → col 3. let area = editor_rect(12, 5); let lines: Vec = vec!["hello".into(), "world".into()]; let map = DecorationMap::default(); // screen_col = GUTTER(1) + 3 = 4 let result = screen_to_doc(1, 4, &area, 0, &lines, &map, false); assert_eq!( result, Some((1, 3)), "si must use click_vis_row - vis so column offset lands on 'r' in 'world'" ); } // cw must subtract GUTTER + GUTTER (= 2) from area.width, not a different value. // // "abcdefghij" is exactly 10 chars. With area.width=12, correct cw=10 → fits // on one visual row, so vis_row 1 maps to line 1. With the `+→-` mutation // (cw = width - 0 = 12) the line also fits, same result — but with the // `+→*` mutation (GUTTER * GUTTER = 1, cw = 11) it also fits. The mutation // that DOES produce a wrong cw is `+→-` giving 12 which still fits... hmm. // // Actually the key mutation to kill here is the old `* → +` form, now // expressed as `+ → *` after rewriting to `GUTTER + GUTTER`: // GUTTER * GUTTER = 1 × 1 = 1 → cw = 12-1=11 (still fits). The `+ → -` // form gives cw = 12-0=12. Both still fit for a 10-char word. // // Use a content-width-sensitive scenario instead: line = "ab cd efghi" // (5+1+2+1+5 = 14 chars). At cw=10 it wraps: ["ab cd", "efghi"] (2 rows). // At cw=9 it wraps: ["ab cd", "efghi"] too (word break before efghi). Need // cw == word length exactly. "0123456789" (10 chars) at cw=10 fits (1 row); // at cw=9 hard-breaks into ["012345678","9"] (2 rows). Click at vis_row=1: // correct (cw=10): line 1 ("second"). // +→* mutant (cw=12-1=11): "0123456789" still fits → vis_row 1 = line 1. Same! // // Better: use "0123456789" at width=12 (cw=10 correct, cw=12 with +→- mutant). // At cw=10: ["0123456789"] 1 row. At cw=12: still 1 row (fits). Same. // // The observable difference requires cw to drop below 10. With area.width=12: // correct: cw = 12 - (1+1) = 10 → 1 row // +→* mutant: cw = 12 - (1*1) = 11 → 1 row (same) // +→- mutant: cw = 12 - (1-1) = 12 → 1 row (same) // // With area.width=11: // correct: cw = 11 - 2 = 9 → hard-break: ["012345678","9"] 2 rows // +→* mutant: cw = 11 - 1 = 10 → "0123456789" fits in 1 row // +→- mutant: cw = 11 - 0 = 11 → 1 row // Click at vis_row=1 with 2 lines ["0123456789", "second"]: // correct (cw=9): line 0 has 2 rows → vis_row 1 is in line 0 → returns (0,_) // +→* mutant (cw=10): line 0 has 1 row → vis_row 1 is line 1 → returns (1,_) #[test] fn screen_to_doc_content_width_gutter_subtraction() { // area.width=11. Correct: cw = 11 - (GUTTER+GUTTER) = 11-2 = 9. // "0123456789" (10 chars) at cw=9: hard-breaks into 2 rows. // Click at vis_row=1 → INSIDE line 0 (not line 1 "second"). let area = Rect { x: 0, y: 0, width: 11, height: 5, }; let lines: Vec = vec!["0123456789".into(), "second".into()]; let map = DecorationMap::default(); let result = screen_to_doc(1, 1, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(0), "with cw = width - (GUTTER+GUTTER), a 10-char line at width=11 wraps into 2 rows" ); } // si == 0 (first visual row of a list item) must NOT subtract continuation_indent // from click_col, because the first row is not indented. // // Kills `> → >=` (line 96): with `>=`, si=0 also subtracts line_ci, so // col_in_row = click_col.saturating_sub(2) instead of click_col. #[test] fn screen_to_doc_list_item_first_row_no_ci_subtraction() { // "- hello" (7 chars) fits on one visual row at cw=10. ci=2. // Click at screen_col = GUTTER(1)+4 = 5 → click_col=4. // si=0 (first row): col_in_row must be 4 (no ci subtraction). // With `>=` mutant: col_in_row = 4 - 2 = 2 → returns (0, 2) instead of (0, 4). let area = editor_rect(12, 5); // cw=10 let lines: Vec = vec!["- hello".into()]; let map = dec_map_with_ci(0, 2); let result = screen_to_doc(0, 5, &area, 0, &lines, &map, false); assert_eq!( result, Some((0, 4)), "si=0 (first row of list item) must not subtract continuation_indent from click_col" ); } // A click below all content (vis_row > last logical line) must land at the // END of the last line, not col 0. // // Old behaviour: fallback was `Some((last, 0))`. // New behaviour: `Some((last, last_line.chars().count()))`. // // This matches every major text editor's convention: clicking in empty space // below the text is treated as clicking past the end of the last line. #[test] fn screen_to_doc_click_below_all_content_lands_at_end_of_last_line() { // Two lines, each one visual row. screen_row=5 is below both lines // (only rows 0 and 1 are occupied). let area = editor_rect(12, 10); let lines: Vec = vec!["hello".into(), "world".into()]; let map = DecorationMap::default(); let result = screen_to_doc(5, 1, &area, 0, &lines, &map, false); assert_eq!( result, Some((1, 5)), "click below all content must land at end of last line (col 5 = len('world'))" ); } // Same fallback with an empty last line: end_col must be 0, not panic. #[test] fn screen_to_doc_click_below_content_with_empty_last_line() { let area = editor_rect(12, 10); let lines: Vec = vec!["hello".into(), "".into()]; let map = DecorationMap::default(); let result = screen_to_doc(5, 1, &area, 0, &lines, &map, false); assert_eq!( result, Some((1, 0)), "click below content with empty last line must land at (last, 0)" ); } // ── Navigation inertness ───────────────────────────────────────────────── // Navigation keys (Up/Down/Left/Right) must NOT set last_keystroke. // // Setting last_keystroke arms the 50ms debounce timer that triggers a full // decoration pass. Pure cursor movement cannot change content, so the // decoration map is already valid — re-running it wastes CPU and causes // perceptible lag on large files. // // Kills: any mutation that routes nav keys through mark_keystroke() instead // of recompute_dirty(). #[test] fn nav_keys_do_not_set_last_keystroke() { for code in [ KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right, KeyCode::Home, KeyCode::End, KeyCode::PageUp, KeyCode::PageDown, ] { let mut app = make_app(); app.textarea.insert_str("line one\nline two"); app.last_keystroke = None; handle_key_event(&mut app, KeyEvent::new(code, KeyModifiers::NONE)); assert!( app.last_keystroke.is_none(), "{code:?} must not set last_keystroke (would trigger redundant decoration pass)" ); } } // ── Exit-prompt cancellation ───────────────────────────────────────────── // Pressing Esc while in ExitPrompt must return to Normal mode without // exiting (KeyOutcome::Continue). // // Regression guard for FEEDBACK-1 §1.1: the original code matched // (NONE, Esc) at the outer level, shadowing the ExitPrompt handler. #[test] fn exit_prompt_esc_cancels_and_returns_to_normal() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!( outcome, KeyOutcome::Continue, "Esc in ExitPrompt must return Continue" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "Esc in ExitPrompt must restore Normal mode" ); } // Pressing 'c' (bare, or Ctrl+C) while in ExitPrompt must also cancel, // not copy to clipboard. The ExitPrompt handler matches on k.code only, // so modifiers do not affect it. #[test] fn exit_prompt_c_cancels_regardless_of_modifier() { for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('c'), modifiers)); assert_eq!( outcome, KeyOutcome::Continue, "'c' (mods={modifiers:?}) in ExitPrompt must return Continue" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "'c' (mods={modifiers:?}) in ExitPrompt must restore Normal mode" ); } } // Ctrl+Y / Ctrl+N while the exit prompt is open must NOT trigger // SaveAndExit / Exit — they are Redo / navigation shortcuts that the user // is likely to press accidentally. #[test] fn exit_prompt_ctrl_y_does_not_save_and_exit() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+Y must not trigger SaveAndExit" ); assert!( matches!(app.status.mode, yame::status::StatusMode::ExitPrompt), "ExitPrompt must remain open after Ctrl+Y" ); } #[test] fn exit_prompt_ctrl_n_does_not_discard_and_exit() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+N must not trigger Exit" ); assert!( matches!(app.status.mode, yame::status::StatusMode::ExitPrompt), "ExitPrompt must remain open after Ctrl+N" ); } // Tab key must insert spaces to the next tab stop, not a raw '\t'. // With app.tab_width=4 and cursor at col 0, pressing Tab should add 4 spaces. // // NOTE: these two tests use tab_width=4, which matches tui-textarea's default // tab_len (also 4). They verify correct behaviour but cannot kill the // `delete match arm Tab` mutant because tui-textarea's built-in Tab handler // happens to produce identical output for tab_width==4. The test below // (`tab_key_uses_app_tab_width_not_tui_textarea_default`) covers that case. #[test] fn tab_key_inserts_spaces_to_next_stop() { let mut app = make_app(); handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], " ", "Tab at col 0 with tab_width=4 must insert 4 spaces" ); } // With the cursor already at col 2, pressing Tab should only insert 2 spaces // (to reach the next multiple-of-4 stop at col 4). #[test] fn tab_key_aligns_to_next_stop_mid_line() { let mut app = make_app(); // Type two chars to reach col 2. handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), ); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), ); handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], "ab ", "Tab at col 2 with tab_width=4 must insert 2 spaces (align to col 4)" ); } // Kills input.rs:308:9 `delete match arm (KeyModifiers::NONE, KeyCode::Tab)`. // // The existing two Tab tests use app.tab_width=4, which happens to equal // tui-textarea's own default tab_len (4). When the arm is deleted the key // falls through to `_ => { textarea.input(k) }`, and tui-textarea's built-in // handler inserts the same number of spaces → tests pass → mutant survives. // // Setting tab_width=2 breaks the equivalence: our arm inserts 2 spaces, but // tui-textarea (tab_len still 4) inserts 4 → assertion on 2 fails → mutant caught. #[test] fn tab_key_uses_app_tab_width_not_tui_textarea_default() { let mut app = make_app(); app.tab_width = 2; // differs from tui-textarea's default tab_len of 4 handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], " ", "Tab at col 0 with tab_width=2 must insert 2 spaces (not 4, which tui-textarea would produce)" ); } // ── Exit-prompt y/n outcomes ──────────────────────────────────────────── // // Kills :218:13 (delete 'y' arm — 'y' would fall to `_` → Continue). #[test] fn exit_prompt_plain_y_saves_and_exits() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE) ), KeyOutcome::SaveAndExit, "plain 'y' in ExitPrompt must return SaveAndExit" ); } // Kills :219:13 (delete 'n' arm — 'n' would fall to `_` → Continue). #[test] fn exit_prompt_plain_n_exits_without_saving() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE) ), KeyOutcome::Exit, "plain 'n' in ExitPrompt must return Exit" ); } // Kills :212:77 `| → &` (missed mutant). // // With `| → &` on the *second* `|`, the guard becomes // `CONTROL | (ALT & SUPER)` = `CONTROL | 0` = `CONTROL`. // ALT is no longer included, so Alt+Y bypasses the guard and reaches the // `KeyCode::Char('y') => SaveAndExit` arm. #[test] fn exit_prompt_alt_y_is_suppressed() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::ALT) ), KeyOutcome::Continue, "Alt+Y in ExitPrompt must be suppressed by the modifier guard" ); } // ── Ctrl+S / Ctrl+X outcomes ──────────────────────────────────────────── // // Kills :234:9 (delete Ctrl+S arm — falls to `_` → textarea input, Continue). #[test] fn ctrl_s_returns_save() { let mut app = make_app(); assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL) ), KeyOutcome::Save, "Ctrl+S must return Save" ); } // Kills: delete Ctrl+Q arm — falls to `_` → textarea input, Continue. #[test] fn ctrl_q_on_clean_file_returns_exit() { let mut app = make_app(); // is_dirty=false (default make_app) → handle_exit returns true → Exit. assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL) ), KeyOutcome::Exit, "Ctrl+Q on a clean file must return Exit" ); } // Kills: delete Ctrl+X cut arm — falls to `_` → textarea inserts 'x', Continue. #[test] fn ctrl_x_cuts_and_returns_continue() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["hello world".to_string()]); // Pre-seed clipboard as unavailable so arboard is never called in CI. // handle_cut is #[mutants::skip]; we only need to verify dispatch: the // Ctrl+X arm must call handle_cut, not fall through to `_` which calls // textarea.input(Ctrl+X) and inserts a literal 'x'. app.clipboard = yame::app::ClipboardState::Unavailable; // Select "hello". app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); for _ in 0..5 { app.textarea.move_cursor(CursorMove::Forward); } let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+X cut must return Continue" ); // With the arm present, handle_cut runs (clipboard unavailable → no-op) and // no 'x' is inserted. The `_` fallthrough inserts a literal 'x'. let text = app.textarea.lines()[0].clone(); assert!( !text.contains('x'), "Ctrl+X must not insert 'x' (got: {text:?})" ); } // ── Shift+Down / Shift+Up visual-move-with-selection ──────────────────── // // Kills :296:9 (delete Shift+Down arm — missed mutant). // // Without the arm, Shift+Down falls to `_` which calls textarea.input(Shift+Down). // tui-textarea's built-in Shift+Down on a single-line document is a no-op (already // at the last logical line), leaving the cursor at (0, 0). The custom // handle_visual_move correctly steps to the second *visual* row of the wrapped line. // // Line "abcde fghij" at content_width=8: // wrap_line → ["abcde", "fghij"] — two visual rows. // First row chars 0..5, second row starts at char 6. // Cursor at (0, 0) → Shift+Down → (0, 6). #[test] fn shift_down_moves_by_visual_row() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 0)); handle_key_event(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)); assert_eq!( app.textarea.cursor(), (0, 6), "Shift+Down must step one visual row (col 0 → col 6 on wrapped line)" ); } // Kills :297:9 (delete Shift+Up arm — timeout due to suite overhead). // // Without the arm, Shift+Up falls to `_` → textarea.input(Shift+Up) → no-op // on first logical line → cursor stays at (0, 6). Custom: moves to (0, 0). #[test] fn shift_up_moves_by_visual_row() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 6)); handle_key_event(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT)); assert_eq!( app.textarea.cursor(), (0, 0), "Shift+Up must step back one visual row (col 6 → col 0 on wrapped line)" ); } // --------------------------------------------------------------------------- // Search / replace integration tests // --------------------------------------------------------------------------- /// Create an App with a single line of text, an active search for `query`, /// and a replacement string `replace`. The first match is pre-selected. fn make_search_app(content: &str, query: &str, replace: &str) -> App { let mut app = make_app(); app.textarea = TextArea::new(vec![content.to_string()]); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = yame::search::SearchState::new(true); s.query = query.to_string(); s.replace = replace.to_string(); s.update_matches(&lines); app.search = Some(s); app } // ---- T-24: do_replace_current replaces the active match ---- #[test] fn do_replace_current_replaces_first_match() { // Kills: lines 222:5 (`replace do_replace_current with ()`), // 230:24 (`replace - with +` and `replace - with /` in `me - ms`). // With `()`: textarea unchanged. // With `-→+` in `me - ms`: match_len = me + ms (wildly large) → over-deletes. let mut app = make_search_app("hello world", "world", "rust"); do_replace_current(&mut app); assert_eq!( app.textarea.lines()[0], "hello rust", "do_replace_current must replace 'world' with 'rust'" ); } #[test] fn do_replace_current_does_not_over_delete_past_match_end() { // Specifically kills line 270:24 `replace - with +` in `match_len = me - ms`. // The match "mid" is at ms=4, me=7 in "pre mid post". // Correct: match_len = 7 - 4 = 3 → deletes only "mid" → "pre X post". // Wrong: match_len = 7 + 4 = 11 → deletes "mid post" (and more) → "pre X". // An end-of-string match obscures this because extra delete_next_char() // calls are no-ops; here there is text after the match that must survive. let mut app = make_search_app("pre mid post", "mid", "X"); do_replace_current(&mut app); assert_eq!( app.textarea.lines()[0], "pre X post", "text after the replaced match must be preserved" ); } // ---- T-25: do_replace_all replaces every match ---- #[test] fn do_replace_all_replaces_every_occurrence() { // Kills: lines 252:5 (`replace do_replace_all with ()`), // 262:25 (`replace - with +` and `replace - with /` in `me - ms`). let mut app = make_search_app("aa aa aa", "aa", "b"); do_replace_all(&mut app); assert_eq!( app.textarea.lines()[0], "b b b", "do_replace_all must replace all 'aa' with 'b'" ); } #[test] fn search_key_ctrl_a_triggers_replace_all_when_show_replace_true() { // Kills line 419:16 `replace match guard ... with false`. // With the guard forced false, Ctrl+A falls through to the swallow arm // and the buffer is never modified. let mut app = make_search_app("foo foo foo", "foo", "bar"); // make_search_app sets show_replace=true; confirm it. assert!(app.search.as_ref().unwrap().show_replace); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "bar bar bar", "Ctrl+A must trigger replace-all when show_replace is true" ); } // ---- T-26: handle_search_key — one assertion per key binding ---- #[test] fn search_key_esc_closes_search() { // Kills: line 296:9 `delete match arm (KeyModifiers::NONE, KeyCode::Esc)` let mut app = make_search_app("hello", "hello", ""); handle_search_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( app.search.is_none(), "Esc must close search (search = None)" ); } #[test] fn search_key_ctrl_f_advances_to_next_match() { // Kills: line 308:9 `delete match arm Ctrl+F / Enter` // Three matches; after Ctrl+F from current=0 → current=1. let mut app = make_app(); app.textarea = TextArea::new(vec!["aaa".to_string()]); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = yame::search::SearchState::new(false); s.query = "a".to_string(); s.update_matches(&lines); // 3 matches, current=0 app.search = Some(s); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), ); let current = app.search.as_ref().unwrap().current; assert_eq!(current, 1, "Ctrl+F must advance current from 0 to 1"); } #[test] fn search_key_shift_enter_retreats_to_prev_match() { // Kills: line 330:9 `delete match arm Shift+Enter` let mut app = make_app(); app.textarea = TextArea::new(vec!["aaa".to_string()]); let lines: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = yame::search::SearchState::new(false); s.query = "a".to_string(); s.update_matches(&lines); // current = 0 s.current = 2; app.search = Some(s); handle_search_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); let current = app.search.as_ref().unwrap().current; assert_eq!(current, 1, "Shift+Enter must retreat current from 2 to 1"); } #[test] fn search_key_backspace_pops_last_char_of_query() { // Kills: line 342:9 `delete match arm Backspace` let mut app = make_search_app("hello", "hell", ""); handle_search_key( &mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); let query = &app.search.as_ref().unwrap().query; assert_eq!(query, "hel", "Backspace must remove last char of query"); } #[test] fn search_key_alt_r_toggles_regex_mode() { // Kills: line 351:9 `delete match arm Alt+R` and line 355:32 `delete !` let mut app = make_search_app("hello", "hello", ""); let before = app.search.as_ref().unwrap().regex_mode; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT), ); let after = app.search.as_ref().unwrap().regex_mode; assert_ne!(before, after, "Alt+R must toggle regex_mode"); } #[test] fn search_key_ctrl_h_opens_replace_row() { // Kills: line 361:9 `delete match arm Ctrl+H` and line 363:34 `delete !` let mut app = make_search_app("hello", "hello", ""); app.search.as_mut().unwrap().show_replace = false; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); let show = app.search.as_ref().unwrap().show_replace; assert!(show, "Ctrl+H must enable show_replace"); // Toggle again: from true → false. handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); let show2 = app.search.as_ref().unwrap().show_replace; assert!(!show2, "second Ctrl+H must disable show_replace"); } #[test] fn search_key_tab_swaps_focus_field() { // Kills: line 369:9 `delete match arm Tab` and line 373:34 `delete !` let mut app = make_search_app("hello", "hello", ""); app.search.as_mut().unwrap().show_replace = true; app.search.as_mut().unwrap().focus_search = true; handle_search_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let focus = app.search.as_ref().unwrap().focus_search; assert!(!focus, "Tab must switch focus from search to replace"); handle_search_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let focus2 = app.search.as_ref().unwrap().focus_search; assert!(focus2, "second Tab must switch focus back to search"); } #[test] fn search_key_char_appends_to_active_field() { // Kills: lines 385:14 `delete !`, 388:13 `&&→||`, and 385:14 match guard mutations. let mut app = make_search_app("hello", "he", ""); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ); let query = &app.search.as_ref().unwrap().query; assert_eq!(query, "hel", "printable char must append to query field"); } #[test] fn search_key_ctrl_a_replaces_all_when_replace_visible() { // Kills: line 379:16 match guard mutations (`with true` / `with false`) let mut app = make_search_app("aa aa", "aa", "b"); app.search.as_mut().unwrap().show_replace = true; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "b b", "Ctrl+A must replace all matches when replace row is visible" ); } #[test] fn search_key_ctrl_a_does_nothing_when_replace_hidden() { // Kills: line 379:16 `replace match guard with true` — guard must be false here. let mut app = make_search_app("aa aa", "aa", "b"); app.search.as_mut().unwrap().show_replace = false; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "aa aa", "Ctrl+A must do nothing when replace row is hidden" ); } // ---- T-27: Normal-mode keys open search / replace ---- #[test] fn ctrl_f_opens_search_mode() { // Kills: line 511:9 `delete match arm (CONTROL, Char('f'))` let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), ); assert!( app.search.is_some(), "Ctrl+F must open search (search = Some)" ); assert!( !app.search.as_ref().unwrap().show_replace, "Ctrl+F must not open replace row" ); } #[test] fn ctrl_h_opens_search_and_replace_mode() { // Kills: line 523:9 `delete match arm (CONTROL, Char('h'))` let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); assert!( app.search.is_some(), "Ctrl+H must open search (search = Some)" ); assert!( app.search.as_ref().unwrap().show_replace, "Ctrl+H must open replace row (show_replace = true)" ); } // ---- T-23: handle_format_table reformats a misaligned table in-place ---- #[test] fn handle_format_table_reformats_table_in_place() { // Kills: lines 243:5 (`replace handle_format_table with ()`), // 258:25 (`replace + with -` and `replace + with *` in `start + i`). // With `()`: textarea unchanged. // With `+→-` in `start + i`: rows written at wrong lines (before start). // With `+→*` in `start + i`: rows written at wrong lines. let mut app = make_app(); app.textarea = TextArea::new(vec![ "| A | Bbb |".to_string(), "| - | --- |".to_string(), "| x | y |".to_string(), ]); app.textarea.move_cursor(CursorMove::Jump(0, 0)); yame::table_format::handle_format_table(&mut app); let out: Vec = app.textarea.lines().iter().map(|s| s.to_string()).collect(); assert_eq!(out.len(), 3, "row count must be unchanged after format"); // All rows must have the same total length after alignment. assert_eq!( out[0].len(), out[2].len(), "header and body row must have equal length after format; rows: {out:?}" ); // Content must still be present (not wiped by no-op mutation). assert!(out[0].contains('A'), "header row must still contain 'A'"); assert!(out[2].contains('x'), "body row must still contain 'x'"); } // ── Shortcuts modal ────────────────────────────────────────────────────── // Kills: `replace false with true` on `show_shortcuts: false` in App::new / // make_app — the modal would be open before F1 is ever pressed. #[test] fn f1_opens_shortcuts_modal() { let mut app = make_app(); assert!(!app.show_shortcuts, "modal must start closed"); handle_key_event(&mut app, key(KeyCode::F(1))); assert!(app.show_shortcuts, "F1 must open the shortcuts modal"); } // Kills: `replace false with true` / `delete app.show_shortcuts = false` in // the modal-swallow intercept — the modal would never close on Esc. #[test] fn esc_closes_shortcuts_modal() { let mut app = make_app(); app.show_shortcuts = true; handle_key_event(&mut app, key(KeyCode::Esc)); assert!(!app.show_shortcuts, "Esc must close the shortcuts modal"); } // Kills: `replace false with true` in the F(1) close arm — pressing F1 // while modal is open would re-open it immediately instead of closing. #[test] fn f1_closes_shortcuts_modal_when_open() { let mut app = make_app(); app.show_shortcuts = true; handle_key_event(&mut app, key(KeyCode::F(1))); assert!( !app.show_shortcuts, "F1 while open must close the shortcuts modal" ); } // Kills: removing the early-return in the modal-swallow intercept — a // character key pressed while the modal is open would reach the textarea. #[test] fn char_key_while_modal_open_does_not_reach_textarea() { let mut app = make_app(); app.show_shortcuts = true; handle_key_event(&mut app, key(KeyCode::Char('x'))); // Modal swallows the key — textarea stays empty AND modal stays open. assert_eq!( app.textarea.lines()[0], "", "char must be swallowed by modal" ); assert!( app.show_shortcuts, "modal must remain open on non-dismiss key" ); } // ── Read-only mode ─────────────────────────────────────────────────────── // Typing a character in read-only mode must be blocked: the textarea stays // empty and a timed status message is set. #[test] fn read_only_blocks_char_input() { let mut app = make_app(); app.read_only = true; handle_key_event(&mut app, key(KeyCode::Char('a'))); assert_eq!( app.textarea.lines()[0], "", "char input must be blocked in read-only mode" ); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "a timed status message must be shown when blocked" ); } // Quit key (Ctrl+Q) must still work in read-only mode. #[test] fn read_only_allows_quit_key() { let mut app = make_app(); app.read_only = true; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Exit, "Ctrl+Q must still quit in read-only mode" ); } // Navigation (arrow keys) must pass through in read-only mode. #[test] fn read_only_allows_navigation() { let mut app = make_app(); app.read_only = true; // Insert two lines so Down has somewhere to go. app.read_only = false; app.textarea .input(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); app.read_only = true; let (row_before, _) = app.textarea.cursor(); handle_key_event(&mut app, key(KeyCode::Up)); let (row_after, _) = app.textarea.cursor(); assert!( row_after <= row_before, "Up arrow must move cursor in read-only mode" ); } // Paste (Ctrl+V) must be blocked in read-only mode. #[test] fn read_only_blocks_paste() { let mut app = make_app(); app.read_only = true; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "", "paste must be blocked in read-only mode" ); } // Undo (Ctrl+Z) must be blocked in read-only mode. #[test] fn read_only_blocks_undo() { let mut app = make_app(); app.read_only = true; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), ); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "undo must be blocked and trigger a status message in read-only mode" ); } // is_ro_allowed must return true for every navigation key code. #[test] fn is_ro_allowed_permits_navigation_keys() { use KeyCode::{Down, End, Home, Left, PageDown, PageUp, Right, Up}; for code in [Up, Down, Left, Right, Home, End, PageUp, PageDown] { for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { assert!( is_ro_allowed(mods, code), "{code:?} with {mods:?} must be allowed in read-only mode" ); } } } // is_ro_allowed must return false for content-modifying keys. #[test] fn is_ro_allowed_blocks_edit_keys() { let blocked = [ (KeyModifiers::NONE, KeyCode::Backspace), (KeyModifiers::NONE, KeyCode::Delete), (KeyModifiers::NONE, KeyCode::Enter), (KeyModifiers::NONE, KeyCode::Tab), (KeyModifiers::NONE, KeyCode::Char('a')), (KeyModifiers::CONTROL, KeyCode::Char('s')), (KeyModifiers::CONTROL, KeyCode::Char('v')), (KeyModifiers::CONTROL, KeyCode::Char('z')), (KeyModifiers::CONTROL, KeyCode::Char('y')), (KeyModifiers::ALT, KeyCode::Char('t')), (KeyModifiers::CONTROL, KeyCode::Char('h')), ]; for (mods, code) in blocked { assert!( !is_ro_allowed(mods, code), "{code:?} with {mods:?} must be blocked in read-only mode" ); } } // Ctrl+E must be allowed through the read-only guard (it's the toggle key). #[test] fn is_ro_allowed_permits_ctrl_e() { assert!( is_ro_allowed(KeyModifiers::CONTROL, KeyCode::Char('e')), "Ctrl+E must be allowed in read-only mode so the toggle works" ); } // is_ro_allowed must permit Ctrl+A and Cmd+A (select-all is non-destructive). #[test] fn is_ro_allowed_permits_select_all() { assert!( is_ro_allowed(KeyModifiers::CONTROL, KeyCode::Char('a')), "Ctrl+A must be allowed in read-only mode" ); assert!( is_ro_allowed(KeyModifiers::SUPER, KeyCode::Char('a')), "Cmd+A must be allowed in read-only mode" ); } // Kills: `lines().len()` replaced by 0 or 1. #[test] fn select_all_returns_line_count() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec![ "alpha".to_string(), "beta".to_string(), "gamma".to_string(), ]); let n = select_all(&mut app); assert_eq!(n, 3, "select_all must return the total line count"); } // Kills: `start_selection()` deleted (no selection active after call). #[test] fn select_all_creates_active_selection() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["first line".to_string(), "second line".to_string()]); select_all(&mut app); assert!( app.textarea.selection_range().is_some(), "select_all must leave an active selection" ); } // Kills: `move_cursor(CursorMove::Bottom)` or `move_cursor(CursorMove::End)` deleted // (cursor would not reach the last character). #[test] fn select_all_places_cursor_at_end_of_last_line() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["line one".to_string(), "line two".to_string()]); select_all(&mut app); let (row, col) = app.textarea.cursor(); assert_eq!(row, 1, "cursor row must be the last line after select_all"); assert_eq!( col, "line two".chars().count(), "cursor col must be end of last line after select_all" ); } // Kills: input.rs:829:9 delete match arm Ctrl+A / Cmd+A in handle_key_event. // With arm deleted, Ctrl+A falls through to wildcard — no selection created. #[test] fn ctrl_a_selects_all_text() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["hello".to_string(), "world".to_string()]); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert!( app.textarea.selection_range().is_some(), "Ctrl+A must create an active selection" ); } // Ctrl+E while read-only → disables read-only and shows a timed message. #[test] fn ctrl_e_unlocks_read_only_mode() { let mut app = make_app(); app.read_only = true; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); assert!(!app.read_only, "Ctrl+E must disable read-only mode"); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "a timed status message must confirm the mode change" ); } // Ctrl+E while in edit mode → enables read-only. #[test] fn ctrl_e_locks_into_read_only_mode() { let mut app = make_app(); app.read_only = false; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); assert!(app.read_only, "Ctrl+E must enable read-only mode"); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "a timed status message must confirm the mode change" ); } // After unlocking with Ctrl+E, typing must reach the textarea. #[test] fn after_unlock_typing_reaches_textarea() { let mut app = make_app(); app.read_only = true; // Unlock. handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); assert!(!app.read_only); // Type a character — must reach the textarea now. handle_key_event(&mut app, key(KeyCode::Char('z'))); assert_eq!( app.textarea.lines()[0], "z", "typing must reach textarea after Ctrl+E unlock" ); } // ── Remaining search-key handler mutants ──────────────────────────────── #[test] fn search_key_ctrl_s_returns_save() { // Kills line 341:9 `delete match arm Ctrl+S in handle_search_key`. // Without the arm, Ctrl+S falls to the swallow arm and returns Continue. let mut app = make_search_app("hello", "hello", ""); let outcome = handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Save, "Ctrl+S must return Save even while search bar is open" ); } #[test] fn search_key_ctrl_h_blocked_in_read_only() { // Kills line 401:56 `replace match guard !app.read_only with true`. // With the guard always true, Ctrl+H would toggle show_replace in read-only mode. let mut app = make_search_app("hello", "hello", ""); app.read_only = true; app.search.as_mut().unwrap().show_replace = false; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); assert!( !app.search.as_ref().unwrap().show_replace, "Ctrl+H must not open replace row when read_only is true" ); } #[test] fn search_key_enter_on_replace_field_calls_replace_current() { // Kills lines 354:54 `replace && with ||` and 354:38 `delete !`. // When focus is on the replace field (focus_search=false) and show_replace=true, // Enter must call do_replace_current, modifying the buffer. let mut app = make_search_app("hello world", "world", "rust"); app.search.as_mut().unwrap().focus_search = false; app.search.as_mut().unwrap().show_replace = true; handle_search_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], "hello rust", "Enter on replace field must replace the current match" ); } #[test] fn search_key_enter_on_search_field_advances_not_replaces() { // Kills lines 350:40 `replace == with !=` and 351:17 `replace && with ||`. // With `!=`, Ctrl+F would mistakenly trigger replace_enter. // With `&&→||`, focus_search=true alone would not prevent replace_enter. // When focus is on the search field, Enter must advance the match without // modifying the buffer. let mut app = make_search_app("hello hello", "hello", "rust"); app.search.as_mut().unwrap().focus_search = true; app.search.as_mut().unwrap().show_replace = true; app.search.as_mut().unwrap().current = 0; handle_search_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], "hello hello", "Enter on search field must advance match selection, not replace text" ); // current must have advanced from 0 to 1. assert_eq!(app.search.as_ref().unwrap().current, 1); } #[test] fn search_key_ctrl_f_with_replace_focused_advances_not_replaces() { // Kills line 350:40 `replace == with !=`. // With `!=`, Ctrl+F (k.code != Enter → true) + focus_search=false + show_replace=true // would make replace_enter=true, triggering a replace instead of advancing. let mut app = make_search_app("hello hello", "hello", "rust"); app.search.as_mut().unwrap().focus_search = false; app.search.as_mut().unwrap().show_replace = true; app.search.as_mut().unwrap().current = 0; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "hello hello", "Ctrl+F must advance match even when replace field has focus" ); } #[test] fn search_key_ctrl_char_not_treated_as_printable() { // Kills line 425:14 `replace match guard with true` and the `| with &` // mutations on line 427 (which reduce the mask to a single modifier bit, // allowing Ctrl+char to slip through as printable). // Ctrl+Z is not handled by any named arm; it must be swallowed, not appended. let mut app = make_search_app("hello", "he", ""); let before = app.search.as_ref().unwrap().query.clone(); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), ); assert_eq!( app.search.as_ref().unwrap().query, before, "Ctrl+Z must be swallowed, not appended as 'z' to the query field" ); } #[test] fn search_key_non_char_key_not_treated_as_printable() { // Kills line 428:13 `replace && with ||`. // With `||`, the arm matches whenever the guard is true OR k.code is Char(_). // An unhandled non-Char key (F5) with no modifier must be swallowed. let mut app = make_search_app("hello", "he", ""); let before = app.search.as_ref().unwrap().query.clone(); handle_search_key(&mut app, KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); assert_eq!( app.search.as_ref().unwrap().query, before, "F5 (non-Char key) must be swallowed, not appended to the query" ); } // ── Ctrl+K: insert fenced code block ──────────────────────────────────────── // // Note: Ctrl+I = Tab; Alt+` unreliable on macOS without Meta key config. // Kills: delete match arm `(CONTROL, Char('k'))`. // Without the arm, Ctrl+K falls into the `_` branch and the textarea receives // a literal control character instead of ```\n```. #[test] fn ctrl_k_inserts_code_block_on_empty_line() { let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); let lines = app.textarea.lines(); assert_eq!(lines[0], "```", "first line must be opening fence"); assert_eq!(lines[1], "```", "second line must be closing fence"); } // Kills: `col + 3 → col + 0` (or any wrong offset) in the CursorMove::Jump. // The cursor must land right after the opening ``` (col 3), not before or // further away. #[test] fn ctrl_k_cursor_lands_after_opening_ticks() { let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); let (row, col) = app.textarea.cursor(); assert_eq!(row, 0, "cursor must be on the opening fence line"); assert_eq!(col, 3, "cursor must be positioned after the opening ```"); } // Cursor was mid-line before the insert — the block is injected at that point // and the cursor returns to the same row, 3 columns past where it started. // // Kills: using the post-insert row instead of the pre-insert row in Jump, or // using an absolute col=3 instead of col_before + 3. #[test] fn ctrl_k_cursor_offset_correct_when_not_at_col_zero() { let mut app = make_app(); app.textarea.insert_str("lang: "); // cursor now at col 6 handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); let (row, col) = app.textarea.cursor(); assert_eq!(row, 0, "cursor must be on the opening fence row"); assert_eq!( col, 9, "cursor must be 3 past the pre-insert column (6 + 3 = 9)" ); } // force_redecorate must be set so the decoration pass picks up the new fences. // // Kills: removing `app.force_redecorate = true` from the Ctrl+K arm. #[test] fn ctrl_k_sets_force_redecorate() { let mut app = make_app(); app.force_redecorate = false; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); assert!( app.force_redecorate, "Ctrl+K must set force_redecorate so decoration reruns" ); } #[test] fn alt_t_reformats_table_via_handle_key_event() { // Kills line 669:9 `delete match arm Alt+T in handle_key_event`. // The existing handle_format_table test calls the function directly; // this test verifies the keybinding wire-up in the dispatch table. let mut app = make_app(); app.textarea = TextArea::new(vec![ "| a | bb |".to_string(), "|---|---|".to_string(), "| c | d |".to_string(), ]); app.textarea.move_cursor(CursorMove::Jump(0, 0)); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('t'), KeyModifiers::ALT), ); // After reformat all rows must have the same length. let l0 = app.textarea.lines()[0].len(); let l2 = app.textarea.lines()[2].len(); assert_eq!(l0, l2, "Alt+T must reformat table rows to equal length"); assert!(l0 > 0, "reformatted table must be non-empty"); } // ── handle_save_as_key ─────────────────────────────────────────────────── fn make_untitled_app() -> App { let mut app = make_app(); app.file_path = None; app.shortened_path = "(untitled)".to_string(); app.status.start_save_as(false); app } // Kills: delete the Char arm → typed chars are swallowed without being pushed. #[test] fn save_as_key_char_appends_to_buffer() { let mut app = make_untitled_app(); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ); assert_eq!( app.status.save_as_state().map(|(i, _)| i), Some("foo"), "typed characters must accumulate in the save-as buffer" ); } // Kills: delete the Backspace arm → pop becomes a no-op. #[test] fn save_as_key_backspace_removes_last_char() { let mut app = make_untitled_app(); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); assert_eq!( app.status.save_as_state().map(|(i, _)| i), Some("f"), "Backspace must remove the last character from the save-as buffer" ); } // Kills: delete the Esc arm → Esc becomes a no-op and mode stays SaveAs. #[test] fn save_as_key_esc_cancels_prompt() { let mut app = make_untitled_app(); handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "Esc must cancel the save-as prompt and return to Normal mode" ); } // Kills: delete Enter arm → Enter becomes a no-op (no SaveAs outcome). #[test] fn save_as_key_enter_with_filename_emits_save_as_outcome() { let mut app = make_untitled_app(); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), ); let outcome = handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( matches!(outcome, KeyOutcome::SaveAs { ref filename, then_exit: false } if filename == "a.md"), "Enter with a filename must emit SaveAs outcome with the typed path" ); } // Kills: invert `then_exit` when propagating from save_as_state → outcome. #[test] fn save_as_key_enter_propagates_then_exit_flag() { let mut app = make_untitled_app(); app.status.start_save_as(true); // then_exit = true handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), ); let outcome = handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( matches!( outcome, KeyOutcome::SaveAs { then_exit: true, .. } ), "then_exit=true must be propagated to the SaveAs outcome" ); } // Kills: delete the empty-filename guard → Enter on empty buffer emits SaveAs(""). #[test] fn save_as_key_enter_on_empty_buffer_stays_in_prompt() { let mut app = make_untitled_app(); // No chars typed — buffer is empty. let outcome = handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( outcome, KeyOutcome::Continue, "Enter on an empty filename must be ignored and keep the prompt open" ); assert!( matches!(app.status.mode, yame::status::StatusMode::SaveAs { .. }), "prompt must remain open when Enter is pressed with no filename" ); } // Untitled buffer: Ctrl+S must open the save-as prompt, not return Save. // Kills: removing the `file_path.is_none()` guard → Ctrl+S returns Save on // untitled buffers, which would call handle_save_to_path on a None path and panic. #[test] fn ctrl_s_on_untitled_buffer_opens_save_as_prompt() { let mut app = make_app(); app.file_path = None; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+S on untitled buffer must return Continue (save-as prompt, not Save)" ); assert!( matches!( app.status.mode, yame::status::StatusMode::SaveAs { then_exit: false, .. } ), "Ctrl+S on untitled buffer must open the save-as prompt" ); } // ── Ctrl+O / file picker ───────────────────────────────────────────────── // Ctrl+O on a clean buffer should go straight to OpenPicker. // Kills: removing the `is_dirty` branch → always sets SwitchFilePrompt. #[test] fn ctrl_o_on_clean_buffer_returns_open_picker() { let mut app = make_app(); app.is_dirty = false; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::OpenPicker, "Ctrl+O on a clean buffer must return OpenPicker immediately" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "status must stay Normal when buffer is clean" ); } // Ctrl+O on a dirty buffer must show the confirmation prompt, not open picker. // Kills: removing the `is_dirty` branch → dirty buffer opens picker without warning. #[test] fn ctrl_o_on_dirty_buffer_shows_switch_prompt() { let mut app = make_app(); app.is_dirty = true; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+O on a dirty buffer must return Continue (prompt shown, not picker)" ); assert!( matches!(app.status.mode, yame::status::StatusMode::SwitchFilePrompt), "Ctrl+O on a dirty buffer must open the SwitchFilePrompt" ); } // SwitchFilePrompt: Y confirms and returns OpenPicker. // Kills: Y arm returning Continue instead of OpenPicker. #[test] fn switch_file_prompt_y_returns_open_picker() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::SwitchFilePrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), ); assert_eq!( outcome, KeyOutcome::OpenPicker, "Y in SwitchFilePrompt must return OpenPicker" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "status must return to Normal after confirming" ); } // SwitchFilePrompt: N cancels and stays Normal. // Kills: N arm returning OpenPicker instead of Continue. #[test] fn switch_file_prompt_n_cancels() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::SwitchFilePrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), ); assert_eq!( outcome, KeyOutcome::Continue, "N in SwitchFilePrompt must return Continue" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "status must return to Normal after cancelling" ); } // SwitchFilePrompt: Esc cancels. // Kills: Esc arm missing → Esc becomes a no-op and mode stays SwitchFilePrompt. #[test] fn switch_file_prompt_esc_cancels() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::SwitchFilePrompt; let outcome = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "Esc must dismiss the SwitchFilePrompt" ); } // --------------------------------------------------------------------------- // detect_list_prefix // --------------------------------------------------------------------------- // Kills: delete the unordered-list arm → unordered lines return None. #[test] fn detect_list_prefix_unordered_dash() { let (cur, next) = detect_list_prefix("- item text").unwrap(); assert_eq!(cur, "- "); assert_eq!(next, "- "); } // Kills: delete the unordered-list arm → `*` returns None. #[test] fn detect_list_prefix_unordered_star() { let (cur, next) = detect_list_prefix("* item").unwrap(); assert_eq!(cur, "* "); assert_eq!(next, "* "); } // Kills: delete indent handling → indented prefix drops leading spaces. #[test] fn detect_list_prefix_indented_unordered() { let (cur, next) = detect_list_prefix(" - nested").unwrap(); assert_eq!(cur, " - "); assert_eq!(next, " - "); } // Kills: delete ordered-list arm → ordered lines return None. #[test] fn detect_list_prefix_ordered_dot() { let (cur, next) = detect_list_prefix("1. first").unwrap(); assert_eq!(cur, "1. "); assert_eq!(next, "2. "); } // Kills: replace num+1 with num → ordered continuation doesn't increment. #[test] fn detect_list_prefix_ordered_increments() { let (_, next) = detect_list_prefix("5. fifth").unwrap(); assert_eq!(next, "6. "); } // Kills: delete ')' branch → ordered list with paren returns None. #[test] fn detect_list_prefix_ordered_paren() { let (cur, next) = detect_list_prefix("3) third").unwrap(); assert_eq!(cur, "3) "); assert_eq!(next, "4) "); } // Kills: delete task-list arm → task items return the plain unordered prefix. #[test] fn detect_list_prefix_task_unchecked() { let (cur, next) = detect_list_prefix("- [ ] task item").unwrap(); assert_eq!(cur, "- [ ] "); assert_eq!(next, "- [ ] "); } // Kills: delete checked-task branch → checked task returns unchecked next. #[test] fn detect_list_prefix_task_checked_continues_unchecked() { let (_, next) = detect_list_prefix("- [x] done").unwrap(); assert_eq!(next, "- [ ] "); } // Kills: delete None branch → non-list lines return Some instead of None. #[test] fn detect_list_prefix_plain_text_returns_none() { assert!(detect_list_prefix("just a sentence").is_none()); assert!(detect_list_prefix("").is_none()); assert!(detect_list_prefix(" ").is_none()); } // Heading lines must not be detected as list prefixes. #[test] fn detect_list_prefix_heading_returns_none() { assert!(detect_list_prefix("# heading").is_none()); } // --------------------------------------------------------------------------- // handle_enter — list continuation // --------------------------------------------------------------------------- // Kills: delete list-continuation arm → Enter on list line inserts plain newline (no prefix). #[test] fn enter_on_list_item_inserts_prefix() { let mut app = make_app(); app.textarea = TextArea::new(vec!["- item".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "- ", "new line must start with list prefix" ); } // Kills: delete exit-list branch → Enter on empty item inserts another prefix instead of exiting. #[test] fn enter_on_empty_list_item_exits_list() { let mut app = make_app(); app.textarea = TextArea::new(vec!["- ".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); // Line count unchanged: the prefix was stripped, no new line added. assert_eq!( app.textarea.lines().len(), 1, "exit-list must not add a line" ); assert_eq!( app.textarea.lines()[0], "", "prefix must be stripped from empty item" ); } // Kills: delete ordered increment → ordered continuation repeats the same number. #[test] fn enter_on_ordered_list_item_increments_number() { let mut app = make_app(); app.textarea = TextArea::new(vec!["3. third".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "4. ", "ordered continuation must use next number" ); } // Kills: delete task-list branch → task item continuation uses wrong prefix. #[test] fn enter_on_task_item_continues_with_unchecked() { let mut app = make_app(); app.textarea = TextArea::new(vec!["- [x] done".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "- [ ] ", "task continuation must be unchecked" ); } // Kills: delete plain-Enter fallback → Enter on non-list line does nothing. #[test] fn enter_on_plain_text_inserts_newline() { let mut app = make_app(); app.textarea = TextArea::new(vec!["hello world".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines().len(), 2, "Enter on plain text must add a new line" ); assert_eq!(app.textarea.lines()[1], "", "new line must be empty"); } // Kills: delete indent-inheritance arm → new line has no leading spaces. #[test] fn enter_on_indented_line_inherits_indent() { let mut app = make_app(); app.textarea = TextArea::new(vec![" hello".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!(app.textarea.lines().len(), 2, "must add a line"); assert_eq!( app.textarea.lines()[1], " ", "new line must carry the 4-space indent" ); } // Kills: negate the is_empty guard → insert_str called on non-indented line (no-op, // but proves the condition is checked). Separate from the above to give a non-empty // indent case and an empty-indent case. #[test] fn enter_on_unindented_line_no_indent_inserted() { let mut app = make_app(); app.textarea = TextArea::new(vec!["hello".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "", "unindented line must yield empty new line" ); } // Kills: delete tab from take_while → tabs are not recognised as indent chars. #[test] fn enter_on_tab_indented_line_inherits_tab() { let mut app = make_app(); app.textarea = TextArea::new(vec!["\t\tcode".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "\t\t", "tab-indented line must carry the tabs" ); } // Kills: delete exit-indent branch → Enter on empty indented line adds another // indented blank line instead of stripping one indent level. #[test] fn enter_on_empty_indented_line_strips_one_level() { let mut app = make_app(); // Line 0 has content; line 1 is pure-whitespace indent (4 spaces, tab_width=4). app.textarea = TextArea::new(vec!["content".to_string(), " ".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::Down); handle_key_event(&mut app, key(KeyCode::Enter)); // Line count must stay the same (no new line inserted). assert_eq!( app.textarea.lines().len(), 2, "exit-indent must not add a line" ); assert_eq!( app.textarea.lines()[1], "", "indent must be stripped to empty" ); } // Kills: replace tab_width with 1 (or a constant) in the strip calculation, // or replace .min with .max — 8 spaces → 4 stripped, not all 8. #[test] fn enter_on_double_indented_empty_line_strips_one_level_only() { let mut app = make_app(); app.textarea = TextArea::new(vec![" ".to_string()]); // 8 spaces, tab_width=4 handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines().len(), 1, "exit-indent must not add a line" ); assert_eq!( app.textarea.lines()[0], " ", "only one tab_width stripped" ); } // Kills: delete the `indent.len() == line.len()` guard → exit-indent fires on // indented lines with content, stripping their prefix instead of inheriting it. #[test] fn enter_on_indented_line_with_content_inherits_not_exits() { let mut app = make_app(); app.textarea = TextArea::new(vec![" hello".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!(app.textarea.lines().len(), 2, "must add a new line"); assert_eq!( app.textarea.lines()[1], " ", "must inherit indent, not exit" ); } // Kills: strip = 1 for tabs → two tabs → one stripped, not two. #[test] fn enter_on_empty_tab_indented_line_strips_one_tab() { let mut app = make_app(); app.textarea = TextArea::new(vec!["\t\t".to_string()]); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines().len(), 1, "exit-indent must not add a line" ); assert_eq!(app.textarea.lines()[0], "\t", "one tab stripped, not both"); } // ── Ctrl+X / F2 arms ──────────────────────────────────────────────────── // Kills: delete Ctrl+X arm — falls to `_` which only sets force_redecorate // when line count changes. Cutting a single-line selection preserves line // count, so the _ fallthrough leaves force_redecorate=false. #[test] fn ctrl_x_sets_force_redecorate() { let mut app = make_app(); app.textarea = TextArea::new(vec!["hello world".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(tui_textarea::CursorMove::Forward); app.force_redecorate = false; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), ); assert!( app.force_redecorate, "Ctrl+X must set force_redecorate=true unconditionally" ); } // Kills: delete F(2) arm — falls to `_` which returns Continue, not OpenSettings. #[test] fn f2_returns_open_settings() { let mut app = make_app(); assert_eq!( handle_key_event(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)), KeyOutcome::OpenSettings, "F2 must return OpenSettings" ); } // Kills: replace != with == in the line-count guard inside the `_` arm. // With ==: force_redecorate is set when line count DOES NOT change (inverted). // Typing a plain char never changes line count, so the mutation would set // force_redecorate where the real code leaves it false. #[test] fn typing_char_does_not_set_force_redecorate() { let mut app = make_app(); app.force_redecorate = false; handle_key_event(&mut app, key(KeyCode::Char('a'))); assert!( !app.force_redecorate, "typing a plain char must not set force_redecorate (no line count change)" ); } // ── detect_list_prefix boundary cases ─────────────────────────────────── // Kills: replace > with >= in the ordered-list digit_end guard. // With >=: digit_end==0 (no leading digits) enters the branch, incorrectly // treating ". item" as an ordered list with an implicit "1." prefix. #[test] fn detect_list_prefix_dot_without_leading_digit_returns_none() { assert!( detect_list_prefix(". item").is_none(), "a line starting with '.' but no leading digit must not match ordered list" ); } // Kills: replace && with || in the ordered-list punctuation filter. // With ||: any char after the digits where the substring-after has a leading // space also matches (e.g. "1x item" where 'x' is not '.'/')' but " item" starts // with ' '). #[test] fn detect_list_prefix_digit_then_non_punct_returns_none() { assert!( detect_list_prefix("1x item").is_none(), "a digit followed by non-punctuation must not match ordered list" ); } }