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        fs::write(path, data)?;
726        self.tabs[idx].dirty = false;
727        self.tabs[idx].preview = false;
728        Ok(())
729    }
730
731    /// Close the active tab; keeps at least one empty buffer open. Returns the
732    /// closed tab's file path, if it had one (for "reopen closed tab").
733    pub fn close_active(&mut self) -> Option<PathBuf> {
734        if self.tabs.is_empty() {
735            return None;
736        }
737        let closed = self.tabs.remove(self.active).path;
738        if self.tabs.is_empty() {
739            self.new_tab();
740        } else if self.active >= self.tabs.len() {
741            self.active = self.tabs.len() - 1;
742        }
743        closed
744    }
745
746    /// Close every tab, leaving a single empty untitled buffer. Returns the file
747    /// paths of the closed tabs, oldest first (for "reopen closed tab").
748    pub fn close_all(&mut self) -> Vec<PathBuf> {
749        let closed: Vec<PathBuf> = self.tabs.drain(..).filter_map(|t| t.path).collect();
750        self.active = 0;
751        self.new_tab();
752        closed
753    }
754
755    /// Activate the next tab, wrapping around.
756    pub fn next_tab(&mut self) {
757        if !self.tabs.is_empty() {
758            self.active = (self.active + 1) % self.tabs.len();
759        }
760    }
761
762    /// Activate the previous tab, wrapping around.
763    pub fn prev_tab(&mut self) {
764        if !self.tabs.is_empty() {
765            self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
766        }
767    }
768
769    /// 1-based (line, column) of the active cursor, for the status bar.
770    #[must_use]
771    pub fn cursor_1based(&self) -> (usize, usize) {
772        self.active_tab().map_or((1, 1), Tab::cursor_1based)
773    }
774
775    /// Total line count of the active buffer (for the scrollbar).
776    #[must_use]
777    pub fn active_line_count(&self) -> usize {
778        self.active_tab()
779            .map_or(1, |t| t.editor.code_ref().len_lines())
780    }
781
782    /// Jump the active buffer to a 1-based line and optional 1-based column,
783    /// keeping the target visible within `area`. The line is clamped to the
784    /// buffer's range (an out-of-range line would otherwise panic).
785    pub fn goto(&mut self, line: usize, col: Option<usize>, area: Rect) {
786        if let Some(t) = self.active_tab_mut() {
787            let last = t.editor.code_ref().len_lines().max(1);
788            let line = line.clamp(1, last);
789            let off = t
790                .editor
791                .code_ref()
792                .offset(line - 1, col.unwrap_or(1).saturating_sub(1));
793            t.editor.set_cursor(off);
794            t.editor.focus(&area);
795        }
796    }
797
798    /// Move the active cursor to the start of its current line.
799    /// Smart Home: jump to the first non-blank character of the line; if already
800    /// there (or the line is blank), jump to column 0. Pressing Home repeatedly
801    /// toggles between the two.
802    pub fn cursor_line_home(&mut self) {
803        if let Some(t) = self.active_tab_mut() {
804            let cur = t.editor.get_cursor();
805            let code = t.editor.code_ref();
806            let row = code.char_to_line(cur);
807            let line_start = code.line_to_char(row);
808            let line_len = code.line_len(row);
809            let cur_col = cur - line_start;
810            let indent = code
811                .char_slice(line_start, line_start + line_len)
812                .chars()
813                .take_while(|c| *c == ' ' || *c == '\t')
814                .count();
815            // A blank/all-whitespace line has no first non-blank: treat it as 0.
816            let indent = if indent >= line_len { 0 } else { indent };
817            let target = if cur_col == indent { 0 } else { indent };
818            t.editor.set_cursor(line_start + target);
819        }
820    }
821
822    /// Move the active cursor to the very start of the buffer and scroll it into
823    /// `area`.
824    pub fn cursor_document_start(&mut self, area: Rect) {
825        if let Some(t) = self.active_tab_mut() {
826            t.editor.set_cursor(0);
827            t.editor.focus(&area);
828        }
829    }
830
831    /// Move the active cursor to the very end of the buffer (past the last
832    /// character) and scroll it into `area`.
833    pub fn cursor_document_end(&mut self, area: Rect) {
834        if let Some(t) = self.active_tab_mut() {
835            let code = t.editor.code_ref();
836            let last = code.len_lines().saturating_sub(1);
837            let off = code.line_to_char(last) + code.line_len(last);
838            t.editor.set_cursor(off);
839            t.editor.focus(&area);
840        }
841    }
842
843    /// Move the active cursor to the end of its current line.
844    pub fn cursor_line_end(&mut self) {
845        if let Some(t) = self.active_tab_mut() {
846            let cur = t.editor.get_cursor();
847            let code = t.editor.code_ref();
848            let row = code.char_to_line(cur);
849            let off = code.line_to_char(row) + code.line_len(row);
850            t.editor.set_cursor(off);
851        }
852    }
853
854    /// Move the active cursor to column 0 of its current line (the literal line
855    /// start, unlike the smart-Home [`cursor_line_home`](Self::cursor_line_home)).
856    pub fn cursor_line_start(&mut self) {
857        if let Some(t) = self.active_tab_mut() {
858            let cur = t.editor.get_cursor();
859            let code = t.editor.code_ref();
860            let row = code.char_to_line(cur);
861            let off = code.line_to_char(row);
862            t.editor.set_cursor(off);
863        }
864    }
865
866    /// Move the active cursor to the first line of the current paragraph and
867    /// scroll it into `area`. Paragraphs are delimited by blank (empty or
868    /// whitespace-only) lines; from a blank line, climbs to the paragraph above.
869    pub fn cursor_paragraph_start(&mut self, area: Rect) {
870        if let Some(t) = self.active_tab_mut() {
871            let cur = t.editor.get_cursor();
872            let off = {
873                let code = t.editor.code_ref();
874                let is_blank = |r: usize| line_is_blank(code, r);
875                let mut r = code.char_to_line(cur);
876                while r > 0 && is_blank(r) {
877                    r -= 1;
878                }
879                while r > 0 && !is_blank(r - 1) {
880                    r -= 1;
881                }
882                code.line_to_char(r)
883            };
884            t.editor.set_cursor(off);
885            t.editor.focus(&area);
886        }
887    }
888
889    /// Move the active cursor to the end of the last line of the current
890    /// paragraph and scroll it into `area`. See [`cursor_paragraph_start`].
891    pub fn cursor_paragraph_end(&mut self, area: Rect) {
892        if let Some(t) = self.active_tab_mut() {
893            let cur = t.editor.get_cursor();
894            let off = {
895                let code = t.editor.code_ref();
896                let n = code.len_lines().max(1);
897                let is_blank = |r: usize| line_is_blank(code, r);
898                let mut r = code.char_to_line(cur);
899                while r + 1 < n && is_blank(r) {
900                    r += 1;
901                }
902                while r + 1 < n && !is_blank(r + 1) {
903                    r += 1;
904                }
905                code.line_to_char(r) + code.line_len(r)
906            };
907            t.editor.set_cursor(off);
908            t.editor.focus(&area);
909        }
910    }
911
912    /// Move the active cursor to the first line of the current section and scroll
913    /// it into `area`. Sections are delimited by a run of two or more consecutive
914    /// blank lines (a larger break than a paragraph).
915    pub fn cursor_section_start(&mut self, area: Rect) {
916        if let Some(t) = self.active_tab_mut() {
917            let cur = t.editor.get_cursor();
918            let off = {
919                let code = t.editor.code_ref();
920                let n = code.len_lines().max(1);
921                let sep = |r: usize| is_section_break(code, r, n);
922                let mut r = code.char_to_line(cur);
923                while r > 0 && !sep(r - 1) {
924                    r -= 1;
925                }
926                code.line_to_char(r)
927            };
928            t.editor.set_cursor(off);
929            t.editor.focus(&area);
930        }
931    }
932
933    /// Move the active cursor to the end of the last line of the current section
934    /// and scroll it into `area`. See [`cursor_section_start`].
935    pub fn cursor_section_end(&mut self, area: Rect) {
936        if let Some(t) = self.active_tab_mut() {
937            let cur = t.editor.get_cursor();
938            let off = {
939                let code = t.editor.code_ref();
940                let n = code.len_lines().max(1);
941                let sep = |r: usize| is_section_break(code, r, n);
942                let mut r = code.char_to_line(cur);
943                while r + 1 < n && !sep(r + 1) {
944                    r += 1;
945                }
946                code.line_to_char(r) + code.line_len(r)
947            };
948            t.editor.set_cursor(off);
949            t.editor.focus(&area);
950        }
951    }
952
953    /// Move the active cursor down one line (Go → Line → Next).
954    pub fn cursor_line_down(&mut self, area: Rect) {
955        if let Some(t) = self.active_tab_mut() {
956            t.editor.cursor_down();
957            t.editor.focus(&area);
958        }
959    }
960
961    /// Move the active cursor up one line (Go → Line → Previous).
962    pub fn cursor_line_up(&mut self, area: Rect) {
963        if let Some(t) = self.active_tab_mut() {
964            t.editor.cursor_up();
965            t.editor.focus(&area);
966        }
967    }
968
969    /// 0-based row of the active cursor (0 when there is no buffer).
970    fn cursor_row(&self) -> usize {
971        self.active_tab().map_or(0, |t| {
972            let code = t.editor.code_ref();
973            code.char_to_line(t.editor.get_cursor())
974        })
975    }
976
977    /// 0-based line of the active cursor (0 when there is no buffer).
978    #[must_use]
979    pub fn cursor_line(&self) -> usize {
980        self.cursor_row()
981    }
982
983    /// Install any completed background reparse for the active buffer (large
984    /// files). Returns `true` if the syntax tree changed (request a redraw).
985    pub fn poll_parse(&mut self) -> bool {
986        self.active_tab_mut().is_some_and(|t| t.editor.poll_parse())
987    }
988
989    /// Whether a background reparse is in flight for the active buffer.
990    #[must_use]
991    pub fn parse_pending(&self) -> bool {
992        self.active_tab().is_some_and(|t| t.editor.parse_pending())
993    }
994
995    /// Cycle which undo-tree branch the next redo follows in the active buffer.
996    /// Returns `true` if the current state has more than one branch.
997    pub fn switch_undo_branch(&mut self) -> bool {
998        self.active_tab_mut()
999            .is_some_and(|t| t.editor.switch_undo_branch())
1000    }
1001
1002    /// The 0-based index of the active buffer's first visible line (vertical
1003    /// scroll offset), or 0 when there is no buffer.
1004    #[must_use]
1005    pub fn top_visible_line(&self) -> usize {
1006        self.active_tab().map_or(0, |t| t.editor.top_line())
1007    }
1008
1009    /// Set the passive word-occurrence marks on the active buffer.
1010    pub fn set_word_marks(&mut self, marks: Vec<(usize, usize)>) {
1011        if let Some(t) = self.active_tab_mut() {
1012            t.editor.set_word_marks(marks);
1013        }
1014    }
1015
1016    /// The 0-based start rows of every paragraph (`section == false`) or section
1017    /// (`section == true`) in the active buffer. A paragraph is a run of non-blank
1018    /// lines; a section is a run separated by a section break (2+ blank lines).
1019    fn block_starts(&self, section: bool) -> Vec<usize> {
1020        let Some(t) = self.active_tab() else {
1021            return Vec::new();
1022        };
1023        let code = t.editor.code_ref();
1024        let n = code.len_lines().max(1);
1025        (0..n)
1026            .filter(|&r| {
1027                if section {
1028                    !is_section_break(code, r, n) && (r == 0 || is_section_break(code, r - 1, n))
1029                } else {
1030                    !line_is_blank(code, r) && (r == 0 || line_is_blank(code, r - 1))
1031                }
1032            })
1033            .collect()
1034    }
1035
1036    /// Move the active cursor to the start of line `row` and scroll it into view.
1037    fn goto_row(&mut self, row: usize, area: Rect) {
1038        if let Some(t) = self.active_tab_mut() {
1039            let off = t.editor.code_ref().line_to_char(row);
1040            t.editor.set_cursor(off);
1041            t.editor.focus(&area);
1042        }
1043    }
1044
1045    /// Move to the start of the next paragraph (Go → Paragraph → Next).
1046    pub fn cursor_paragraph_next(&mut self, area: Rect) {
1047        let row = self.cursor_row();
1048        if let Some(&next) = self.block_starts(false).iter().find(|&&r| r > row) {
1049            self.goto_row(next, area);
1050        }
1051    }
1052
1053    /// Move to the start of the previous paragraph (Go → Paragraph → Previous).
1054    pub fn cursor_paragraph_prev(&mut self, area: Rect) {
1055        let row = self.cursor_row();
1056        if let Some(&prev) = self.block_starts(false).iter().rev().find(|&&r| r < row) {
1057            self.goto_row(prev, area);
1058        }
1059    }
1060
1061    /// Jump to the 1-based `n`th paragraph (Go → Paragraph → Number).
1062    pub fn goto_paragraph(&mut self, n: usize, area: Rect) {
1063        if let Some(&row) = self.block_starts(false).get(n.saturating_sub(1)) {
1064            self.goto_row(row, area);
1065        }
1066    }
1067
1068    /// Move to the start of the next section (Go → Section → Next).
1069    pub fn cursor_section_next(&mut self, area: Rect) {
1070        let row = self.cursor_row();
1071        if let Some(&next) = self.block_starts(true).iter().find(|&&r| r > row) {
1072            self.goto_row(next, area);
1073        }
1074    }
1075
1076    /// Move to the start of the previous section (Go → Section → Previous).
1077    pub fn cursor_section_prev(&mut self, area: Rect) {
1078        let row = self.cursor_row();
1079        if let Some(&prev) = self.block_starts(true).iter().rev().find(|&&r| r < row) {
1080            self.goto_row(prev, area);
1081        }
1082    }
1083
1084    /// Jump to the 1-based `n`th section (Go → Section → Number).
1085    pub fn goto_section(&mut self, n: usize, area: Rect) {
1086        if let Some(&row) = self.block_starts(true).get(n.saturating_sub(1)) {
1087            self.goto_row(row, area);
1088        }
1089    }
1090
1091    /// Move the active cursor to char offset `off` and scroll it into view.
1092    /// Move the active cursor to char offset `off` and scroll it into view.
1093    pub fn goto_offset(&mut self, off: usize, area: Rect) {
1094        if let Some(t) = self.active_tab_mut() {
1095            t.editor.set_cursor(off);
1096            t.editor.focus(&area);
1097        }
1098    }
1099
1100    /// Move to the start of the current sentence (Go → Sentence → Start).
1101    pub fn cursor_sentence_start(&mut self, area: Rect) {
1102        let Some(t) = self.active_tab() else { return };
1103        let cur = t.editor.get_cursor();
1104        let off = sentence_starts(&t.editor.get_content())
1105            .into_iter()
1106            .rev()
1107            .find(|&s| s <= cur)
1108            .unwrap_or(0);
1109        self.goto_offset(off, area);
1110    }
1111
1112    /// Move to the end of the current sentence — its last non-blank char (Go →
1113    /// Sentence → End).
1114    pub fn cursor_sentence_end(&mut self, area: Rect) {
1115        let Some(t) = self.active_tab() else { return };
1116        let cur = t.editor.get_cursor();
1117        let text = t.editor.get_content();
1118        let chars: Vec<char> = text.chars().collect();
1119        let off = match sentence_starts(&text).into_iter().find(|&s| s > cur) {
1120            Some(mut e) => {
1121                while e > 0 && chars.get(e - 1).is_some_and(|c| c.is_whitespace()) {
1122                    e -= 1;
1123                }
1124                e
1125            }
1126            None => chars.len(),
1127        };
1128        self.goto_offset(off, area);
1129    }
1130
1131    /// Move to the start of the next sentence (Go → Sentence → Next).
1132    pub fn cursor_sentence_next(&mut self, area: Rect) {
1133        let Some(t) = self.active_tab() else { return };
1134        let cur = t.editor.get_cursor();
1135        if let Some(next) = sentence_starts(&t.editor.get_content())
1136            .into_iter()
1137            .find(|&s| s > cur)
1138        {
1139            self.goto_offset(next, area);
1140        }
1141    }
1142
1143    /// Move to the start of the previous sentence (Go → Sentence → Previous).
1144    pub fn cursor_sentence_prev(&mut self, area: Rect) {
1145        let Some(t) = self.active_tab() else { return };
1146        let cur = t.editor.get_cursor();
1147        if let Some(prev) = sentence_starts(&t.editor.get_content())
1148            .into_iter()
1149            .rev()
1150            .find(|&s| s < cur)
1151        {
1152            self.goto_offset(prev, area);
1153        }
1154    }
1155
1156    /// Jump to the 1-based `n`th sentence (Go → Sentence → Number).
1157    pub fn goto_sentence(&mut self, n: usize, area: Rect) {
1158        let Some(t) = self.active_tab() else { return };
1159        let starts = sentence_starts(&t.editor.get_content());
1160        if let Some(&off) = starts.get(n.saturating_sub(1)) {
1161            self.goto_offset(off, area);
1162        }
1163    }
1164
1165    /// Move to the start of the current word — or the previous word's start if the
1166    /// cursor is not on a word (Go → Word → Start).
1167    pub fn cursor_word_start(&mut self, area: Rect) {
1168        let Some(t) = self.active_tab() else { return };
1169        let cur = t.editor.get_cursor();
1170        let off = word_starts(&t.editor.get_content())
1171            .into_iter()
1172            .rev()
1173            .find(|&s| s <= cur)
1174            .unwrap_or(0);
1175        self.goto_offset(off, area);
1176    }
1177
1178    /// Move to the end of the current word (Go → Word → End).
1179    pub fn cursor_word_end(&mut self, area: Rect) {
1180        let Some(t) = self.active_tab() else { return };
1181        let cur = t.editor.get_cursor();
1182        let chars: Vec<char> = t.editor.get_content().chars().collect();
1183        // Skip any non-word chars under the cursor, then run to the word's end.
1184        let mut e = cur;
1185        while e < chars.len() && !is_word_char(chars[e]) {
1186            e += 1;
1187        }
1188        while e < chars.len() && is_word_char(chars[e]) {
1189            e += 1;
1190        }
1191        self.goto_offset(e, area);
1192    }
1193
1194    /// Move to the start of the next word (Go → Word → Next).
1195    pub fn cursor_word_next(&mut self, area: Rect) {
1196        let Some(t) = self.active_tab() else { return };
1197        let cur = t.editor.get_cursor();
1198        if let Some(next) = word_starts(&t.editor.get_content())
1199            .into_iter()
1200            .find(|&s| s > cur)
1201        {
1202            self.goto_offset(next, area);
1203        }
1204    }
1205
1206    /// Move to the start of the previous word (Go → Word → Previous).
1207    pub fn cursor_word_prev(&mut self, area: Rect) {
1208        let Some(t) = self.active_tab() else { return };
1209        let cur = t.editor.get_cursor();
1210        if let Some(prev) = word_starts(&t.editor.get_content())
1211            .into_iter()
1212            .rev()
1213            .find(|&s| s < cur)
1214        {
1215            self.goto_offset(prev, area);
1216        }
1217    }
1218
1219    /// Jump to the 1-based `n`th word (Go → Word → Number).
1220    pub fn goto_word(&mut self, n: usize, area: Rect) {
1221        let Some(t) = self.active_tab() else { return };
1222        let starts = word_starts(&t.editor.get_content());
1223        if let Some(&off) = starts.get(n.saturating_sub(1)) {
1224            self.goto_offset(off, area);
1225        }
1226    }
1227
1228    /// Jump to `pct`% through the file (by line), clamped to 0..=100.
1229    pub fn goto_percent(&mut self, pct: usize, area: Rect) {
1230        let Some(t) = self.active_tab() else { return };
1231        let total = t.editor.code_ref().len_lines().max(1);
1232        let line = pct.min(100) * total.saturating_sub(1) / 100;
1233        self.goto(line + 1, None, area); // goto is 1-based
1234    }
1235
1236    /// Jump to the character at byte offset `byte` (clamped to the buffer end).
1237    pub fn goto_byte(&mut self, byte: usize, area: Rect) {
1238        let Some(t) = self.active_tab() else { return };
1239        let code = t.editor.code_ref();
1240        let off = code.byte_to_char(byte.min(code.len_bytes()));
1241        self.goto_offset(off, area);
1242    }
1243
1244    /// Select the current line, including its trailing newline (so it can be cut
1245    /// as a whole line), and scroll it into `area`.
1246    pub fn select_line(&mut self, area: Rect) {
1247        if let Some(t) = self.active_tab_mut() {
1248            let (start, end) = {
1249                let code = t.editor.code_ref();
1250                let row = code.char_to_line(t.editor.get_cursor());
1251                let start = code.line_to_char(row);
1252                let end = if row + 1 < code.len_lines() {
1253                    code.line_to_char(row + 1)
1254                } else {
1255                    start + code.line_len(row)
1256                };
1257                (start, end)
1258            };
1259            t.editor.set_selection_range(start, end);
1260            t.editor.focus(&area);
1261        }
1262    }
1263
1264    /// Select the whole paragraph under the cursor (blank-line delimited) and
1265    /// scroll it into `area`.
1266    pub fn select_paragraph(&mut self, area: Rect) {
1267        if let Some(t) = self.active_tab_mut() {
1268            let (start, end) = {
1269                let code = t.editor.code_ref();
1270                let n = code.len_lines().max(1);
1271                let is_blank = |r: usize| line_is_blank(code, r);
1272                let row = code.char_to_line(t.editor.get_cursor());
1273                let mut s = row;
1274                while s > 0 && is_blank(s) {
1275                    s -= 1;
1276                }
1277                while s > 0 && !is_blank(s - 1) {
1278                    s -= 1;
1279                }
1280                let mut e = row;
1281                while e + 1 < n && is_blank(e) {
1282                    e += 1;
1283                }
1284                while e + 1 < n && !is_blank(e + 1) {
1285                    e += 1;
1286                }
1287                (
1288                    code.line_to_char(s),
1289                    code.line_to_char(e) + code.line_len(e),
1290                )
1291            };
1292            t.editor.set_selection_range(start, end);
1293            t.editor.focus(&area);
1294        }
1295    }
1296
1297    /// Select the whole section under the cursor (delimited by runs of two or
1298    /// more blank lines) and scroll it into `area`.
1299    pub fn select_section(&mut self, area: Rect) {
1300        if let Some(t) = self.active_tab_mut() {
1301            let (start, end) = {
1302                let code = t.editor.code_ref();
1303                let n = code.len_lines().max(1);
1304                let sep = |r: usize| is_section_break(code, r, n);
1305                let row = code.char_to_line(t.editor.get_cursor());
1306                let mut s = row;
1307                while s > 0 && !sep(s - 1) {
1308                    s -= 1;
1309                }
1310                let mut e = row;
1311                while e + 1 < n && !sep(e + 1) {
1312                    e += 1;
1313                }
1314                (
1315                    code.line_to_char(s),
1316                    code.line_to_char(e) + code.line_len(e),
1317                )
1318            };
1319            t.editor.set_selection_range(start, end);
1320            t.editor.focus(&area);
1321        }
1322    }
1323
1324    /// Insert `text` at the active buffer's cursor and scroll it into `area`.
1325    /// Returns whether the text was inserted (false when there is no editable
1326    /// buffer, e.g. an image tab). Marks the buffer dirty and promotes a preview.
1327    pub fn insert_str(&mut self, text: &str, area: Rect) -> bool {
1328        if let Some(t) = self.active_tab_mut() {
1329            if t.is_image() || t.read_only {
1330                return false;
1331            }
1332            t.editor.apply(InsertText {
1333                text: text.to_string(),
1334            });
1335            t.editor.focus(&area);
1336            t.dirty = true;
1337            t.preview = false;
1338            return true;
1339        }
1340        false
1341    }
1342
1343    /// Select the entire active buffer.
1344    pub fn select_all(&mut self) {
1345        if let Some(t) = self.active_tab_mut() {
1346            t.editor.apply(SelectAll {});
1347        }
1348    }
1349
1350    /// Duplicate the cursor line (or the selection) in the active buffer.
1351    pub fn duplicate_line(&mut self) {
1352        if let Some(t) = self.active_tab_mut() {
1353            if t.is_image() {
1354                return;
1355            }
1356            t.editor.apply(Duplicate {});
1357            t.dirty = true;
1358            t.preview = false;
1359        }
1360    }
1361
1362    /// Join the active buffer's current line with the next (or all selected
1363    /// lines into one).
1364    pub fn join_lines(&mut self) {
1365        if let Some(t) = self.active_tab_mut() {
1366            if t.is_image() {
1367                return;
1368            }
1369            t.editor.join_lines();
1370            t.dirty = true;
1371            t.preview = false;
1372        }
1373    }
1374
1375    /// Sort the active buffer's selected lines ascending, or the whole buffer
1376    /// when nothing is selected.
1377    pub fn sort_lines(&mut self) {
1378        if let Some(t) = self.active_tab_mut() {
1379            if t.is_image() {
1380                return;
1381            }
1382            t.editor.sort_lines();
1383            t.dirty = true;
1384            t.preview = false;
1385        }
1386    }
1387
1388    /// Strip trailing whitespace from the selected lines (or the whole buffer).
1389    pub fn trim_trailing_whitespace(&mut self) {
1390        self.edit_active_lines(CodeEditor::trim_trailing_whitespace);
1391    }
1392
1393    /// Remove duplicate lines in the selection (or the whole buffer).
1394    pub fn remove_duplicate_lines(&mut self) {
1395        self.edit_active_lines(CodeEditor::remove_duplicate_lines);
1396    }
1397
1398    /// Reverse the order of the selected lines (or the whole buffer).
1399    pub fn reverse_lines(&mut self) {
1400        self.edit_active_lines(CodeEditor::reverse_lines);
1401    }
1402
1403    /// Sort the selected lines ascending and drop duplicates (or the whole buffer).
1404    pub fn sort_unique(&mut self) {
1405        self.edit_active_lines(CodeEditor::sort_unique);
1406    }
1407
1408    /// Randomly reorder the selected lines (or the whole buffer).
1409    pub fn shuffle_lines(&mut self) {
1410        self.edit_active_lines(CodeEditor::shuffle_lines);
1411    }
1412
1413    /// Run a line-editing op on the active (non-image) buffer, marking it dirty.
1414    fn edit_active_lines(&mut self, f: impl FnOnce(&mut CodeEditor)) {
1415        if let Some(t) = self.active_tab_mut() {
1416            if t.is_image() {
1417                return;
1418            }
1419            f(&mut t.editor);
1420            t.dirty = true;
1421            t.preview = false;
1422        }
1423    }
1424
1425    /// Move the active buffer's cursor line up or down by one row, scrolling it
1426    /// into `area`.
1427    pub fn move_line(&mut self, down: bool, area: Rect) {
1428        if let Some(t) = self.active_tab_mut() {
1429            if t.is_image() {
1430                return;
1431            }
1432            if down {
1433                t.editor.move_line_down();
1434            } else {
1435                t.editor.move_line_up();
1436            }
1437            t.editor.focus(&area);
1438            t.dirty = true;
1439            t.preview = false;
1440        }
1441    }
1442
1443    /// Extend the selection by one word: `forward` grows the active end to the
1444    /// next word boundary on the right ("Select More"), otherwise to the previous
1445    /// word boundary on the left ("Select Less").
1446    pub fn select_word(&mut self, forward: bool, area: Rect) {
1447        if let Some(t) = self.active_tab_mut() {
1448            if t.is_image() {
1449                return;
1450            }
1451            let text: Vec<char> = t.text().chars().collect();
1452            let cur = t.editor.get_cursor();
1453            let target = if forward {
1454                next_word(&text, cur)
1455            } else {
1456                prev_word(&text, cur)
1457            };
1458            if target != cur {
1459                t.editor.extend_selection(target);
1460                t.editor.set_cursor(target);
1461                t.editor.focus(&area);
1462            }
1463        }
1464    }
1465
1466    /// Jump the cursor to the partner of the bracket at (or just before) it,
1467    /// scrolling it into `area`. No-op when the cursor is not on a bracket.
1468    pub fn jump_matching_bracket(&mut self, area: Rect) {
1469        if let Some(t) = self.active_tab_mut()
1470            && let Some(off) = t.editor.matching_bracket_offset()
1471        {
1472            t.editor.set_cursor(off);
1473            t.editor.focus(&area);
1474        }
1475    }
1476
1477    /// Forward-delete (the `Delete` key): step right, then delete back.
1478    pub fn delete_forward(&mut self) {
1479        if let Some(t) = self.active_tab_mut() {
1480            t.editor.apply(MoveRight { shift: false });
1481            t.editor.apply(Delete {});
1482        }
1483    }
1484
1485    /// Move the cursor up by `lines` (`PageUp`).
1486    pub fn page_up(&mut self, lines: usize) {
1487        if let Some(t) = self.active_tab_mut() {
1488            for _ in 0..lines.max(1) {
1489                t.editor.apply(MoveUp { shift: false });
1490            }
1491        }
1492    }
1493
1494    /// Move the cursor down by `lines` (`PageDown`).
1495    pub fn page_down(&mut self, lines: usize) {
1496        if let Some(t) = self.active_tab_mut() {
1497            for _ in 0..lines.max(1) {
1498                t.editor.apply(MoveDown { shift: false });
1499            }
1500        }
1501    }
1502}
1503
1504/// Whether `c` is part of a word (alphanumeric or underscore).
1505fn is_word_char(c: char) -> bool {
1506    c.is_alphanumeric() || c == '_'
1507}
1508
1509/// The next word-boundary char index at or after `pos`: skip any non-word
1510/// characters, then the following word run.
1511fn next_word(text: &[char], pos: usize) -> usize {
1512    let n = text.len();
1513    let mut i = pos.min(n);
1514    while i < n && !is_word_char(text[i]) {
1515        i += 1;
1516    }
1517    while i < n && is_word_char(text[i]) {
1518        i += 1;
1519    }
1520    i
1521}
1522
1523/// The previous word-boundary char index at or before `pos`: skip any non-word
1524/// characters to the left, then the preceding word run.
1525fn prev_word(text: &[char], pos: usize) -> usize {
1526    let mut i = pos.min(text.len());
1527    while i > 0 && !is_word_char(text[i - 1]) {
1528        i -= 1;
1529    }
1530    while i > 0 && is_word_char(text[i - 1]) {
1531        i -= 1;
1532    }
1533    i
1534}
1535
1536#[cfg(test)]
1537mod nav_tests {
1538    use super::{sentence_starts, word_starts};
1539
1540    #[test]
1541    fn word_starts_finds_each_word() {
1542        // "foo  bar_baz qux" — words at 0, 5, 13 (underscore stays in one word).
1543        assert_eq!(word_starts("foo  bar_baz qux"), vec![0, 5, 13]);
1544        assert_eq!(word_starts("   leading"), vec![3]);
1545        assert!(word_starts("...").is_empty());
1546    }
1547
1548    #[test]
1549    fn sentence_starts_splits_on_terminators() {
1550        let text = "One. Two! Three?  Four.";
1551        // Starts at "One"(0), "Two"(5), "Three"(10), "Four"(18).
1552        assert_eq!(sentence_starts(text), vec![0, 5, 10, 18]);
1553        // A period not followed by whitespace (e.g. a decimal) is not a boundary.
1554        assert_eq!(sentence_starts("pi is 3.14 today"), vec![0]);
1555    }
1556}