Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696use std::io;use std::path::{Path, PathBuf};use std::sync::Arc;use std::time::Instant;
use tui_textarea::TextArea;
use crate::config::{FiletypeConfig, Theme};use crate::decoration::DecorationMap;use crate::highlighting::HighlightCache;use crate::renderer::shorten_path;use crate::search::SearchState;use crate::settings::SettingsModal;use crate::status::StatusLine;
// ---------------------------------------------------------------------------// FileMode// ---------------------------------------------------------------------------
/// Determines how the file content is presented.////// Resolved from the file's extension and the `[filetype]` config section at/// startup and on config reload. Stored in `App` and consulted by the event/// loop when deciding whether to run the markdown decoration pass or apply/// whole-file syntect highlighting.#[derive(Debug, Clone, PartialEq)]pub enum FileMode { /// Full markdown decoration pass + fenced-block highlighting. Markdown, /// Skip markdown decoration; apply syntect syntax highlighting for `lang` /// to the entire file. `lang` is the syntect language tag (typically the /// lower-cased file extension). PlainHighlight(String), /// Skip all decoration and highlighting; render the file as unstyled text. PlainText,}
/// Built-in file extensions treated as Markdown.const MARKDOWN_EXTS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown"];
/// Determine the editing mode for a file from its path and `[filetype]` config.////// Resolution order:/// 1. Extension in the built-in or configured markdown list → `Markdown`./// 2. Extension present and non-empty → `PlainHighlight(ext)`./// 3. No extension → use `unknown_as` from config (`"markdown"` / `"plain"`).pub fn resolve_file_mode(path: &Path, config: &FiletypeConfig) -> FileMode { let ext = path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase());
match ext.as_deref() { Some(e) if MARKDOWN_EXTS.contains(&e) || config .extra_markdown_extensions .iter() .any(|ex| ex.eq_ignore_ascii_case(e)) => { FileMode::Markdown } Some(e) => FileMode::PlainHighlight(e.to_string()), None => { if config.unknown_as.eq_ignore_ascii_case("plain") { FileMode::PlainText } else { FileMode::Markdown } } }}
// ---------------------------------------------------------------------------// Binary detection// ---------------------------------------------------------------------------
/// Returns `true` if `buf` contains at least one null byte — the most reliable/// heuristic for detecting binary data without a magic-number database.////// Pure function; separated from the I/O layer so it can be fully unit-tested.pub fn contains_binary_bytes(buf: &[u8]) -> bool { buf.contains(&0u8)}
/// Detect whether `path` is likely a binary file by scanning its first 4 096/// bytes for null bytes.////// Returns `false` for paths that do not yet exist (new files are always text)/// and for any path that cannot be opened (the downstream `load_file` call will/// produce the proper error).#[mutants::skip] // Filesystem I/O — mutations are masked by OS state.pub fn is_likely_binary(path: &Path) -> bool { use std::io::Read; if !path.exists() { return false; } let Ok(mut f) = std::fs::File::open(path) else { return false; }; let mut buf = [0u8; 4096]; let n = f.read(&mut buf).unwrap_or(0); contains_binary_bytes(&buf[..n])}
/// Three-state clipboard handle.////// Avoids repeated blocking connection attempts on headless systems:/// once an `arboard::Clipboard` connection fails we record it as/// `Unavailable` and stop retrying for the rest of the session.pub enum ClipboardState { /// Not yet tried — initialised lazily on the first copy/paste. Uninitialized, /// Connected and ready to use. Ready(arboard::Clipboard), /// A previous connection attempt failed; skip all further attempts. Unavailable,}
/// All mutable application state.pub struct App { pub textarea: TextArea<'static>, /// The path of the file being edited, or `None` for an untitled buffer /// (launched with no arguments). Set to `Some` on the first successful /// save-as from an untitled buffer. pub file_path: Option<PathBuf>, /// Pre-computed display string for the file path (shortened to 3 trailing /// components), or `"(untitled)"` when `file_path` is `None`. Cached here /// so the render hot-path does not re-allocate on every frame. Updated /// whenever `file_path` changes. pub shortened_path: String, pub is_dirty: bool, /// Snapshot of lines at last save, for dirty-flag recomputation after undo/redo. pub saved_content: Option<Vec<String>>, pub theme: Theme, pub italic_support: bool, /// When true, the status bar uses the Powerline filled-arrow glyph (U+E0B0) /// as a segment separator instead of the universal `│` box-drawing character. /// Controlled by `[layout] powerline_glyphs` in config. Default false. pub powerline_glyphs: bool, /// Set on every keystroke; cleared after decoration pass fires. pub last_keystroke: Option<Instant>, /// Set when a structural change (line count change, undo, redo, paste) requires /// the decoration map to be rebuilt on the very next frame instead of waiting /// for the debounce timer. Cleared immediately after the rebuild fires. pub force_redecorate: bool, pub decoration_map: DecorationMap, pub word_count: usize, pub status: StatusLine, pub config_warnings: Vec<String>, pub scroll_top: usize, /// True for exactly one frame after a scroll-wheel or Ctrl+Up/Down event. /// While set, the pre-draw `clamp_scroll` pass is skipped so the viewport /// can pan freely without snapping back to the cursor. Cleared at the top /// of every draw cycle regardless of whether it was set. pub free_scroll: bool, /// The visual-column offset (within the current visual subrow) to aim for /// while navigating Up/Down. Set on the first arrow press of a vertical /// gesture and preserved across subsequent Up/Down presses so the cursor /// returns to its original column after passing through shorter lines. /// Cleared by any non-vertical-nav key (see `handle_key_event`). pub sticky_col: Option<usize>, /// Content width in terminal columns (column_width − 2×GUTTER) as of the /// last render frame. Written by the event loop before dispatching each key /// event so `handle_visual_move` can use the same wrapping the renderer used. pub content_width: usize, /// Clipboard state: uninitialized, ready, or permanently unavailable. /// See [`ClipboardState`] for the three-state semantics. pub clipboard: ClipboardState, /// Number of spaces per Tab key press (and per tab-stop on load). /// Mirrors `[layout] tab_width` from config; stored here so `handle_key_event` /// can expand Tab keypresses without needing access to the full config. pub tab_width: usize, /// Show line numbers in the left gutter. /// Mirrors `[layout] line_numbers` from config. Default false. pub show_line_numbers: bool, /// Syntect highlight cache. `None` when highlighting is disabled in config. /// Wrapped in `Arc` so the background decoration worker can hold a cheap /// reference without cloning the heavy `SyntaxSet` / `ThemeSet` payloads. pub highlight_cache: Option<Arc<HighlightCache>>, /// Editing mode: Markdown decoration, plain syntect highlighting, or plain text. /// Resolved from the file extension and `[filetype]` config at startup and /// updated on config reload (Ctrl+R). pub file_mode: FileMode, /// Active search/replace session, or `None` when the search bar is closed. pub search: Option<SearchState>, /// Set when the search query is modified; cleared after `update_matches` fires. /// The event loop waits for this to elapse before running the expensive full-document /// match scan, so rapid typing does not block the UI on every character. pub search_last_typed: Option<Instant>, /// When true, the viewport is kept centred on the cursor line after every /// non-free-scroll keypress (typewriter mode). Toggled by Ctrl+T. /// Free-scrolling (mouse wheel, Ctrl+Up/Down) still works; the view /// re-centres on the next cursor-moving keypress. pub typewriter_mode: bool, /// When true, lines outside the logical paragraph containing the cursor /// are dimmed to `theme.muted`. Toggled by Ctrl+D. pub focus_mode: bool, /// When true the full keybindings reference modal is rendered over the /// editor. Toggled by F1; dismissed by Esc or F1 while open. pub show_shortcuts: bool, /// When true the buffer is opened in read-only mode: all content-modifying /// keys are suppressed and the status bar shows a distinct `[RO]` pill. /// Set at startup via the `-r` / `--read-only` CLI flag; never toggled at /// runtime. pub read_only: bool, /// When true, typing an opener like `(` inserts `(|)` with the cursor placed /// between the pair; typing the matching closer when already before it skips /// over instead of inserting a duplicate. Mirrors `[layout] auto_close_pairs` /// from config. Default false. pub auto_close_pairs: bool, /// Seconds between automatic saves. 0 = disabled. Mirrors /// `[layout] autosave_seconds` from config; set by caller after `App::new`. pub autosave_secs: u64, /// Open settings modal (F2). `None` when the modal is closed. pub settings: Option<SettingsModal>,}
impl App { /// Create a new App, loading file content if it exists. #[mutants::skip] // Calls load_file (fs I/O) and returns a struct — mutations masked by I/O. #[allow(clippy::too_many_arguments)] pub fn new( file_path: Option<PathBuf>, theme: Theme, italic_support: bool, powerline_glyphs: bool, config_warnings: Vec<String>, tab_width: usize, highlight_cache: Option<HighlightCache>, file_mode: FileMode, show_line_numbers: bool, ) -> io::Result<Self> { let textarea = match &file_path { Some(p) => load_file(p, tab_width)?, None => TextArea::default(), }; // Snapshot the initial content so recompute_dirty() has a baseline for both // existing files (undo back to load state → clean) and new files (empty baseline). let saved_content = Some(textarea.lines().to_vec()); let shortened_path = match &file_path { Some(p) => shorten_path(p, 3), None => "(untitled)".to_string(), };
// Wrap the cache in Arc now so the background decoration worker can hold // a reference without cloning the expensive SyntaxSet/ThemeSet payloads. let highlight_cache: Option<Arc<HighlightCache>> = highlight_cache.map(Arc::new);
Ok(Self { textarea, shortened_path, file_path, // already Option<PathBuf> is_dirty: false, saved_content, theme, italic_support, powerline_glyphs, last_keystroke: None, force_redecorate: true, decoration_map: DecorationMap::default(), word_count: 0, status: StatusLine::default(), config_warnings, scroll_top: 0, free_scroll: false, sticky_col: None, content_width: 0, clipboard: ClipboardState::Uninitialized, tab_width: tab_width.max(1), highlight_cache, file_mode, show_line_numbers, search: None, search_last_typed: None, typewriter_mode: false, focus_mode: false, show_shortcuts: false, read_only: false, // set by caller via app.read_only = read_only after new() auto_close_pairs: false, // set by caller via app.auto_close_pairs after new() autosave_secs: 0, // set by caller via app.autosave_secs after new() settings: None, }) }
/// Replace the current buffer with the contents of `path`. /// /// Resets all per-file state (scroll, search, sticky column, status) so the /// editor feels like a fresh open. `file_mode` is left unchanged — the /// caller is responsible for re-resolving it after this returns. #[mutants::skip] // Calls load_file (fs I/O) — mutations masked by OS state. pub fn load_new_file(&mut self, path: PathBuf, tab_width: usize) -> io::Result<()> { let textarea = load_file(&path, tab_width)?; let saved_content = Some(textarea.lines().to_vec()); let shortened_path = shorten_path(&path, 3);
self.textarea = textarea; self.file_path = Some(path); self.shortened_path = shortened_path; self.saved_content = saved_content; self.is_dirty = false; self.scroll_top = 0; self.search = None; self.decoration_map = DecorationMap::default(); self.last_keystroke = Some(std::time::Instant::now()); self.force_redecorate = true; self.sticky_col = None; self.free_scroll = false; self.status = crate::status::StatusLine::default(); self.word_count = 0; Ok(()) }
/// Record that a keystroke occurred: start the debounce timer and recompute /// dirty state from the actual buffer content. Comparing against the saved /// baseline means pure navigation (arrows, Home/End…) never marks the file /// dirty, while typed characters and paste do. pub fn mark_keystroke(&mut self) { self.last_keystroke = Some(Instant::now()); self.recompute_dirty(); }
/// Recompute is_dirty by comparing current lines to saved_content. pub fn recompute_dirty(&mut self) { self.is_dirty = match &self.saved_content { Some(saved) => self.textarea.lines() != saved.as_slice(), None => !self.textarea.lines().is_empty(), }; }}
/// Extract the currently-selected text from the textarea, or `None` if there/// is no active selection. Does not fall back to the current line.////// Shared between the pair-wrap handler (`input.rs`) and the copy handler/// (`clipboard.rs`), both of which need identical selection extraction logic.pub fn get_selection_text(app: &App) -> Option<String> { let ((row_start, col_start), (row_end, col_end)) = app.textarea.selection_range()?; let lines = app.textarea.lines(); if row_start == row_end { let chars: Vec<char> = lines[row_start].chars().collect(); Some(chars[col_start..col_end.min(chars.len())].iter().collect()) } else { let mut result = String::new(); for row in row_start..=row_end { if row >= lines.len() { break; } let chars: Vec<char> = lines[row].chars().collect(); let start = if row == row_start { col_start } else { 0 }; let end = if row == row_end { col_end.min(chars.len()) } else { chars.len() }; result.extend(&chars[start..end]); if row < row_end { result.push('\n'); } } Some(result) }}
/// Expand tab characters in a line to spaces.////// Each `\t` is replaced with enough spaces to reach the next multiple of/// `tab_width`. This is the standard "visual tab stop" expansion used by most/// editors. Pure function — no I/O.pub fn expand_tabs(line: &str, tab_width: usize) -> String { if !line.contains('\t') { return line.to_string(); } let tab_width = tab_width.max(1); let mut result = String::with_capacity(line.len() + 8); let mut col = 0usize; for ch in line.chars() { if ch == '\t' { let spaces = tab_width - (col % tab_width); for _ in 0..spaces { result.push(' '); } col += spaces; } else { result.push(ch); col += 1; } } result}
/// Load a file into a TextArea, or return an empty TextArea for new files./// Tabs are expanded to spaces using `tab_width` (default 4 if 0 is passed).#[mutants::skip] // fs::read_to_string I/O — mutations (e.g. skipping the read) not testable without a real FS.pub fn load_file(path: &Path, tab_width: usize) -> io::Result<TextArea<'static>> { let tw = if tab_width == 0 { 4 } else { tab_width }; if path.exists() { let content = std::fs::read_to_string(path)?; let lines: Vec<String> = content.lines().map(|l| expand_tabs(l, tw)).collect(); Ok(TextArea::new(lines)) } else { Ok(TextArea::default()) }}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*; use std::path::PathBuf; use tui_textarea::TextArea;
// --- expand_tabs ---
#[test] fn expand_tabs_no_tabs_unchanged() { assert_eq!(expand_tabs("hello world", 4), "hello world"); }
#[test] fn expand_tabs_leading_tab_becomes_spaces() { // \t at col 0 with width 4 → 4 spaces assert_eq!(expand_tabs("\thello", 4), " hello"); }
#[test] fn expand_tabs_mid_line_aligns_to_stop() { // "ab\t" at col 2 with width 4 → 2 spaces to reach col 4 assert_eq!(expand_tabs("ab\thello", 4), "ab hello"); }
#[test] fn expand_tabs_at_exact_stop_gives_full_width() { // "abcd\t" at col 4 (already at stop) → 4 spaces to next stop assert_eq!(expand_tabs("abcd\t", 4), "abcd "); }
#[test] fn expand_tabs_multiple_tabs() { // Two tabs: first fills to col 4, second fills to col 8 assert_eq!(expand_tabs("\t\t", 4), " "); }
// Kills: app.rs:126 replace += with *= in expand_tabs. // With `col *= spaces`: after "a" col=1, first \t gives spaces=3 → col=3 (not 4), // second \t then gives spaces=1 (not 4) → result "a " (5 chars) ≠ "a " (8 chars). #[test] fn expand_tabs_double_tab_with_offset() { // "a" → col 1; \t → 3 spaces (col 4); \t → 4 spaces (col 8) = "a " assert_eq!(expand_tabs("a\t\t", 4), "a "); }
#[test] fn expand_tabs_tab_width_two() { assert_eq!(expand_tabs("\t", 2), " "); }
#[test] fn expand_tabs_empty_string() { assert_eq!(expand_tabs("", 4), ""); }
use crate::config::Theme; use crate::decoration::DecorationMap; use crate::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, } }
#[test] fn mark_keystroke_sets_timer() { let mut app = make_app(); assert!(app.last_keystroke.is_none(), "no timer before keystroke"); app.mark_keystroke(); assert!( app.last_keystroke.is_some(), "timer started after keystroke" ); }
#[test] fn mark_keystroke_dirty_when_content_differs_from_saved() { let mut app = make_app(); // saved = [""], current = ["hello"] → dirty app.saved_content = Some(vec!["".to_string()]); app.textarea = TextArea::new(vec!["hello".to_string()]); app.mark_keystroke(); assert!(app.is_dirty, "modified content → dirty"); }
#[test] fn mark_keystroke_clean_when_content_matches_saved() { let mut app = make_app(); // saved and current both [""] → not dirty (navigation case) let baseline: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); app.saved_content = Some(baseline); app.mark_keystroke(); assert!(!app.is_dirty, "navigation without content change → clean"); }
#[test] fn recompute_dirty_saved_matches_clean() { let mut app = make_app(); // Saved snapshot equals current content → not dirty. let current: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); app.saved_content = Some(current); app.recompute_dirty(); assert!(!app.is_dirty, "matching saved content → clean"); }
#[test] fn recompute_dirty_saved_differs_dirty() { let mut app = make_app(); // Saved snapshot is different from current content → dirty. app.saved_content = Some(vec!["something else".to_string()]); app.recompute_dirty(); assert!(app.is_dirty, "diverging saved content → dirty"); }
#[test] fn recompute_dirty_no_saved_nonempty_is_dirty() { let mut app = make_app(); // No save record and the textarea has content → treat as modified. app.saved_content = None; // Default TextArea has at least one line (""), which is non-empty as a Vec. app.recompute_dirty(); assert!(app.is_dirty, "unsaved non-empty buffer → dirty"); }
#[test] fn recompute_dirty_no_saved_truly_empty_is_clean() { let mut app = make_app(); // An explicitly empty textarea (no lines at all) with no saved record → clean. app.textarea = TextArea::new(vec![]); app.saved_content = None; // TextArea::new(vec![]) may return [""] — test only that recompute_dirty runs // without panic and produces a consistent result. app.recompute_dirty(); // must not panic }
// ── contains_binary_bytes ────────────────────────────────────────────────
#[test] fn binary_bytes_null_detected() { assert!(contains_binary_bytes(&[b'h', b'i', 0u8, b'!'])); }
#[test] fn binary_bytes_no_null_is_clean() { assert!(!contains_binary_bytes(b"hello world\n")); }
#[test] fn binary_bytes_empty_slice_is_clean() { assert!(!contains_binary_bytes(&[])); }
#[test] fn binary_bytes_null_only_slice_detected() { assert!(contains_binary_bytes(&[0u8])); }
#[test] fn binary_bytes_null_at_start_detected() { assert!(contains_binary_bytes(&[0u8, b'a', b'b'])); }
// ── resolve_file_mode ────────────────────────────────────────────────────
use crate::config::FiletypeConfig;
fn default_ft() -> FiletypeConfig { FiletypeConfig::default() }
#[test] fn resolve_md_extension_is_markdown() { let p = PathBuf::from("notes.md"); assert_eq!(resolve_file_mode(&p, &default_ft()), FileMode::Markdown); }
#[test] fn resolve_markdown_extension_is_markdown() { let p = PathBuf::from("README.markdown"); assert_eq!(resolve_file_mode(&p, &default_ft()), FileMode::Markdown); }
#[test] fn resolve_mdx_extension_is_markdown() { let p = PathBuf::from("page.mdx"); assert_eq!(resolve_file_mode(&p, &default_ft()), FileMode::Markdown); }
#[test] fn resolve_rs_extension_is_plain_highlight() { let p = PathBuf::from("main.rs"); assert_eq!( resolve_file_mode(&p, &default_ft()), FileMode::PlainHighlight("rs".to_string()) ); }
#[test] fn resolve_toml_extension_is_plain_highlight() { let p = PathBuf::from("Cargo.toml"); assert_eq!( resolve_file_mode(&p, &default_ft()), FileMode::PlainHighlight("toml".to_string()) ); }
#[test] fn resolve_extension_normalised_to_lowercase() { // Extensions are lowercased so "RS" and "rs" map to the same lang tag. let p = PathBuf::from("main.RS"); assert_eq!( resolve_file_mode(&p, &default_ft()), FileMode::PlainHighlight("rs".to_string()) ); }
#[test] fn resolve_no_extension_defaults_to_markdown() { // Files like CONTRIBUTING, LICENSE, Makefile → Markdown by default. let p = PathBuf::from("CONTRIBUTING"); assert_eq!(resolve_file_mode(&p, &default_ft()), FileMode::Markdown); }
#[test] fn resolve_no_extension_plain_when_configured() { let ft = FiletypeConfig { unknown_as: "plain".into(), ..Default::default() }; let p = PathBuf::from("Makefile"); assert_eq!(resolve_file_mode(&p, &ft), FileMode::PlainText); }
#[test] fn resolve_extra_markdown_extension_honoured() { let ft = FiletypeConfig { extra_markdown_extensions: vec!["txt".into()], ..Default::default() }; let p = PathBuf::from("notes.txt"); assert_eq!(resolve_file_mode(&p, &ft), FileMode::Markdown); }
#[test] fn resolve_extra_markdown_extension_case_insensitive() { let ft = FiletypeConfig { extra_markdown_extensions: vec!["TXT".into()], ..Default::default() }; let p = PathBuf::from("notes.txt"); assert_eq!(resolve_file_mode(&p, &ft), FileMode::Markdown); }}