use 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, /// 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>, 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, /// 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, 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, /// 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>, /// 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, /// 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, /// 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, } 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, theme: Theme, italic_support: bool, powerline_glyphs: bool, config_warnings: Vec, tab_width: usize, highlight_cache: Option, file_mode: FileMode, show_line_numbers: bool, ) -> io::Result { 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> = highlight_cache.map(Arc::new); Ok(Self { textarea, shortened_path, file_path, // already Option 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 { 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 = 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 = 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> { let tw = if tab_width == 0 { 4 } else { tab_width }; if path.exists() { let content = std::fs::read_to_string(path)?; let lines: Vec = 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 = 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 = 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); } }