Skip to main content

vix_editor/
editor.rs

1//! Tabbed editor: a stack of buffers, each backed by an `editor_core`
2//! widget (Tree-sitter syntax highlighting, history, selection, clipboard).
3//!
4//! The code editor addresses the cursor as a flat character offset; this module
5//! converts to/from 1-based line/column for the status bar and go-to-line.
6
7#![warn(clippy::pedantic)]
8
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use ratatui::layout::Rect;
14use ratatui::style::Style;
15#[cfg(feature = "image")]
16pub use ratatui_image::protocol::StatefulProtocol as ImageProtocol;
17
18/// Stand-in for the terminal-graphics protocol in a build without the `image`
19/// feature: it can never be constructed, so `Tab::image` is always `None`.
20#[cfg(not(feature = "image"))]
21pub enum ImageProtocol {}
22use vix_editor_core::actions::{
23    Delete, Duplicate, InsertText, MoveDown, MoveRight, MoveUp, SelectAll,
24};
25use vix_editor_core::code::Code;
26pub use vix_editor_core::editor::Editor as CodeEditor;
27use vix_editor_core::utils::get_lang;
28// Sentence boundaries are shared with the Edit → Transpose → Sentences command,
29// so navigation and transposition split text the same way.
30use vix_textops::sentence_starts;
31
32use vix_theme as theme;
33
34/// Whether line `row` is blank — empty or only whitespace. Out-of-range rows are
35/// treated as blank.
36fn line_is_blank(code: &Code, row: usize) -> bool {
37    if row >= code.len_lines() {
38        return true;
39    }
40    let start = code.line_to_char(row);
41    let len = code.line_len(row);
42    code.char_slice(start, start + len)
43        .chars()
44        .all(|c: char| c.is_whitespace())
45}
46
47/// Whether `row` is part of a section break: a run of two or more consecutive
48/// blank lines. `n` is the total line count.
49fn is_section_break(code: &Code, row: usize, n: usize) -> bool {
50    line_is_blank(code, row)
51        && ((row > 0 && line_is_blank(code, row - 1))
52            || (row + 1 < n && line_is_blank(code, row + 1)))
53}
54
55/// Char offsets where each word begins: a word char whose predecessor is not a
56/// word char. Used by the Go → Word navigation.
57fn word_starts(text: &str) -> Vec<usize> {
58    let mut starts = Vec::new();
59    let mut prev_word = false;
60    for (i, c) in text.chars().enumerate() {
61        let word = is_word_char(c);
62        if word && !prev_word {
63            starts.push(i);
64        }
65        prev_word = word;
66    }
67    starts
68}
69
70/// On-save text-normalization options (from `Settings` in `vix-settings`).
71#[derive(Clone, Copy)]
72pub struct SaveOptions {
73    /// Strip trailing spaces/tabs from every line.
74    pub trim_trailing_whitespace: bool,
75    /// Append a final newline if the (non-empty) file lacks one.
76    pub ensure_final_newline: bool,
77}
78
79/// Strip trailing spaces and tabs from each line, preserving the line structure
80/// (including any final newline and any `\r` before a `\n`).
81fn trim_trailing_whitespace(text: &str) -> String {
82    let mut out = String::with_capacity(text.len());
83    let mut lines = text.split('\n');
84    if let Some(first) = lines.next() {
85        out.push_str(first.trim_end_matches([' ', '\t']));
86    }
87    for line in lines {
88        out.push('\n');
89        out.push_str(line.trim_end_matches([' ', '\t']));
90    }
91    out
92}
93
94/// File extensions opened as images rather than text.
95#[must_use]
96pub fn is_image_path(path: &Path) -> bool {
97    matches!(
98        path.extension()
99            .and_then(|e| e.to_str())
100            .map(str::to_ascii_lowercase)
101            .as_deref(),
102        Some("png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif")
103    )
104}
105
106/// Marker passed to the code editor for search hits. The monochrome theme
107/// renders marks as underlines, so the specific value is not shown as a color.
108pub const SEARCH_MARK: &str = "search";
109
110/// Apply the current theme's styles to a code editor.
111///
112/// With a custom JSON theme active, the editor uses its per-region foreground,
113/// its syntax colors, and its cursor color; otherwise everything is monochrome
114/// (foreground only, no token colors) and the cursor is a reversed block.
115fn apply_theme(ed: &mut CodeEditor) {
116    ed.set_text_style(
117        Style::default()
118            .fg(theme::region_fg(theme::Region::Editor))
119            .add_modifier(theme::region_modifiers(theme::Region::Editor)),
120    );
121    ed.set_line_number_style(theme::dim());
122    ed.set_selection_style(theme::selected());
123    ed.set_whitespace_style(theme::dim());
124    ed.set_bracket_style(theme::selected());
125
126    // Syntax token colors from the active custom theme (empty == monochrome).
127    let syntax = theme::syntax_theme();
128    let pairs: Vec<(&str, &str)> = syntax.iter().map(|(t, hex)| (*t, hex.as_str())).collect();
129    ed.set_syntax_theme(&pairs);
130
131    // Block cursor: the custom theme's cursor color (drawn as the cell bg, with
132    // the editor background as fg so the glyph stays legible), else reversed.
133    let cursor = match theme::editor_cursor() {
134        Some(color) => Style::default()
135            .bg(color)
136            .fg(theme::region_bg(theme::Region::Editor)),
137        None => theme::selected(),
138    };
139    ed.set_cursor_style(Some(cursor));
140}
141
142fn make_editor(path: Option<&Path>, text: &str, flags: Flags, indent: &str) -> CodeEditor {
143    let name = path
144        .and_then(|p| p.file_name())
145        .map(|s| s.to_string_lossy().into_owned())
146        .unwrap_or_default();
147    let lang = if name.is_empty() {
148        "text".to_string()
149    } else {
150        get_lang(&name)
151    };
152    // An empty syntax theme keeps the editor monochrome (no token colors), as the
153    // theme spec requires. `CodeEditor::new` falls back to plain "text" on an
154    // unknown grammar.
155    let mut ed = CodeEditor::new(&lang, text, Vec::new())
156        .or_else(|_| CodeEditor::new("text", text, Vec::new()))
157        .expect("code editor init for plain text never fails");
158    ed.show_line_numbers(flags.contains(Flags::LINE_NUMBERS));
159    ed.show_whitespace(flags.contains(Flags::SHOW_WHITESPACE));
160    ed.set_soft_wrap(flags.contains(Flags::SOFT_WRAP));
161    ed.set_indent(Some(indent.to_string()));
162    apply_theme(&mut ed);
163    ed
164}
165
166/// Build a small, theme-styled, single-line text field holding `content`. Used by
167/// the Vix menu's Website/Email dialogs so the text is selectable and copyable
168/// from inside the TUI (select with the mouse or keyboard, then `Ctrl+C`).
169///
170/// # Panics
171///
172/// Never in practice: the underlying `"text"` grammar always initializes.
173#[must_use]
174pub fn text_field(content: &str) -> CodeEditor {
175    let mut ed = CodeEditor::new("text", content, Vec::new())
176        .expect("text field init for plain text never fails");
177    ed.show_line_numbers(false);
178    apply_theme(&mut ed);
179    ed
180}
181
182/// Whether the OS lets this user write a tab's file (T572).
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
184pub enum FsAccess {
185    /// The file can be written (or has no file yet).
186    #[default]
187    Writable,
188    /// The OS won't let this user write the file.
189    ReadOnly,
190}
191
192/// One open buffer shown as one tab.
193pub struct Tab {
194    /// The underlying code-editor widget/state.
195    pub editor: CodeEditor,
196    /// File path backing this buffer, or `None` for an untitled buffer.
197    pub path: Option<PathBuf>,
198    /// Set when the buffer has unsaved edits.
199    pub dirty: bool,
200    /// Ephemeral preview tab (single-click / arrow-scan from the explorer).
201    pub preview: bool,
202    /// When set, this tab shows an image instead of the text editor.
203    pub image: Option<ImageProtocol>,
204    /// When set, the buffer is locked against edits (view-only).
205    pub read_only: bool,
206    /// When set, the file on disk isn't writable by this user (T572).
207    /// Separate from `read_only`: edits stay allowed (so Save As works) and
208    /// toggling View-only never clears it.
209    pub fs_access: FsAccess,
210}
211
212impl Tab {
213    /// Whether this tab displays an image rather than editable text.
214    pub fn is_image(&self) -> bool {
215        self.image.is_some()
216    }
217
218    /// Whole buffer text.
219    pub fn text(&self) -> String {
220        self.editor.get_content()
221    }
222
223    /// Buffer split into lines (no trailing empty element).
224    pub fn lines(&self) -> Vec<String> {
225        self.text()
226            .lines()
227            .map(std::string::ToString::to_string)
228            .collect()
229    }
230
231    /// Title shown on the tab and in the buffer switcher.
232    pub fn title(&self) -> String {
233        let name = self.path.as_ref().and_then(|p| p.file_name()).map_or_else(
234            || "untitled".to_string(),
235            |s| s.to_string_lossy().into_owned(),
236        );
237        let icon = theme::file_icon(&name);
238        let flag = if self.dirty {
239            format!(" {}", theme::icon::FILE_DIRTY)
240        } else {
241            String::new()
242        };
243        let lock = if self.fs_access == FsAccess::ReadOnly {
244            " 🔒"
245        } else {
246            ""
247        };
248        format!("{icon} {name}{flag}{lock}")
249    }
250
251    /// Full path for display (status bar / buffer switcher), or `"untitled"`.
252    pub fn display_path(&self) -> String {
253        self.path
254            .as_ref()
255            .map_or_else(|| "untitled".to_string(), |p| p.display().to_string())
256    }
257
258    /// 1-based (line, column) of the cursor.
259    pub fn cursor_1based(&self) -> (usize, usize) {
260        let cur = self.editor.get_cursor();
261        let code = self.editor.code_ref();
262        let row = code.char_to_line(cur);
263        let col = cur - code.line_to_char(row);
264        (row + 1, col + 1)
265    }
266
267    /// Total line count of this buffer (for the scrollbar).
268    #[must_use]
269    pub fn line_count(&self) -> usize {
270        self.editor.code_ref().len_lines()
271    }
272}
273
274/// Orientation of an editor split.
275#[derive(Clone, Copy, PartialEq, Eq, Debug)]
276pub enum SplitDir {
277    /// Two panes side by side.
278    Vertical,
279    /// Two panes stacked top/bottom.
280    Horizontal,
281}
282
283bitflags::bitflags! {
284    /// [`Editor`]'s independent display toggles (T149): each bit is a
285    /// distinct, freely-combinable preference, not one of several
286    /// mutually-exclusive modes — a bitset with named accessors is what
287    /// actually satisfies `clippy::struct_excessive_bools` here (grouping
288    /// the same bools into a sub-struct would not: the lint counts `bool`
289    /// fields anywhere, not just on `Editor` itself).
290    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
291    pub struct Flags: u8 {
292        /// The line-number gutter is shown.
293        const LINE_NUMBERS = 1 << 0;
294        /// Line numbers are shown relative to the cursor line.
295        const RELATIVE_LINE_NUMBERS = 1 << 1;
296        /// Visible-whitespace glyphs are shown.
297        const SHOW_WHITESPACE = 1 << 2;
298        /// Long lines soft-wrap.
299        const SOFT_WRAP = 1 << 3;
300    }
301}
302
303/// The tab strip: a stack of open buffers and the active index.
304pub struct Editor {
305    /// Open buffers, left to right.
306    pub tabs: Vec<Tab>,
307    /// Index of the active tab.
308    pub active: usize,
309    /// Display toggles (line numbers, relative line numbers, whitespace,
310    /// soft wrap) — see [`Flags`] for what each bit means.
311    pub flags: Flags,
312    /// String Tab inserts in every buffer (spaces or a tab).
313    pub indent: String,
314    /// Split layout (a binary tree of panes), when the editor area is divided.
315    /// `None` is the normal single-pane case.
316    pub split_root: Option<crate::pane_tree::Pane>,
317    /// In-order index of the focused leaf within `split_root` (0 when unsplit).
318    pub focused_leaf: usize,
319    /// The largest file [`Editor::open`] reads without being told to (T571); a
320    /// bigger one is refused with a typed error the host can turn into a
321    /// confirmation, then reopen with [`Editor::open_unbounded`].
322    pub max_text_bytes: u64,
323}
324
325impl Default for Editor {
326    fn default() -> Self {
327        Editor::new(Flags::LINE_NUMBERS, "    ".to_string())
328    }
329}
330
331impl Editor {
332    /// Create an editor with one empty buffer and the given display /
333    /// indentation settings.
334    #[must_use]
335    pub fn new(flags: Flags, indent: String) -> Self {
336        let mut e = Editor {
337            tabs: Vec::new(),
338            active: 0,
339            flags,
340            indent,
341            split_root: None,
342            focused_leaf: 0,
343            max_text_bytes: vix_fileops::MAX_TEXT_BYTES,
344        };
345        e.new_tab();
346        e
347    }
348
349    /// The active tab, if any.
350    #[must_use]
351    pub fn active_tab(&self) -> Option<&Tab> {
352        self.tabs.get(self.active)
353    }
354
355    /// Mutable access to the active tab, if any.
356    pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
357        self.tabs.get_mut(self.active)
358    }
359
360    /// Whether the editor area is split into more than one pane.
361    #[must_use]
362    pub fn is_split(&self) -> bool {
363        self.split_root.is_some()
364    }
365
366    /// Split the focused pane in `dir`, showing another tab (the next one, else
367    /// the active tab) in the new pane. Creates the split on the first call; later
368    /// calls split the currently focused pane. Capped at `MAX_LEAVES` panes.
369    pub fn set_split(&mut self, dir: SplitDir) {
370        let other = (0..self.tabs.len())
371            .find(|&i| i != self.active)
372            .unwrap_or(self.active);
373        match self.split_root.as_mut() {
374            None => {
375                self.split_root = Some(crate::pane_tree::Pane::Split {
376                    dir,
377                    ratio: 50,
378                    first: Box::new(crate::pane_tree::Pane::Leaf(self.active)),
379                    second: Box::new(crate::pane_tree::Pane::Leaf(other)),
380                });
381                self.focused_leaf = 0;
382            }
383            Some(root) => {
384                root.set_leaf_tab(self.focused_leaf, self.active);
385                root.split_leaf(self.focused_leaf, dir, other);
386            }
387        }
388    }
389
390    /// Remove the focused pane, collapsing the split; a single remaining pane
391    /// returns to the unsplit state.
392    pub fn unsplit(&mut self) {
393        let Some(root) = self.split_root.take() else {
394            return;
395        };
396        match root.remove_leaf(self.focused_leaf) {
397            Some(tree) if tree.leaf_count() > 1 => {
398                self.focused_leaf = self.focused_leaf.min(tree.leaf_count() - 1);
399                if let Some(tab) = tree.leaf_tab(self.focused_leaf) {
400                    self.active = tab.min(self.tabs.len().saturating_sub(1));
401                }
402                self.split_root = Some(tree);
403            }
404            Some(tree) => {
405                if let Some(tab) = tree.leaf_tab(0) {
406                    self.active = tab.min(self.tabs.len().saturating_sub(1));
407                }
408                self.split_root = None;
409                self.focused_leaf = 0;
410            }
411            None => {
412                self.split_root = None;
413                self.focused_leaf = 0;
414            }
415        }
416    }
417
418    /// Sync the focused leaf to the active tab and clamp every leaf to the open
419    /// tabs. Call before reading the layout so the focused pane follows `active`.
420    fn sync_split(&mut self) {
421        let count = self.tabs.len();
422        if let Some(root) = self.split_root.as_mut() {
423            root.set_leaf_tab(self.focused_leaf, self.active);
424            root.clamp_tabs(count);
425            self.focused_leaf = self.focused_leaf.min(root.leaf_count().saturating_sub(1));
426        }
427    }
428
429    /// Focus the leaf at in-order index `i`, making its tab active.
430    pub fn focus_leaf(&mut self, i: usize) {
431        self.sync_split();
432        let Some(root) = self.split_root.as_ref() else {
433            return;
434        };
435        if let Some(tab) = root.leaf_tab(i) {
436            self.focused_leaf = i;
437            self.active = tab.min(self.tabs.len().saturating_sub(1));
438        }
439    }
440
441    /// Move focus to the next pane (wrapping), making its tab active.
442    pub fn focus_other_pane(&mut self) {
443        self.sync_split();
444        let Some(root) = self.split_root.as_ref() else {
445            return;
446        };
447        let n = root.leaf_count();
448        if n > 0 {
449            self.focus_leaf((self.focused_leaf + 1) % n);
450        }
451    }
452
453    /// The in-order index of the focused pane.
454    #[must_use]
455    pub fn focused_leaf(&self) -> usize {
456        self.focused_leaf
457    }
458
459    /// Lay out the split panes into `area` (focused leaf synced to `active`).
460    /// Empty when unsplit.
461    pub fn split_layout(&mut self, area: Rect) -> Vec<crate::pane_tree::LeafBox> {
462        self.sync_split();
463        self.split_root
464            .as_ref()
465            .map(|r| r.layout(area))
466            .unwrap_or_default()
467    }
468
469    /// The split divider rectangles for drawing, given the editor `area`.
470    #[must_use]
471    pub fn split_dividers(&self, area: Rect) -> Vec<(SplitDir, Rect)> {
472        self.split_root
473            .as_ref()
474            .map(|r| r.dividers(area))
475            .unwrap_or_default()
476    }
477
478    /// Focus the pane under `(col, row)` within `area`; returns whether a pane was
479    /// hit.
480    pub fn focus_pane_at(&mut self, area: Rect, col: u16, row: u16) -> bool {
481        self.sync_split();
482        let Some(leaf) = self
483            .split_root
484            .as_ref()
485            .and_then(|r| r.leaf_at(area, col, row))
486        else {
487            return false;
488        };
489        self.focus_leaf(leaf);
490        true
491    }
492
493    /// Drag a split divider under `(col, row)` within `area`; returns whether one
494    /// was resized.
495    pub fn resize_split_at(&mut self, area: Rect, col: u16, row: u16) -> bool {
496        self.split_root
497            .as_mut()
498            .is_some_and(|r| r.resize_at(area, col, row))
499    }
500
501    /// Create an empty untitled buffer and focus it.
502    pub fn new_tab(&mut self) {
503        let editor = make_editor(None, "", self.flags, &self.indent);
504        self.tabs.push(Tab {
505            editor,
506            path: None,
507            dirty: false,
508            preview: false,
509            image: None,
510            read_only: false,
511            fs_access: FsAccess::Writable,
512        });
513        self.active = self.tabs.len() - 1;
514    }
515
516    /// Open a new untitled tab pre-filled with `content` (e.g. AI output), marked
517    /// dirty so the user is reminded to save it. Becomes the active tab.
518    pub fn new_tab_with_content(&mut self, content: &str) {
519        let editor = make_editor(None, content, self.flags, &self.indent);
520        self.tabs.push(Tab {
521            editor,
522            path: None,
523            dirty: true,
524            preview: false,
525            image: None,
526            read_only: false,
527            fs_access: FsAccess::Writable,
528        });
529        self.active = self.tabs.len() - 1;
530    }
531
532    /// Open an image file in a tab, rendered with the given protocol.
533    #[cfg(feature = "image")]
534    pub fn open_image(&mut self, path: &Path, proto: ImageProtocol) {
535        let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
536        if let Some(i) = self
537            .tabs
538            .iter()
539            .position(|t| t.path.as_deref() == Some(canon.as_path()))
540        {
541            self.tabs[i].image = Some(proto);
542            self.active = i;
543            return;
544        }
545        let editor = make_editor(Some(&canon), "", self.flags, &self.indent);
546        self.tabs.push(Tab {
547            editor,
548            path: Some(canon),
549            dirty: false,
550            preview: false,
551            image: Some(proto),
552            read_only: false,
553            fs_access: FsAccess::Writable,
554        });
555        self.active = self.tabs.len() - 1;
556    }
557
558    /// Apply the current line-number settings (visibility + relative) to every
559    /// buffer.
560    pub fn refresh_line_numbers(&mut self) {
561        for tab in &mut self.tabs {
562            tab.editor
563                .show_line_numbers(self.flags.contains(Flags::LINE_NUMBERS));
564            tab.editor
565                .set_relative_line_numbers(self.flags.contains(Flags::RELATIVE_LINE_NUMBERS));
566        }
567    }
568
569    /// Apply the current visible-whitespace setting to every buffer.
570    pub fn refresh_whitespace(&mut self) {
571        for tab in &mut self.tabs {
572            tab.editor
573                .show_whitespace(self.flags.contains(Flags::SHOW_WHITESPACE));
574        }
575    }
576
577    /// Apply the current soft-wrap setting to every buffer.
578    pub fn refresh_soft_wrap(&mut self) {
579        for tab in &mut self.tabs {
580            tab.editor
581                .set_soft_wrap(self.flags.contains(Flags::SOFT_WRAP));
582        }
583    }
584
585    /// Re-apply the current theme's styles to every buffer (after a theme switch).
586    pub fn refresh_theme(&mut self) {
587        for tab in &mut self.tabs {
588            apply_theme(&mut tab.editor);
589        }
590    }
591
592    /// Open a file: focus it if already open, otherwise load it. `preview`
593    /// requests an ephemeral tab that the next preview reuses.
594    ///
595    /// # Errors
596    ///
597    /// Returns an error if the file cannot be read.
598    pub fn open(&mut self, path: &Path, preview: bool) -> io::Result<()> {
599        self.open_with_limit(path, preview, self.max_text_bytes)
600    }
601
602    /// [`Editor::open`] past the size limit the user confirmed: only the hard
603    /// ceiling (`vix_fileops::HARD_MAX_TEXT_BYTES`) still applies (T571).
604    ///
605    /// # Errors
606    /// As [`Editor::open`].
607    pub fn open_unbounded(&mut self, path: &Path, preview: bool) -> io::Result<()> {
608        self.open_with_limit(path, preview, vix_fileops::HARD_MAX_TEXT_BYTES)
609    }
610
611    fn open_with_limit(&mut self, path: &Path, preview: bool, max_bytes: u64) -> io::Result<()> {
612        let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
613        if let Some(i) = self
614            .tabs
615            .iter()
616            .position(|t| t.path.as_deref() == Some(canon.as_path()))
617        {
618            self.active = i;
619            if !preview {
620                self.tabs[i].preview = false;
621            }
622            return Ok(());
623        }
624
625        // Guarded (T571): a FIFO, huge file, or binary refuses with a typed
626        // error; non-UTF-8 text opens lossily and read-only.
627        let fs_read_only = vix_fileops::is_unwritable(&canon);
628        let (content, lossy) = vix_fileops::read_text_guarded(&canon, max_bytes)?;
629        let editor = make_editor(Some(&canon), &content, self.flags, &self.indent);
630        let tab = Tab {
631            editor,
632            path: Some(canon),
633            dirty: false,
634            preview,
635            image: None,
636            read_only: lossy,
637            fs_access: if fs_read_only {
638                FsAccess::ReadOnly
639            } else {
640                FsAccess::Writable
641            },
642        };
643
644        if preview && let Some(i) = self.tabs.iter().position(|t| t.preview) {
645            self.tabs[i] = tab;
646            self.active = i;
647            return Ok(());
648        }
649        self.tabs.push(tab);
650        self.active = self.tabs.len() - 1;
651        Ok(())
652    }
653
654    /// Reload every clean (non-dirty) file-backed buffer from disk, preserving the
655    /// cursor where it still fits. Used after an external change to the working
656    /// tree (e.g. a git branch switch). Dirty buffers are left untouched so unsaved
657    /// edits are never discarded. Returns the number of buffers reloaded.
658    pub fn reload_clean_from_disk(&mut self) -> usize {
659        let mut reloaded = 0;
660        for tab in &mut self.tabs {
661            if tab.dirty || tab.image.is_some() {
662                continue;
663            }
664            let Some(path) = tab.path.clone() else {
665                continue;
666            };
667            let Ok(content) = fs::read_to_string(&path) else {
668                continue;
669            };
670            if content == tab.editor.get_content() {
671                continue;
672            }
673            let cursor = tab.editor.get_cursor();
674            tab.editor.set_content(&content);
675            tab.editor.set_cursor(cursor.min(content.chars().count()));
676            tab.dirty = false;
677            reloaded += 1;
678        }
679        reloaded
680    }
681
682    /// Promote the active preview tab to a permanent tab.
683    pub fn promote_active(&mut self) {
684        if let Some(t) = self.active_tab_mut() {
685            t.preview = false;
686        }
687    }
688
689    /// Save the active buffer to its existing path.
690    ///
691    /// # Errors
692    ///
693    /// Returns an error if the buffer has no path, or the write fails.
694    pub fn save_active(&mut self, opts: SaveOptions) -> io::Result<PathBuf> {
695        let idx = self.active;
696        let path = self.tabs[idx]
697            .path
698            .clone()
699            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "buffer has no path"))?;
700        self.write_to(idx, &path, opts)?;
701        Ok(path)
702    }
703
704    /// Save the active buffer to `path` and adopt it as the buffer's path.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the write fails.
709    pub fn save_active_as(&mut self, path: PathBuf, opts: SaveOptions) -> io::Result<PathBuf> {
710        let idx = self.active;
711        self.write_to(idx, &path, opts)?;
712        self.tabs[idx].path = Some(path.clone());
713        Ok(path)
714    }
715
716    fn write_to(&mut self, idx: usize, path: &Path, opts: SaveOptions) -> io::Result<()> {
717        let mut data = self.tabs[idx].text();
718        if opts.trim_trailing_whitespace {
719            data = trim_trailing_whitespace(&data);
720        }
721        if opts.ensure_final_newline && !data.is_empty() && !data.ends_with('\n') {
722            data.push('\n');
723        }
724        // Atomic, crash-safe save: a temp file is flushed and renamed over the
725        // target, so a crash or full disk mid-write can't truncate the user's
726        // file, and a concurrent reader never sees a half-written buffer.
727        vix_fileops::write_atomic(path, data.as_bytes())?;
728        self.tabs[idx].dirty = false;
729        self.tabs[idx].preview = false;
730        Ok(())
731    }
732
733    /// Close the active tab; keeps at least one empty buffer open. Returns the
734    /// closed tab's file path, if it had one (for "reopen closed tab").
735    pub fn close_active(&mut self) -> Option<PathBuf> {
736        if self.tabs.is_empty() {
737            return None;
738        }
739        let closed = self.tabs.remove(self.active).path;
740        if self.tabs.is_empty() {
741            self.new_tab();
742        } else if self.active >= self.tabs.len() {
743            self.active = self.tabs.len() - 1;
744        }
745        closed
746    }
747
748    /// Close every tab, leaving a single empty untitled buffer. Returns the file
749    /// paths of the closed tabs, oldest first (for "reopen closed tab").
750    pub fn close_all(&mut self) -> Vec<PathBuf> {
751        let closed: Vec<PathBuf> = self.tabs.drain(..).filter_map(|t| t.path).collect();
752        self.active = 0;
753        self.new_tab();
754        closed
755    }
756
757    /// Activate the next tab, wrapping around.
758    pub fn next_tab(&mut self) {
759        if !self.tabs.is_empty() {
760            self.active = (self.active + 1) % self.tabs.len();
761        }
762    }
763
764    /// Activate the previous tab, wrapping around.
765    pub fn prev_tab(&mut self) {
766        if !self.tabs.is_empty() {
767            self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
768        }
769    }
770
771    /// 1-based (line, column) of the active cursor, for the status bar.
772    #[must_use]
773    pub fn cursor_1based(&self) -> (usize, usize) {
774        self.active_tab().map_or((1, 1), Tab::cursor_1based)
775    }
776
777    /// Total line count of the active buffer (for the scrollbar).
778    #[must_use]
779    pub fn active_line_count(&self) -> usize {
780        self.active_tab()
781            .map_or(1, |t| t.editor.code_ref().len_lines())
782    }
783
784    /// Jump the active buffer to a 1-based line and optional 1-based column,
785    /// keeping the target visible within `area`. The line is clamped to the
786    /// buffer's range (an out-of-range line would otherwise panic).
787    pub fn goto(&mut self, line: usize, col: Option<usize>, area: Rect) {
788        if let Some(t) = self.active_tab_mut() {
789            let last = t.editor.code_ref().len_lines().max(1);
790            let line = line.clamp(1, last);
791            let off = t
792                .editor
793                .code_ref()
794                .offset(line - 1, col.unwrap_or(1).saturating_sub(1));
795            t.editor.set_cursor(off);
796            t.editor.focus(&area);
797        }
798    }
799
800    /// Move the active cursor to the start of its current line.
801    /// Smart Home: jump to the first non-blank character of the line; if already
802    /// there (or the line is blank), jump to column 0. Pressing Home repeatedly
803    /// toggles between the two.
804    pub fn cursor_line_home(&mut self) {
805        if let Some(t) = self.active_tab_mut() {
806            let cur = t.editor.get_cursor();
807            let code = t.editor.code_ref();
808            let row = code.char_to_line(cur);
809            let line_start = code.line_to_char(row);
810            let line_len = code.line_len(row);
811            let cur_col = cur - line_start;
812            let indent = code
813                .char_slice(line_start, line_start + line_len)
814                .chars()
815                .take_while(|c| *c == ' ' || *c == '\t')
816                .count();
817            // A blank/all-whitespace line has no first non-blank: treat it as 0.
818            let indent = if indent >= line_len { 0 } else { indent };
819            let target = if cur_col == indent { 0 } else { indent };
820            t.editor.set_cursor(line_start + target);
821        }
822    }
823
824    /// Move the active cursor to the very start of the buffer and scroll it into
825    /// `area`.
826    pub fn cursor_document_start(&mut self, area: Rect) {
827        if let Some(t) = self.active_tab_mut() {
828            t.editor.set_cursor(0);
829            t.editor.focus(&area);
830        }
831    }
832
833    /// Move the active cursor to the very end of the buffer (past the last
834    /// character) and scroll it into `area`.
835    pub fn cursor_document_end(&mut self, area: Rect) {
836        if let Some(t) = self.active_tab_mut() {
837            let code = t.editor.code_ref();
838            let last = code.len_lines().saturating_sub(1);
839            let off = code.line_to_char(last) + code.line_len(last);
840            t.editor.set_cursor(off);
841            t.editor.focus(&area);
842        }
843    }
844
845    /// Move the active cursor to the end of its current line.
846    pub fn cursor_line_end(&mut self) {
847        if let Some(t) = self.active_tab_mut() {
848            let cur = t.editor.get_cursor();
849            let code = t.editor.code_ref();
850            let row = code.char_to_line(cur);
851            let off = code.line_to_char(row) + code.line_len(row);
852            t.editor.set_cursor(off);
853        }
854    }
855
856    /// Move the active cursor to column 0 of its current line (the literal line
857    /// start, unlike the smart-Home [`cursor_line_home`](Self::cursor_line_home)).
858    pub fn cursor_line_start(&mut self) {
859        if let Some(t) = self.active_tab_mut() {
860            let cur = t.editor.get_cursor();
861            let code = t.editor.code_ref();
862            let row = code.char_to_line(cur);
863            let off = code.line_to_char(row);
864            t.editor.set_cursor(off);
865        }
866    }
867
868    /// Move the active cursor to the first line of the current paragraph and
869    /// scroll it into `area`. Paragraphs are delimited by blank (empty or
870    /// whitespace-only) lines; from a blank line, climbs to the paragraph above.
871    pub fn cursor_paragraph_start(&mut self, area: Rect) {
872        if let Some(t) = self.active_tab_mut() {
873            let cur = t.editor.get_cursor();
874            let off = {
875                let code = t.editor.code_ref();
876                let is_blank = |r: usize| line_is_blank(code, r);
877                let mut r = code.char_to_line(cur);
878                while r > 0 && is_blank(r) {
879                    r -= 1;
880                }
881                while r > 0 && !is_blank(r - 1) {
882                    r -= 1;
883                }
884                code.line_to_char(r)
885            };
886            t.editor.set_cursor(off);
887            t.editor.focus(&area);
888        }
889    }
890
891    /// Move the active cursor to the end of the last line of the current
892    /// paragraph and scroll it into `area`. See [`Self::cursor_paragraph_start`].
893    pub fn cursor_paragraph_end(&mut self, area: Rect) {
894        if let Some(t) = self.active_tab_mut() {
895            let cur = t.editor.get_cursor();
896            let off = {
897                let code = t.editor.code_ref();
898                let n = code.len_lines().max(1);
899                let is_blank = |r: usize| line_is_blank(code, r);
900                let mut r = code.char_to_line(cur);
901                while r + 1 < n && is_blank(r) {
902                    r += 1;
903                }
904                while r + 1 < n && !is_blank(r + 1) {
905                    r += 1;
906                }
907                code.line_to_char(r) + code.line_len(r)
908            };
909            t.editor.set_cursor(off);
910            t.editor.focus(&area);
911        }
912    }
913
914    /// Move the active cursor to the first line of the current section and scroll
915    /// it into `area`. Sections are delimited by a run of two or more consecutive
916    /// blank lines (a larger break than a paragraph).
917    pub fn cursor_section_start(&mut self, area: Rect) {
918        if let Some(t) = self.active_tab_mut() {
919            let cur = t.editor.get_cursor();
920            let off = {
921                let code = t.editor.code_ref();
922                let n = code.len_lines().max(1);
923                let sep = |r: usize| is_section_break(code, r, n);
924                let mut r = code.char_to_line(cur);
925                while r > 0 && !sep(r - 1) {
926                    r -= 1;
927                }
928                code.line_to_char(r)
929            };
930            t.editor.set_cursor(off);
931            t.editor.focus(&area);
932        }
933    }
934
935    /// Move the active cursor to the end of the last line of the current section
936    /// and scroll it into `area`. See [`Self::cursor_section_start`].
937    pub fn cursor_section_end(&mut self, area: Rect) {
938        if let Some(t) = self.active_tab_mut() {
939            let cur = t.editor.get_cursor();
940            let off = {
941                let code = t.editor.code_ref();
942                let n = code.len_lines().max(1);
943                let sep = |r: usize| is_section_break(code, r, n);
944                let mut r = code.char_to_line(cur);
945                while r + 1 < n && !sep(r + 1) {
946                    r += 1;
947                }
948                code.line_to_char(r) + code.line_len(r)
949            };
950            t.editor.set_cursor(off);
951            t.editor.focus(&area);
952        }
953    }
954
955    /// Move the active cursor down one line (Go → Line → Next).
956    pub fn cursor_line_down(&mut self, area: Rect) {
957        if let Some(t) = self.active_tab_mut() {
958            t.editor.cursor_down();
959            t.editor.focus(&area);
960        }
961    }
962
963    /// Move the active cursor up one line (Go → Line → Previous).
964    pub fn cursor_line_up(&mut self, area: Rect) {
965        if let Some(t) = self.active_tab_mut() {
966            t.editor.cursor_up();
967            t.editor.focus(&area);
968        }
969    }
970
971    /// 0-based row of the active cursor (0 when there is no buffer).
972    fn cursor_row(&self) -> usize {
973        self.active_tab().map_or(0, |t| {
974            let code = t.editor.code_ref();
975            code.char_to_line(t.editor.get_cursor())
976        })
977    }
978
979    /// 0-based line of the active cursor (0 when there is no buffer).
980    #[must_use]
981    pub fn cursor_line(&self) -> usize {
982        self.cursor_row()
983    }
984
985    /// Install any completed background reparse for the active buffer (large
986    /// files). Returns `true` if the syntax tree changed (request a redraw).
987    pub fn poll_parse(&mut self) -> bool {
988        self.active_tab_mut().is_some_and(|t| t.editor.poll_parse())
989    }
990
991    /// Whether a background reparse is in flight for the active buffer.
992    #[must_use]
993    pub fn parse_pending(&self) -> bool {
994        self.active_tab().is_some_and(|t| t.editor.parse_pending())
995    }
996
997    /// Cycle which undo-tree branch the next redo follows in the active buffer.
998    /// Returns `true` if the current state has more than one branch.
999    pub fn switch_undo_branch(&mut self) -> bool {
1000        self.active_tab_mut()
1001            .is_some_and(|t| t.editor.switch_undo_branch())
1002    }
1003
1004    /// The 0-based index of the active buffer's first visible line (vertical
1005    /// scroll offset), or 0 when there is no buffer.
1006    #[must_use]
1007    pub fn top_visible_line(&self) -> usize {
1008        self.active_tab().map_or(0, |t| t.editor.top_line())
1009    }
1010
1011    /// Set the passive word-occurrence marks on the active buffer.
1012    pub fn set_word_marks(&mut self, marks: Vec<(usize, usize)>) {
1013        if let Some(t) = self.active_tab_mut() {
1014            t.editor.set_word_marks(marks);
1015        }
1016    }
1017
1018    /// The 0-based start rows of every paragraph (`section == false`) or section
1019    /// (`section == true`) in the active buffer. A paragraph is a run of non-blank
1020    /// lines; a section is a run separated by a section break (2+ blank lines).
1021    fn block_starts(&self, section: bool) -> Vec<usize> {
1022        let Some(t) = self.active_tab() else {
1023            return Vec::new();
1024        };
1025        let code = t.editor.code_ref();
1026        let n = code.len_lines().max(1);
1027        (0..n)
1028            .filter(|&r| {
1029                if section {
1030                    !is_section_break(code, r, n) && (r == 0 || is_section_break(code, r - 1, n))
1031                } else {
1032                    !line_is_blank(code, r) && (r == 0 || line_is_blank(code, r - 1))
1033                }
1034            })
1035            .collect()
1036    }
1037
1038    /// Move the active cursor to the start of line `row` and scroll it into view.
1039    fn goto_row(&mut self, row: usize, area: Rect) {
1040        if let Some(t) = self.active_tab_mut() {
1041            let off = t.editor.code_ref().line_to_char(row);
1042            t.editor.set_cursor(off);
1043            t.editor.focus(&area);
1044        }
1045    }
1046
1047    /// Move to the start of the next paragraph (Go → Paragraph → Next).
1048    pub fn cursor_paragraph_next(&mut self, area: Rect) {
1049        let row = self.cursor_row();
1050        if let Some(&next) = self.block_starts(false).iter().find(|&&r| r > row) {
1051            self.goto_row(next, area);
1052        }
1053    }
1054
1055    /// Move to the start of the previous paragraph (Go → Paragraph → Previous).
1056    pub fn cursor_paragraph_prev(&mut self, area: Rect) {
1057        let row = self.cursor_row();
1058        if let Some(&prev) = self.block_starts(false).iter().rev().find(|&&r| r < row) {
1059            self.goto_row(prev, area);
1060        }
1061    }
1062
1063    /// Jump to the 1-based `n`th paragraph (Go → Paragraph → Number).
1064    pub fn goto_paragraph(&mut self, n: usize, area: Rect) {
1065        if let Some(&row) = self.block_starts(false).get(n.saturating_sub(1)) {
1066            self.goto_row(row, area);
1067        }
1068    }
1069
1070    /// Move to the start of the next section (Go → Section → Next).
1071    pub fn cursor_section_next(&mut self, area: Rect) {
1072        let row = self.cursor_row();
1073        if let Some(&next) = self.block_starts(true).iter().find(|&&r| r > row) {
1074            self.goto_row(next, area);
1075        }
1076    }
1077
1078    /// Move to the start of the previous section (Go → Section → Previous).
1079    pub fn cursor_section_prev(&mut self, area: Rect) {
1080        let row = self.cursor_row();
1081        if let Some(&prev) = self.block_starts(true).iter().rev().find(|&&r| r < row) {
1082            self.goto_row(prev, area);
1083        }
1084    }
1085
1086    /// Jump to the 1-based `n`th section (Go → Section → Number).
1087    pub fn goto_section(&mut self, n: usize, area: Rect) {
1088        if let Some(&row) = self.block_starts(true).get(n.saturating_sub(1)) {
1089            self.goto_row(row, area);
1090        }
1091    }
1092
1093    /// Move the active cursor to char offset `off` and scroll it into view.
1094    /// Move the active cursor to char offset `off` and scroll it into view.
1095    pub fn goto_offset(&mut self, off: usize, area: Rect) {
1096        if let Some(t) = self.active_tab_mut() {
1097            t.editor.set_cursor(off);
1098            t.editor.focus(&area);
1099        }
1100    }
1101
1102    /// Move to the start of the current sentence (Go → Sentence → Start).
1103    pub fn cursor_sentence_start(&mut self, area: Rect) {
1104        let Some(t) = self.active_tab() else { return };
1105        let cur = t.editor.get_cursor();
1106        let off = sentence_starts(&t.editor.get_content())
1107            .into_iter()
1108            .rev()
1109            .find(|&s| s <= cur)
1110            .unwrap_or(0);
1111        self.goto_offset(off, area);
1112    }
1113
1114    /// Move to the end of the current sentence — its last non-blank char (Go →
1115    /// Sentence → End).
1116    pub fn cursor_sentence_end(&mut self, area: Rect) {
1117        let Some(t) = self.active_tab() else { return };
1118        let cur = t.editor.get_cursor();
1119        let text = t.editor.get_content();
1120        let chars: Vec<char> = text.chars().collect();
1121        let off = match sentence_starts(&text).into_iter().find(|&s| s > cur) {
1122            Some(mut e) => {
1123                while e > 0 && chars.get(e - 1).is_some_and(|c| c.is_whitespace()) {
1124                    e -= 1;
1125                }
1126                e
1127            }
1128            None => chars.len(),
1129        };
1130        self.goto_offset(off, area);
1131    }
1132
1133    /// Move to the start of the next sentence (Go → Sentence → Next).
1134    pub fn cursor_sentence_next(&mut self, area: Rect) {
1135        let Some(t) = self.active_tab() else { return };
1136        let cur = t.editor.get_cursor();
1137        if let Some(next) = sentence_starts(&t.editor.get_content())
1138            .into_iter()
1139            .find(|&s| s > cur)
1140        {
1141            self.goto_offset(next, area);
1142        }
1143    }
1144
1145    /// Move to the start of the previous sentence (Go → Sentence → Previous).
1146    pub fn cursor_sentence_prev(&mut self, area: Rect) {
1147        let Some(t) = self.active_tab() else { return };
1148        let cur = t.editor.get_cursor();
1149        if let Some(prev) = sentence_starts(&t.editor.get_content())
1150            .into_iter()
1151            .rev()
1152            .find(|&s| s < cur)
1153        {
1154            self.goto_offset(prev, area);
1155        }
1156    }
1157
1158    /// Jump to the 1-based `n`th sentence (Go → Sentence → Number).
1159    pub fn goto_sentence(&mut self, n: usize, area: Rect) {
1160        let Some(t) = self.active_tab() else { return };
1161        let starts = sentence_starts(&t.editor.get_content());
1162        if let Some(&off) = starts.get(n.saturating_sub(1)) {
1163            self.goto_offset(off, area);
1164        }
1165    }
1166
1167    /// Move to the start of the current word — or the previous word's start if the
1168    /// cursor is not on a word (Go → Word → Start).
1169    pub fn cursor_word_start(&mut self, area: Rect) {
1170        let Some(t) = self.active_tab() else { return };
1171        let cur = t.editor.get_cursor();
1172        let off = word_starts(&t.editor.get_content())
1173            .into_iter()
1174            .rev()
1175            .find(|&s| s <= cur)
1176            .unwrap_or(0);
1177        self.goto_offset(off, area);
1178    }
1179
1180    /// Move to the end of the current word (Go → Word → End).
1181    pub fn cursor_word_end(&mut self, area: Rect) {
1182        let Some(t) = self.active_tab() else { return };
1183        let cur = t.editor.get_cursor();
1184        let chars: Vec<char> = t.editor.get_content().chars().collect();
1185        // Skip any non-word chars under the cursor, then run to the word's end.
1186        let mut e = cur;
1187        while e < chars.len() && !is_word_char(chars[e]) {
1188            e += 1;
1189        }
1190        while e < chars.len() && is_word_char(chars[e]) {
1191            e += 1;
1192        }
1193        self.goto_offset(e, area);
1194    }
1195
1196    /// Move to the start of the next word (Go → Word → Next).
1197    pub fn cursor_word_next(&mut self, area: Rect) {
1198        let Some(t) = self.active_tab() else { return };
1199        let cur = t.editor.get_cursor();
1200        if let Some(next) = word_starts(&t.editor.get_content())
1201            .into_iter()
1202            .find(|&s| s > cur)
1203        {
1204            self.goto_offset(next, area);
1205        }
1206    }
1207
1208    /// Move to the start of the previous word (Go → Word → Previous).
1209    pub fn cursor_word_prev(&mut self, area: Rect) {
1210        let Some(t) = self.active_tab() else { return };
1211        let cur = t.editor.get_cursor();
1212        if let Some(prev) = word_starts(&t.editor.get_content())
1213            .into_iter()
1214            .rev()
1215            .find(|&s| s < cur)
1216        {
1217            self.goto_offset(prev, area);
1218        }
1219    }
1220
1221    /// Jump to the 1-based `n`th word (Go → Word → Number).
1222    pub fn goto_word(&mut self, n: usize, area: Rect) {
1223        let Some(t) = self.active_tab() else { return };
1224        let starts = word_starts(&t.editor.get_content());
1225        if let Some(&off) = starts.get(n.saturating_sub(1)) {
1226            self.goto_offset(off, area);
1227        }
1228    }
1229
1230    /// Jump to `pct`% through the file (by line), clamped to 0..=100.
1231    pub fn goto_percent(&mut self, pct: usize, area: Rect) {
1232        let Some(t) = self.active_tab() else { return };
1233        let total = t.editor.code_ref().len_lines().max(1);
1234        let line = pct.min(100) * total.saturating_sub(1) / 100;
1235        self.goto(line + 1, None, area); // goto is 1-based
1236    }
1237
1238    /// Jump to the character at byte offset `byte` (clamped to the buffer end).
1239    pub fn goto_byte(&mut self, byte: usize, area: Rect) {
1240        let Some(t) = self.active_tab() else { return };
1241        let code = t.editor.code_ref();
1242        let off = code.byte_to_char(byte.min(code.len_bytes()));
1243        self.goto_offset(off, area);
1244    }
1245
1246    /// Select the current line, including its trailing newline (so it can be cut
1247    /// as a whole line), and scroll it into `area`.
1248    pub fn select_line(&mut self, area: Rect) {
1249        if let Some(t) = self.active_tab_mut() {
1250            let (start, end) = {
1251                let code = t.editor.code_ref();
1252                let row = code.char_to_line(t.editor.get_cursor());
1253                let start = code.line_to_char(row);
1254                let end = if row + 1 < code.len_lines() {
1255                    code.line_to_char(row + 1)
1256                } else {
1257                    start + code.line_len(row)
1258                };
1259                (start, end)
1260            };
1261            t.editor.set_selection_range(start, end);
1262            t.editor.focus(&area);
1263        }
1264    }
1265
1266    /// Select the whole paragraph under the cursor (blank-line delimited) and
1267    /// scroll it into `area`.
1268    pub fn select_paragraph(&mut self, area: Rect) {
1269        if let Some(t) = self.active_tab_mut() {
1270            let (start, end) = {
1271                let code = t.editor.code_ref();
1272                let n = code.len_lines().max(1);
1273                let is_blank = |r: usize| line_is_blank(code, r);
1274                let row = code.char_to_line(t.editor.get_cursor());
1275                let mut s = row;
1276                while s > 0 && is_blank(s) {
1277                    s -= 1;
1278                }
1279                while s > 0 && !is_blank(s - 1) {
1280                    s -= 1;
1281                }
1282                let mut e = row;
1283                while e + 1 < n && is_blank(e) {
1284                    e += 1;
1285                }
1286                while e + 1 < n && !is_blank(e + 1) {
1287                    e += 1;
1288                }
1289                (
1290                    code.line_to_char(s),
1291                    code.line_to_char(e) + code.line_len(e),
1292                )
1293            };
1294            t.editor.set_selection_range(start, end);
1295            t.editor.focus(&area);
1296        }
1297    }
1298
1299    /// Select the whole section under the cursor (delimited by runs of two or
1300    /// more blank lines) and scroll it into `area`.
1301    pub fn select_section(&mut self, area: Rect) {
1302        if let Some(t) = self.active_tab_mut() {
1303            let (start, end) = {
1304                let code = t.editor.code_ref();
1305                let n = code.len_lines().max(1);
1306                let sep = |r: usize| is_section_break(code, r, n);
1307                let row = code.char_to_line(t.editor.get_cursor());
1308                let mut s = row;
1309                while s > 0 && !sep(s - 1) {
1310                    s -= 1;
1311                }
1312                let mut e = row;
1313                while e + 1 < n && !sep(e + 1) {
1314                    e += 1;
1315                }
1316                (
1317                    code.line_to_char(s),
1318                    code.line_to_char(e) + code.line_len(e),
1319                )
1320            };
1321            t.editor.set_selection_range(start, end);
1322            t.editor.focus(&area);
1323        }
1324    }
1325
1326    /// Insert `text` at the active buffer's cursor and scroll it into `area`.
1327    /// Returns whether the text was inserted (false when there is no editable
1328    /// buffer, e.g. an image tab). Marks the buffer dirty and promotes a preview.
1329    pub fn insert_str(&mut self, text: &str, area: Rect) -> bool {
1330        if let Some(t) = self.active_tab_mut() {
1331            if t.is_image() || t.read_only {
1332                return false;
1333            }
1334            t.editor.apply(InsertText {
1335                text: text.to_string(),
1336            });
1337            t.editor.focus(&area);
1338            t.dirty = true;
1339            t.preview = false;
1340            return true;
1341        }
1342        false
1343    }
1344
1345    /// Paste `text` into the active buffer at the cursor as **one** edit — one
1346    /// undo step — replacing the selection and re-indenting the block to the
1347    /// cursor, exactly as pasting the clipboard does. Scrolls the cursor into
1348    /// `area`. Returns whether the text was pasted (false when there is no
1349    /// editable buffer: no tab, an image, or a read-only one). Used for the
1350    /// terminal's bracketed paste.
1351    pub fn paste_str(&mut self, text: &str, area: Rect) -> bool {
1352        if let Some(t) = self.active_tab_mut() {
1353            if t.is_image() || t.read_only {
1354                return false;
1355            }
1356            t.editor.paste_text(text);
1357            t.editor.focus(&area);
1358            t.dirty = true;
1359            t.preview = false;
1360            return true;
1361        }
1362        false
1363    }
1364
1365    /// Select the entire active buffer.
1366    pub fn select_all(&mut self) {
1367        if let Some(t) = self.active_tab_mut() {
1368            t.editor.apply(SelectAll {});
1369        }
1370    }
1371
1372    /// Duplicate the cursor line (or the selection) in the active buffer.
1373    pub fn duplicate_line(&mut self) {
1374        if let Some(t) = self.active_tab_mut() {
1375            if t.is_image() {
1376                return;
1377            }
1378            t.editor.apply(Duplicate {});
1379            t.dirty = true;
1380            t.preview = false;
1381        }
1382    }
1383
1384    /// Join the active buffer's current line with the next (or all selected
1385    /// lines into one).
1386    pub fn join_lines(&mut self) {
1387        if let Some(t) = self.active_tab_mut() {
1388            if t.is_image() {
1389                return;
1390            }
1391            t.editor.join_lines();
1392            t.dirty = true;
1393            t.preview = false;
1394        }
1395    }
1396
1397    /// Sort the active buffer's selected lines ascending, or the whole buffer
1398    /// when nothing is selected.
1399    pub fn sort_lines(&mut self) {
1400        if let Some(t) = self.active_tab_mut() {
1401            if t.is_image() {
1402                return;
1403            }
1404            t.editor.sort_lines();
1405            t.dirty = true;
1406            t.preview = false;
1407        }
1408    }
1409
1410    /// Strip trailing whitespace from the selected lines (or the whole buffer).
1411    pub fn trim_trailing_whitespace(&mut self) {
1412        self.edit_active_lines(CodeEditor::trim_trailing_whitespace);
1413    }
1414
1415    /// Remove duplicate lines in the selection (or the whole buffer).
1416    pub fn remove_duplicate_lines(&mut self) {
1417        self.edit_active_lines(CodeEditor::remove_duplicate_lines);
1418    }
1419
1420    /// Reverse the order of the selected lines (or the whole buffer).
1421    pub fn reverse_lines(&mut self) {
1422        self.edit_active_lines(CodeEditor::reverse_lines);
1423    }
1424
1425    /// Sort the selected lines ascending and drop duplicates (or the whole buffer).
1426    pub fn sort_unique(&mut self) {
1427        self.edit_active_lines(CodeEditor::sort_unique);
1428    }
1429
1430    /// Randomly reorder the selected lines (or the whole buffer).
1431    pub fn shuffle_lines(&mut self) {
1432        self.edit_active_lines(CodeEditor::shuffle_lines);
1433    }
1434
1435    /// Run a line-editing op on the active (non-image) buffer, marking it dirty.
1436    fn edit_active_lines(&mut self, f: impl FnOnce(&mut CodeEditor)) {
1437        if let Some(t) = self.active_tab_mut() {
1438            if t.is_image() {
1439                return;
1440            }
1441            f(&mut t.editor);
1442            t.dirty = true;
1443            t.preview = false;
1444        }
1445    }
1446
1447    /// Move the active buffer's cursor line up or down by one row, scrolling it
1448    /// into `area`.
1449    pub fn move_line(&mut self, down: bool, area: Rect) {
1450        if let Some(t) = self.active_tab_mut() {
1451            if t.is_image() {
1452                return;
1453            }
1454            if down {
1455                t.editor.move_line_down();
1456            } else {
1457                t.editor.move_line_up();
1458            }
1459            t.editor.focus(&area);
1460            t.dirty = true;
1461            t.preview = false;
1462        }
1463    }
1464
1465    /// Extend the selection by one word: `forward` grows the active end to the
1466    /// next word boundary on the right ("Select More"), otherwise to the previous
1467    /// word boundary on the left ("Select Less").
1468    pub fn select_word(&mut self, forward: bool, area: Rect) {
1469        if let Some(t) = self.active_tab_mut() {
1470            if t.is_image() {
1471                return;
1472            }
1473            let text: Vec<char> = t.text().chars().collect();
1474            let cur = t.editor.get_cursor();
1475            let target = if forward {
1476                next_word(&text, cur)
1477            } else {
1478                prev_word(&text, cur)
1479            };
1480            if target != cur {
1481                t.editor.extend_selection(target);
1482                t.editor.set_cursor(target);
1483                t.editor.focus(&area);
1484            }
1485        }
1486    }
1487
1488    /// Jump the cursor to the partner of the bracket at (or just before) it,
1489    /// scrolling it into `area`. No-op when the cursor is not on a bracket.
1490    pub fn jump_matching_bracket(&mut self, area: Rect) {
1491        if let Some(t) = self.active_tab_mut()
1492            && let Some(off) = t.editor.matching_bracket_offset()
1493        {
1494            t.editor.set_cursor(off);
1495            t.editor.focus(&area);
1496        }
1497    }
1498
1499    /// Forward-delete (the `Delete` key): step right, then delete back.
1500    ///
1501    /// At the very end of the buffer there is nothing ahead to delete: the step
1502    /// right would be a no-op and the delete would eat the character *behind*
1503    /// the cursor, turning forward delete into a backspace. Guard against that,
1504    /// unless a selection is pending — that is what gets deleted then.
1505    pub fn delete_forward(&mut self) {
1506        if let Some(t) = self.active_tab_mut() {
1507            let at_end = t.editor.get_cursor() >= t.editor.code_ref().len_chars();
1508            let selecting = t.editor.get_selection().is_some_and(|s| !s.is_empty());
1509            if at_end && !selecting {
1510                return;
1511            }
1512            t.editor.apply(MoveRight { shift: false });
1513            t.editor.apply(Delete {});
1514        }
1515    }
1516
1517    /// Move the cursor up by `lines` (`PageUp`).
1518    pub fn page_up(&mut self, lines: usize) {
1519        if let Some(t) = self.active_tab_mut() {
1520            for _ in 0..lines.max(1) {
1521                t.editor.apply(MoveUp { shift: false });
1522            }
1523        }
1524    }
1525
1526    /// Move the cursor down by `lines` (`PageDown`).
1527    pub fn page_down(&mut self, lines: usize) {
1528        if let Some(t) = self.active_tab_mut() {
1529            for _ in 0..lines.max(1) {
1530                t.editor.apply(MoveDown { shift: false });
1531            }
1532        }
1533    }
1534}
1535
1536/// Whether `c` is part of a word (alphanumeric or underscore).
1537fn is_word_char(c: char) -> bool {
1538    c.is_alphanumeric() || c == '_'
1539}
1540
1541/// The next word-boundary char index at or after `pos`: skip any non-word
1542/// characters, then the following word run.
1543fn next_word(text: &[char], pos: usize) -> usize {
1544    let n = text.len();
1545    let mut i = pos.min(n);
1546    while i < n && !is_word_char(text[i]) {
1547        i += 1;
1548    }
1549    while i < n && is_word_char(text[i]) {
1550        i += 1;
1551    }
1552    i
1553}
1554
1555/// The previous word-boundary char index at or before `pos`: skip any non-word
1556/// characters to the left, then the preceding word run.
1557fn prev_word(text: &[char], pos: usize) -> usize {
1558    let mut i = pos.min(text.len());
1559    while i > 0 && !is_word_char(text[i - 1]) {
1560        i -= 1;
1561    }
1562    while i > 0 && is_word_char(text[i - 1]) {
1563        i -= 1;
1564    }
1565    i
1566}
1567
1568#[cfg(test)]
1569mod nav_tests {
1570    use super::{sentence_starts, word_starts};
1571
1572    #[test]
1573    fn word_starts_finds_each_word() {
1574        // "foo  bar_baz qux" — words at 0, 5, 13 (underscore stays in one word).
1575        assert_eq!(word_starts("foo  bar_baz qux"), vec![0, 5, 13]);
1576        assert_eq!(word_starts("   leading"), vec![3]);
1577        assert_eq!(word_starts("..."), [] as [usize; 0]);
1578    }
1579
1580    #[test]
1581    fn sentence_starts_splits_on_terminators() {
1582        let text = "One. Two! Three?  Four.";
1583        // Starts at "One"(0), "Two"(5), "Three"(10), "Four"(18).
1584        assert_eq!(sentence_starts(text), vec![0, 5, 10, 18]);
1585        // A period not followed by whitespace (e.g. a decimal) is not a boundary.
1586        assert_eq!(sentence_starts("pi is 3.14 today"), vec![0]);
1587    }
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592    use super::*;
1593
1594    /// T572: a filesystem-read-only tab shows a lock in its title; a
1595    /// writable one doesn't, and toggling View-only doesn't touch it.
1596    #[test]
1597    fn title_shows_a_lock_only_for_filesystem_read_only_tabs() {
1598        let mut e = Editor::new(Flags::LINE_NUMBERS, "    ".into());
1599        let tab = e.active_tab_mut().unwrap();
1600        assert!(!tab.title().contains('🔒'));
1601        tab.fs_access = FsAccess::ReadOnly;
1602        assert!(tab.title().contains('🔒'));
1603        tab.read_only = true;
1604        tab.read_only = false;
1605        assert_eq!(tab.fs_access, FsAccess::ReadOnly);
1606    }
1607}