Skip to main content

tess/
app.rs

1use std::collections::HashMap;
2use std::io::{self, Write};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Duration;
6
7use crossterm::cursor::MoveTo;
8use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyModifiers};
9use crossterm::style::{Print, ResetColor, SetAttribute, SetForegroundColor, SetBackgroundColor, Attribute};
10use crossterm::terminal::{Clear, ClearType, size};
11use crossterm::QueueableCommand;
12
13use crate::error::Result;
14use crate::input::{translate, Command};
15use crate::marks::{mark_set, mark_jump, jump_previous, update_prev_position, is_valid_mark_name, MarkTarget};
16use crate::line_index::LineIndex;
17use crate::prettify::PrettifyMode;
18use crate::render::Cell;
19use crate::source::{find_tail_offset, Source};
20use crate::viewport::{Frame, RowStyle, SearchDirection, Viewport};
21
22/// Constraints to re-apply when the source content has been replaced wholesale
23/// (`--live`). The line index is rebuilt from scratch each time, so caps that
24/// were originally honored at startup need to be reasserted.
25#[derive(Default, Clone, Copy)]
26pub struct RebuildSpec {
27    pub head: Option<usize>,
28    pub tail: Option<usize>,
29}
30
31/// Per-keystroke modes the app event loop can be in.
32#[derive(Debug, Clone)]
33enum InputMode {
34    Normal,
35    /// User pressed `-`; the next keystroke selects an option to toggle.
36    OptionPrefix,
37    /// User pressed `-P`; the next keystroke chooses a prettify mode
38    /// (`j`/`y`/`t`/`x`/`h`/`c`/`a`/`r`).
39    PrettifyPrefix,
40    /// User pressed `/` or `?`; subsequent characters accumulate into a
41    /// search pattern until Enter (commit) or Esc (cancel).
42    SearchPrompt {
43        direction: SearchDirection,
44        buffer: String,
45        /// If a search compile error occurred, show this in place of the
46        /// buffer until the next keystroke.
47        error: Option<String>,
48    },
49    /// User pressed `!`. The next keystrokes build a shell command in
50    /// `buffer`; Enter executes via shell::run_shell_command, Esc cancels.
51    ShellPrompt { buffer: String, error: Option<String> },
52    /// Set-mark prefix: the next keystroke names the mark to set.
53    MarkSetPending,
54    /// Jump-to-mark prefix: the next keystroke names the mark to jump to.
55    MarkJumpPending,
56    /// First half of the Ctrl-X Ctrl-X chord.
57    CtrlXPending,
58    /// User pressed `:`. The next keystrokes build a colon command in
59    /// `buffer`; Enter dispatches, Esc cancels.
60    ColonPrompt { buffer: String, error: Option<String> },
61    /// User pressed Ctrl-]. The next keystrokes build a tag name in
62    /// `buffer`; Enter dispatches, Esc cancels.
63    /// `buffer` accumulates the tag name; `error` holds an error or hint
64    /// message (e.g. "[3 matches]" after a second consecutive Tab).
65    /// `last_tab_matches` carries the prefix-match list from the most
66    /// recent Tab so a second Tab can show the count without re-querying.
67    TagPrompt {
68        buffer: String,
69        error: Option<String>,
70        last_tab_matches: Option<Vec<String>>,
71    },
72}
73
74#[derive(Debug, Clone, PartialEq)]
75enum ColonCommand {
76    Next,
77    Prev,
78    Edit(std::path::PathBuf),
79    ShowFile,
80    Quit,
81    Delete,
82    First,
83    Last,
84    Tag(String),
85    TagNext,
86    TagPrev,
87    /// `:tselect [NAME]` — open the tag picker overlay. With a name, look
88    /// up matches; without, use the currently-active TagStack matches.
89    TagSelect(Option<String>),
90    OpenPicker,
91    OpenHelp,
92    /// `:hex N` — set hex group width to N hex characters (2/4/8/16/32).
93    HexGroup(usize),
94    /// `:color [strict|interpret|raw]` — set or cycle the ANSI render mode.
95    Color(Option<crate::render::AnsiMode>),
96    /// `:case [sensitive|smart|insensitive]` — set or cycle the search
97    /// case-sensitivity policy.
98    Case(Option<crate::viewport::CaseMode>),
99    /// `:hlsearch` (true) / `:nohlsearch` (false) — toggle search-match
100    /// highlighting at runtime.
101    HlSearch(bool),
102    /// `:incsearch` — toggle incremental search (preview-as-you-type in the
103    /// `/`/`?` prompt) at runtime. Bare command; flips the current state.
104    IncSearch,
105    /// `:header L [C]` — pin top L source rows and left C cols.
106    Header(usize, usize),
107    /// `:yank` — copy the current top logical line to the system clipboard
108    /// (only acts when `--clipboard` was passed).
109    Yank,
110}
111
112#[derive(Debug, Clone, PartialEq)]
113enum ColonParseError {
114    UnknownCommand(String),
115    MissingPath,
116    TagRequiresName,
117    HexGroupRequiresValue,
118    HexGroupInvalid(String),
119    ColorInvalid(String),
120    CaseInvalid(String),
121    HeaderInvalid(String),
122}
123
124impl std::fmt::Display for ColonParseError {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
128            ColonParseError::MissingPath => write!(f, ":e requires a path"),
129            ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
130            ColonParseError::HexGroupRequiresValue => {
131                write!(f, ":hex requires N (one of 2, 4, 8, 16, 32)")
132            }
133            ColonParseError::HexGroupInvalid(v) => {
134                write!(f, ":hex N must be one of 2, 4, 8, 16, 32 (got {v})")
135            }
136            ColonParseError::ColorInvalid(v) => {
137                write!(f, ":color mode must be strict, interpret, or raw (got {v})")
138            }
139            ColonParseError::CaseInvalid(v) => {
140                write!(f, ":case mode must be sensitive, smart, or insensitive (got {v})")
141            }
142            ColonParseError::HeaderInvalid(v) => {
143                write!(f, ":header expects `L` or `L C` (got {v})")
144            }
145        }
146    }
147}
148
149fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
150    let buf = buf.trim();
151    if buf.is_empty() {
152        return Err(ColonParseError::UnknownCommand(String::new()));
153    }
154    let mut parts = buf.splitn(2, char::is_whitespace);
155    let cmd = parts.next().unwrap();
156    let rest = parts.next().unwrap_or("").trim();
157    match cmd {
158        "n" | "next" => Ok(ColonCommand::Next),
159        "p" | "prev" => Ok(ColonCommand::Prev),
160        "e" | "edit" => {
161            if rest.is_empty() {
162                Err(ColonParseError::MissingPath)
163            } else {
164                // Tilde expansion.
165                let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
166                    if let Some(home) = std::env::var_os("HOME") {
167                        let mut p = std::path::PathBuf::from(home);
168                        p.push(stripped);
169                        p
170                    } else {
171                        std::path::PathBuf::from(rest)
172                    }
173                } else {
174                    std::path::PathBuf::from(rest)
175                };
176                Ok(ColonCommand::Edit(expanded))
177            }
178        }
179        "f" => Ok(ColonCommand::ShowFile),
180        "q" | "quit" => Ok(ColonCommand::Quit),
181        "d" | "delete" => Ok(ColonCommand::Delete),
182        "x" | "first" => Ok(ColonCommand::First),
183        "t" | "last" => Ok(ColonCommand::Last),
184        "tag" => {
185            if rest.is_empty() {
186                Err(ColonParseError::TagRequiresName)
187            } else {
188                Ok(ColonCommand::Tag(rest.to_string()))
189            }
190        }
191        "tnext" => Ok(ColonCommand::TagNext),
192        "tprev" => Ok(ColonCommand::TagPrev),
193        "tselect" => {
194            if rest.is_empty() {
195                Ok(ColonCommand::TagSelect(None))
196            } else {
197                Ok(ColonCommand::TagSelect(Some(rest.to_string())))
198            }
199        }
200        "b" | "buffers" => Ok(ColonCommand::OpenPicker),
201        "h" | "help"    => Ok(ColonCommand::OpenHelp),
202        "hex" => {
203            if rest.is_empty() {
204                Err(ColonParseError::HexGroupRequiresValue)
205            } else {
206                match rest.parse::<usize>() {
207                    Ok(n) if matches!(n, 2 | 4 | 8 | 16 | 32) => Ok(ColonCommand::HexGroup(n)),
208                    _ => Err(ColonParseError::HexGroupInvalid(rest.to_string())),
209                }
210            }
211        }
212        "color" => {
213            if rest.is_empty() {
214                Ok(ColonCommand::Color(None))
215            } else {
216                match rest {
217                    "strict" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Strict))),
218                    "interpret" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Interpret))),
219                    "raw" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Raw))),
220                    other => Err(ColonParseError::ColorInvalid(other.to_string())),
221                }
222            }
223        }
224        "hlsearch"   => Ok(ColonCommand::HlSearch(true)),
225        "nohlsearch" => Ok(ColonCommand::HlSearch(false)),
226        "incsearch"  => Ok(ColonCommand::IncSearch),
227        "yank"       => Ok(ColonCommand::Yank),
228        "header" => {
229            let parts: Vec<&str> = rest.split_whitespace().collect();
230            match parts.as_slice() {
231                [l] => {
232                    let n: usize = l.parse()
233                        .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
234                    Ok(ColonCommand::Header(n, 0))
235                }
236                [l, c] => {
237                    let nl: usize = l.parse()
238                        .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
239                    let nc: usize = c.parse()
240                        .map_err(|_| ColonParseError::HeaderInvalid(c.to_string()))?;
241                    Ok(ColonCommand::Header(nl, nc))
242                }
243                _ => Err(ColonParseError::HeaderInvalid(rest.to_string())),
244            }
245        }
246        "case" => {
247            if rest.is_empty() {
248                Ok(ColonCommand::Case(None))
249            } else {
250                match rest {
251                    "sensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Sensitive))),
252                    "smart" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Smart))),
253                    "insensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Insensitive))),
254                    other => Err(ColonParseError::CaseInvalid(other.to_string())),
255                }
256            }
257        }
258        other => Err(ColonParseError::UnknownCommand(other.to_string())),
259    }
260}
261
262enum ColonOutcome {
263    Continue(Option<String>),  // Some(msg) = transient status to show
264    Quit,
265    /// Hand a command to the outer dispatch loop. Used so colon commands
266    /// like `:b` can install overlays via the same Command path as their
267    /// keymap counterparts, without taking a `&mut overlay` argument.
268    DispatchCommand(Command),
269}
270
271#[derive(Debug, Default)]
272struct TagStack {
273    /// Where we jumped FROM, in reverse-chronological order. Tuples are
274    /// (file_index, top_line) at the time of the jump.
275    history: Vec<(usize, usize)>,
276    /// Currently-active match list, set when a tag has at least one match
277    /// and cleared on Ctrl-T or on a fresh tag jump.
278    active: Option<ActiveMatches>,
279}
280
281#[derive(Debug, Clone)]
282struct ActiveMatches {
283    name: String,
284    matches: Vec<crate::tags::TagEntry>,
285    cursor: usize,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289enum TagStepResult {
290    /// Cursor moved; new index is `usize`.
291    Moved(usize),
292    /// Already at the boundary; show a transient message.
293    AtBoundary,
294    /// `active` was None — caller should show "no active tag".
295    NoActive,
296}
297
298impl TagStack {
299    fn push(&mut self, file_index: usize, top_line: usize) {
300        self.history.push((file_index, top_line));
301    }
302
303    fn pop(&mut self) -> Option<(usize, usize)> {
304        let popped = self.history.pop();
305        if popped.is_some() {
306            self.active = None;
307        }
308        popped
309    }
310
311    fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
312        self.active = Some(ActiveMatches {
313            name,
314            matches,
315            cursor: 0,
316        });
317    }
318
319    fn next(&mut self) -> TagStepResult {
320        let Some(a) = &mut self.active else {
321            return TagStepResult::NoActive;
322        };
323        if a.cursor + 1 >= a.matches.len() {
324            TagStepResult::AtBoundary
325        } else {
326            a.cursor += 1;
327            TagStepResult::Moved(a.cursor)
328        }
329    }
330
331    fn prev(&mut self) -> TagStepResult {
332        let Some(a) = &mut self.active else {
333            return TagStepResult::NoActive;
334        };
335        if a.cursor == 0 {
336            TagStepResult::AtBoundary
337        } else {
338            a.cursor -= 1;
339            TagStepResult::Moved(a.cursor)
340        }
341    }
342}
343
344/// Resolve a tag name to a list of matches, push the current position
345/// onto the tag stack, set it as the active match list, and dispatch
346/// Stat the tag file and reload it if its mtime moved. Returns a transient
347/// status message when a reload happened so the caller can surface it.
348/// Errors are swallowed (the previously-loaded state stays valid).
349fn refresh_tag_file(tag_file: &mut Option<crate::tags::TagFile>) -> Option<String> {
350    match tag_file.as_mut()?.reload_if_changed() {
351        Ok(true) => Some("[tags reloaded]".into()),
352        _ => None,
353    }
354}
355
356/// Longest common prefix among a slice of strings. Returns "" for an empty
357/// slice or when the items don't share any prefix. Used by Tab-completion
358/// in the `:tag` / `Ctrl-]` prompt.
359fn longest_common_prefix(items: &[String]) -> String {
360    let mut iter = items.iter();
361    let Some(first) = iter.next() else { return String::new() };
362    let mut prefix = first.clone();
363    for s in iter {
364        while !s.starts_with(&prefix) {
365            prefix.pop();
366            if prefix.is_empty() {
367                return prefix;
368            }
369        }
370    }
371    prefix
372}
373
374/// the first match. Returns a transient status string when something
375/// goes wrong, or `None` on success.
376#[allow(clippy::too_many_arguments)]
377fn dispatch_tag_jump(
378    name: &str,
379    tag_file: Option<&crate::tags::TagFile>,
380    tag_stack: &mut TagStack,
381    file_set: &mut crate::file_set::FileSet,
382    current_file_index: &mut usize,
383    args: &crate::cli::Args,
384    preprocessor: Option<&crate::preprocess::Preprocessor>,
385    record_start_regex: Option<&regex::bytes::Regex>,
386    viewport: &mut crate::viewport::Viewport,
387    src: &mut Box<dyn crate::source::Source>,
388    idx: &mut crate::line_index::LineIndex,
389) -> Option<String> {
390    let Some(tf) = tag_file else {
391        return Some("[no tags file loaded]".into());
392    };
393    let matches = tf.lookup(name);
394    if matches.is_empty() {
395        return Some(format!("[tag not found: {name}]"));
396    }
397    let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
398    tag_stack.push(*current_file_index, viewport.top_line());
399    tag_stack.set_active(name.to_string(), matches.clone());
400    let msg = dispatch_match(
401        &matches[0],
402        file_set,
403        current_file_index,
404        args,
405        preprocessor,
406        record_start_regex,
407        viewport,
408        src,
409        idx,
410    );
411    update_viewport_tag_indicator(tag_stack, viewport);
412    msg
413}
414
415#[allow(clippy::too_many_arguments)]
416fn dispatch_match(
417    entry: &crate::tags::TagEntry,
418    file_set: &mut crate::file_set::FileSet,
419    current_file_index: &mut usize,
420    args: &crate::cli::Args,
421    preprocessor: Option<&crate::preprocess::Preprocessor>,
422    record_start_regex: Option<&regex::bytes::Regex>,
423    viewport: &mut crate::viewport::Viewport,
424    src: &mut Box<dyn crate::source::Source>,
425    idx: &mut crate::line_index::LineIndex,
426) -> Option<String> {
427    let target_file = entry.file.as_path();
428    let already_current = file_set
429        .current()
430        .map(|p| p == target_file)
431        .unwrap_or(false);
432
433    if !already_current {
434        let existing_idx = (0..file_set.len()).find(|i| {
435            file_set
436                .nth(*i)
437                .map(|p| p == target_file)
438                .unwrap_or(false)
439        });
440        match existing_idx {
441            Some(i) => {
442                file_set.set_current_index(i);
443            }
444            None => {
445                file_set.append_and_switch(target_file.to_path_buf());
446            }
447        }
448        let path = file_set.current().unwrap().to_path_buf();
449        if let Err(e) = switch_file(
450            &path,
451            file_set.current_index(),
452            file_set.len(),
453            args,
454            preprocessor,
455            viewport,
456            src,
457            idx,
458            record_start_regex,
459        ) {
460            return Some(format!("[open: {e}]"));
461        }
462        *current_file_index = file_set.current_index();
463    }
464
465    let (line, hint) = match resolve_tag_address(&entry.address, src.as_ref(), idx, 0) {
466        AddressResult::Line(l) => (l, None),
467        AddressResult::NotFound => (0, Some("[tag pattern not found]".into())),
468        AddressResult::Unsupported(raw) => (
469            0,
470            Some(format!("[tag address not supported: {raw}]")),
471        ),
472    };
473
474    let clamped = line.min(idx.line_count().saturating_sub(1));
475    viewport.goto_line(clamped, src.as_ref(), idx);
476    hint
477}
478
479enum AddressResult {
480    Line(usize),
481    NotFound,
482    Unsupported(String),
483}
484
485/// Resolve a `TagAddress` to a 0-based line number, starting the search at
486/// `from_line` (used for the chained-address case where the second step
487/// resumes from the line matched by the first).
488fn resolve_tag_address(
489    addr: &crate::tags::TagAddress,
490    src: &dyn crate::source::Source,
491    idx: &mut crate::line_index::LineIndex,
492    from_line: usize,
493) -> AddressResult {
494    match addr {
495        crate::tags::TagAddress::Line(n) => AddressResult::Line(n.saturating_sub(1)),
496        crate::tags::TagAddress::Pattern(p) => {
497            let re_src = crate::tags::pattern_to_regex(p);
498            let re = match regex::bytes::Regex::new(&re_src) {
499                Ok(r) => r,
500                Err(_) => return AddressResult::NotFound,
501            };
502            match find_pattern_line(src, idx, &re, from_line) {
503                Some(l) => AddressResult::Line(l),
504                None => AddressResult::NotFound,
505            }
506        }
507        crate::tags::TagAddress::Chained(parts) => {
508            let mut here = from_line;
509            for step in parts {
510                match resolve_tag_address(step, src, idx, here) {
511                    AddressResult::Line(l) => here = l + 1,
512                    other => return other,
513                }
514            }
515            // Subtract 1 from the final "next-search start" to land on the
516            // last matched line itself.
517            AddressResult::Line(here.saturating_sub(1))
518        }
519        crate::tags::TagAddress::Unsupported(raw) => {
520            AddressResult::Unsupported(raw.clone())
521        }
522    }
523}
524
525fn find_pattern_line(
526    src: &dyn crate::source::Source,
527    idx: &mut crate::line_index::LineIndex,
528    re: &regex::bytes::Regex,
529    from_line: usize,
530) -> Option<usize> {
531    idx.extend_to_end(src);
532    for line_no in from_line..idx.line_count() {
533        let bytes = idx.line_bytes_stripped(line_no, src);
534        if re.is_match(&bytes) {
535            return Some(line_no);
536        }
537    }
538    None
539}
540
541fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
542    viewport.set_tag_active(stack.active.as_ref().map(|a| {
543        (a.name.clone(), a.cursor + 1, a.matches.len())
544    }));
545}
546
547/// Open whatever file is at `file_set.current()`, updating viewport and
548/// `current_file_index`. Returns `Some(msg)` if anything went wrong (for
549/// transient status). The cursor in `file_set` must be set before calling.
550#[allow(clippy::too_many_arguments)]
551fn switch_to_current_file(
552    file_set: &mut crate::file_set::FileSet,
553    current_file_index: &mut usize,
554    args: &crate::cli::Args,
555    preprocessor: Option<&crate::preprocess::Preprocessor>,
556    record_start_regex: Option<&regex::bytes::Regex>,
557    viewport: &mut crate::viewport::Viewport,
558    src: &mut Box<dyn crate::source::Source>,
559    idx: &mut crate::line_index::LineIndex,
560) -> Option<String> {
561    let path = match file_set.current() {
562        Some(p) => p.to_path_buf(),
563        None => return Some("[empty file set]".into()),
564    };
565    let new_idx_val = file_set.current_index();
566    match switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
567        Ok(()) => {
568            *current_file_index = new_idx_val;
569            None
570        }
571        Err(e) => Some(format!("[open: {e}]")),
572    }
573}
574
575#[allow(clippy::too_many_arguments)]
576fn switch_file(
577    new_path: &std::path::Path,
578    new_file_index: usize,
579    total_files: usize,
580    args: &crate::cli::Args,
581    preprocessor: Option<&crate::preprocess::Preprocessor>,
582    viewport: &mut crate::viewport::Viewport,
583    src: &mut Box<dyn crate::source::Source>,
584    idx: &mut crate::line_index::LineIndex,
585    record_start_regex: Option<&regex::bytes::Regex>,
586) -> crate::error::Result<()> {
587    let (new_src, new_label, new_failure) =
588        crate::open::open_source_for_path(new_path, args, preprocessor)?;
589
590    *src = new_src;
591    let mut new_idx = crate::line_index::LineIndex::new();
592    if let Some(re) = record_start_regex {
593        new_idx.set_record_start(re.clone());
594    }
595    *idx = new_idx;
596
597    viewport.set_source_label(new_label);
598    viewport.set_file_index(new_file_index, total_files);
599    viewport.set_preprocess_failure(new_failure);
600    viewport.goto_top();
601    viewport.reset_hscroll(); // new file: drop any horizontal scroll offset
602
603    Ok(())
604}
605
606/// Copy the current top logical line's raw bytes (trailing newline already
607/// stripped by `LineIndex::line_range`) to the system clipboard. Returns a
608/// human-facing status string for the caller to flash. When `--clipboard`
609/// wasn't passed, reports that and copies nothing.
610fn yank_current_line(
611    clipboard_enabled: bool,
612    viewport: &crate::viewport::Viewport,
613    src: &dyn crate::source::Source,
614    idx: &mut crate::line_index::LineIndex,
615) -> String {
616    if !clipboard_enabled {
617        return "[clipboard not enabled (pass --clipboard)]".to_string();
618    }
619    if idx.line_count() == 0 {
620        return "[nothing to copy]".to_string();
621    }
622    let line = viewport.top_line();
623    let bytes = current_line_bytes(idx, src, line);
624    match crate::clipboard::write(&bytes) {
625        Ok(()) => format!("[copied {} bytes]", bytes.len()),
626        Err(e) => format!("[{e}]"),
627    }
628}
629
630/// Raw bytes of the logical line at `line` (trailing newline already stripped
631/// by `LineIndex::line_range`). Pulled out of `yank_current_line` so it can be
632/// unit-tested without touching the OS clipboard.
633fn current_line_bytes(
634    idx: &crate::line_index::LineIndex,
635    src: &dyn crate::source::Source,
636    line: usize,
637) -> Vec<u8> {
638    let range = idx.line_range(line, src);
639    src.bytes(range).into_owned()
640}
641
642#[allow(clippy::too_many_arguments)]
643fn dispatch_colon_command(
644    cmd: ColonCommand,
645    file_set: &mut crate::file_set::FileSet,
646    current_file_index: &mut usize,
647    args: &crate::cli::Args,
648    preprocessor: Option<&crate::preprocess::Preprocessor>,
649    record_start_regex: Option<&regex::bytes::Regex>,
650    viewport: &mut crate::viewport::Viewport,
651    src: &mut Box<dyn crate::source::Source>,
652    idx: &mut crate::line_index::LineIndex,
653    tag_stack: &mut TagStack,
654    tag_file: Option<&crate::tags::TagFile>,
655) -> ColonOutcome {
656    match cmd {
657        ColonCommand::Next => {
658            match file_set.next() {
659                Ok(path) => {
660                    let path = path.to_path_buf();
661                    let new_idx_val = file_set.current_index();
662                    if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
663                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
664                    } else {
665                        *current_file_index = new_idx_val;
666                        ColonOutcome::Continue(None)
667                    }
668                }
669                Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
670            }
671        }
672        ColonCommand::Prev => {
673            match file_set.prev() {
674                Ok(path) => {
675                    let path = path.to_path_buf();
676                    let new_idx_val = file_set.current_index();
677                    if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
678                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
679                    } else {
680                        *current_file_index = new_idx_val;
681                        ColonOutcome::Continue(None)
682                    }
683                }
684                Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
685            }
686        }
687        ColonCommand::Edit(path) => {
688            // Try to open first; if successful, append + switch.
689            match crate::open::open_source_for_path(&path, args, preprocessor) {
690                Ok(_) => {
691                    // Successful open; commit to the FileSet.
692                    let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
693                    let new_idx_val = file_set.current_index();
694                    if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
695                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
696                    } else {
697                        *current_file_index = new_idx_val;
698                        ColonOutcome::Continue(None)
699                    }
700                }
701                Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
702            }
703        }
704        ColonCommand::ShowFile => {
705            let label = viewport.source_label_clone();
706            let cur = file_set.current_index() + 1;
707            let total = file_set.len();
708            let top = viewport.top_line() + 1;
709            let total_lines = idx.line_count();
710            let msg = if total > 1 {
711                format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
712            } else {
713                format!("{label}: line {top}/{total_lines}")
714            };
715            ColonOutcome::Continue(Some(msg))
716        }
717        ColonCommand::Quit => ColonOutcome::Quit,
718        ColonCommand::Delete => {
719            match file_set.delete_current() {
720                Ok(path) => {
721                    let path = path.to_path_buf();
722                    let new_idx_val = file_set.current_index();
723                    if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
724                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
725                    } else {
726                        *current_file_index = new_idx_val;
727                        ColonOutcome::Continue(None)
728                    }
729                }
730                Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
731            }
732        }
733        ColonCommand::First => {
734            if file_set.current_index() == 0 {
735                ColonOutcome::Continue(None)  // silent no-op
736            } else if let Some(path) = file_set.first() {
737                let path = path.to_path_buf();
738                let new_idx_val = file_set.current_index();
739                if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
740                    ColonOutcome::Continue(Some(format!("[open: {e}]")))
741                } else {
742                    *current_file_index = new_idx_val;
743                    ColonOutcome::Continue(None)
744                }
745            } else {
746                ColonOutcome::Continue(None)
747            }
748        }
749        ColonCommand::Last => {
750            if file_set.current_index() + 1 == file_set.len() {
751                ColonOutcome::Continue(None)
752            } else if let Some(path) = file_set.last() {
753                let path = path.to_path_buf();
754                let new_idx_val = file_set.current_index();
755                if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
756                    ColonOutcome::Continue(Some(format!("[open: {e}]")))
757                } else {
758                    *current_file_index = new_idx_val;
759                    ColonOutcome::Continue(None)
760                }
761            } else {
762                ColonOutcome::Continue(None)
763            }
764        }
765        ColonCommand::Tag(name) => {
766            match dispatch_tag_jump(
767                &name,
768                tag_file,
769                tag_stack,
770                file_set,
771                current_file_index,
772                args,
773                preprocessor,
774                record_start_regex,
775                viewport,
776                src,
777                idx,
778            ) {
779                Some(msg) => ColonOutcome::Continue(Some(msg)),
780                None => ColonOutcome::Continue(None),
781            }
782        }
783        ColonCommand::TagNext => match tag_stack.next() {
784            TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
785            TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
786            TagStepResult::Moved(cur) => {
787                let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
788                let msg = dispatch_match(
789                    &entry,
790                    file_set,
791                    current_file_index,
792                    args,
793                    preprocessor,
794                    record_start_regex,
795                    viewport,
796                    src,
797                    idx,
798                );
799                update_viewport_tag_indicator(tag_stack, viewport);
800                ColonOutcome::Continue(msg)
801            }
802        },
803        ColonCommand::TagSelect(name) => {
804            let prepared = match name {
805                Some(n) => {
806                    let tf = match tag_file {
807                        Some(t) => t,
808                        None => {
809                            return ColonOutcome::Continue(Some(
810                                "[no tags file loaded]".into(),
811                            ))
812                        }
813                    };
814                    let matches: Vec<crate::tags::TagEntry> = tf.lookup(&n).to_vec();
815                    if matches.is_empty() {
816                        return ColonOutcome::Continue(Some(
817                            format!("[no matches for `{n}`]"),
818                        ));
819                    }
820                    tag_stack.set_active(n, matches);
821                    true
822                }
823                None => tag_stack.active.is_some(),
824            };
825            if prepared {
826                ColonOutcome::DispatchCommand(Command::OpenTagPicker)
827            } else {
828                ColonOutcome::Continue(Some("[no active tag]".into()))
829            }
830        }
831        ColonCommand::TagPrev => match tag_stack.prev() {
832            TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
833            TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
834            TagStepResult::Moved(cur) => {
835                let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
836                let msg = dispatch_match(
837                    &entry,
838                    file_set,
839                    current_file_index,
840                    args,
841                    preprocessor,
842                    record_start_regex,
843                    viewport,
844                    src,
845                    idx,
846                );
847                update_viewport_tag_indicator(tag_stack, viewport);
848                ColonOutcome::Continue(msg)
849            }
850        },
851        // Hand off to the outer command dispatcher so the same install path
852        // services both `:b` and the (future) F2 keybinding.
853        ColonCommand::OpenPicker => ColonOutcome::DispatchCommand(Command::OpenPicker),
854        ColonCommand::OpenHelp => ColonOutcome::DispatchCommand(Command::OpenHelp),
855        ColonCommand::HexGroup(hex_chars) => {
856            if !viewport.hex_mode() {
857                return ColonOutcome::Continue(Some(
858                    "[:hex requires --hex mode]".into(),
859                ));
860            }
861            // Already validated in parse_colon_command, so unwrap is safe.
862            let bpg = crate::hex::hex_chars_to_bytes_per_group(hex_chars).unwrap();
863            viewport.set_hex_group_size(bpg);
864            ColonOutcome::Continue(Some(format!("[hex group: {hex_chars} chars]")))
865        }
866        ColonCommand::Color(mode) => {
867            use crate::render::AnsiMode;
868            let next = mode.unwrap_or_else(|| match viewport.ansi_mode() {
869                AnsiMode::Strict => AnsiMode::Interpret,
870                AnsiMode::Interpret => AnsiMode::Raw,
871                AnsiMode::Raw => AnsiMode::Strict,
872            });
873            viewport.set_ansi_mode(next);
874            let label = match next {
875                AnsiMode::Strict => "strict",
876                AnsiMode::Interpret => "interpret",
877                AnsiMode::Raw => "raw",
878            };
879            ColonOutcome::Continue(Some(format!("[color: {label}]")))
880        }
881        ColonCommand::Header(l, c) => {
882            viewport.set_header(l, c);
883            ColonOutcome::Continue(Some(format!("[header: {l} rows, {c} cols]")))
884        }
885        ColonCommand::HlSearch(on) => {
886            viewport.set_hilite_search(on);
887            let msg = if on { "[hlsearch on]" } else { "[hlsearch off]" };
888            ColonOutcome::Continue(Some(msg.into()))
889        }
890        ColonCommand::IncSearch => {
891            let on = !viewport.incsearch();
892            viewport.set_incsearch(on);
893            let msg = if on { "[incsearch on]" } else { "[incsearch off]" };
894            ColonOutcome::Continue(Some(msg.into()))
895        }
896        ColonCommand::Yank => {
897            ColonOutcome::Continue(Some(yank_current_line(args.clipboard, viewport, src.as_ref(), idx)))
898        }
899        ColonCommand::Case(mode) => {
900            use crate::viewport::CaseMode;
901            let next = mode.unwrap_or_else(|| match viewport.case_mode() {
902                CaseMode::Sensitive => CaseMode::Smart,
903                CaseMode::Smart => CaseMode::Insensitive,
904                CaseMode::Insensitive => CaseMode::Sensitive,
905            });
906            viewport.set_case_mode(next);
907            let label = match next {
908                CaseMode::Sensitive => "sensitive",
909                CaseMode::Smart => "smart",
910                CaseMode::Insensitive => "insensitive",
911            };
912            ColonOutcome::Continue(Some(format!("[case: {label}]")))
913        }
914    }
915}
916
917#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
918pub fn run(
919    mut src: Box<dyn Source>,
920    mut viewport: Viewport,
921    mut idx: LineIndex,
922    sigterm: Arc<AtomicBool>,
923    rebuild_spec: RebuildSpec,
924    keymap: crate::keys::KeyMap,
925    mut file_set: crate::file_set::FileSet,
926    record_start_regex: Option<regex::bytes::Regex>,
927    args: crate::cli::Args,
928    preprocessor: Option<crate::preprocess::Preprocessor>,
929    mut tag_file: Option<crate::tags::TagFile>,
930) -> Result<()> {
931    let (mut cols, mut rows) = size().unwrap_or((80, 24));
932    viewport.resize(cols, rows);
933
934    let truecolor = match args.truecolor.as_str() {
935        "always" => true,
936        "never" => false,
937        _ => crate::render::TrueColor::Auto.resolve(),
938    };
939
940    let mut stdout = io::stdout();
941    const BASE_POLL: Duration = Duration::from_millis(250);
942    #[cfg(feature = "image")]
943    let mut last_tick = std::time::Instant::now();
944    let mut last_revision = src.revision();
945
946    // If hide-mode filtering is active (--filter or --grep without --dim),
947    // we need to scan the whole source up front to find matching lines.
948    // Without any predicate this is intentionally skipped — lazy indexing
949    // keeps `tess` fast on huge files.
950    if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
951        idx.extend_to_end(src.as_ref());
952        viewport.extend_visible_lines(&idx, src.as_ref());
953    }
954
955    // If follow or live mode is on at startup, snap to the bottom of the
956    // (possibly filtered) source so the user sees the newest content
957    // (tail-style). Live mode tracks whole-file rewrites; starting at the end
958    // keeps the latest content in view as the file is regenerated.
959    if viewport.follow_mode() || viewport.live_mode() {
960        src.pump();
961        viewport.extend_visible_lines(&idx, src.as_ref());
962        viewport.goto_bottom(src.as_ref(), &mut idx);
963    }
964
965    // Always draw the initial frame before entering the event loop.
966    let mut needs_redraw = true;
967    let mut mode = InputMode::Normal;
968    let mut numeric_prefix: Option<usize> = None;
969    let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
970    let mut previous_position: Option<(usize, usize)> = None;
971    // Scroll position captured when an incremental-search prompt opens, so a
972    // mid-type preview searches from there and Esc can restore it.
973    let mut incsearch_origin: (usize, usize) = (0, 0);
974    let mut current_file_index: usize = file_set.current_index();
975    let mut transient_status: Option<String> = None;
976    let mut tag_stack = TagStack::default();
977    let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
978    let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
979    let mouse_enabled = args.mouse;
980    let clipboard_enabled = args.clipboard;
981    let hscroll_shift = args.shift.unwrap_or(0);
982    let wheel_lines = args.wheel_lines.unwrap_or(3).max(1);
983
984    if let Some(tag_name) = args.tag.as_deref() {
985        let _ = refresh_tag_file(&mut tag_file);
986        if let Some(msg) = dispatch_tag_jump(
987            tag_name,
988            tag_file.as_ref(),
989            &mut tag_stack,
990            &mut file_set,
991            &mut current_file_index,
992            &args,
993            preprocessor.as_ref(),
994            record_start_regex.as_ref(),
995            &mut viewport,
996            &mut src,
997            &mut idx,
998        ) {
999            return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
1000        }
1001    }
1002
1003    loop {
1004        if sigterm.load(Ordering::SeqCst) {
1005            break;
1006        }
1007
1008        if needs_redraw {
1009            if let Some(ov) = overlay.as_ref() {
1010                let w = cols;
1011                let h = viewport.body_rows() + 1;
1012                let mut ovframe = ov.render(w, h);
1013                if let Some((msg, started)) = overlay_flash {
1014                    if started.elapsed() < std::time::Duration::from_millis(1500) {
1015                        ovframe.status = format!("[{msg}]");
1016                    } else {
1017                        overlay_flash = None;
1018                    }
1019                }
1020                render_overlay(&mut stdout, &ovframe, w, h)
1021                    .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1022                needs_redraw = false;
1023                continue;
1024            }
1025            // `-J` status column: feed the current file's marks (line → letter)
1026            // into the viewport so it can render mark glyphs in the far-left
1027            // gutter. Empty when the feature is off.
1028            if viewport.status_column() {
1029                let status_marks: HashMap<usize, char> = marks
1030                    .iter()
1031                    .filter(|(_, (fi, _))| *fi == current_file_index)
1032                    .map(|(ch, (_, line))| (*line, *ch))
1033                    .collect();
1034                viewport.set_status_marks(status_marks);
1035            }
1036            let mut frame = viewport.frame(src.as_ref(), &mut idx);
1037            // Override the status row when we're in an interactive prompt OR
1038            // when a transient status message is pending.
1039            match &mode {
1040                InputMode::SearchPrompt { direction, buffer, error } => {
1041                    let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
1042                    frame.status = match error {
1043                        Some(e) => format!("{prefix}{buffer}  [error: {e}]"),
1044                        None => format!("{prefix}{buffer}"),
1045                    };
1046                }
1047                InputMode::ShellPrompt { buffer, error } => {
1048                    frame.status = match error {
1049                        Some(e) => format!("!{buffer}  [error: {e}]"),
1050                        None => format!("!{buffer}"),
1051                    };
1052                }
1053                InputMode::ColonPrompt { buffer, error } => {
1054                    frame.status = match error {
1055                        Some(e) => format!(":{buffer}  [error: {e}]"),
1056                        None => format!(":{buffer}"),
1057                    };
1058                }
1059                InputMode::TagPrompt { buffer, error, .. } => {
1060                    frame.status = match error {
1061                        Some(e) => format!("tag: {buffer}  [error: {e}]"),
1062                        None => format!("tag: {buffer}"),
1063                    };
1064                }
1065                _ => {
1066                    if let Some(msg) = transient_status.take() {
1067                        frame.status = msg;
1068                    }
1069                }
1070            }
1071            write_frame(&mut stdout, &frame, cols, rows, truecolor)
1072                .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1073            needs_redraw = false;
1074        }
1075
1076        // Poll with timeout so stdin sources can be re-checked. When an
1077        // animation is playing, shorten the wait to its next-frame deadline
1078        // so the timeout branch can advance frames on time.
1079        #[cfg(feature = "image")]
1080        let timeout = viewport.anim_deadline()
1081            .map(|d| d.min(BASE_POLL))
1082            .unwrap_or(BASE_POLL);
1083        #[cfg(not(feature = "image"))]
1084        let timeout = BASE_POLL;
1085        match poll(timeout) {
1086            Ok(true) => {
1087                let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1088                // Modal input handling: the search prompt and option prefix
1089                // intercept keys before they're translated to commands.
1090                match &mut mode {
1091                    InputMode::SearchPrompt { direction, buffer, error } => {
1092                        if let Event::Key(KeyEvent { code, .. }) = event {
1093                            match code {
1094                                KeyCode::Esc => {
1095                                    if viewport.incsearch() {
1096                                        viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1097                                    }
1098                                    mode = InputMode::Normal;
1099                                    needs_redraw = true;
1100                                }
1101                                KeyCode::Enter => {
1102                                    if viewport.incsearch() {
1103                                        viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1104                                    }
1105                                    if buffer.is_empty() {
1106                                        // Empty buffer: repeat the last search in the
1107                                        // newly-typed direction (less compat). If no
1108                                        // prior search exists, just dismiss.
1109                                        if viewport.search_active() {
1110                                            let reverse = !matches!(
1111                                                (viewport.search_direction(), *direction),
1112                                                (SearchDirection::Forward, SearchDirection::Forward)
1113                                                | (SearchDirection::Backward, SearchDirection::Backward)
1114                                            );
1115                                            update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1116                                            viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1117                                        }
1118                                        mode = InputMode::Normal;
1119                                    } else {
1120                                        match viewport.set_search(buffer.clone(), *direction) {
1121                                            Ok(()) => {
1122                                                update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1123                                                viewport.search_repeat(src.as_ref(), &mut idx, false);
1124                                                mode = InputMode::Normal;
1125                                            }
1126                                            Err(e) => { *error = Some(e); }
1127                                        }
1128                                    }
1129                                    needs_redraw = true;
1130                                }
1131                                KeyCode::Backspace => {
1132                                    buffer.pop();
1133                                    *error = None;
1134                                    if viewport.incsearch() {
1135                                        viewport.incsearch_preview(
1136                                            src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1137                                    }
1138                                    needs_redraw = true;
1139                                }
1140                                KeyCode::Char(c) => {
1141                                    buffer.push(c);
1142                                    *error = None;
1143                                    if viewport.incsearch() {
1144                                        viewport.incsearch_preview(
1145                                            src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1146                                    }
1147                                    needs_redraw = true;
1148                                }
1149                                _ => {}
1150                            }
1151                        }
1152                        continue;
1153                    }
1154                    InputMode::OptionPrefix => {
1155                        if let Event::Key(KeyEvent { code, .. }) = event {
1156                            match code {
1157                                KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1158                                KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1159                                KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1160                                KeyCode::Char('P') | KeyCode::Char('p') => {
1161                                    // Two-key prefix: `-P` then a letter for the mode.
1162                                    mode = InputMode::PrettifyPrefix;
1163                                    needs_redraw = true;
1164                                    continue;
1165                                }
1166                                _ => {}
1167                            }
1168                        }
1169                        mode = InputMode::Normal;
1170                        needs_redraw = true;
1171                        continue;
1172                    }
1173                    InputMode::PrettifyPrefix => {
1174                        if let Event::Key(KeyEvent { code, .. }) = event {
1175                            let target: Option<PrettifyTarget> = match code {
1176                                KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1177                                KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1178                                KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1179                                KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1180                                KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1181                                KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1182                                KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1183                                KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1184                                _ => None,
1185                            };
1186                            if let Some(t) = target {
1187                                apply_prettify(
1188                                    src.as_ref(),
1189                                    &mut viewport,
1190                                    &mut idx,
1191                                    rebuild_spec,
1192                                    t,
1193                                );
1194                                last_revision = src.revision();
1195                            }
1196                        }
1197                        mode = InputMode::Normal;
1198                        needs_redraw = true;
1199                        continue;
1200                    }
1201                    InputMode::MarkSetPending => {
1202                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1203                            if is_valid_mark_name(c) {
1204                                mark_set(&mut marks, c, current_file_index, viewport.top_line());
1205                            }
1206                        }
1207                        mode = InputMode::Normal;
1208                        continue;
1209                    }
1210                    InputMode::MarkJumpPending => {
1211                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1212                            if is_valid_mark_name(c) {
1213                                match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1214                                    Some(MarkTarget::SameFile { line }) => {
1215                                        let clamped = line.min(idx.line_count().saturating_sub(1));
1216                                        viewport.goto_line(clamped, src.as_ref(), &mut idx);
1217                                        needs_redraw = true;
1218                                    }
1219                                    Some(MarkTarget::OtherFile { file_index, line }) => {
1220                                        if file_index < file_set.len() {
1221                                            file_set.set_current_index(file_index);
1222                                            let path = file_set.current().unwrap().to_path_buf();
1223                                            if let Err(e) = switch_file(
1224                                                &path, file_index, file_set.len(),
1225                                                &args, preprocessor.as_ref(),
1226                                                &mut viewport, &mut src, &mut idx,
1227                                                record_start_regex.as_ref(),
1228                                            ) {
1229                                                transient_status = Some(format!("[open: {e}]"));
1230                                            } else {
1231                                                let clamped = line.min(idx.line_count().saturating_sub(1));
1232                                                viewport.goto_line(clamped, src.as_ref(), &mut idx);
1233                                                current_file_index = file_index;
1234                                                needs_redraw = true;
1235                                            }
1236                                        }
1237                                    }
1238                                    None => {}
1239                                }
1240                            }
1241                        }
1242                        mode = InputMode::Normal;
1243                        continue;
1244                    }
1245                    InputMode::ShellPrompt { buffer, error } => {
1246                        if let Event::Key(KeyEvent { code, .. }) = event {
1247                            match code {
1248                                KeyCode::Esc => {
1249                                    mode = InputMode::Normal;
1250                                    needs_redraw = true;
1251                                }
1252                                KeyCode::Enter => {
1253                                    if buffer.is_empty() {
1254                                        mode = InputMode::Normal;
1255                                    } else {
1256                                        match crate::shell::run_shell_command(buffer) {
1257                                            Ok(()) => {
1258                                                mode = InputMode::Normal;
1259                                            }
1260                                            Err(e) => {
1261                                                *error = Some(e.to_string());
1262                                            }
1263                                        }
1264                                    }
1265                                    needs_redraw = true;
1266                                }
1267                                KeyCode::Backspace => {
1268                                    buffer.pop();
1269                                    *error = None;
1270                                    needs_redraw = true;
1271                                }
1272                                KeyCode::Char(c) => {
1273                                    buffer.push(c);
1274                                    *error = None;
1275                                    needs_redraw = true;
1276                                }
1277                                _ => {}
1278                            }
1279                        }
1280                        continue;
1281                    }
1282                    InputMode::CtrlXPending => {
1283                        let is_ctrl_x = matches!(
1284                            event,
1285                            Event::Key(KeyEvent {
1286                                code: KeyCode::Char('x'),
1287                                modifiers: KeyModifiers::CONTROL,
1288                                ..
1289                            })
1290                        );
1291                        if is_ctrl_x {
1292                            match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1293                                Some(MarkTarget::SameFile { line }) => {
1294                                    let clamped = line.min(idx.line_count().saturating_sub(1));
1295                                    viewport.goto_line(clamped, src.as_ref(), &mut idx);
1296                                    needs_redraw = true;
1297                                }
1298                                Some(MarkTarget::OtherFile { file_index, line }) => {
1299                                    if file_index < file_set.len() {
1300                                        file_set.set_current_index(file_index);
1301                                        let path = file_set.current().unwrap().to_path_buf();
1302                                        if let Err(e) = switch_file(
1303                                            &path, file_index, file_set.len(),
1304                                            &args, preprocessor.as_ref(),
1305                                            &mut viewport, &mut src, &mut idx,
1306                                            record_start_regex.as_ref(),
1307                                        ) {
1308                                            transient_status = Some(format!("[open: {e}]"));
1309                                        } else {
1310                                            let clamped = line.min(idx.line_count().saturating_sub(1));
1311                                            viewport.goto_line(clamped, src.as_ref(), &mut idx);
1312                                            current_file_index = file_index;
1313                                            needs_redraw = true;
1314                                        }
1315                                    }
1316                                }
1317                                None => {}
1318                            }
1319                            mode = InputMode::Normal;
1320                            continue;
1321                        }
1322                        // Anything else: cancel and fall through to normal dispatch.
1323                        mode = InputMode::Normal;
1324                        // Don't `continue` — let the event fall through.
1325                    }
1326                    InputMode::ColonPrompt { buffer, error } => {
1327                        if let Event::Key(KeyEvent { code, .. }) = event {
1328                            match code {
1329                                KeyCode::Esc => {
1330                                    mode = InputMode::Normal;
1331                                    needs_redraw = true;
1332                                }
1333                                KeyCode::Enter => {
1334                                    if buffer.is_empty() {
1335                                        mode = InputMode::Normal;
1336                                    } else {
1337                                        match parse_colon_command(buffer) {
1338                                            Ok(cmd) => {
1339                                                let is_tag_cmd = matches!(
1340                                                    &cmd,
1341                                                    ColonCommand::Tag(_)
1342                                                        | ColonCommand::TagNext
1343                                                        | ColonCommand::TagPrev
1344                                                        | ColonCommand::TagSelect(_),
1345                                                );
1346                                                let reload_msg = if is_tag_cmd {
1347                                                    refresh_tag_file(&mut tag_file)
1348                                                } else {
1349                                                    None
1350                                                };
1351                                                let outcome = dispatch_colon_command(
1352                                                    cmd,
1353                                                    &mut file_set,
1354                                                    &mut current_file_index,
1355                                                    &args,
1356                                                    preprocessor.as_ref(),
1357                                                    record_start_regex.as_ref(),
1358                                                    &mut viewport,
1359                                                    &mut src,
1360                                                    &mut idx,
1361                                                    &mut tag_stack,
1362                                                    tag_file.as_ref(),
1363                                                );
1364                                                match outcome {
1365                                                    ColonOutcome::Continue(msg) => {
1366                                                        transient_status = msg.or(reload_msg);
1367                                                    }
1368                                                    ColonOutcome::Quit => break,
1369                                                    ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1370                                                        let saved = (0..file_set.len())
1371                                                            .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1372                                                            .collect::<Vec<_>>();
1373                                                        overlay = Some(Box::new(
1374                                                            crate::overlay::picker::FilePicker::new(&file_set, saved)
1375                                                        ));
1376                                                    }
1377                                                    ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1378                                                        let remaps = keymap.user_keys_by_command_name();
1379                                                        overlay = Some(Box::new(
1380                                                            crate::overlay::help::HelpOverlay::new(remaps)
1381                                                        ));
1382                                                    }
1383                                                    ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1384                                                        if let Some(active) = tag_stack.active.as_ref() {
1385                                                            overlay = Some(Box::new(
1386                                                                crate::overlay::tag_picker::TagPicker::new(
1387                                                                    active.name.clone(),
1388                                                                    active.matches.clone(),
1389                                                                    active.cursor,
1390                                                                )
1391                                                            ));
1392                                                        }
1393                                                    }
1394                                                    ColonOutcome::DispatchCommand(cmd) => {
1395                                                        debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1396                                                        // In release builds, silently no-op.
1397                                                    }
1398                                                }
1399                                                mode = InputMode::Normal;
1400                                            }
1401                                            Err(e) => {
1402                                                *error = Some(e.to_string());
1403                                            }
1404                                        }
1405                                    }
1406                                    needs_redraw = true;
1407                                }
1408                                KeyCode::Backspace => {
1409                                    buffer.pop();
1410                                    *error = None;
1411                                    needs_redraw = true;
1412                                }
1413                                KeyCode::Char(c) => {
1414                                    buffer.push(c);
1415                                    *error = None;
1416                                    needs_redraw = true;
1417                                }
1418                                _ => {}
1419                            }
1420                        }
1421                        continue;
1422                    }
1423                    InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1424                        if let Event::Key(KeyEvent { code, .. }) = event {
1425                            match code {
1426                                KeyCode::Esc => {
1427                                    mode = InputMode::Normal;
1428                                    needs_redraw = true;
1429                                }
1430                                KeyCode::Enter => {
1431                                    if buffer.is_empty() {
1432                                        mode = InputMode::Normal;
1433                                    } else {
1434                                        let name = buffer.clone();
1435                                        let reload_msg = refresh_tag_file(&mut tag_file);
1436                                        let msg = dispatch_tag_jump(
1437                                            &name,
1438                                            tag_file.as_ref(),
1439                                            &mut tag_stack,
1440                                            &mut file_set,
1441                                            &mut current_file_index,
1442                                            &args,
1443                                            preprocessor.as_ref(),
1444                                            record_start_regex.as_ref(),
1445                                            &mut viewport,
1446                                            &mut src,
1447                                            &mut idx,
1448                                        );
1449                                        transient_status = msg.or(reload_msg);
1450                                        mode = InputMode::Normal;
1451                                    }
1452                                    needs_redraw = true;
1453                                }
1454                                KeyCode::Backspace => {
1455                                    buffer.pop();
1456                                    *error = None;
1457                                    *last_tab_matches = None;
1458                                    needs_redraw = true;
1459                                }
1460                                KeyCode::Tab => {
1461                                    let _ = refresh_tag_file(&mut tag_file);
1462                                    let names: Vec<String> = match tag_file.as_ref() {
1463                                        Some(tf) => tf
1464                                            .names()
1465                                            .filter(|n| n.starts_with(buffer.as_str()))
1466                                            .map(String::from)
1467                                            .collect(),
1468                                        None => Vec::new(),
1469                                    };
1470                                    match (names.len(), last_tab_matches.as_ref()) {
1471                                        (0, _) => {
1472                                            *error = Some("no tags match".into());
1473                                            *last_tab_matches = None;
1474                                        }
1475                                        (1, _) => {
1476                                            *buffer = names.into_iter().next().unwrap();
1477                                            *error = None;
1478                                            *last_tab_matches = None;
1479                                        }
1480                                        (n, Some(prev)) if prev.len() == n => {
1481                                            *error = Some(format!("{n} matches"));
1482                                        }
1483                                        (n, _) => {
1484                                            let lcp = longest_common_prefix(&names);
1485                                            if lcp.len() > buffer.len() {
1486                                                *buffer = lcp;
1487                                                *error = None;
1488                                            } else {
1489                                                *error = Some(format!("{n} matches"));
1490                                            }
1491                                            *last_tab_matches = Some(names);
1492                                        }
1493                                    }
1494                                    needs_redraw = true;
1495                                }
1496                                KeyCode::Char(c) => {
1497                                    buffer.push(c);
1498                                    *error = None;
1499                                    *last_tab_matches = None;
1500                                    needs_redraw = true;
1501                                }
1502                                _ => {}
1503                            }
1504                        }
1505                        continue;
1506                    }
1507                    InputMode::Normal => {}
1508                }
1509                // Resize must update stored dims even when an overlay is active —
1510                // otherwise the overlay renders at stale dimensions until it closes.
1511                if let crossterm::event::Event::Resize(c, r) = event {
1512                    // Pin the bottom across resizes: a viewport sitting at the
1513                    // end (follow/live, or just scrolled to the bottom) must
1514                    // stay there when body height changes — otherwise the
1515                    // newest content drifts off-screen. Terminals/multiplexers
1516                    // often emit a resize right after startup, which is what
1517                    // made --follow/--live land short of the end.
1518                    let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1519                    cols = c;
1520                    rows = r;
1521                    viewport.resize(c, r);
1522                    if was_at_bottom {
1523                        viewport.goto_bottom(src.as_ref(), &mut idx);
1524                    }
1525                    needs_redraw = true;
1526                    if overlay.is_some() {
1527                        // Overlay still owns the screen; nothing else to do this tick.
1528                        continue;
1529                    }
1530                    // No overlay: fall through to normal handling so the
1531                    // existing Command::Resize path can do whatever else it does.
1532                }
1533                // Active overlay swallows input. Apply/Refuse/Close outcomes
1534                // are handled inline; CloseAnd defers to the normal command
1535                // dispatcher below.
1536                if let Some(ov) = overlay.as_mut() {
1537                    let outcome = match &event {
1538                        Event::Key(ke) => ov.handle_key(*ke),
1539                        Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1540                        Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1541                        _ => crate::overlay::OverlayOutcome::Stay,
1542                    };
1543                    match outcome {
1544                        crate::overlay::OverlayOutcome::Stay => {
1545                            needs_redraw = true;
1546                            continue;
1547                        }
1548                        crate::overlay::OverlayOutcome::Close => {
1549                            overlay = None;
1550                            overlay_flash = None;
1551                            needs_redraw = true;
1552                            continue;
1553                        }
1554                        crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1555                            overlay = None;
1556                            overlay_flash = None;
1557                            if let Command::SelectFile(i) = cmd {
1558                                if i < file_set.len() {
1559                                    file_set.set_current_index(i);
1560                                    if let Some(msg) = switch_to_current_file(
1561                                        &mut file_set, &mut current_file_index,
1562                                        &args, preprocessor.as_ref(),
1563                                        record_start_regex.as_ref(),
1564                                        &mut viewport, &mut src, &mut idx,
1565                                    ) {
1566                                        transient_status = Some(msg);
1567                                    }
1568                                }
1569                            } else if let Command::SelectTagMatch(idx_pick) = cmd {
1570                                if let Some(active) = tag_stack.active.as_mut() {
1571                                    if idx_pick < active.matches.len() {
1572                                        active.cursor = idx_pick;
1573                                        let entry = active.matches[idx_pick].clone();
1574                                        let msg = dispatch_match(
1575                                            &entry,
1576                                            &mut file_set,
1577                                            &mut current_file_index,
1578                                            &args,
1579                                            preprocessor.as_ref(),
1580                                            record_start_regex.as_ref(),
1581                                            &mut viewport,
1582                                            &mut src,
1583                                            &mut idx,
1584                                        );
1585                                        update_viewport_tag_indicator(&tag_stack, &mut viewport);
1586                                        if let Some(m) = msg {
1587                                            transient_status = Some(m);
1588                                        }
1589                                    }
1590                                }
1591                            }
1592                            needs_redraw = true;
1593                            continue;
1594                        }
1595                        crate::overlay::OverlayOutcome::Apply(cmd) => {
1596                            if let Command::DropFileAt(target) = cmd {
1597                                if file_set.len() > 1 && target < file_set.len() {
1598                                    let saved_cur = file_set.current_index();
1599                                    file_set.set_current_index(target);
1600                                    let _ = file_set.delete_current();
1601                                    // delete_current() moved the cursor itself; restore
1602                                    // the pre-drop position when the deletion was not OF
1603                                    // the saved cursor.
1604                                    if target < saved_cur {
1605                                        let restored = saved_cur.saturating_sub(1);
1606                                        file_set.set_current_index(restored);
1607                                    } else if target > saved_cur {
1608                                        file_set.set_current_index(saved_cur);
1609                                    }
1610                                    // (target == saved_cur: delete_current already landed on the nearest
1611                                    //  surviving file; nothing to restore.)
1612                                    if let Some(msg) = switch_to_current_file(
1613                                        &mut file_set, &mut current_file_index,
1614                                        &args, preprocessor.as_ref(),
1615                                        record_start_regex.as_ref(),
1616                                        &mut viewport, &mut src, &mut idx,
1617                                    ) {
1618                                        transient_status = Some(msg);
1619                                    }
1620                                    if let Some(ov) = overlay.as_mut() {
1621                                        ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1622                                    }
1623                                }
1624                            }
1625                            needs_redraw = true;
1626                            continue;
1627                        }
1628                        crate::overlay::OverlayOutcome::Refuse(msg) => {
1629                            overlay_flash = Some((msg, std::time::Instant::now()));
1630                            needs_redraw = true;
1631                            continue;
1632                        }
1633                    }
1634                }
1635                // No-overlay mouse: scrollwheel scrolls the body. Other mouse
1636                // events are ignored to keep the body inert when --mouse is on
1637                // but no overlay is active.
1638                if let crossterm::event::Event::Mouse(me) = &event {
1639                    if mouse_enabled {
1640                        use crossterm::event::{KeyModifiers, MouseEventKind};
1641                        // Shift+wheel = horizontal scroll: the widely-supported
1642                        // convention for terminals that don't emit native
1643                        // ScrollLeft/ScrollRight (e.g. macOS Terminal.app). Only
1644                        // when there's a horizontal axis; otherwise fall through
1645                        // to normal vertical scroll.
1646                        let hshift = me.modifiers.contains(KeyModifiers::SHIFT)
1647                            && viewport.hscroll_active();
1648                        match me.kind {
1649                            MouseEventKind::ScrollDown if hshift => {
1650                                viewport.hscroll_right_step();
1651                                needs_redraw = true;
1652                            }
1653                            MouseEventKind::ScrollUp if hshift => {
1654                                viewport.hscroll_left_step();
1655                                needs_redraw = true;
1656                            }
1657                            MouseEventKind::ScrollDown => {
1658                                viewport.scroll_lines(wheel_lines as i64, src.as_ref(), &mut idx);
1659                                needs_redraw = true;
1660                            }
1661                            MouseEventKind::ScrollUp => {
1662                                viewport.scroll_lines(-(wheel_lines as i64), src.as_ref(), &mut idx);
1663                                needs_redraw = true;
1664                            }
1665                            MouseEventKind::ScrollLeft => {
1666                                viewport.hscroll_left_step();
1667                                needs_redraw = true;
1668                            }
1669                            MouseEventKind::ScrollRight => {
1670                                viewport.hscroll_right_step();
1671                                needs_redraw = true;
1672                            }
1673                            _ => {}
1674                        }
1675                    }
1676                    continue;
1677                }
1678                // Pre-translate keymap interception. Only consult the keymap
1679                // when in Normal mode (not inside a search/option/prettify/
1680                // shell prompt).
1681                let mut cmd: Option<Command> = None;
1682                if let InputMode::Normal = mode {
1683                    if let Event::Key(ke) = &event {
1684                        if let Some(target) = keymap.lookup(ke) {
1685                            match target {
1686                                crate::keys::BindingTarget::Shell(cmd_text) => {
1687                                    let cmd_text = cmd_text.clone();
1688                                    if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1689                                        let _ = writeln!(std::io::stderr(),
1690                                            "[shell: {e}]");
1691                                    }
1692                                    needs_redraw = true;
1693                                    continue;
1694                                }
1695                                crate::keys::BindingTarget::Command(c) => {
1696                                    cmd = Some(c.clone());
1697                                }
1698                            }
1699                        }
1700                    }
1701                }
1702                let cmd = cmd.unwrap_or_else(|| translate(event));
1703                // Consume the numeric prefix at the top of each dispatch so
1704                // commands that don't need it drop it implicitly.
1705                let prefix_at_cmd = numeric_prefix.take();
1706                match cmd {
1707                    Command::Digit(d) => {
1708                        let cur = prefix_at_cmd.unwrap_or(0);
1709                        let next = cur.saturating_mul(10).saturating_add(d as usize);
1710                        if next <= 99_999_999 {
1711                            numeric_prefix = Some(next);
1712                        } else {
1713                            // Overflow: keep previous prefix, ignore this digit.
1714                            numeric_prefix = prefix_at_cmd;
1715                        }
1716                        continue;
1717                    }
1718                    Command::Cancel => {
1719                        // prefix_at_cmd already consumed; nothing else to do.
1720                        continue;
1721                    }
1722                    Command::GotoLine => {
1723                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1724                        match prefix_at_cmd {
1725                            Some(line) if line > 0 => {
1726                                viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1727                                viewport.suspend_follow_if(args.follow_suspend_on_motion);
1728                            }
1729                            _ => {
1730                                viewport.goto_top();
1731                                viewport.suspend_follow_if(args.follow_suspend_on_motion);
1732                            }
1733                        }
1734                        needs_redraw = true;
1735                    }
1736                    Command::GotoRecord => {
1737                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1738                        match prefix_at_cmd {
1739                            Some(rec) if rec > 0 => {
1740                                viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1741                                viewport.suspend_follow_if(args.follow_suspend_on_motion);
1742                            }
1743                            _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1744                        }
1745                        needs_redraw = true;
1746                    }
1747                    Command::GotoPercent => {
1748                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1749                        match prefix_at_cmd {
1750                            Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1751                            _ => viewport.goto_top(),
1752                        }
1753                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1754                        needs_redraw = true;
1755                    }
1756                    Command::Quit => break,
1757                    Command::Resize(c, r) => {
1758                        let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1759                        cols = c; rows = r;
1760                        viewport.resize(c, r);
1761                        if was_at_bottom {
1762                            viewport.goto_bottom(src.as_ref(), &mut idx);
1763                        }
1764                        needs_redraw = true;
1765                    }
1766                    Command::ScrollLines(n) => {
1767                        viewport.scroll_lines(n, src.as_ref(), &mut idx);
1768                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1769                        if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1770                        needs_redraw = true;
1771                    }
1772                    Command::ScrollLogicalLines(n) => {
1773                        viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1774                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1775                        if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1776                        needs_redraw = true;
1777                    }
1778                    Command::PageDown => {
1779                        viewport.page_down(src.as_ref(), &mut idx);
1780                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1781                        if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1782                        needs_redraw = true;
1783                    }
1784                    Command::PageUp => {
1785                        viewport.page_up(src.as_ref(), &mut idx);
1786                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1787                        viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1788                        needs_redraw = true;
1789                    }
1790                    Command::HalfPageDown => {
1791                        viewport.half_page_down(src.as_ref(), &mut idx);
1792                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1793                        if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1794                        needs_redraw = true;
1795                    }
1796                    Command::HalfPageUp => {
1797                        viewport.half_page_up(src.as_ref(), &mut idx);
1798                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1799                        viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1800                        needs_redraw = true;
1801                    }
1802                    Command::Refresh => {
1803                        needs_redraw = true;
1804                    }
1805                    Command::Reload => {
1806                        // Force a stat+reread now (only meaningful for live
1807                        // sources; static FileSource::pump() is a no-op).
1808                        src.pump();
1809                        if src.revision() != last_revision {
1810                            rebuild_after_replace(
1811                                src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1812                            );
1813                            last_revision = src.revision();
1814                            needs_redraw = true;
1815                        }
1816                    }
1817                    Command::TogglePrettify => {
1818                        apply_prettify(
1819                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1820                            PrettifyTarget::Toggle,
1821                        );
1822                        last_revision = src.revision();
1823                        needs_redraw = true;
1824                    }
1825                    Command::SetPrettifyMode(m) => {
1826                        apply_prettify(
1827                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1828                            PrettifyTarget::Mode(m),
1829                        );
1830                        last_revision = src.revision();
1831                        needs_redraw = true;
1832                    }
1833                    Command::RedetectPrettify => {
1834                        apply_prettify(
1835                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1836                            PrettifyTarget::Auto,
1837                        );
1838                        last_revision = src.revision();
1839                        needs_redraw = true;
1840                    }
1841                    Command::ToggleLineNumbers => {
1842                        viewport.toggle_line_numbers();
1843                        needs_redraw = true;
1844                    }
1845                    Command::ToggleChop => {
1846                        viewport.toggle_chop();
1847                        needs_redraw = true;
1848                    }
1849                    Command::ToggleFollow => {
1850                        viewport.toggle_follow();
1851                        if viewport.follow_mode() {
1852                            // Re-engaging: pump any pending bytes and snap to bottom.
1853                            src.pump();
1854                            idx.notice_new_bytes(src.as_ref());
1855                            viewport.goto_bottom(src.as_ref(), &mut idx);
1856                        }
1857                        needs_redraw = true;
1858                    }
1859                    Command::SearchForward => {
1860                        incsearch_origin = (viewport.top_line(), viewport.top_row());
1861                        mode = InputMode::SearchPrompt {
1862                            direction: SearchDirection::Forward,
1863                            buffer: String::new(),
1864                            error: None,
1865                        };
1866                        needs_redraw = true;
1867                    }
1868                    Command::SearchBackward => {
1869                        incsearch_origin = (viewport.top_line(), viewport.top_row());
1870                        mode = InputMode::SearchPrompt {
1871                            direction: SearchDirection::Backward,
1872                            buffer: String::new(),
1873                            error: None,
1874                        };
1875                        needs_redraw = true;
1876                    }
1877                    Command::ShellEscape => {
1878                        mode = InputMode::ShellPrompt {
1879                            buffer: String::new(),
1880                            error: None,
1881                        };
1882                        needs_redraw = true;
1883                    }
1884                    Command::ColonPrompt => {
1885                        mode = InputMode::ColonPrompt {
1886                            buffer: String::new(),
1887                            error: None,
1888                        };
1889                        needs_redraw = true;
1890                    }
1891                    Command::NextMatch => {
1892                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1893                        if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1894                            needs_redraw = true;
1895                        }
1896                    }
1897                    Command::PreviousMatch => {
1898                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1899                        if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1900                            needs_redraw = true;
1901                        }
1902                    }
1903                    Command::OptionPrefix => {
1904                        mode = InputMode::OptionPrefix;
1905                    }
1906                    Command::MarkSet => {
1907                        mode = InputMode::MarkSetPending;
1908                    }
1909                    Command::MarkJump => {
1910                        mode = InputMode::MarkJumpPending;
1911                    }
1912                    Command::CtrlXPrefix => {
1913                        mode = InputMode::CtrlXPending;
1914                    }
1915                    Command::JumpPrevious => {
1916                        // Resolved inside the CtrlXPending mode intercept; this
1917                        // arm is defensive and should never fire.
1918                    }
1919                    Command::TagPrompt => {
1920                        if tag_file.is_none() {
1921                            transient_status = Some("[no tags file loaded]".into());
1922                            needs_redraw = true;
1923                        } else {
1924                            mode = InputMode::TagPrompt {
1925                                buffer: String::new(),
1926                                error: None,
1927                                last_tab_matches: None,
1928                            };
1929                            needs_redraw = true;
1930                        }
1931                    }
1932                    Command::TagPop => match tag_stack.pop() {
1933                        Some((file_index, line)) => {
1934                            if file_index != current_file_index && file_index < file_set.len() {
1935                                file_set.set_current_index(file_index);
1936                                let path = file_set.current().unwrap().to_path_buf();
1937                                if let Err(e) = switch_file(
1938                                    &path,
1939                                    file_index,
1940                                    file_set.len(),
1941                                    &args,
1942                                    preprocessor.as_ref(),
1943                                    &mut viewport,
1944                                    &mut src,
1945                                    &mut idx,
1946                                    record_start_regex.as_ref(),
1947                                ) {
1948                                    transient_status = Some(format!("[open: {e}]"));
1949                                } else {
1950                                    current_file_index = file_index;
1951                                }
1952                            }
1953                            let clamped = line.min(idx.line_count().saturating_sub(1));
1954                            viewport.goto_line(clamped, src.as_ref(), &mut idx);
1955                            update_viewport_tag_indicator(&tag_stack, &mut viewport);
1956                            needs_redraw = true;
1957                        }
1958                        None => {
1959                            transient_status = Some("[tag stack empty]".into());
1960                            needs_redraw = true;
1961                        }
1962                    },
1963                    Command::OpenPicker => {
1964                        let saved = (0..file_set.len())
1965                            .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1966                            .collect::<Vec<_>>();
1967                        overlay = Some(Box::new(
1968                            crate::overlay::picker::FilePicker::new(&file_set, saved)
1969                        ));
1970                        needs_redraw = true;
1971                    }
1972                    Command::OpenHelp => {
1973                        let remaps = keymap.user_keys_by_command_name();
1974                        overlay = Some(Box::new(
1975                            crate::overlay::help::HelpOverlay::new(remaps)
1976                        ));
1977                        needs_redraw = true;
1978                    }
1979                    Command::SelectFile(_)
1980                    | Command::DropFileAt(_)
1981                    | Command::SelectTagMatch(_)
1982                    | Command::OpenTagPicker => {
1983                        // Overlay-only outcomes; consumed by the routing block above.
1984                    }
1985                    Command::MouseEvent(_) => {
1986                        // Mouse handling lives in the event-routing block, not here.
1987                    }
1988                    Command::HScrollLeft => {
1989                        if hscroll_shift != 0 {
1990                            viewport.hscroll_left_cols(hscroll_shift);
1991                        } else {
1992                            viewport.hscroll_left_half();
1993                        }
1994                        needs_redraw = true;
1995                    }
1996                    Command::HScrollRight => {
1997                        if hscroll_shift != 0 {
1998                            viewport.hscroll_right_cols(hscroll_shift);
1999                        } else {
2000                            viewport.hscroll_right_half();
2001                        }
2002                        needs_redraw = true;
2003                    }
2004                    Command::HScrollLeftStep => {
2005                        viewport.hscroll_left_step();
2006                        needs_redraw = true;
2007                    }
2008                    Command::HScrollRightStep => {
2009                        viewport.hscroll_right_step();
2010                        needs_redraw = true;
2011                    }
2012                    Command::YankLine => {
2013                        let msg = yank_current_line(clipboard_enabled, &viewport, src.as_ref(), &mut idx);
2014                        transient_status = Some(msg);
2015                        needs_redraw = true;
2016                    }
2017                    Command::AnimPause => {
2018                        #[cfg(feature = "image")]
2019                        viewport.anim_toggle_pause();
2020                        needs_redraw = true;
2021                    }
2022                    Command::AnimStepForward => {
2023                        #[cfg(feature = "image")]
2024                        viewport.anim_step(1);
2025                        needs_redraw = true;
2026                    }
2027                    Command::AnimStepBack => {
2028                        #[cfg(feature = "image")]
2029                        viewport.anim_step(-1);
2030                        needs_redraw = true;
2031                    }
2032                    Command::AnimRestart => {
2033                        #[cfg(feature = "image")]
2034                        viewport.anim_restart();
2035                        needs_redraw = true;
2036                    }
2037                    Command::Noop => {}
2038                }
2039                // Reset the tick clock after handling input so a long idle
2040                // between keystrokes doesn't collapse into a burst of frame
2041                // advances on the next timeout.
2042                #[cfg(feature = "image")]
2043                {
2044                    last_tick = std::time::Instant::now();
2045                }
2046            }
2047            Ok(false) => {
2048                // Advance any playing animation by the elapsed time since the
2049                // last tick or input. tick() returns true when the visible
2050                // frame changed and a redraw is needed.
2051                #[cfg(feature = "image")]
2052                {
2053                    let dt = last_tick.elapsed();
2054                    last_tick = std::time::Instant::now();
2055                    if viewport.tick(dt) { needs_redraw = true; }
2056                }
2057                // Timeout — check whether the source has grown or been rewritten.
2058                if viewport.live_mode() {
2059                    let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2060                    src.pump();
2061                    if src.revision() != last_revision {
2062                        rebuild_after_replace(
2063                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2064                        );
2065                        if was_at_bottom {
2066                            viewport.goto_bottom(src.as_ref(), &mut idx);
2067                        }
2068                        last_revision = src.revision();
2069                        needs_redraw = true;
2070                    }
2071                } else if viewport.follow_mode() {
2072                    let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2073                    src.pump();
2074                    if src.take_rotated() {
2075                        // File was rotated or truncated. Re-open from offset 0
2076                        // and reset the line index so we're not staring at
2077                        // stale mmap content. Snap to bottom of the fresh
2078                        // content (follow mode is on, so that's the natural
2079                        // place to land).
2080                        if let Some(path) = src.path().map(|p| p.to_path_buf()) {
2081                            match crate::open::open_source_for_path(
2082                                &path, &args, preprocessor.as_ref(),
2083                            ) {
2084                                Ok((new_src, _label, _err)) => {
2085                                    src = new_src;
2086                                    idx = LineIndex::new();
2087                                    if let Some(n) = rebuild_spec.head {
2088                                        idx.set_head_cap(n);
2089                                    }
2090                                    viewport.invalidate_filter_cache();
2091                                    idx.notice_new_bytes(src.as_ref());
2092                                    viewport.extend_visible_lines(&idx, src.as_ref());
2093                                    viewport.goto_bottom(src.as_ref(), &mut idx);
2094                                    viewport.flash("(F reopened)", 4);
2095                                    needs_redraw = true;
2096                                    continue;
2097                                }
2098                                Err(e) => {
2099                                    transient_status = Some(format!("[reopen failed: {e}]"));
2100                                    needs_redraw = true;
2101                                }
2102                            }
2103                        }
2104                    }
2105                    let lines_before = idx.line_count();
2106                    idx.notice_new_bytes(src.as_ref());
2107                    viewport.extend_visible_lines(&idx, src.as_ref());
2108                    if idx.line_count() != lines_before {
2109                        needs_redraw = true;
2110                        viewport.note_growth();
2111                        if was_at_bottom {
2112                            viewport.goto_bottom(src.as_ref(), &mut idx);
2113                        }
2114                    } else {
2115                        viewport.tick_idle();
2116                    }
2117                    viewport.tick_flash();
2118                    // `--exit-follow-on-close`: when the source signals
2119                    // that the upstream writer has finished (streaming
2120                    // stdin's reader thread exited), exit the pager.
2121                    // File sources are always complete from open, so this
2122                    // condition only fires for piped stdin.
2123                    if args.exit_follow_on_close && src.is_complete() {
2124                        break;
2125                    }
2126                } else if !src.is_complete() {
2127                    // Streaming stdin without follow mode: still keep the index
2128                    // up-to-date so line counts stay accurate, but don't auto-scroll.
2129                    let lines_before = idx.line_count();
2130                    idx.notice_new_bytes(src.as_ref());
2131                    viewport.extend_visible_lines(&idx, src.as_ref());
2132                    if idx.line_count() != lines_before {
2133                        needs_redraw = true;
2134                    }
2135                }
2136            }
2137            Err(_) => {
2138                // poll() error — sleep the timeout duration to avoid tight-spinning.
2139                std::thread::sleep(timeout);
2140            }
2141        }
2142    }
2143    Ok(())
2144}
2145
2146/// What `apply_prettify` should do to the source's prettify state.
2147#[derive(Debug, Clone, Copy)]
2148enum PrettifyTarget {
2149    /// Set a specific mode (including `Off` for "raw").
2150    Mode(PrettifyMode),
2151    /// Flip between current mode and last-active mode.
2152    Toggle,
2153    /// Re-run byte-based content detection and apply the result.
2154    Auto,
2155}
2156
2157/// Apply a prettify-state change to the source and propagate any visible
2158/// effects (line index rebuild, viewport label, scroll clamp). No-op if the
2159/// source isn't a `TransformingSource` (i.e. `prettify_mode()` is `None`).
2160fn apply_prettify(
2161    src: &dyn Source,
2162    viewport: &mut Viewport,
2163    idx: &mut LineIndex,
2164    spec: RebuildSpec,
2165    target: PrettifyTarget,
2166) {
2167    // Sources without a wrapper return None — nothing to do.
2168    if src.prettify_mode().is_none() {
2169        return;
2170    }
2171    match target {
2172        PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
2173        PrettifyTarget::Toggle => src.toggle_prettify(),
2174        PrettifyTarget::Auto => src.redetect_prettify(),
2175    }
2176    rebuild_after_replace(src, viewport, idx, spec);
2177    viewport.set_prettify_label(src.prettify_label());
2178}
2179
2180/// Rebuild line index and visible-line cache after the source content has
2181/// been replaced wholesale (e.g. an editor saved over the file). Re-applies
2182/// `--head`/`--tail` caps from the original CLI args; clamps `top_line` so the
2183/// user stays roughly where they were rather than jumping. Auto snap-to-bottom
2184/// (when the user *was* at the bottom) is the caller's responsibility.
2185fn rebuild_after_replace(
2186    src: &dyn Source,
2187    viewport: &mut Viewport,
2188    idx: &mut LineIndex,
2189    spec: RebuildSpec,
2190) {
2191    let new_off = match spec.tail {
2192        Some(n) => find_tail_offset(src, n),
2193        None => 0,
2194    };
2195    *idx = LineIndex::new_starting_at(new_off);
2196    if let Some(n) = spec.head {
2197        idx.set_head_cap(n);
2198    }
2199    viewport.invalidate_filter_cache();
2200    idx.notice_new_bytes(src);
2201    viewport.extend_visible_lines(idx, src);
2202    viewport.clamp_top_line(idx.line_count());
2203}
2204
2205fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2206    use crossterm::style::Color as CC;
2207    use crate::ansi::Color;
2208    match c {
2209        Color::Ansi(0) => CC::Black,
2210        Color::Ansi(1) => CC::DarkRed,
2211        Color::Ansi(2) => CC::DarkGreen,
2212        Color::Ansi(3) => CC::DarkYellow,
2213        Color::Ansi(4) => CC::DarkBlue,
2214        Color::Ansi(5) => CC::DarkMagenta,
2215        Color::Ansi(6) => CC::DarkCyan,
2216        Color::Ansi(7) => CC::Grey,
2217        Color::Ansi(8) => CC::DarkGrey,
2218        Color::Ansi(9) => CC::Red,
2219        Color::Ansi(10) => CC::Green,
2220        Color::Ansi(11) => CC::Yellow,
2221        Color::Ansi(12) => CC::Blue,
2222        Color::Ansi(13) => CC::Magenta,
2223        Color::Ansi(14) => CC::Cyan,
2224        Color::Ansi(15) => CC::White,
2225        Color::Ansi(_) => CC::Reset,
2226        Color::Indexed(n) => CC::AnsiValue(n),
2227        Color::Rgb(r, g, b) => {
2228            if truecolor {
2229                CC::Rgb { r, g, b }
2230            } else {
2231                CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2232            }
2233        }
2234        Color::Default => CC::Reset,
2235    }
2236}
2237
2238/// Emit crossterm commands to transition `prev` → `next`. Caller must
2239/// already have written prior cells using `prev`'s state.
2240fn emit_style_diff<W: Write>(
2241    out: &mut W,
2242    prev: &crate::ansi::Style,
2243    next: &crate::ansi::Style,
2244    truecolor: bool,
2245) -> io::Result<()> {
2246    // For attribute toggles, crossterm has individual on/off pairs.
2247    // `NormalIntensity` cancels both bold AND dim — handle them together
2248    // to avoid emitting it twice when only one changed.
2249    let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2250
2251    // Color changes. ResetColor clears BOTH fg and bg simultaneously, so
2252    // if either changed to None we emit ResetColor first and then re-emit
2253    // the other if it's Some.
2254    let fg_changed = prev.fg != next.fg;
2255    let bg_changed = prev.bg != next.bg;
2256
2257    if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2258        out.queue(ResetColor)?;
2259        // After ResetColor, re-emit any color that should remain set.
2260        if let Some(c) = next.fg {
2261            out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2262        }
2263        if let Some(c) = next.bg {
2264            out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2265        }
2266    } else {
2267        if fg_changed {
2268            if let Some(c) = next.fg {
2269                out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2270            }
2271        }
2272        if bg_changed {
2273            if let Some(c) = next.bg {
2274                out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2275            }
2276        }
2277    }
2278
2279    if intensity_changed {
2280        if next.bold {
2281            out.queue(SetAttribute(Attribute::Bold))?;
2282        } else if next.dim {
2283            out.queue(SetAttribute(Attribute::Dim))?;
2284        } else {
2285            out.queue(SetAttribute(Attribute::NormalIntensity))?;
2286        }
2287    }
2288    if prev.italic != next.italic {
2289        out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2290    }
2291    if prev.underline != next.underline {
2292        out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2293    }
2294    if prev.reverse != next.reverse {
2295        out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2296    }
2297    if prev.strike != next.strike {
2298        out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2299    }
2300    Ok(())
2301}
2302
2303fn emit_hyperlink_diff<W: Write>(
2304    out: &mut W,
2305    prev: &Option<Arc<str>>,
2306    next: &Option<Arc<str>>,
2307) -> io::Result<()> {
2308    if prev == next {
2309        return Ok(());
2310    }
2311    if prev.is_some() {
2312        out.write_all(b"\x1b]8;;\x1b\\")?;
2313    }
2314    if let Some(uri) = next {
2315        out.write_all(b"\x1b]8;;")?;
2316        out.write_all(uri.as_bytes())?;
2317        out.write_all(b"\x1b\\")?;
2318    }
2319    Ok(())
2320}
2321
2322/// DEC private mode 2026: synchronized output. Terminals that support it
2323/// (iTerm2, Kitty, WezTerm, Alacritty, Ghostty, foot, recent VTE,
2324/// Windows Terminal) buffer everything between `BEGIN` and `END` and
2325/// present the whole frame atomically; terminals that don't recognize the
2326/// sequence silently ignore it. This kills the flicker that would
2327/// otherwise appear during a frame's per-row repaint.
2328const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2329const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2330
2331fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2332    // Raw mode (`-r` / `:color raw`) routes each visible body row through the
2333    // `frame.raw_rows` slot (populated by `Viewport::frame` when ansi_mode is
2334    // Raw). The writer below blasts those bytes to the terminal verbatim so
2335    // escape sequences like cursor moves and SGR pass through. Wrap math is
2336    // best-effort — terminal-driven wrapping may shift sub-rows under long
2337    // lines, matching `less -r`'s documented limitation.
2338
2339    // Begin a synchronized update so the whole frame is presented atomically
2340    // (see SYNC_UPDATE_BEGIN). Paired with per-row `Clear(UntilNewLine)`
2341    // below, this replaces the previous global `Clear(All)` redraw and
2342    // eliminates the visible blank-frame flicker on every scroll keystroke.
2343    out.write_all(SYNC_UPDATE_BEGIN)?;
2344
2345    // Reset attributes once before drawing so the first row starts clean.
2346    out.queue(SetAttribute(Attribute::Reset))?;
2347    out.queue(ResetColor)?;
2348
2349    if let Some(blob) = &frame.image_blob {
2350        // Clear the body region once so a prior frame's cells don't bleed
2351        // around/under the image, then emit the graphics escape verbatim.
2352        for r in 0..frame.body.len() as u16 {
2353            out.queue(MoveTo(0, r))?;
2354            out.queue(Clear(ClearType::UntilNewLine))?;
2355        }
2356        out.queue(MoveTo(0, 0))?;
2357        out.write_all(blob)?;
2358        // Status row, mirroring the normal path's status handling.
2359        out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2360        out.queue(Clear(ClearType::UntilNewLine))?;
2361        emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2362        let mut status = frame.status.clone();
2363        if status.len() > cols as usize { status.truncate(cols as usize); }
2364        else { let pad = cols as usize - status.len(); status.push_str(&" ".repeat(pad)); }
2365        out.queue(Print(status))?;
2366        out.queue(ResetColor)?;
2367        out.queue(SetAttribute(Attribute::Reset))?;
2368        out.write_all(SYNC_UPDATE_END)?;
2369        return out.flush();
2370    }
2371
2372    for (i, row) in frame.body.iter().enumerate() {
2373        out.queue(MoveTo(0, i as u16))?;
2374        // Wipe whatever was on this row in the previous frame. Cursor is
2375        // at col 0 so UntilNewLine clears the full row width, which also
2376        // covers the shrink-on-resize case (old cells past the new edge).
2377        out.queue(Clear(ClearType::UntilNewLine))?;
2378        // Defensive: every row begins with a full attribute reset, so a
2379        // mis-handled reset on the previous row can't bleed forward.
2380        out.queue(SetAttribute(Attribute::Reset))?;
2381
2382        // Raw passthrough: when the viewport set this row's `raw_rows` entry,
2383        // write the original source bytes directly to the terminal instead of
2384        // rendering cells. Empty Vec means "skip" (mid-line wrap continuation
2385        // whose first row already emitted the bytes).
2386        if let Some(Some(raw)) = frame.raw_rows.get(i) {
2387            if !raw.is_empty() {
2388                out.write_all(raw)?;
2389            }
2390            // Trailing reset so a bare `\x1b[31m` doesn't leak into the next row.
2391            out.queue(ResetColor)?;
2392            out.queue(SetAttribute(Attribute::Reset))?;
2393            continue;
2394        }
2395
2396        let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2397        // Build the base style representing the terminal state after the
2398        // defensive reset above. Dim rows get a dim base so the style-diff
2399        // tracker inside write_row_with_highlights starts from the correct
2400        // live terminal state.
2401        let base_style = if matches!(row_style, RowStyle::Dim) {
2402            out.queue(SetAttribute(Attribute::Dim))?;
2403            crate::ansi::Style { dim: true, ..Default::default() }
2404        } else {
2405            crate::ansi::Style::default()
2406        };
2407        let no_highlights = Vec::new();
2408        let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2409        write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2410    }
2411    // Status row
2412    out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2413    out.queue(Clear(ClearType::UntilNewLine))?;
2414    emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2415    let mut status = frame.status.clone();
2416    if status.len() > cols as usize {
2417        status.truncate(cols as usize);
2418    } else {
2419        let pad = cols as usize - status.len();
2420        status.push_str(&" ".repeat(pad));
2421    }
2422    out.queue(Print(status))?;
2423    out.queue(ResetColor)?;
2424    out.queue(SetAttribute(Attribute::Reset))?;
2425
2426    // End the synchronized update. The terminal flushes the buffered frame
2427    // atomically on receipt of this sequence.
2428    out.write_all(SYNC_UPDATE_END)?;
2429    out.flush()
2430}
2431
2432
2433/// Emit a single row with per-cell color/attribute transitions and
2434/// reverse-video highlights. Walks each cell, diffing style and hyperlink
2435/// from the previous cell, emitting only the transitions needed.
2436///
2437/// `base_style` is the terminal's live style state when this function is
2438/// entered (reflects any row-level attribute the caller already emitted,
2439/// e.g. `Dim` for `--dim` rows).
2440///
2441/// Highlight ranges toggle each cell's `reverse` attribute so highlights
2442/// compose correctly with cells that are already reverse-video.
2443fn write_row_with_highlights(
2444    out: &mut impl Write,
2445    row: &[Cell],
2446    cols: u16,
2447    highlights: &[std::ops::Range<usize>],
2448    base_style: crate::ansi::Style,
2449    truecolor: bool,
2450) -> io::Result<()> {
2451    let cols_usize = cols as usize;
2452
2453    let mut ranges: Vec<std::ops::Range<usize>> = highlights
2454        .iter()
2455        .filter_map(|r| {
2456            let s = r.start.min(cols_usize);
2457            let e = r.end.min(cols_usize);
2458            if e > s { Some(s..e) } else { None }
2459        })
2460        .collect();
2461    ranges.sort_by_key(|r| r.start);
2462
2463    // Style register starts at `base_style` — what the terminal currently
2464    // has live after any row-level attribute the caller emitted.
2465    let mut prev_style = base_style;
2466    let mut prev_link: Option<Arc<str>> = None;
2467
2468    let mut col = 0usize;
2469    let mut i = 0usize;
2470    while col < cols_usize && i < row.len() {
2471        let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2472
2473        match &row[i] {
2474            Cell::Char { ch, width, style, hyperlink } => {
2475                // Effective style: cell's style with reverse toggled when in
2476                // a highlight, so highlight composes with already-reverse content.
2477                // Row-level dim (from `--dim` non-matching rows) is OR'd into
2478                // each cell unless the cell explicitly sets bold (bold and dim
2479                // share the SGR intensity slot; bold wins).
2480                let mut eff = *style;
2481                if in_highlight {
2482                    eff.reverse = !eff.reverse;
2483                }
2484                if base_style.dim && !eff.bold {
2485                    eff.dim = true;
2486                }
2487                emit_style_diff(out, &prev_style, &eff, truecolor)?;
2488                emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2489                out.queue(Print(*ch))?;
2490                prev_style = eff;
2491                prev_link = hyperlink.clone();
2492                col += *width as usize;
2493            }
2494            Cell::Continuation => {
2495                // Already accounted for by the preceding wide char.
2496            }
2497            Cell::Empty => {
2498                // Background padding. Reset style to default so we don't
2499                // paint the rest of the line in the last active color —
2500                // but preserve the row-level dim so trailing padding on a
2501                // dim row stays dim.
2502                let default = if base_style.dim {
2503                    crate::ansi::Style { dim: true, ..Default::default() }
2504                } else {
2505                    crate::ansi::Style::default()
2506                };
2507                emit_style_diff(out, &prev_style, &default, truecolor)?;
2508                emit_hyperlink_diff(out, &prev_link, &None)?;
2509                out.queue(Print(' '))?;
2510                prev_style = default;
2511                prev_link = None;
2512                col += 1;
2513            }
2514        }
2515        i += 1;
2516    }
2517
2518    // End-of-row: close any open hyperlink and reset color/attrs so the
2519    // next row's defensive Reset is a true no-op.
2520    emit_hyperlink_diff(out, &prev_link, &None)?;
2521    out.queue(ResetColor)?;
2522    out.queue(SetAttribute(Attribute::Reset))?;
2523
2524    Ok(())
2525}
2526
2527fn render_overlay(
2528    out: &mut impl Write,
2529    frame: &crate::overlay::OverlayFrame,
2530    width: u16,
2531    height: u16,
2532) -> io::Result<()> {
2533    // Mirror write_frame's atomic-frame discipline: synchronized update +
2534    // per-row clear, with a reverse-video status row to match the regular
2535    // viewport's look.
2536    out.write_all(SYNC_UPDATE_BEGIN)?;
2537    out.queue(SetAttribute(Attribute::Reset))?;
2538    out.queue(ResetColor)?;
2539    for row in 0..height.saturating_sub(1) {
2540        out.queue(MoveTo(0, row))?;
2541        out.queue(Clear(ClearType::UntilNewLine))?;
2542        out.queue(SetAttribute(Attribute::Reset))?;
2543        if let Some(line) = frame.body.get(row as usize) {
2544            let mut written = 0usize;
2545            for ch in line.chars() {
2546                let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2547                if written + w > width as usize { break; }
2548                write!(out, "{ch}")?;
2549                written += w;
2550            }
2551        }
2552    }
2553    out.queue(MoveTo(0, height.saturating_sub(1)))?;
2554    out.queue(Clear(ClearType::UntilNewLine))?;
2555    out.queue(SetAttribute(Attribute::Reverse))?;
2556    let mut status = frame.status.clone();
2557    // TODO: use display width (not byte count) — mirrors write_frame's latent limitation.
2558    if status.len() > width as usize {
2559        status.truncate(width as usize);
2560    } else {
2561        let pad = width as usize - status.len();
2562        status.push_str(&" ".repeat(pad));
2563    }
2564    out.queue(Print(status))?;
2565    out.queue(ResetColor)?;
2566    out.queue(SetAttribute(Attribute::Reset))?;
2567    out.write_all(SYNC_UPDATE_END)?;
2568    out.flush()
2569}
2570
2571#[cfg(test)]
2572mod tests {
2573    use super::*;
2574
2575    #[test]
2576    fn parse_colon_n() {
2577        assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2578        assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2579    }
2580
2581    #[test]
2582    fn current_line_bytes_strips_trailing_newline() {
2583        use crate::line_index::LineIndex;
2584        use crate::source::MockSource;
2585        let m = MockSource::new();
2586        // Three lines; the last has no trailing newline.
2587        m.append(b"alpha\nbravo\ncharlie");
2588        let mut idx = LineIndex::new();
2589        idx.extend_to_end(&m);
2590        assert_eq!(idx.line_count(), 3);
2591        assert_eq!(current_line_bytes(&idx, &m, 0), b"alpha");
2592        assert_eq!(current_line_bytes(&idx, &m, 1), b"bravo");
2593        // Last line, no trailing newline, returned verbatim.
2594        assert_eq!(current_line_bytes(&idx, &m, 2), b"charlie");
2595    }
2596
2597    #[test]
2598    fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2599        // Locks in the flicker fix: every frame is wrapped in DEC mode 2026
2600        // begin/end escapes, and the previous global `Clear(All)` is gone
2601        // (replaced by per-row `Clear(UntilNewLine)`). If any of these
2602        // assumptions changes, flicker is likely to come back.
2603        use crate::ansi::Style;
2604        use crate::render::Cell;
2605        use crate::viewport::{Frame, RowStyle};
2606
2607        let row: Vec<Cell> = (0..3)
2608            .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2609            .collect();
2610        let frame = Frame {
2611            body: vec![row.clone(), row],
2612            row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2613            highlights: vec![Vec::new(), Vec::new()],
2614            status: "status".into(),
2615            status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2616            raw_rows: vec![None, None],
2617            image_blob: None,
2618        };
2619
2620        let mut buf: Vec<u8> = Vec::new();
2621        write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2622        let s = std::str::from_utf8(&buf).expect("ascii");
2623
2624        // Begin and end synchronized-update markers, in that order.
2625        let begin = s.find("\x1b[?2026h").expect("begin sync update");
2626        let end = s.find("\x1b[?2026l").expect("end sync update");
2627        assert!(begin < end, "begin must precede end");
2628        // Body content must sit between the markers.
2629        let first_a = s.find('a').expect("body char");
2630        assert!(begin < first_a && first_a < end, "body must be inside sync update");
2631
2632        // Full-screen `Clear(All)` (`\x1b[2J`) must NOT appear — it was the
2633        // source of the flicker. Per-row clear-to-EOL (`\x1b[K`) is fine.
2634        assert!(
2635            !s.contains("\x1b[2J"),
2636            "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2637        );
2638        assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2639    }
2640
2641    #[test]
2642    fn write_frame_emits_image_blob_verbatim_and_skips_cell_rows() {
2643        use crate::viewport::{Frame, RowStyle};
2644        let body_rows = 3usize;
2645        let cols = 10u16;
2646        let blob = b"\x1bPqDATA\x1b\\".to_vec();
2647        // Seed a body cell with a distinctive printable char; the image-blob
2648        // path must skip the per-row cell loop, so this char must NOT appear.
2649        let mut body = vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows];
2650        body[0][0] = crate::render::Cell::Char { ch: 'Z', width: 1, style: crate::ansi::Style::default(), hyperlink: None };
2651        let frame = Frame {
2652            body,
2653            row_styles: vec![RowStyle::Normal; body_rows],
2654            highlights: vec![Vec::new(); body_rows],
2655            status: "img".to_string(),
2656            status_style: crate::ansi::Style::default(),
2657            raw_rows: vec![None; body_rows],
2658            image_blob: Some(blob.clone()),
2659        };
2660        let mut out: Vec<u8> = Vec::new();
2661        write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
2662        let needle = b"\x1bPqDATA\x1b\\";
2663        assert!(out.windows(needle.len()).any(|w| w == needle), "image blob emitted verbatim");
2664        assert!(String::from_utf8_lossy(&out).contains("img"), "status still drawn");
2665        assert!(!String::from_utf8_lossy(&out).contains('Z'), "cell loop skipped: body cell not rendered");
2666    }
2667
2668    #[test]
2669    fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2670        use crate::ansi::Style;
2671        use crate::render::Cell;
2672        use crate::viewport::{Frame, RowStyle};
2673
2674        // One body row (since rows=2 means body_rows=1).
2675        let placeholder_row: Vec<Cell> = (0..3)
2676            .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2677            .collect();
2678        let frame = Frame {
2679            body: vec![placeholder_row.clone(), placeholder_row],
2680            row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2681            highlights: vec![Vec::new(), Vec::new()],
2682            status: "s".into(),
2683            status_style: Style { reverse: true, ..Default::default() },
2684            // Row 0 emits raw bytes (with an embedded ESC); row 1 is a
2685            // continuation and emits nothing.
2686            raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2687            image_blob: None,
2688        };
2689
2690        let mut buf: Vec<u8> = Vec::new();
2691        write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2692        let s = std::str::from_utf8(&buf).expect("ascii");
2693
2694        // The original SGR bytes must appear (raw passthrough).
2695        assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2696        // The placeholder cells must NOT appear — we bypassed the cell pipeline.
2697        assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2698    }
2699
2700    #[test]
2701    fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2702        // Regression: a row with base_style.dim=true and Cell::Char carrying
2703        // Style::default() used to emit `\x1b[22m` (NormalIntensity) on the
2704        // first char, killing the row-level dim and rendering the whole
2705        // line at normal intensity. Same for Cell::Empty padding cells.
2706        use crate::ansi::Style;
2707        use crate::render::Cell;
2708        let row = vec![
2709            Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2710            Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2711            Cell::Empty,
2712            Cell::Empty,
2713        ];
2714        let mut buf: Vec<u8> = Vec::new();
2715        let base = Style { dim: true, ..Default::default() };
2716        write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2717        let s = String::from_utf8_lossy(&buf);
2718
2719        // Locate every emitted character; before any of them is printed, the
2720        // dim attribute must NOT have been cleared.
2721        for needle in ['h', 'i'] {
2722            let pos = s.find(needle).expect("char printed");
2723            let before = &s[..pos];
2724            assert!(
2725                !before.contains("\x1b[22m"),
2726                "dim cleared before {needle:?}: {before:?}",
2727            );
2728        }
2729        // The Cell::Empty padding shouldn't clear dim either. Look at the
2730        // bytes between 'i' and the end-of-row Reset.
2731        let after_i = s.find('i').unwrap() + 1;
2732        let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2733        let pad = &s[after_i..after_i + eor];
2734        assert!(
2735            !pad.contains("\x1b[22m"),
2736            "dim cleared in padding region: {pad:?}",
2737        );
2738    }
2739
2740    #[test]
2741    fn dim_row_yields_to_explicit_bold_cell() {
2742        // If a cell carries bold=true from ANSI, that wins over row-level
2743        // dim (bold and dim share the SGR intensity slot).
2744        use crate::ansi::Style;
2745        use crate::render::Cell;
2746        let row = vec![
2747            Cell::Char {
2748                ch: 'B',
2749                width: 1,
2750                style: Style { bold: true, ..Default::default() },
2751                hyperlink: None,
2752            },
2753        ];
2754        let mut buf: Vec<u8> = Vec::new();
2755        let base = Style { dim: true, ..Default::default() };
2756        write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2757        let s = String::from_utf8_lossy(&buf);
2758        // Bold should be emitted (\x1b[1m); dim should not re-appear.
2759        assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2760    }
2761
2762    #[test]
2763    fn parse_colon_p() {
2764        assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2765        assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2766    }
2767
2768    #[test]
2769    fn parse_colon_e_with_path() {
2770        match parse_colon_command("e /tmp/foo.log").unwrap() {
2771            ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2772            other => panic!("expected Edit, got {other:?}"),
2773        }
2774    }
2775
2776    #[test]
2777    fn parse_colon_e_with_tilde() {
2778        std::env::set_var("HOME", "/home/user");
2779        match parse_colon_command("e ~/foo.log").unwrap() {
2780            ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2781            other => panic!("expected Edit, got {other:?}"),
2782        }
2783    }
2784
2785    #[test]
2786    fn parse_colon_e_missing_path_errors() {
2787        assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2788        assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2789    }
2790
2791    #[test]
2792    fn parse_colon_f_q_d_x_t() {
2793        assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2794        assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2795        assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2796        assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2797        assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2798    }
2799
2800    #[test]
2801    fn parse_unknown_command_errors() {
2802        let err = parse_colon_command("bogus").unwrap_err();
2803        match err {
2804            ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2805            other => panic!("expected UnknownCommand, got {other:?}"),
2806        }
2807    }
2808
2809    #[test]
2810    fn parse_handles_whitespace() {
2811        // Trailing whitespace OK.
2812        assert_eq!(parse_colon_command("n  ").unwrap(), ColonCommand::Next);
2813        assert_eq!(parse_colon_command("  n").unwrap(), ColonCommand::Next);
2814    }
2815
2816    #[test]
2817    fn parse_colon_tag_with_name() {
2818        assert_eq!(
2819            parse_colon_command("tag foo").unwrap(),
2820            ColonCommand::Tag("foo".into())
2821        );
2822    }
2823
2824    #[test]
2825    fn parse_colon_tag_strips_trailing_whitespace() {
2826        assert_eq!(
2827            parse_colon_command("tag foo  ").unwrap(),
2828            ColonCommand::Tag("foo".into())
2829        );
2830    }
2831
2832    #[test]
2833    fn parse_colon_tag_without_name_errors() {
2834        assert_eq!(
2835            parse_colon_command("tag").unwrap_err(),
2836            ColonParseError::TagRequiresName
2837        );
2838        assert_eq!(
2839            parse_colon_command("tag  ").unwrap_err(),
2840            ColonParseError::TagRequiresName
2841        );
2842    }
2843
2844    #[test]
2845    fn parse_colon_tnext_and_tprev() {
2846        assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2847        assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2848    }
2849
2850    #[test]
2851    fn parse_colon_tselect_without_arg_uses_active() {
2852        assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2853    }
2854
2855    #[test]
2856    fn parse_colon_tselect_with_name() {
2857        assert_eq!(
2858            parse_colon_command("tselect foo").unwrap(),
2859            ColonCommand::TagSelect(Some("foo".into())),
2860        );
2861    }
2862
2863    #[test]
2864    fn parse_colon_b_opens_picker() {
2865        assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2866        assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2867    }
2868
2869    #[test]
2870    fn parse_colon_help_opens_help() {
2871        assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2872        assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2873    }
2874
2875    #[test]
2876    fn parse_colon_hex_with_valid_widths() {
2877        for n in [2usize, 4, 8, 16, 32] {
2878            assert_eq!(
2879                parse_colon_command(&format!("hex {n}")).unwrap(),
2880                ColonCommand::HexGroup(n),
2881            );
2882        }
2883    }
2884
2885    #[test]
2886    fn parse_colon_hex_without_value_errors() {
2887        assert_eq!(
2888            parse_colon_command("hex").unwrap_err(),
2889            ColonParseError::HexGroupRequiresValue,
2890        );
2891    }
2892
2893    #[test]
2894    fn parse_colon_hex_with_invalid_value_errors() {
2895        match parse_colon_command("hex 3").unwrap_err() {
2896            ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2897            other => panic!("expected HexGroupInvalid, got {other:?}"),
2898        }
2899        match parse_colon_command("hex banana").unwrap_err() {
2900            ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2901            other => panic!("expected HexGroupInvalid, got {other:?}"),
2902        }
2903    }
2904
2905    #[test]
2906    fn parse_colon_color_without_arg_cycles() {
2907        assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2908    }
2909
2910    #[test]
2911    fn parse_colon_color_with_named_mode() {
2912        use crate::render::AnsiMode;
2913        assert_eq!(
2914            parse_colon_command("color strict").unwrap(),
2915            ColonCommand::Color(Some(AnsiMode::Strict)),
2916        );
2917        assert_eq!(
2918            parse_colon_command("color interpret").unwrap(),
2919            ColonCommand::Color(Some(AnsiMode::Interpret)),
2920        );
2921        assert_eq!(
2922            parse_colon_command("color raw").unwrap(),
2923            ColonCommand::Color(Some(AnsiMode::Raw)),
2924        );
2925    }
2926
2927    #[test]
2928    fn parse_colon_color_with_unknown_mode_errors() {
2929        match parse_colon_command("color rainbow").unwrap_err() {
2930            ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2931            other => panic!("expected ColorInvalid, got {other:?}"),
2932        }
2933    }
2934
2935    #[test]
2936    fn parse_colon_case_without_arg_cycles() {
2937        assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2938    }
2939
2940    #[test]
2941    fn parse_colon_case_with_named_mode() {
2942        use crate::viewport::CaseMode;
2943        assert_eq!(parse_colon_command("case smart").unwrap(),
2944                   ColonCommand::Case(Some(CaseMode::Smart)));
2945        assert_eq!(parse_colon_command("case sensitive").unwrap(),
2946                   ColonCommand::Case(Some(CaseMode::Sensitive)));
2947        assert_eq!(parse_colon_command("case insensitive").unwrap(),
2948                   ColonCommand::Case(Some(CaseMode::Insensitive)));
2949    }
2950
2951    #[test]
2952    fn parse_colon_case_unknown_errors() {
2953        match parse_colon_command("case rainbow").unwrap_err() {
2954            ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2955            other => panic!("expected CaseInvalid, got {other:?}"),
2956        }
2957    }
2958
2959    #[test]
2960    fn parse_colon_hlsearch_on_off() {
2961        assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2962        assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2963    }
2964
2965    #[test]
2966    fn parse_colon_incsearch_toggle() {
2967        assert_eq!(parse_colon_command("incsearch").unwrap(), ColonCommand::IncSearch);
2968    }
2969
2970    #[test]
2971    fn lcp_empty_slice() {
2972        assert_eq!(longest_common_prefix(&[]), "");
2973    }
2974
2975    #[test]
2976    fn lcp_single_item_returns_self() {
2977        assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2978    }
2979
2980    #[test]
2981    fn lcp_finds_shared_prefix() {
2982        let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2983        assert_eq!(longest_common_prefix(&v), "foo");
2984    }
2985
2986    #[test]
2987    fn lcp_no_shared_prefix_returns_empty() {
2988        let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2989        assert_eq!(longest_common_prefix(&v), "");
2990    }
2991
2992    #[test]
2993    fn lcp_one_item_is_prefix_of_others() {
2994        let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2995        assert_eq!(longest_common_prefix(&v), "foo");
2996    }
2997
2998    #[test]
2999    fn tag_stack_push_pop_lifo() {
3000        let mut s = TagStack::default();
3001        s.push(0, 10);
3002        s.push(1, 20);
3003        assert_eq!(s.pop(), Some((1, 20)));
3004        assert_eq!(s.pop(), Some((0, 10)));
3005        assert_eq!(s.pop(), None);
3006    }
3007
3008    #[test]
3009    fn tag_stack_pop_clears_active() {
3010        let mut s = TagStack::default();
3011        s.push(0, 10);
3012        s.set_active(
3013            "foo".into(),
3014            vec![crate::tags::TagEntry {
3015                file: std::path::PathBuf::from("/a"),
3016                address: crate::tags::TagAddress::Line(1),
3017            }],
3018        );
3019        assert!(s.active.is_some());
3020        let _ = s.pop();
3021        assert!(s.active.is_none());
3022    }
3023
3024    #[test]
3025    fn tag_stack_next_advances_then_clamps() {
3026        let mut s = TagStack::default();
3027        s.set_active(
3028            "foo".into(),
3029            vec![
3030                crate::tags::TagEntry {
3031                    file: std::path::PathBuf::from("/a"),
3032                    address: crate::tags::TagAddress::Line(1),
3033                },
3034                crate::tags::TagEntry {
3035                    file: std::path::PathBuf::from("/b"),
3036                    address: crate::tags::TagAddress::Line(2),
3037                },
3038            ],
3039        );
3040        assert_eq!(s.next(), TagStepResult::Moved(1));
3041        assert_eq!(s.next(), TagStepResult::AtBoundary);
3042    }
3043
3044    #[test]
3045    fn tag_stack_prev_clamps_at_zero() {
3046        let mut s = TagStack::default();
3047        s.set_active(
3048            "foo".into(),
3049            vec![crate::tags::TagEntry {
3050                file: std::path::PathBuf::from("/a"),
3051                address: crate::tags::TagAddress::Line(1),
3052            }],
3053        );
3054        assert_eq!(s.prev(), TagStepResult::AtBoundary);
3055    }
3056
3057    #[test]
3058    fn tag_stack_next_with_no_active_returns_no_active() {
3059        let mut s = TagStack::default();
3060        assert_eq!(s.next(), TagStepResult::NoActive);
3061        assert_eq!(s.prev(), TagStepResult::NoActive);
3062    }
3063
3064    #[test]
3065    fn tag_stack_set_active_replaces_previous_list() {
3066        let mut s = TagStack::default();
3067        s.set_active(
3068            "foo".into(),
3069            vec![crate::tags::TagEntry {
3070                file: std::path::PathBuf::from("/a"),
3071                address: crate::tags::TagAddress::Line(1),
3072            }],
3073        );
3074        s.set_active(
3075            "bar".into(),
3076            vec![
3077                crate::tags::TagEntry {
3078                    file: std::path::PathBuf::from("/x"),
3079                    address: crate::tags::TagAddress::Line(5),
3080                },
3081                crate::tags::TagEntry {
3082                    file: std::path::PathBuf::from("/y"),
3083                    address: crate::tags::TagAddress::Line(6),
3084                },
3085            ],
3086        );
3087        let active = s.active.as_ref().unwrap();
3088        assert_eq!(active.name, "bar");
3089        assert_eq!(active.matches.len(), 2);
3090        assert_eq!(active.cursor, 0);
3091    }
3092
3093    #[test]
3094    fn writer_emits_color_for_red_cell() {
3095        let cells = vec![Cell::Char {
3096            ch: 'h',
3097            width: 1,
3098            style: crate::ansi::Style {
3099                fg: Some(crate::ansi::Color::Ansi(1)),
3100                ..Default::default()
3101            },
3102            hyperlink: None,
3103        }];
3104        let mut buf: Vec<u8> = Vec::new();
3105        write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3106        let s = String::from_utf8_lossy(&buf);
3107        assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
3108        assert!(s.contains('h'));
3109    }
3110
3111    #[test]
3112    fn writer_emits_osc8_for_hyperlink_cell() {
3113        let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
3114        let cells = vec![Cell::Char {
3115            ch: 'c',
3116            width: 1,
3117            style: crate::ansi::Style::default(),
3118            hyperlink: Some(link),
3119        }];
3120        let mut buf: Vec<u8> = Vec::new();
3121        write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3122        let s = String::from_utf8_lossy(&buf);
3123        assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
3124    }
3125}