Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288use std::io;use std::time::Duration;
use ratatui::layout::Rect;
use yame::app::App;use yame::renderer;use yame::status::StatusMode;
#[mutants::skip] // Calls std::fs::write — I/O side effect.pub(super) fn handle_save(app: &mut App) -> io::Result<()> { // `KeyOutcome::Save` is only emitted when `file_path.is_some()` (the untitled // path branches to `start_save_as` in `handle_key_event` and returns `Continue`), // so reaching here with `file_path = None` would be a logic error. handle_save_to_path(app)}
/// Write the buffer to `app.file_path` (which must be `Some`).////// Separated from `handle_save` so `handle_save_as_confirm` can reuse the same/// write logic after the path has been set.#[mutants::skip] // Calls std::fs::write — I/O side effect.pub(super) fn handle_save_to_path(app: &mut App) -> io::Result<()> { let path = app.file_path.as_ref().expect("file_path must be Some"); let lines = app.textarea.lines(); // Always write 0 bytes for an empty buffer — consistent regardless of whether // the file was empty at open time. A non-empty save always gets a POSIX newline. let content = if lines == [""] { String::new() } else { lines.join("\n") + "\n" }; // Create parent directories if they don't exist (e.g. `yame notes/new.md`). if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() && let Err(e) = std::fs::create_dir_all(parent) { app.status.set_dismissible(format!("⚠ Save failed: {e}")); return Ok(()); } match std::fs::write(path, &content) { Ok(()) => { app.saved_content = Some(app.textarea.lines().to_vec()); app.is_dirty = false; app.status.set_timed("Saved.", Duration::from_millis(1500)); } Err(e) => { app.status.set_dismissible(format!("⚠ Save failed: {e}")); } } Ok(())}
/// Returns true if the app should exit.pub(super) fn handle_exit(app: &mut App) -> bool { if app.is_dirty { app.status.mode = StatusMode::ExitPrompt; false } else { true }}
/// Clamp `app.scroll_top` so the cursor remains visible within `editor_area`.////// Extracted from the draw closure so that `terminal.draw` can be a pure render/// with no state mutations.////// `bottom_padding` – virtual empty rows to keep below the cursor.////// Uses `app.content_width` (set by the event loop pre-draw pass) so that the/// wrap width here exactly matches what the renderer sees — including any extra/// gutter width from enabled line numbers.pub(super) fn clamp_scroll(app: &mut App, editor_area: Rect, bottom_padding: usize) { let (cursor_row, cursor_col) = app.textarea.cursor(); let visible_rows = editor_area.height as usize; let lines = app.textarea.lines(); let cw = app.content_width.max(1);
// Scroll up: cursor above the viewport. // Equivalent mutant note: `< → <=` when cursor_row == scroll_top sets // scroll_top = cursor_row — a no-op (same value) — so both produce identical // behaviour and no test can distinguish them. if cursor_row < app.scroll_top { app.scroll_top = cursor_row; }
// Pre-clamp: if scroll_top is more than visible_rows logical lines above // the cursor, advance it before the expensive wrap scan below. Each // logical line occupies ≥1 visual row, so visible_rows is a safe upper // bound — the forward scan becomes O(visible_rows) rather than // O(cursor_row − scroll_top), which matters after a large paste. if cursor_row.saturating_sub(app.scroll_top) > visible_rows { app.scroll_top = cursor_row.saturating_sub(visible_rows); }
// Scroll down: check cursor's visual (post-wrap) position let above_visual: usize = lines .get(app.scroll_top..cursor_row.min(lines.len())) .unwrap_or(&[]) .iter() .map(|l| renderer::wrap_line(l, cw).len()) .sum();
let cursor_line_str = lines.get(cursor_row).map_or("", |s| s.as_str()); let cursor_wraps = renderer::wrap_line(cursor_line_str, cw); let cursor_subrow = { let char_ranges = renderer::wrap_char_ranges(cursor_line_str, &cursor_wraps); let mut subrow = 0usize; for (i, &(char_start, char_len)) in char_ranges.iter().enumerate() { let char_end = char_start + char_len; if cursor_col < char_end || i + 1 == char_ranges.len() { subrow = i; break; } } subrow };
let cursor_visual = above_visual + cursor_subrow;
if cursor_visual + bottom_padding >= visible_rows { let headroom = visible_rows.saturating_sub(1 + cursor_subrow + bottom_padding); let mut remaining = headroom; let mut new_top = cursor_row; while new_top > 0 { let prev_wraps = renderer::wrap_line(lines.get(new_top - 1).map_or("", |s| s.as_str()), cw).len(); if prev_wraps > remaining { break; } remaining -= prev_wraps; new_top -= 1; } app.scroll_top = new_top; }}
/// Centre the viewport on the cursor line for typewriter mode.////// Sets `scroll_top` so the cursor logical line sits at `viewport_height / 2`./// Uses logical-line counting (not visual rows) — fast, allocation-free, and/// imperceptibly off for prose where wrapped lines are uncommon. Called/// instead of `clamp_scroll` when `app.typewriter_mode` is active and/// `app.free_scroll` is false.pub(super) fn center_scroll(app: &mut App, editor_area: Rect) { let (cursor_row, _) = app.textarea.cursor(); let half = (editor_area.height as usize) / 2; app.scroll_top = cursor_row.saturating_sub(half);}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use std::path::PathBuf;
use ratatui::layout::Rect; use tui_textarea::TextArea;
use yame::app::{App, ClipboardState, FileMode}; use yame::config::Theme; use yame::decoration::DecorationMap; use yame::status::StatusLine;
use super::clamp_scroll;
fn make_app(lines: Vec<&str>, content_width: usize) -> App { App { textarea: TextArea::new(lines.into_iter().map(String::from).collect()), 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, 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, } }
// Kills commands.rs `replace + with * in clamp_scroll`. // // The mutation changes `i + 1 == char_ranges.len()` to `i * 1 == char_ranges.len()` // (i.e., `i == len`, always false for a valid iterator since i < len). Without // the fallback arm, cursor_subrow is never set in the loop when cursor_col >= // char_end for every intermediate segment, leaving it at 0 (the first visual row) // instead of the correct last visual row. // // Setup (app.content_width=10): // line 0: "aaaa" → 1 visual row // line 1: "bbbb" → 1 visual row // line 2: "0123456789 cc" → 2 visual rows: ["0123456789", "cc"] // char_ranges: (0,10) and (11,2) // cursor_col = 13 (one past the final 'c' = end-of-line) // → cursor_col < char_end(10)? No. fallback (i+1==2)? Yes → subrow=1. ✓ // → mutation: fallback never fires → subrow=0 (wrong). // // above_visual = 1 + 1 = 2; cursor_visual(correct)=3, cursor_visual(mutant)=2. // With visible_rows=3: correct triggers scroll adjustment → scroll_top=1; // mutant does not → scroll_top stays 0. #[test] fn clamp_scroll_subrow_fallback_fires_at_end_of_wrapped_line() { // content_width=10. "0123456789 cc" = 10+space+2 = 13 chars. // At cw=10 it soft-wraps: ["0123456789", "cc"]. let mut app = make_app(vec!["aaaa", "bbbb", "0123456789 cc"], 10); app.scroll_top = 0;
// Place cursor at (2, 13): one past the last char of "0123456789 cc". // tui-textarea clamps to the line length, so this is the end-of-line position. use tui_textarea::CursorMove; app.textarea.move_cursor(CursorMove::Jump(2, 13));
// editor_area height=3 (exactly 3 visual rows visible), width=12. let area = Rect { x: 0, y: 0, width: 12, height: 3, }; clamp_scroll(&mut app, area, 0);
assert_eq!( app.scroll_top, 1, "cursor on the 2nd visual row of line 2 (total vis=3) must \ cause scroll_top to advance to 1 so the cursor remains visible" ); }
// Kills the pre-clamp added for #244: if the guard `cursor_row - scroll_top > // visible_rows` is removed (or the assignment dropped), scroll_top stays at 0 // and the expensive O(N) scan runs over all 20 lines instead of at most 5. // More concretely: after the pre-clamp, scroll_top must be ≥ cursor_row − // visible_rows = 20 − 5 = 15. Without the pre-clamp it stays at 0. #[test] fn clamp_scroll_preclamp_limits_scan_after_large_jump() { // 21 short lines, cursor jumps to row 20, visible_rows = 5. // scroll_top starts at 0 (as if a large paste moved cursor far down). let lines: Vec<&str> = (0..21).map(|_| "x").collect(); let mut app = make_app(lines, 80); app.scroll_top = 0;
use tui_textarea::CursorMove; app.textarea.move_cursor(CursorMove::Jump(20, 0));
let area = Rect { x: 0, y: 0, width: 82, height: 5, }; clamp_scroll(&mut app, area, 0);
// After clamping, cursor (row 20) must be visible within 5 rows. // scroll_top must be in [16, 20]. assert!( app.scroll_top >= 16, "scroll_top ({}) should be ≥ 16 so cursor at row 20 fits in 5 visible rows", app.scroll_top ); }}