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