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    let timeout = Duration::from_millis(250);
942    let mut last_revision = src.revision();
943
944    // If hide-mode filtering is active (--filter or --grep without --dim),
945    // we need to scan the whole source up front to find matching lines.
946    // Without any predicate this is intentionally skipped — lazy indexing
947    // keeps `tess` fast on huge files.
948    if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
949        idx.extend_to_end(src.as_ref());
950        viewport.extend_visible_lines(&idx, src.as_ref());
951    }
952
953    // If follow or live mode is on at startup, snap to the bottom of the
954    // (possibly filtered) source so the user sees the newest content
955    // (tail-style). Live mode tracks whole-file rewrites; starting at the end
956    // keeps the latest content in view as the file is regenerated.
957    if viewport.follow_mode() || viewport.live_mode() {
958        src.pump();
959        viewport.extend_visible_lines(&idx, src.as_ref());
960        viewport.goto_bottom(src.as_ref(), &mut idx);
961    }
962
963    // Always draw the initial frame before entering the event loop.
964    let mut needs_redraw = true;
965    let mut mode = InputMode::Normal;
966    let mut numeric_prefix: Option<usize> = None;
967    let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
968    let mut previous_position: Option<(usize, usize)> = None;
969    // Scroll position captured when an incremental-search prompt opens, so a
970    // mid-type preview searches from there and Esc can restore it.
971    let mut incsearch_origin: (usize, usize) = (0, 0);
972    let mut current_file_index: usize = file_set.current_index();
973    let mut transient_status: Option<String> = None;
974    let mut tag_stack = TagStack::default();
975    let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
976    let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
977    let mouse_enabled = args.mouse;
978    let clipboard_enabled = args.clipboard;
979    let hscroll_shift = args.shift.unwrap_or(0);
980    let wheel_lines = args.wheel_lines.unwrap_or(3).max(1);
981
982    if let Some(tag_name) = args.tag.as_deref() {
983        let _ = refresh_tag_file(&mut tag_file);
984        if let Some(msg) = dispatch_tag_jump(
985            tag_name,
986            tag_file.as_ref(),
987            &mut tag_stack,
988            &mut file_set,
989            &mut current_file_index,
990            &args,
991            preprocessor.as_ref(),
992            record_start_regex.as_ref(),
993            &mut viewport,
994            &mut src,
995            &mut idx,
996        ) {
997            return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
998        }
999    }
1000
1001    loop {
1002        if sigterm.load(Ordering::SeqCst) {
1003            break;
1004        }
1005
1006        if needs_redraw {
1007            if let Some(ov) = overlay.as_ref() {
1008                let w = cols;
1009                let h = viewport.body_rows() + 1;
1010                let mut ovframe = ov.render(w, h);
1011                if let Some((msg, started)) = overlay_flash {
1012                    if started.elapsed() < std::time::Duration::from_millis(1500) {
1013                        ovframe.status = format!("[{msg}]");
1014                    } else {
1015                        overlay_flash = None;
1016                    }
1017                }
1018                render_overlay(&mut stdout, &ovframe, w, h)
1019                    .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1020                needs_redraw = false;
1021                continue;
1022            }
1023            // `-J` status column: feed the current file's marks (line → letter)
1024            // into the viewport so it can render mark glyphs in the far-left
1025            // gutter. Empty when the feature is off.
1026            if viewport.status_column() {
1027                let status_marks: HashMap<usize, char> = marks
1028                    .iter()
1029                    .filter(|(_, (fi, _))| *fi == current_file_index)
1030                    .map(|(ch, (_, line))| (*line, *ch))
1031                    .collect();
1032                viewport.set_status_marks(status_marks);
1033            }
1034            let mut frame = viewport.frame(src.as_ref(), &mut idx);
1035            // Override the status row when we're in an interactive prompt OR
1036            // when a transient status message is pending.
1037            match &mode {
1038                InputMode::SearchPrompt { direction, buffer, error } => {
1039                    let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
1040                    frame.status = match error {
1041                        Some(e) => format!("{prefix}{buffer}  [error: {e}]"),
1042                        None => format!("{prefix}{buffer}"),
1043                    };
1044                }
1045                InputMode::ShellPrompt { buffer, error } => {
1046                    frame.status = match error {
1047                        Some(e) => format!("!{buffer}  [error: {e}]"),
1048                        None => format!("!{buffer}"),
1049                    };
1050                }
1051                InputMode::ColonPrompt { buffer, error } => {
1052                    frame.status = match error {
1053                        Some(e) => format!(":{buffer}  [error: {e}]"),
1054                        None => format!(":{buffer}"),
1055                    };
1056                }
1057                InputMode::TagPrompt { buffer, error, .. } => {
1058                    frame.status = match error {
1059                        Some(e) => format!("tag: {buffer}  [error: {e}]"),
1060                        None => format!("tag: {buffer}"),
1061                    };
1062                }
1063                _ => {
1064                    if let Some(msg) = transient_status.take() {
1065                        frame.status = msg;
1066                    }
1067                }
1068            }
1069            write_frame(&mut stdout, &frame, cols, rows, truecolor)
1070                .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1071            needs_redraw = false;
1072        }
1073
1074        // Poll with timeout so stdin sources can be re-checked.
1075        match poll(timeout) {
1076            Ok(true) => {
1077                let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1078                // Modal input handling: the search prompt and option prefix
1079                // intercept keys before they're translated to commands.
1080                match &mut mode {
1081                    InputMode::SearchPrompt { direction, buffer, error } => {
1082                        if let Event::Key(KeyEvent { code, .. }) = event {
1083                            match code {
1084                                KeyCode::Esc => {
1085                                    if viewport.incsearch() {
1086                                        viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1087                                    }
1088                                    mode = InputMode::Normal;
1089                                    needs_redraw = true;
1090                                }
1091                                KeyCode::Enter => {
1092                                    if viewport.incsearch() {
1093                                        viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1094                                    }
1095                                    if buffer.is_empty() {
1096                                        // Empty buffer: repeat the last search in the
1097                                        // newly-typed direction (less compat). If no
1098                                        // prior search exists, just dismiss.
1099                                        if viewport.search_active() {
1100                                            let reverse = !matches!(
1101                                                (viewport.search_direction(), *direction),
1102                                                (SearchDirection::Forward, SearchDirection::Forward)
1103                                                | (SearchDirection::Backward, SearchDirection::Backward)
1104                                            );
1105                                            update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1106                                            viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1107                                        }
1108                                        mode = InputMode::Normal;
1109                                    } else {
1110                                        match viewport.set_search(buffer.clone(), *direction) {
1111                                            Ok(()) => {
1112                                                update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1113                                                viewport.search_repeat(src.as_ref(), &mut idx, false);
1114                                                mode = InputMode::Normal;
1115                                            }
1116                                            Err(e) => { *error = Some(e); }
1117                                        }
1118                                    }
1119                                    needs_redraw = true;
1120                                }
1121                                KeyCode::Backspace => {
1122                                    buffer.pop();
1123                                    *error = None;
1124                                    if viewport.incsearch() {
1125                                        viewport.incsearch_preview(
1126                                            src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1127                                    }
1128                                    needs_redraw = true;
1129                                }
1130                                KeyCode::Char(c) => {
1131                                    buffer.push(c);
1132                                    *error = None;
1133                                    if viewport.incsearch() {
1134                                        viewport.incsearch_preview(
1135                                            src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1136                                    }
1137                                    needs_redraw = true;
1138                                }
1139                                _ => {}
1140                            }
1141                        }
1142                        continue;
1143                    }
1144                    InputMode::OptionPrefix => {
1145                        if let Event::Key(KeyEvent { code, .. }) = event {
1146                            match code {
1147                                KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1148                                KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1149                                KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1150                                KeyCode::Char('P') | KeyCode::Char('p') => {
1151                                    // Two-key prefix: `-P` then a letter for the mode.
1152                                    mode = InputMode::PrettifyPrefix;
1153                                    needs_redraw = true;
1154                                    continue;
1155                                }
1156                                _ => {}
1157                            }
1158                        }
1159                        mode = InputMode::Normal;
1160                        needs_redraw = true;
1161                        continue;
1162                    }
1163                    InputMode::PrettifyPrefix => {
1164                        if let Event::Key(KeyEvent { code, .. }) = event {
1165                            let target: Option<PrettifyTarget> = match code {
1166                                KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1167                                KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1168                                KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1169                                KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1170                                KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1171                                KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1172                                KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1173                                KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1174                                _ => None,
1175                            };
1176                            if let Some(t) = target {
1177                                apply_prettify(
1178                                    src.as_ref(),
1179                                    &mut viewport,
1180                                    &mut idx,
1181                                    rebuild_spec,
1182                                    t,
1183                                );
1184                                last_revision = src.revision();
1185                            }
1186                        }
1187                        mode = InputMode::Normal;
1188                        needs_redraw = true;
1189                        continue;
1190                    }
1191                    InputMode::MarkSetPending => {
1192                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1193                            if is_valid_mark_name(c) {
1194                                mark_set(&mut marks, c, current_file_index, viewport.top_line());
1195                            }
1196                        }
1197                        mode = InputMode::Normal;
1198                        continue;
1199                    }
1200                    InputMode::MarkJumpPending => {
1201                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1202                            if is_valid_mark_name(c) {
1203                                match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1204                                    Some(MarkTarget::SameFile { line }) => {
1205                                        let clamped = line.min(idx.line_count().saturating_sub(1));
1206                                        viewport.goto_line(clamped, src.as_ref(), &mut idx);
1207                                        needs_redraw = true;
1208                                    }
1209                                    Some(MarkTarget::OtherFile { file_index, line }) => {
1210                                        if file_index < file_set.len() {
1211                                            file_set.set_current_index(file_index);
1212                                            let path = file_set.current().unwrap().to_path_buf();
1213                                            if let Err(e) = switch_file(
1214                                                &path, file_index, file_set.len(),
1215                                                &args, preprocessor.as_ref(),
1216                                                &mut viewport, &mut src, &mut idx,
1217                                                record_start_regex.as_ref(),
1218                                            ) {
1219                                                transient_status = Some(format!("[open: {e}]"));
1220                                            } else {
1221                                                let clamped = line.min(idx.line_count().saturating_sub(1));
1222                                                viewport.goto_line(clamped, src.as_ref(), &mut idx);
1223                                                current_file_index = file_index;
1224                                                needs_redraw = true;
1225                                            }
1226                                        }
1227                                    }
1228                                    None => {}
1229                                }
1230                            }
1231                        }
1232                        mode = InputMode::Normal;
1233                        continue;
1234                    }
1235                    InputMode::ShellPrompt { buffer, error } => {
1236                        if let Event::Key(KeyEvent { code, .. }) = event {
1237                            match code {
1238                                KeyCode::Esc => {
1239                                    mode = InputMode::Normal;
1240                                    needs_redraw = true;
1241                                }
1242                                KeyCode::Enter => {
1243                                    if buffer.is_empty() {
1244                                        mode = InputMode::Normal;
1245                                    } else {
1246                                        match crate::shell::run_shell_command(buffer) {
1247                                            Ok(()) => {
1248                                                mode = InputMode::Normal;
1249                                            }
1250                                            Err(e) => {
1251                                                *error = Some(e.to_string());
1252                                            }
1253                                        }
1254                                    }
1255                                    needs_redraw = true;
1256                                }
1257                                KeyCode::Backspace => {
1258                                    buffer.pop();
1259                                    *error = None;
1260                                    needs_redraw = true;
1261                                }
1262                                KeyCode::Char(c) => {
1263                                    buffer.push(c);
1264                                    *error = None;
1265                                    needs_redraw = true;
1266                                }
1267                                _ => {}
1268                            }
1269                        }
1270                        continue;
1271                    }
1272                    InputMode::CtrlXPending => {
1273                        let is_ctrl_x = matches!(
1274                            event,
1275                            Event::Key(KeyEvent {
1276                                code: KeyCode::Char('x'),
1277                                modifiers: KeyModifiers::CONTROL,
1278                                ..
1279                            })
1280                        );
1281                        if is_ctrl_x {
1282                            match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1283                                Some(MarkTarget::SameFile { line }) => {
1284                                    let clamped = line.min(idx.line_count().saturating_sub(1));
1285                                    viewport.goto_line(clamped, src.as_ref(), &mut idx);
1286                                    needs_redraw = true;
1287                                }
1288                                Some(MarkTarget::OtherFile { file_index, line }) => {
1289                                    if file_index < file_set.len() {
1290                                        file_set.set_current_index(file_index);
1291                                        let path = file_set.current().unwrap().to_path_buf();
1292                                        if let Err(e) = switch_file(
1293                                            &path, file_index, file_set.len(),
1294                                            &args, preprocessor.as_ref(),
1295                                            &mut viewport, &mut src, &mut idx,
1296                                            record_start_regex.as_ref(),
1297                                        ) {
1298                                            transient_status = Some(format!("[open: {e}]"));
1299                                        } else {
1300                                            let clamped = line.min(idx.line_count().saturating_sub(1));
1301                                            viewport.goto_line(clamped, src.as_ref(), &mut idx);
1302                                            current_file_index = file_index;
1303                                            needs_redraw = true;
1304                                        }
1305                                    }
1306                                }
1307                                None => {}
1308                            }
1309                            mode = InputMode::Normal;
1310                            continue;
1311                        }
1312                        // Anything else: cancel and fall through to normal dispatch.
1313                        mode = InputMode::Normal;
1314                        // Don't `continue` — let the event fall through.
1315                    }
1316                    InputMode::ColonPrompt { buffer, error } => {
1317                        if let Event::Key(KeyEvent { code, .. }) = event {
1318                            match code {
1319                                KeyCode::Esc => {
1320                                    mode = InputMode::Normal;
1321                                    needs_redraw = true;
1322                                }
1323                                KeyCode::Enter => {
1324                                    if buffer.is_empty() {
1325                                        mode = InputMode::Normal;
1326                                    } else {
1327                                        match parse_colon_command(buffer) {
1328                                            Ok(cmd) => {
1329                                                let is_tag_cmd = matches!(
1330                                                    &cmd,
1331                                                    ColonCommand::Tag(_)
1332                                                        | ColonCommand::TagNext
1333                                                        | ColonCommand::TagPrev
1334                                                        | ColonCommand::TagSelect(_),
1335                                                );
1336                                                let reload_msg = if is_tag_cmd {
1337                                                    refresh_tag_file(&mut tag_file)
1338                                                } else {
1339                                                    None
1340                                                };
1341                                                let outcome = dispatch_colon_command(
1342                                                    cmd,
1343                                                    &mut file_set,
1344                                                    &mut current_file_index,
1345                                                    &args,
1346                                                    preprocessor.as_ref(),
1347                                                    record_start_regex.as_ref(),
1348                                                    &mut viewport,
1349                                                    &mut src,
1350                                                    &mut idx,
1351                                                    &mut tag_stack,
1352                                                    tag_file.as_ref(),
1353                                                );
1354                                                match outcome {
1355                                                    ColonOutcome::Continue(msg) => {
1356                                                        transient_status = msg.or(reload_msg);
1357                                                    }
1358                                                    ColonOutcome::Quit => break,
1359                                                    ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1360                                                        let saved = (0..file_set.len())
1361                                                            .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1362                                                            .collect::<Vec<_>>();
1363                                                        overlay = Some(Box::new(
1364                                                            crate::overlay::picker::FilePicker::new(&file_set, saved)
1365                                                        ));
1366                                                    }
1367                                                    ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1368                                                        let remaps = keymap.user_keys_by_command_name();
1369                                                        overlay = Some(Box::new(
1370                                                            crate::overlay::help::HelpOverlay::new(remaps)
1371                                                        ));
1372                                                    }
1373                                                    ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1374                                                        if let Some(active) = tag_stack.active.as_ref() {
1375                                                            overlay = Some(Box::new(
1376                                                                crate::overlay::tag_picker::TagPicker::new(
1377                                                                    active.name.clone(),
1378                                                                    active.matches.clone(),
1379                                                                    active.cursor,
1380                                                                )
1381                                                            ));
1382                                                        }
1383                                                    }
1384                                                    ColonOutcome::DispatchCommand(cmd) => {
1385                                                        debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1386                                                        // In release builds, silently no-op.
1387                                                    }
1388                                                }
1389                                                mode = InputMode::Normal;
1390                                            }
1391                                            Err(e) => {
1392                                                *error = Some(e.to_string());
1393                                            }
1394                                        }
1395                                    }
1396                                    needs_redraw = true;
1397                                }
1398                                KeyCode::Backspace => {
1399                                    buffer.pop();
1400                                    *error = None;
1401                                    needs_redraw = true;
1402                                }
1403                                KeyCode::Char(c) => {
1404                                    buffer.push(c);
1405                                    *error = None;
1406                                    needs_redraw = true;
1407                                }
1408                                _ => {}
1409                            }
1410                        }
1411                        continue;
1412                    }
1413                    InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1414                        if let Event::Key(KeyEvent { code, .. }) = event {
1415                            match code {
1416                                KeyCode::Esc => {
1417                                    mode = InputMode::Normal;
1418                                    needs_redraw = true;
1419                                }
1420                                KeyCode::Enter => {
1421                                    if buffer.is_empty() {
1422                                        mode = InputMode::Normal;
1423                                    } else {
1424                                        let name = buffer.clone();
1425                                        let reload_msg = refresh_tag_file(&mut tag_file);
1426                                        let msg = dispatch_tag_jump(
1427                                            &name,
1428                                            tag_file.as_ref(),
1429                                            &mut tag_stack,
1430                                            &mut file_set,
1431                                            &mut current_file_index,
1432                                            &args,
1433                                            preprocessor.as_ref(),
1434                                            record_start_regex.as_ref(),
1435                                            &mut viewport,
1436                                            &mut src,
1437                                            &mut idx,
1438                                        );
1439                                        transient_status = msg.or(reload_msg);
1440                                        mode = InputMode::Normal;
1441                                    }
1442                                    needs_redraw = true;
1443                                }
1444                                KeyCode::Backspace => {
1445                                    buffer.pop();
1446                                    *error = None;
1447                                    *last_tab_matches = None;
1448                                    needs_redraw = true;
1449                                }
1450                                KeyCode::Tab => {
1451                                    let _ = refresh_tag_file(&mut tag_file);
1452                                    let names: Vec<String> = match tag_file.as_ref() {
1453                                        Some(tf) => tf
1454                                            .names()
1455                                            .filter(|n| n.starts_with(buffer.as_str()))
1456                                            .map(String::from)
1457                                            .collect(),
1458                                        None => Vec::new(),
1459                                    };
1460                                    match (names.len(), last_tab_matches.as_ref()) {
1461                                        (0, _) => {
1462                                            *error = Some("no tags match".into());
1463                                            *last_tab_matches = None;
1464                                        }
1465                                        (1, _) => {
1466                                            *buffer = names.into_iter().next().unwrap();
1467                                            *error = None;
1468                                            *last_tab_matches = None;
1469                                        }
1470                                        (n, Some(prev)) if prev.len() == n => {
1471                                            *error = Some(format!("{n} matches"));
1472                                        }
1473                                        (n, _) => {
1474                                            let lcp = longest_common_prefix(&names);
1475                                            if lcp.len() > buffer.len() {
1476                                                *buffer = lcp;
1477                                                *error = None;
1478                                            } else {
1479                                                *error = Some(format!("{n} matches"));
1480                                            }
1481                                            *last_tab_matches = Some(names);
1482                                        }
1483                                    }
1484                                    needs_redraw = true;
1485                                }
1486                                KeyCode::Char(c) => {
1487                                    buffer.push(c);
1488                                    *error = None;
1489                                    *last_tab_matches = None;
1490                                    needs_redraw = true;
1491                                }
1492                                _ => {}
1493                            }
1494                        }
1495                        continue;
1496                    }
1497                    InputMode::Normal => {}
1498                }
1499                // Resize must update stored dims even when an overlay is active —
1500                // otherwise the overlay renders at stale dimensions until it closes.
1501                if let crossterm::event::Event::Resize(c, r) = event {
1502                    // Pin the bottom across resizes: a viewport sitting at the
1503                    // end (follow/live, or just scrolled to the bottom) must
1504                    // stay there when body height changes — otherwise the
1505                    // newest content drifts off-screen. Terminals/multiplexers
1506                    // often emit a resize right after startup, which is what
1507                    // made --follow/--live land short of the end.
1508                    let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1509                    cols = c;
1510                    rows = r;
1511                    viewport.resize(c, r);
1512                    if was_at_bottom {
1513                        viewport.goto_bottom(src.as_ref(), &mut idx);
1514                    }
1515                    needs_redraw = true;
1516                    if overlay.is_some() {
1517                        // Overlay still owns the screen; nothing else to do this tick.
1518                        continue;
1519                    }
1520                    // No overlay: fall through to normal handling so the
1521                    // existing Command::Resize path can do whatever else it does.
1522                }
1523                // Active overlay swallows input. Apply/Refuse/Close outcomes
1524                // are handled inline; CloseAnd defers to the normal command
1525                // dispatcher below.
1526                if let Some(ov) = overlay.as_mut() {
1527                    let outcome = match &event {
1528                        Event::Key(ke) => ov.handle_key(*ke),
1529                        Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1530                        Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1531                        _ => crate::overlay::OverlayOutcome::Stay,
1532                    };
1533                    match outcome {
1534                        crate::overlay::OverlayOutcome::Stay => {
1535                            needs_redraw = true;
1536                            continue;
1537                        }
1538                        crate::overlay::OverlayOutcome::Close => {
1539                            overlay = None;
1540                            overlay_flash = None;
1541                            needs_redraw = true;
1542                            continue;
1543                        }
1544                        crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1545                            overlay = None;
1546                            overlay_flash = None;
1547                            if let Command::SelectFile(i) = cmd {
1548                                if i < file_set.len() {
1549                                    file_set.set_current_index(i);
1550                                    if let Some(msg) = switch_to_current_file(
1551                                        &mut file_set, &mut current_file_index,
1552                                        &args, preprocessor.as_ref(),
1553                                        record_start_regex.as_ref(),
1554                                        &mut viewport, &mut src, &mut idx,
1555                                    ) {
1556                                        transient_status = Some(msg);
1557                                    }
1558                                }
1559                            } else if let Command::SelectTagMatch(idx_pick) = cmd {
1560                                if let Some(active) = tag_stack.active.as_mut() {
1561                                    if idx_pick < active.matches.len() {
1562                                        active.cursor = idx_pick;
1563                                        let entry = active.matches[idx_pick].clone();
1564                                        let msg = dispatch_match(
1565                                            &entry,
1566                                            &mut file_set,
1567                                            &mut current_file_index,
1568                                            &args,
1569                                            preprocessor.as_ref(),
1570                                            record_start_regex.as_ref(),
1571                                            &mut viewport,
1572                                            &mut src,
1573                                            &mut idx,
1574                                        );
1575                                        update_viewport_tag_indicator(&tag_stack, &mut viewport);
1576                                        if let Some(m) = msg {
1577                                            transient_status = Some(m);
1578                                        }
1579                                    }
1580                                }
1581                            }
1582                            needs_redraw = true;
1583                            continue;
1584                        }
1585                        crate::overlay::OverlayOutcome::Apply(cmd) => {
1586                            if let Command::DropFileAt(target) = cmd {
1587                                if file_set.len() > 1 && target < file_set.len() {
1588                                    let saved_cur = file_set.current_index();
1589                                    file_set.set_current_index(target);
1590                                    let _ = file_set.delete_current();
1591                                    // delete_current() moved the cursor itself; restore
1592                                    // the pre-drop position when the deletion was not OF
1593                                    // the saved cursor.
1594                                    if target < saved_cur {
1595                                        let restored = saved_cur.saturating_sub(1);
1596                                        file_set.set_current_index(restored);
1597                                    } else if target > saved_cur {
1598                                        file_set.set_current_index(saved_cur);
1599                                    }
1600                                    // (target == saved_cur: delete_current already landed on the nearest
1601                                    //  surviving file; nothing to restore.)
1602                                    if let Some(msg) = switch_to_current_file(
1603                                        &mut file_set, &mut current_file_index,
1604                                        &args, preprocessor.as_ref(),
1605                                        record_start_regex.as_ref(),
1606                                        &mut viewport, &mut src, &mut idx,
1607                                    ) {
1608                                        transient_status = Some(msg);
1609                                    }
1610                                    if let Some(ov) = overlay.as_mut() {
1611                                        ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1612                                    }
1613                                }
1614                            }
1615                            needs_redraw = true;
1616                            continue;
1617                        }
1618                        crate::overlay::OverlayOutcome::Refuse(msg) => {
1619                            overlay_flash = Some((msg, std::time::Instant::now()));
1620                            needs_redraw = true;
1621                            continue;
1622                        }
1623                    }
1624                }
1625                // No-overlay mouse: scrollwheel scrolls the body. Other mouse
1626                // events are ignored to keep the body inert when --mouse is on
1627                // but no overlay is active.
1628                if let crossterm::event::Event::Mouse(me) = &event {
1629                    if mouse_enabled {
1630                        use crossterm::event::{KeyModifiers, MouseEventKind};
1631                        // Shift+wheel = horizontal scroll: the widely-supported
1632                        // convention for terminals that don't emit native
1633                        // ScrollLeft/ScrollRight (e.g. macOS Terminal.app). Only
1634                        // when there's a horizontal axis; otherwise fall through
1635                        // to normal vertical scroll.
1636                        let hshift = me.modifiers.contains(KeyModifiers::SHIFT)
1637                            && viewport.hscroll_active();
1638                        match me.kind {
1639                            MouseEventKind::ScrollDown if hshift => {
1640                                viewport.hscroll_right_step();
1641                                needs_redraw = true;
1642                            }
1643                            MouseEventKind::ScrollUp if hshift => {
1644                                viewport.hscroll_left_step();
1645                                needs_redraw = true;
1646                            }
1647                            MouseEventKind::ScrollDown => {
1648                                viewport.scroll_lines(wheel_lines as i64, src.as_ref(), &mut idx);
1649                                needs_redraw = true;
1650                            }
1651                            MouseEventKind::ScrollUp => {
1652                                viewport.scroll_lines(-(wheel_lines as i64), src.as_ref(), &mut idx);
1653                                needs_redraw = true;
1654                            }
1655                            MouseEventKind::ScrollLeft => {
1656                                viewport.hscroll_left_step();
1657                                needs_redraw = true;
1658                            }
1659                            MouseEventKind::ScrollRight => {
1660                                viewport.hscroll_right_step();
1661                                needs_redraw = true;
1662                            }
1663                            _ => {}
1664                        }
1665                    }
1666                    continue;
1667                }
1668                // Pre-translate keymap interception. Only consult the keymap
1669                // when in Normal mode (not inside a search/option/prettify/
1670                // shell prompt).
1671                let mut cmd: Option<Command> = None;
1672                if let InputMode::Normal = mode {
1673                    if let Event::Key(ke) = &event {
1674                        if let Some(target) = keymap.lookup(ke) {
1675                            match target {
1676                                crate::keys::BindingTarget::Shell(cmd_text) => {
1677                                    let cmd_text = cmd_text.clone();
1678                                    if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1679                                        let _ = writeln!(std::io::stderr(),
1680                                            "[shell: {e}]");
1681                                    }
1682                                    needs_redraw = true;
1683                                    continue;
1684                                }
1685                                crate::keys::BindingTarget::Command(c) => {
1686                                    cmd = Some(c.clone());
1687                                }
1688                            }
1689                        }
1690                    }
1691                }
1692                let cmd = cmd.unwrap_or_else(|| translate(event));
1693                // Consume the numeric prefix at the top of each dispatch so
1694                // commands that don't need it drop it implicitly.
1695                let prefix_at_cmd = numeric_prefix.take();
1696                match cmd {
1697                    Command::Digit(d) => {
1698                        let cur = prefix_at_cmd.unwrap_or(0);
1699                        let next = cur.saturating_mul(10).saturating_add(d as usize);
1700                        if next <= 99_999_999 {
1701                            numeric_prefix = Some(next);
1702                        } else {
1703                            // Overflow: keep previous prefix, ignore this digit.
1704                            numeric_prefix = prefix_at_cmd;
1705                        }
1706                        continue;
1707                    }
1708                    Command::Cancel => {
1709                        // prefix_at_cmd already consumed; nothing else to do.
1710                        continue;
1711                    }
1712                    Command::GotoLine => {
1713                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1714                        match prefix_at_cmd {
1715                            Some(line) if line > 0 => {
1716                                viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1717                                viewport.suspend_follow_if(args.follow_suspend_on_motion);
1718                            }
1719                            _ => {
1720                                viewport.goto_top();
1721                                viewport.suspend_follow_if(args.follow_suspend_on_motion);
1722                            }
1723                        }
1724                        needs_redraw = true;
1725                    }
1726                    Command::GotoRecord => {
1727                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1728                        match prefix_at_cmd {
1729                            Some(rec) if rec > 0 => {
1730                                viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1731                                viewport.suspend_follow_if(args.follow_suspend_on_motion);
1732                            }
1733                            _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1734                        }
1735                        needs_redraw = true;
1736                    }
1737                    Command::GotoPercent => {
1738                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1739                        match prefix_at_cmd {
1740                            Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1741                            _ => viewport.goto_top(),
1742                        }
1743                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1744                        needs_redraw = true;
1745                    }
1746                    Command::Quit => break,
1747                    Command::Resize(c, r) => {
1748                        let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1749                        cols = c; rows = r;
1750                        viewport.resize(c, r);
1751                        if was_at_bottom {
1752                            viewport.goto_bottom(src.as_ref(), &mut idx);
1753                        }
1754                        needs_redraw = true;
1755                    }
1756                    Command::ScrollLines(n) => {
1757                        viewport.scroll_lines(n, src.as_ref(), &mut idx);
1758                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1759                        if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1760                        needs_redraw = true;
1761                    }
1762                    Command::ScrollLogicalLines(n) => {
1763                        viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1764                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1765                        if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1766                        needs_redraw = true;
1767                    }
1768                    Command::PageDown => {
1769                        viewport.page_down(src.as_ref(), &mut idx);
1770                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1771                        if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1772                        needs_redraw = true;
1773                    }
1774                    Command::PageUp => {
1775                        viewport.page_up(src.as_ref(), &mut idx);
1776                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1777                        viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1778                        needs_redraw = true;
1779                    }
1780                    Command::HalfPageDown => {
1781                        viewport.half_page_down(src.as_ref(), &mut idx);
1782                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1783                        if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1784                        needs_redraw = true;
1785                    }
1786                    Command::HalfPageUp => {
1787                        viewport.half_page_up(src.as_ref(), &mut idx);
1788                        viewport.suspend_follow_if(args.follow_suspend_on_motion);
1789                        viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1790                        needs_redraw = true;
1791                    }
1792                    Command::Refresh => {
1793                        needs_redraw = true;
1794                    }
1795                    Command::Reload => {
1796                        // Force a stat+reread now (only meaningful for live
1797                        // sources; static FileSource::pump() is a no-op).
1798                        src.pump();
1799                        if src.revision() != last_revision {
1800                            rebuild_after_replace(
1801                                src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1802                            );
1803                            last_revision = src.revision();
1804                            needs_redraw = true;
1805                        }
1806                    }
1807                    Command::TogglePrettify => {
1808                        apply_prettify(
1809                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1810                            PrettifyTarget::Toggle,
1811                        );
1812                        last_revision = src.revision();
1813                        needs_redraw = true;
1814                    }
1815                    Command::SetPrettifyMode(m) => {
1816                        apply_prettify(
1817                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1818                            PrettifyTarget::Mode(m),
1819                        );
1820                        last_revision = src.revision();
1821                        needs_redraw = true;
1822                    }
1823                    Command::RedetectPrettify => {
1824                        apply_prettify(
1825                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1826                            PrettifyTarget::Auto,
1827                        );
1828                        last_revision = src.revision();
1829                        needs_redraw = true;
1830                    }
1831                    Command::ToggleLineNumbers => {
1832                        viewport.toggle_line_numbers();
1833                        needs_redraw = true;
1834                    }
1835                    Command::ToggleChop => {
1836                        viewport.toggle_chop();
1837                        needs_redraw = true;
1838                    }
1839                    Command::ToggleFollow => {
1840                        viewport.toggle_follow();
1841                        if viewport.follow_mode() {
1842                            // Re-engaging: pump any pending bytes and snap to bottom.
1843                            src.pump();
1844                            idx.notice_new_bytes(src.as_ref());
1845                            viewport.goto_bottom(src.as_ref(), &mut idx);
1846                        }
1847                        needs_redraw = true;
1848                    }
1849                    Command::SearchForward => {
1850                        incsearch_origin = (viewport.top_line(), viewport.top_row());
1851                        mode = InputMode::SearchPrompt {
1852                            direction: SearchDirection::Forward,
1853                            buffer: String::new(),
1854                            error: None,
1855                        };
1856                        needs_redraw = true;
1857                    }
1858                    Command::SearchBackward => {
1859                        incsearch_origin = (viewport.top_line(), viewport.top_row());
1860                        mode = InputMode::SearchPrompt {
1861                            direction: SearchDirection::Backward,
1862                            buffer: String::new(),
1863                            error: None,
1864                        };
1865                        needs_redraw = true;
1866                    }
1867                    Command::ShellEscape => {
1868                        mode = InputMode::ShellPrompt {
1869                            buffer: String::new(),
1870                            error: None,
1871                        };
1872                        needs_redraw = true;
1873                    }
1874                    Command::ColonPrompt => {
1875                        mode = InputMode::ColonPrompt {
1876                            buffer: String::new(),
1877                            error: None,
1878                        };
1879                        needs_redraw = true;
1880                    }
1881                    Command::NextMatch => {
1882                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1883                        if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1884                            needs_redraw = true;
1885                        }
1886                    }
1887                    Command::PreviousMatch => {
1888                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1889                        if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1890                            needs_redraw = true;
1891                        }
1892                    }
1893                    Command::OptionPrefix => {
1894                        mode = InputMode::OptionPrefix;
1895                    }
1896                    Command::MarkSet => {
1897                        mode = InputMode::MarkSetPending;
1898                    }
1899                    Command::MarkJump => {
1900                        mode = InputMode::MarkJumpPending;
1901                    }
1902                    Command::CtrlXPrefix => {
1903                        mode = InputMode::CtrlXPending;
1904                    }
1905                    Command::JumpPrevious => {
1906                        // Resolved inside the CtrlXPending mode intercept; this
1907                        // arm is defensive and should never fire.
1908                    }
1909                    Command::TagPrompt => {
1910                        if tag_file.is_none() {
1911                            transient_status = Some("[no tags file loaded]".into());
1912                            needs_redraw = true;
1913                        } else {
1914                            mode = InputMode::TagPrompt {
1915                                buffer: String::new(),
1916                                error: None,
1917                                last_tab_matches: None,
1918                            };
1919                            needs_redraw = true;
1920                        }
1921                    }
1922                    Command::TagPop => match tag_stack.pop() {
1923                        Some((file_index, line)) => {
1924                            if file_index != current_file_index && file_index < file_set.len() {
1925                                file_set.set_current_index(file_index);
1926                                let path = file_set.current().unwrap().to_path_buf();
1927                                if let Err(e) = switch_file(
1928                                    &path,
1929                                    file_index,
1930                                    file_set.len(),
1931                                    &args,
1932                                    preprocessor.as_ref(),
1933                                    &mut viewport,
1934                                    &mut src,
1935                                    &mut idx,
1936                                    record_start_regex.as_ref(),
1937                                ) {
1938                                    transient_status = Some(format!("[open: {e}]"));
1939                                } else {
1940                                    current_file_index = file_index;
1941                                }
1942                            }
1943                            let clamped = line.min(idx.line_count().saturating_sub(1));
1944                            viewport.goto_line(clamped, src.as_ref(), &mut idx);
1945                            update_viewport_tag_indicator(&tag_stack, &mut viewport);
1946                            needs_redraw = true;
1947                        }
1948                        None => {
1949                            transient_status = Some("[tag stack empty]".into());
1950                            needs_redraw = true;
1951                        }
1952                    },
1953                    Command::OpenPicker => {
1954                        let saved = (0..file_set.len())
1955                            .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1956                            .collect::<Vec<_>>();
1957                        overlay = Some(Box::new(
1958                            crate::overlay::picker::FilePicker::new(&file_set, saved)
1959                        ));
1960                        needs_redraw = true;
1961                    }
1962                    Command::OpenHelp => {
1963                        let remaps = keymap.user_keys_by_command_name();
1964                        overlay = Some(Box::new(
1965                            crate::overlay::help::HelpOverlay::new(remaps)
1966                        ));
1967                        needs_redraw = true;
1968                    }
1969                    Command::SelectFile(_)
1970                    | Command::DropFileAt(_)
1971                    | Command::SelectTagMatch(_)
1972                    | Command::OpenTagPicker => {
1973                        // Overlay-only outcomes; consumed by the routing block above.
1974                    }
1975                    Command::MouseEvent(_) => {
1976                        // Mouse handling lives in the event-routing block, not here.
1977                    }
1978                    Command::HScrollLeft => {
1979                        if hscroll_shift != 0 {
1980                            viewport.hscroll_left_cols(hscroll_shift);
1981                        } else {
1982                            viewport.hscroll_left_half();
1983                        }
1984                        needs_redraw = true;
1985                    }
1986                    Command::HScrollRight => {
1987                        if hscroll_shift != 0 {
1988                            viewport.hscroll_right_cols(hscroll_shift);
1989                        } else {
1990                            viewport.hscroll_right_half();
1991                        }
1992                        needs_redraw = true;
1993                    }
1994                    Command::HScrollLeftStep => {
1995                        viewport.hscroll_left_step();
1996                        needs_redraw = true;
1997                    }
1998                    Command::HScrollRightStep => {
1999                        viewport.hscroll_right_step();
2000                        needs_redraw = true;
2001                    }
2002                    Command::YankLine => {
2003                        let msg = yank_current_line(clipboard_enabled, &viewport, src.as_ref(), &mut idx);
2004                        transient_status = Some(msg);
2005                        needs_redraw = true;
2006                    }
2007                    Command::Noop => {}
2008                }
2009            }
2010            Ok(false) => {
2011                // Timeout — check whether the source has grown or been rewritten.
2012                if viewport.live_mode() {
2013                    let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2014                    src.pump();
2015                    if src.revision() != last_revision {
2016                        rebuild_after_replace(
2017                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2018                        );
2019                        if was_at_bottom {
2020                            viewport.goto_bottom(src.as_ref(), &mut idx);
2021                        }
2022                        last_revision = src.revision();
2023                        needs_redraw = true;
2024                    }
2025                } else if viewport.follow_mode() {
2026                    let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2027                    src.pump();
2028                    if src.take_rotated() {
2029                        // File was rotated or truncated. Re-open from offset 0
2030                        // and reset the line index so we're not staring at
2031                        // stale mmap content. Snap to bottom of the fresh
2032                        // content (follow mode is on, so that's the natural
2033                        // place to land).
2034                        if let Some(path) = src.path().map(|p| p.to_path_buf()) {
2035                            match crate::open::open_source_for_path(
2036                                &path, &args, preprocessor.as_ref(),
2037                            ) {
2038                                Ok((new_src, _label, _err)) => {
2039                                    src = new_src;
2040                                    idx = LineIndex::new();
2041                                    if let Some(n) = rebuild_spec.head {
2042                                        idx.set_head_cap(n);
2043                                    }
2044                                    viewport.invalidate_filter_cache();
2045                                    idx.notice_new_bytes(src.as_ref());
2046                                    viewport.extend_visible_lines(&idx, src.as_ref());
2047                                    viewport.goto_bottom(src.as_ref(), &mut idx);
2048                                    viewport.flash("(F reopened)", 4);
2049                                    needs_redraw = true;
2050                                    continue;
2051                                }
2052                                Err(e) => {
2053                                    transient_status = Some(format!("[reopen failed: {e}]"));
2054                                    needs_redraw = true;
2055                                }
2056                            }
2057                        }
2058                    }
2059                    let lines_before = idx.line_count();
2060                    idx.notice_new_bytes(src.as_ref());
2061                    viewport.extend_visible_lines(&idx, src.as_ref());
2062                    if idx.line_count() != lines_before {
2063                        needs_redraw = true;
2064                        viewport.note_growth();
2065                        if was_at_bottom {
2066                            viewport.goto_bottom(src.as_ref(), &mut idx);
2067                        }
2068                    } else {
2069                        viewport.tick_idle();
2070                    }
2071                    viewport.tick_flash();
2072                    // `--exit-follow-on-close`: when the source signals
2073                    // that the upstream writer has finished (streaming
2074                    // stdin's reader thread exited), exit the pager.
2075                    // File sources are always complete from open, so this
2076                    // condition only fires for piped stdin.
2077                    if args.exit_follow_on_close && src.is_complete() {
2078                        break;
2079                    }
2080                } else if !src.is_complete() {
2081                    // Streaming stdin without follow mode: still keep the index
2082                    // up-to-date so line counts stay accurate, but don't auto-scroll.
2083                    let lines_before = idx.line_count();
2084                    idx.notice_new_bytes(src.as_ref());
2085                    viewport.extend_visible_lines(&idx, src.as_ref());
2086                    if idx.line_count() != lines_before {
2087                        needs_redraw = true;
2088                    }
2089                }
2090            }
2091            Err(_) => {
2092                // poll() error — sleep the timeout duration to avoid tight-spinning.
2093                std::thread::sleep(timeout);
2094            }
2095        }
2096    }
2097    Ok(())
2098}
2099
2100/// What `apply_prettify` should do to the source's prettify state.
2101#[derive(Debug, Clone, Copy)]
2102enum PrettifyTarget {
2103    /// Set a specific mode (including `Off` for "raw").
2104    Mode(PrettifyMode),
2105    /// Flip between current mode and last-active mode.
2106    Toggle,
2107    /// Re-run byte-based content detection and apply the result.
2108    Auto,
2109}
2110
2111/// Apply a prettify-state change to the source and propagate any visible
2112/// effects (line index rebuild, viewport label, scroll clamp). No-op if the
2113/// source isn't a `TransformingSource` (i.e. `prettify_mode()` is `None`).
2114fn apply_prettify(
2115    src: &dyn Source,
2116    viewport: &mut Viewport,
2117    idx: &mut LineIndex,
2118    spec: RebuildSpec,
2119    target: PrettifyTarget,
2120) {
2121    // Sources without a wrapper return None — nothing to do.
2122    if src.prettify_mode().is_none() {
2123        return;
2124    }
2125    match target {
2126        PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
2127        PrettifyTarget::Toggle => src.toggle_prettify(),
2128        PrettifyTarget::Auto => src.redetect_prettify(),
2129    }
2130    rebuild_after_replace(src, viewport, idx, spec);
2131    viewport.set_prettify_label(src.prettify_label());
2132}
2133
2134/// Rebuild line index and visible-line cache after the source content has
2135/// been replaced wholesale (e.g. an editor saved over the file). Re-applies
2136/// `--head`/`--tail` caps from the original CLI args; clamps `top_line` so the
2137/// user stays roughly where they were rather than jumping. Auto snap-to-bottom
2138/// (when the user *was* at the bottom) is the caller's responsibility.
2139fn rebuild_after_replace(
2140    src: &dyn Source,
2141    viewport: &mut Viewport,
2142    idx: &mut LineIndex,
2143    spec: RebuildSpec,
2144) {
2145    let new_off = match spec.tail {
2146        Some(n) => find_tail_offset(src, n),
2147        None => 0,
2148    };
2149    *idx = LineIndex::new_starting_at(new_off);
2150    if let Some(n) = spec.head {
2151        idx.set_head_cap(n);
2152    }
2153    viewport.invalidate_filter_cache();
2154    idx.notice_new_bytes(src);
2155    viewport.extend_visible_lines(idx, src);
2156    viewport.clamp_top_line(idx.line_count());
2157}
2158
2159fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2160    use crossterm::style::Color as CC;
2161    use crate::ansi::Color;
2162    match c {
2163        Color::Ansi(0) => CC::Black,
2164        Color::Ansi(1) => CC::DarkRed,
2165        Color::Ansi(2) => CC::DarkGreen,
2166        Color::Ansi(3) => CC::DarkYellow,
2167        Color::Ansi(4) => CC::DarkBlue,
2168        Color::Ansi(5) => CC::DarkMagenta,
2169        Color::Ansi(6) => CC::DarkCyan,
2170        Color::Ansi(7) => CC::Grey,
2171        Color::Ansi(8) => CC::DarkGrey,
2172        Color::Ansi(9) => CC::Red,
2173        Color::Ansi(10) => CC::Green,
2174        Color::Ansi(11) => CC::Yellow,
2175        Color::Ansi(12) => CC::Blue,
2176        Color::Ansi(13) => CC::Magenta,
2177        Color::Ansi(14) => CC::Cyan,
2178        Color::Ansi(15) => CC::White,
2179        Color::Ansi(_) => CC::Reset,
2180        Color::Indexed(n) => CC::AnsiValue(n),
2181        Color::Rgb(r, g, b) => {
2182            if truecolor {
2183                CC::Rgb { r, g, b }
2184            } else {
2185                CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2186            }
2187        }
2188        Color::Default => CC::Reset,
2189    }
2190}
2191
2192/// Emit crossterm commands to transition `prev` → `next`. Caller must
2193/// already have written prior cells using `prev`'s state.
2194fn emit_style_diff<W: Write>(
2195    out: &mut W,
2196    prev: &crate::ansi::Style,
2197    next: &crate::ansi::Style,
2198    truecolor: bool,
2199) -> io::Result<()> {
2200    // For attribute toggles, crossterm has individual on/off pairs.
2201    // `NormalIntensity` cancels both bold AND dim — handle them together
2202    // to avoid emitting it twice when only one changed.
2203    let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2204
2205    // Color changes. ResetColor clears BOTH fg and bg simultaneously, so
2206    // if either changed to None we emit ResetColor first and then re-emit
2207    // the other if it's Some.
2208    let fg_changed = prev.fg != next.fg;
2209    let bg_changed = prev.bg != next.bg;
2210
2211    if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2212        out.queue(ResetColor)?;
2213        // After ResetColor, re-emit any color that should remain set.
2214        if let Some(c) = next.fg {
2215            out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2216        }
2217        if let Some(c) = next.bg {
2218            out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2219        }
2220    } else {
2221        if fg_changed {
2222            if let Some(c) = next.fg {
2223                out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2224            }
2225        }
2226        if bg_changed {
2227            if let Some(c) = next.bg {
2228                out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2229            }
2230        }
2231    }
2232
2233    if intensity_changed {
2234        if next.bold {
2235            out.queue(SetAttribute(Attribute::Bold))?;
2236        } else if next.dim {
2237            out.queue(SetAttribute(Attribute::Dim))?;
2238        } else {
2239            out.queue(SetAttribute(Attribute::NormalIntensity))?;
2240        }
2241    }
2242    if prev.italic != next.italic {
2243        out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2244    }
2245    if prev.underline != next.underline {
2246        out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2247    }
2248    if prev.reverse != next.reverse {
2249        out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2250    }
2251    if prev.strike != next.strike {
2252        out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2253    }
2254    Ok(())
2255}
2256
2257fn emit_hyperlink_diff<W: Write>(
2258    out: &mut W,
2259    prev: &Option<Arc<str>>,
2260    next: &Option<Arc<str>>,
2261) -> io::Result<()> {
2262    if prev == next {
2263        return Ok(());
2264    }
2265    if prev.is_some() {
2266        out.write_all(b"\x1b]8;;\x1b\\")?;
2267    }
2268    if let Some(uri) = next {
2269        out.write_all(b"\x1b]8;;")?;
2270        out.write_all(uri.as_bytes())?;
2271        out.write_all(b"\x1b\\")?;
2272    }
2273    Ok(())
2274}
2275
2276/// DEC private mode 2026: synchronized output. Terminals that support it
2277/// (iTerm2, Kitty, WezTerm, Alacritty, Ghostty, foot, recent VTE,
2278/// Windows Terminal) buffer everything between `BEGIN` and `END` and
2279/// present the whole frame atomically; terminals that don't recognize the
2280/// sequence silently ignore it. This kills the flicker that would
2281/// otherwise appear during a frame's per-row repaint.
2282const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2283const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2284
2285fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2286    // Raw mode (`-r` / `:color raw`) routes each visible body row through the
2287    // `frame.raw_rows` slot (populated by `Viewport::frame` when ansi_mode is
2288    // Raw). The writer below blasts those bytes to the terminal verbatim so
2289    // escape sequences like cursor moves and SGR pass through. Wrap math is
2290    // best-effort — terminal-driven wrapping may shift sub-rows under long
2291    // lines, matching `less -r`'s documented limitation.
2292
2293    // Begin a synchronized update so the whole frame is presented atomically
2294    // (see SYNC_UPDATE_BEGIN). Paired with per-row `Clear(UntilNewLine)`
2295    // below, this replaces the previous global `Clear(All)` redraw and
2296    // eliminates the visible blank-frame flicker on every scroll keystroke.
2297    out.write_all(SYNC_UPDATE_BEGIN)?;
2298
2299    // Reset attributes once before drawing so the first row starts clean.
2300    out.queue(SetAttribute(Attribute::Reset))?;
2301    out.queue(ResetColor)?;
2302
2303    if let Some(blob) = &frame.image_blob {
2304        // Clear the body region once so a prior frame's cells don't bleed
2305        // around/under the image, then emit the graphics escape verbatim.
2306        for r in 0..frame.body.len() as u16 {
2307            out.queue(MoveTo(0, r))?;
2308            out.queue(Clear(ClearType::UntilNewLine))?;
2309        }
2310        out.queue(MoveTo(0, 0))?;
2311        out.write_all(blob)?;
2312        // Status row, mirroring the normal path's status handling.
2313        out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2314        out.queue(Clear(ClearType::UntilNewLine))?;
2315        emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2316        let mut status = frame.status.clone();
2317        if status.len() > cols as usize { status.truncate(cols as usize); }
2318        else { let pad = cols as usize - status.len(); status.push_str(&" ".repeat(pad)); }
2319        out.queue(Print(status))?;
2320        out.queue(ResetColor)?;
2321        out.queue(SetAttribute(Attribute::Reset))?;
2322        out.write_all(SYNC_UPDATE_END)?;
2323        return out.flush();
2324    }
2325
2326    for (i, row) in frame.body.iter().enumerate() {
2327        out.queue(MoveTo(0, i as u16))?;
2328        // Wipe whatever was on this row in the previous frame. Cursor is
2329        // at col 0 so UntilNewLine clears the full row width, which also
2330        // covers the shrink-on-resize case (old cells past the new edge).
2331        out.queue(Clear(ClearType::UntilNewLine))?;
2332        // Defensive: every row begins with a full attribute reset, so a
2333        // mis-handled reset on the previous row can't bleed forward.
2334        out.queue(SetAttribute(Attribute::Reset))?;
2335
2336        // Raw passthrough: when the viewport set this row's `raw_rows` entry,
2337        // write the original source bytes directly to the terminal instead of
2338        // rendering cells. Empty Vec means "skip" (mid-line wrap continuation
2339        // whose first row already emitted the bytes).
2340        if let Some(Some(raw)) = frame.raw_rows.get(i) {
2341            if !raw.is_empty() {
2342                out.write_all(raw)?;
2343            }
2344            // Trailing reset so a bare `\x1b[31m` doesn't leak into the next row.
2345            out.queue(ResetColor)?;
2346            out.queue(SetAttribute(Attribute::Reset))?;
2347            continue;
2348        }
2349
2350        let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2351        // Build the base style representing the terminal state after the
2352        // defensive reset above. Dim rows get a dim base so the style-diff
2353        // tracker inside write_row_with_highlights starts from the correct
2354        // live terminal state.
2355        let base_style = if matches!(row_style, RowStyle::Dim) {
2356            out.queue(SetAttribute(Attribute::Dim))?;
2357            crate::ansi::Style { dim: true, ..Default::default() }
2358        } else {
2359            crate::ansi::Style::default()
2360        };
2361        let no_highlights = Vec::new();
2362        let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2363        write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2364    }
2365    // Status row
2366    out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2367    out.queue(Clear(ClearType::UntilNewLine))?;
2368    emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2369    let mut status = frame.status.clone();
2370    if status.len() > cols as usize {
2371        status.truncate(cols as usize);
2372    } else {
2373        let pad = cols as usize - status.len();
2374        status.push_str(&" ".repeat(pad));
2375    }
2376    out.queue(Print(status))?;
2377    out.queue(ResetColor)?;
2378    out.queue(SetAttribute(Attribute::Reset))?;
2379
2380    // End the synchronized update. The terminal flushes the buffered frame
2381    // atomically on receipt of this sequence.
2382    out.write_all(SYNC_UPDATE_END)?;
2383    out.flush()
2384}
2385
2386
2387/// Emit a single row with per-cell color/attribute transitions and
2388/// reverse-video highlights. Walks each cell, diffing style and hyperlink
2389/// from the previous cell, emitting only the transitions needed.
2390///
2391/// `base_style` is the terminal's live style state when this function is
2392/// entered (reflects any row-level attribute the caller already emitted,
2393/// e.g. `Dim` for `--dim` rows).
2394///
2395/// Highlight ranges toggle each cell's `reverse` attribute so highlights
2396/// compose correctly with cells that are already reverse-video.
2397fn write_row_with_highlights(
2398    out: &mut impl Write,
2399    row: &[Cell],
2400    cols: u16,
2401    highlights: &[std::ops::Range<usize>],
2402    base_style: crate::ansi::Style,
2403    truecolor: bool,
2404) -> io::Result<()> {
2405    let cols_usize = cols as usize;
2406
2407    let mut ranges: Vec<std::ops::Range<usize>> = highlights
2408        .iter()
2409        .filter_map(|r| {
2410            let s = r.start.min(cols_usize);
2411            let e = r.end.min(cols_usize);
2412            if e > s { Some(s..e) } else { None }
2413        })
2414        .collect();
2415    ranges.sort_by_key(|r| r.start);
2416
2417    // Style register starts at `base_style` — what the terminal currently
2418    // has live after any row-level attribute the caller emitted.
2419    let mut prev_style = base_style;
2420    let mut prev_link: Option<Arc<str>> = None;
2421
2422    let mut col = 0usize;
2423    let mut i = 0usize;
2424    while col < cols_usize && i < row.len() {
2425        let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2426
2427        match &row[i] {
2428            Cell::Char { ch, width, style, hyperlink } => {
2429                // Effective style: cell's style with reverse toggled when in
2430                // a highlight, so highlight composes with already-reverse content.
2431                // Row-level dim (from `--dim` non-matching rows) is OR'd into
2432                // each cell unless the cell explicitly sets bold (bold and dim
2433                // share the SGR intensity slot; bold wins).
2434                let mut eff = *style;
2435                if in_highlight {
2436                    eff.reverse = !eff.reverse;
2437                }
2438                if base_style.dim && !eff.bold {
2439                    eff.dim = true;
2440                }
2441                emit_style_diff(out, &prev_style, &eff, truecolor)?;
2442                emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2443                out.queue(Print(*ch))?;
2444                prev_style = eff;
2445                prev_link = hyperlink.clone();
2446                col += *width as usize;
2447            }
2448            Cell::Continuation => {
2449                // Already accounted for by the preceding wide char.
2450            }
2451            Cell::Empty => {
2452                // Background padding. Reset style to default so we don't
2453                // paint the rest of the line in the last active color —
2454                // but preserve the row-level dim so trailing padding on a
2455                // dim row stays dim.
2456                let default = if base_style.dim {
2457                    crate::ansi::Style { dim: true, ..Default::default() }
2458                } else {
2459                    crate::ansi::Style::default()
2460                };
2461                emit_style_diff(out, &prev_style, &default, truecolor)?;
2462                emit_hyperlink_diff(out, &prev_link, &None)?;
2463                out.queue(Print(' '))?;
2464                prev_style = default;
2465                prev_link = None;
2466                col += 1;
2467            }
2468        }
2469        i += 1;
2470    }
2471
2472    // End-of-row: close any open hyperlink and reset color/attrs so the
2473    // next row's defensive Reset is a true no-op.
2474    emit_hyperlink_diff(out, &prev_link, &None)?;
2475    out.queue(ResetColor)?;
2476    out.queue(SetAttribute(Attribute::Reset))?;
2477
2478    Ok(())
2479}
2480
2481fn render_overlay(
2482    out: &mut impl Write,
2483    frame: &crate::overlay::OverlayFrame,
2484    width: u16,
2485    height: u16,
2486) -> io::Result<()> {
2487    // Mirror write_frame's atomic-frame discipline: synchronized update +
2488    // per-row clear, with a reverse-video status row to match the regular
2489    // viewport's look.
2490    out.write_all(SYNC_UPDATE_BEGIN)?;
2491    out.queue(SetAttribute(Attribute::Reset))?;
2492    out.queue(ResetColor)?;
2493    for row in 0..height.saturating_sub(1) {
2494        out.queue(MoveTo(0, row))?;
2495        out.queue(Clear(ClearType::UntilNewLine))?;
2496        out.queue(SetAttribute(Attribute::Reset))?;
2497        if let Some(line) = frame.body.get(row as usize) {
2498            let mut written = 0usize;
2499            for ch in line.chars() {
2500                let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2501                if written + w > width as usize { break; }
2502                write!(out, "{ch}")?;
2503                written += w;
2504            }
2505        }
2506    }
2507    out.queue(MoveTo(0, height.saturating_sub(1)))?;
2508    out.queue(Clear(ClearType::UntilNewLine))?;
2509    out.queue(SetAttribute(Attribute::Reverse))?;
2510    let mut status = frame.status.clone();
2511    // TODO: use display width (not byte count) — mirrors write_frame's latent limitation.
2512    if status.len() > width as usize {
2513        status.truncate(width as usize);
2514    } else {
2515        let pad = width as usize - status.len();
2516        status.push_str(&" ".repeat(pad));
2517    }
2518    out.queue(Print(status))?;
2519    out.queue(ResetColor)?;
2520    out.queue(SetAttribute(Attribute::Reset))?;
2521    out.write_all(SYNC_UPDATE_END)?;
2522    out.flush()
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527    use super::*;
2528
2529    #[test]
2530    fn parse_colon_n() {
2531        assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2532        assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2533    }
2534
2535    #[test]
2536    fn current_line_bytes_strips_trailing_newline() {
2537        use crate::line_index::LineIndex;
2538        use crate::source::MockSource;
2539        let m = MockSource::new();
2540        // Three lines; the last has no trailing newline.
2541        m.append(b"alpha\nbravo\ncharlie");
2542        let mut idx = LineIndex::new();
2543        idx.extend_to_end(&m);
2544        assert_eq!(idx.line_count(), 3);
2545        assert_eq!(current_line_bytes(&idx, &m, 0), b"alpha");
2546        assert_eq!(current_line_bytes(&idx, &m, 1), b"bravo");
2547        // Last line, no trailing newline, returned verbatim.
2548        assert_eq!(current_line_bytes(&idx, &m, 2), b"charlie");
2549    }
2550
2551    #[test]
2552    fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2553        // Locks in the flicker fix: every frame is wrapped in DEC mode 2026
2554        // begin/end escapes, and the previous global `Clear(All)` is gone
2555        // (replaced by per-row `Clear(UntilNewLine)`). If any of these
2556        // assumptions changes, flicker is likely to come back.
2557        use crate::ansi::Style;
2558        use crate::render::Cell;
2559        use crate::viewport::{Frame, RowStyle};
2560
2561        let row: Vec<Cell> = (0..3)
2562            .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2563            .collect();
2564        let frame = Frame {
2565            body: vec![row.clone(), row],
2566            row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2567            highlights: vec![Vec::new(), Vec::new()],
2568            status: "status".into(),
2569            status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2570            raw_rows: vec![None, None],
2571            image_blob: None,
2572        };
2573
2574        let mut buf: Vec<u8> = Vec::new();
2575        write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2576        let s = std::str::from_utf8(&buf).expect("ascii");
2577
2578        // Begin and end synchronized-update markers, in that order.
2579        let begin = s.find("\x1b[?2026h").expect("begin sync update");
2580        let end = s.find("\x1b[?2026l").expect("end sync update");
2581        assert!(begin < end, "begin must precede end");
2582        // Body content must sit between the markers.
2583        let first_a = s.find('a').expect("body char");
2584        assert!(begin < first_a && first_a < end, "body must be inside sync update");
2585
2586        // Full-screen `Clear(All)` (`\x1b[2J`) must NOT appear — it was the
2587        // source of the flicker. Per-row clear-to-EOL (`\x1b[K`) is fine.
2588        assert!(
2589            !s.contains("\x1b[2J"),
2590            "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2591        );
2592        assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2593    }
2594
2595    #[test]
2596    fn write_frame_emits_image_blob_verbatim_and_skips_cell_rows() {
2597        use crate::viewport::{Frame, RowStyle};
2598        let body_rows = 3usize;
2599        let cols = 10u16;
2600        let blob = b"\x1bPqDATA\x1b\\".to_vec();
2601        // Seed a body cell with a distinctive printable char; the image-blob
2602        // path must skip the per-row cell loop, so this char must NOT appear.
2603        let mut body = vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows];
2604        body[0][0] = crate::render::Cell::Char { ch: 'Z', width: 1, style: crate::ansi::Style::default(), hyperlink: None };
2605        let frame = Frame {
2606            body,
2607            row_styles: vec![RowStyle::Normal; body_rows],
2608            highlights: vec![Vec::new(); body_rows],
2609            status: "img".to_string(),
2610            status_style: crate::ansi::Style::default(),
2611            raw_rows: vec![None; body_rows],
2612            image_blob: Some(blob.clone()),
2613        };
2614        let mut out: Vec<u8> = Vec::new();
2615        write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
2616        let needle = b"\x1bPqDATA\x1b\\";
2617        assert!(out.windows(needle.len()).any(|w| w == needle), "image blob emitted verbatim");
2618        assert!(String::from_utf8_lossy(&out).contains("img"), "status still drawn");
2619        assert!(!String::from_utf8_lossy(&out).contains('Z'), "cell loop skipped: body cell not rendered");
2620    }
2621
2622    #[test]
2623    fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2624        use crate::ansi::Style;
2625        use crate::render::Cell;
2626        use crate::viewport::{Frame, RowStyle};
2627
2628        // One body row (since rows=2 means body_rows=1).
2629        let placeholder_row: Vec<Cell> = (0..3)
2630            .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2631            .collect();
2632        let frame = Frame {
2633            body: vec![placeholder_row.clone(), placeholder_row],
2634            row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2635            highlights: vec![Vec::new(), Vec::new()],
2636            status: "s".into(),
2637            status_style: Style { reverse: true, ..Default::default() },
2638            // Row 0 emits raw bytes (with an embedded ESC); row 1 is a
2639            // continuation and emits nothing.
2640            raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2641            image_blob: None,
2642        };
2643
2644        let mut buf: Vec<u8> = Vec::new();
2645        write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2646        let s = std::str::from_utf8(&buf).expect("ascii");
2647
2648        // The original SGR bytes must appear (raw passthrough).
2649        assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2650        // The placeholder cells must NOT appear — we bypassed the cell pipeline.
2651        assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2652    }
2653
2654    #[test]
2655    fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2656        // Regression: a row with base_style.dim=true and Cell::Char carrying
2657        // Style::default() used to emit `\x1b[22m` (NormalIntensity) on the
2658        // first char, killing the row-level dim and rendering the whole
2659        // line at normal intensity. Same for Cell::Empty padding cells.
2660        use crate::ansi::Style;
2661        use crate::render::Cell;
2662        let row = vec![
2663            Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2664            Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2665            Cell::Empty,
2666            Cell::Empty,
2667        ];
2668        let mut buf: Vec<u8> = Vec::new();
2669        let base = Style { dim: true, ..Default::default() };
2670        write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2671        let s = String::from_utf8_lossy(&buf);
2672
2673        // Locate every emitted character; before any of them is printed, the
2674        // dim attribute must NOT have been cleared.
2675        for needle in ['h', 'i'] {
2676            let pos = s.find(needle).expect("char printed");
2677            let before = &s[..pos];
2678            assert!(
2679                !before.contains("\x1b[22m"),
2680                "dim cleared before {needle:?}: {before:?}",
2681            );
2682        }
2683        // The Cell::Empty padding shouldn't clear dim either. Look at the
2684        // bytes between 'i' and the end-of-row Reset.
2685        let after_i = s.find('i').unwrap() + 1;
2686        let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2687        let pad = &s[after_i..after_i + eor];
2688        assert!(
2689            !pad.contains("\x1b[22m"),
2690            "dim cleared in padding region: {pad:?}",
2691        );
2692    }
2693
2694    #[test]
2695    fn dim_row_yields_to_explicit_bold_cell() {
2696        // If a cell carries bold=true from ANSI, that wins over row-level
2697        // dim (bold and dim share the SGR intensity slot).
2698        use crate::ansi::Style;
2699        use crate::render::Cell;
2700        let row = vec![
2701            Cell::Char {
2702                ch: 'B',
2703                width: 1,
2704                style: Style { bold: true, ..Default::default() },
2705                hyperlink: None,
2706            },
2707        ];
2708        let mut buf: Vec<u8> = Vec::new();
2709        let base = Style { dim: true, ..Default::default() };
2710        write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2711        let s = String::from_utf8_lossy(&buf);
2712        // Bold should be emitted (\x1b[1m); dim should not re-appear.
2713        assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2714    }
2715
2716    #[test]
2717    fn parse_colon_p() {
2718        assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2719        assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2720    }
2721
2722    #[test]
2723    fn parse_colon_e_with_path() {
2724        match parse_colon_command("e /tmp/foo.log").unwrap() {
2725            ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2726            other => panic!("expected Edit, got {other:?}"),
2727        }
2728    }
2729
2730    #[test]
2731    fn parse_colon_e_with_tilde() {
2732        std::env::set_var("HOME", "/home/user");
2733        match parse_colon_command("e ~/foo.log").unwrap() {
2734            ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2735            other => panic!("expected Edit, got {other:?}"),
2736        }
2737    }
2738
2739    #[test]
2740    fn parse_colon_e_missing_path_errors() {
2741        assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2742        assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2743    }
2744
2745    #[test]
2746    fn parse_colon_f_q_d_x_t() {
2747        assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2748        assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2749        assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2750        assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2751        assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2752    }
2753
2754    #[test]
2755    fn parse_unknown_command_errors() {
2756        let err = parse_colon_command("bogus").unwrap_err();
2757        match err {
2758            ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2759            other => panic!("expected UnknownCommand, got {other:?}"),
2760        }
2761    }
2762
2763    #[test]
2764    fn parse_handles_whitespace() {
2765        // Trailing whitespace OK.
2766        assert_eq!(parse_colon_command("n  ").unwrap(), ColonCommand::Next);
2767        assert_eq!(parse_colon_command("  n").unwrap(), ColonCommand::Next);
2768    }
2769
2770    #[test]
2771    fn parse_colon_tag_with_name() {
2772        assert_eq!(
2773            parse_colon_command("tag foo").unwrap(),
2774            ColonCommand::Tag("foo".into())
2775        );
2776    }
2777
2778    #[test]
2779    fn parse_colon_tag_strips_trailing_whitespace() {
2780        assert_eq!(
2781            parse_colon_command("tag foo  ").unwrap(),
2782            ColonCommand::Tag("foo".into())
2783        );
2784    }
2785
2786    #[test]
2787    fn parse_colon_tag_without_name_errors() {
2788        assert_eq!(
2789            parse_colon_command("tag").unwrap_err(),
2790            ColonParseError::TagRequiresName
2791        );
2792        assert_eq!(
2793            parse_colon_command("tag  ").unwrap_err(),
2794            ColonParseError::TagRequiresName
2795        );
2796    }
2797
2798    #[test]
2799    fn parse_colon_tnext_and_tprev() {
2800        assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2801        assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2802    }
2803
2804    #[test]
2805    fn parse_colon_tselect_without_arg_uses_active() {
2806        assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2807    }
2808
2809    #[test]
2810    fn parse_colon_tselect_with_name() {
2811        assert_eq!(
2812            parse_colon_command("tselect foo").unwrap(),
2813            ColonCommand::TagSelect(Some("foo".into())),
2814        );
2815    }
2816
2817    #[test]
2818    fn parse_colon_b_opens_picker() {
2819        assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2820        assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2821    }
2822
2823    #[test]
2824    fn parse_colon_help_opens_help() {
2825        assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2826        assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2827    }
2828
2829    #[test]
2830    fn parse_colon_hex_with_valid_widths() {
2831        for n in [2usize, 4, 8, 16, 32] {
2832            assert_eq!(
2833                parse_colon_command(&format!("hex {n}")).unwrap(),
2834                ColonCommand::HexGroup(n),
2835            );
2836        }
2837    }
2838
2839    #[test]
2840    fn parse_colon_hex_without_value_errors() {
2841        assert_eq!(
2842            parse_colon_command("hex").unwrap_err(),
2843            ColonParseError::HexGroupRequiresValue,
2844        );
2845    }
2846
2847    #[test]
2848    fn parse_colon_hex_with_invalid_value_errors() {
2849        match parse_colon_command("hex 3").unwrap_err() {
2850            ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2851            other => panic!("expected HexGroupInvalid, got {other:?}"),
2852        }
2853        match parse_colon_command("hex banana").unwrap_err() {
2854            ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2855            other => panic!("expected HexGroupInvalid, got {other:?}"),
2856        }
2857    }
2858
2859    #[test]
2860    fn parse_colon_color_without_arg_cycles() {
2861        assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2862    }
2863
2864    #[test]
2865    fn parse_colon_color_with_named_mode() {
2866        use crate::render::AnsiMode;
2867        assert_eq!(
2868            parse_colon_command("color strict").unwrap(),
2869            ColonCommand::Color(Some(AnsiMode::Strict)),
2870        );
2871        assert_eq!(
2872            parse_colon_command("color interpret").unwrap(),
2873            ColonCommand::Color(Some(AnsiMode::Interpret)),
2874        );
2875        assert_eq!(
2876            parse_colon_command("color raw").unwrap(),
2877            ColonCommand::Color(Some(AnsiMode::Raw)),
2878        );
2879    }
2880
2881    #[test]
2882    fn parse_colon_color_with_unknown_mode_errors() {
2883        match parse_colon_command("color rainbow").unwrap_err() {
2884            ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2885            other => panic!("expected ColorInvalid, got {other:?}"),
2886        }
2887    }
2888
2889    #[test]
2890    fn parse_colon_case_without_arg_cycles() {
2891        assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2892    }
2893
2894    #[test]
2895    fn parse_colon_case_with_named_mode() {
2896        use crate::viewport::CaseMode;
2897        assert_eq!(parse_colon_command("case smart").unwrap(),
2898                   ColonCommand::Case(Some(CaseMode::Smart)));
2899        assert_eq!(parse_colon_command("case sensitive").unwrap(),
2900                   ColonCommand::Case(Some(CaseMode::Sensitive)));
2901        assert_eq!(parse_colon_command("case insensitive").unwrap(),
2902                   ColonCommand::Case(Some(CaseMode::Insensitive)));
2903    }
2904
2905    #[test]
2906    fn parse_colon_case_unknown_errors() {
2907        match parse_colon_command("case rainbow").unwrap_err() {
2908            ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2909            other => panic!("expected CaseInvalid, got {other:?}"),
2910        }
2911    }
2912
2913    #[test]
2914    fn parse_colon_hlsearch_on_off() {
2915        assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2916        assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2917    }
2918
2919    #[test]
2920    fn parse_colon_incsearch_toggle() {
2921        assert_eq!(parse_colon_command("incsearch").unwrap(), ColonCommand::IncSearch);
2922    }
2923
2924    #[test]
2925    fn lcp_empty_slice() {
2926        assert_eq!(longest_common_prefix(&[]), "");
2927    }
2928
2929    #[test]
2930    fn lcp_single_item_returns_self() {
2931        assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2932    }
2933
2934    #[test]
2935    fn lcp_finds_shared_prefix() {
2936        let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2937        assert_eq!(longest_common_prefix(&v), "foo");
2938    }
2939
2940    #[test]
2941    fn lcp_no_shared_prefix_returns_empty() {
2942        let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2943        assert_eq!(longest_common_prefix(&v), "");
2944    }
2945
2946    #[test]
2947    fn lcp_one_item_is_prefix_of_others() {
2948        let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2949        assert_eq!(longest_common_prefix(&v), "foo");
2950    }
2951
2952    #[test]
2953    fn tag_stack_push_pop_lifo() {
2954        let mut s = TagStack::default();
2955        s.push(0, 10);
2956        s.push(1, 20);
2957        assert_eq!(s.pop(), Some((1, 20)));
2958        assert_eq!(s.pop(), Some((0, 10)));
2959        assert_eq!(s.pop(), None);
2960    }
2961
2962    #[test]
2963    fn tag_stack_pop_clears_active() {
2964        let mut s = TagStack::default();
2965        s.push(0, 10);
2966        s.set_active(
2967            "foo".into(),
2968            vec![crate::tags::TagEntry {
2969                file: std::path::PathBuf::from("/a"),
2970                address: crate::tags::TagAddress::Line(1),
2971            }],
2972        );
2973        assert!(s.active.is_some());
2974        let _ = s.pop();
2975        assert!(s.active.is_none());
2976    }
2977
2978    #[test]
2979    fn tag_stack_next_advances_then_clamps() {
2980        let mut s = TagStack::default();
2981        s.set_active(
2982            "foo".into(),
2983            vec![
2984                crate::tags::TagEntry {
2985                    file: std::path::PathBuf::from("/a"),
2986                    address: crate::tags::TagAddress::Line(1),
2987                },
2988                crate::tags::TagEntry {
2989                    file: std::path::PathBuf::from("/b"),
2990                    address: crate::tags::TagAddress::Line(2),
2991                },
2992            ],
2993        );
2994        assert_eq!(s.next(), TagStepResult::Moved(1));
2995        assert_eq!(s.next(), TagStepResult::AtBoundary);
2996    }
2997
2998    #[test]
2999    fn tag_stack_prev_clamps_at_zero() {
3000        let mut s = TagStack::default();
3001        s.set_active(
3002            "foo".into(),
3003            vec![crate::tags::TagEntry {
3004                file: std::path::PathBuf::from("/a"),
3005                address: crate::tags::TagAddress::Line(1),
3006            }],
3007        );
3008        assert_eq!(s.prev(), TagStepResult::AtBoundary);
3009    }
3010
3011    #[test]
3012    fn tag_stack_next_with_no_active_returns_no_active() {
3013        let mut s = TagStack::default();
3014        assert_eq!(s.next(), TagStepResult::NoActive);
3015        assert_eq!(s.prev(), TagStepResult::NoActive);
3016    }
3017
3018    #[test]
3019    fn tag_stack_set_active_replaces_previous_list() {
3020        let mut s = TagStack::default();
3021        s.set_active(
3022            "foo".into(),
3023            vec![crate::tags::TagEntry {
3024                file: std::path::PathBuf::from("/a"),
3025                address: crate::tags::TagAddress::Line(1),
3026            }],
3027        );
3028        s.set_active(
3029            "bar".into(),
3030            vec![
3031                crate::tags::TagEntry {
3032                    file: std::path::PathBuf::from("/x"),
3033                    address: crate::tags::TagAddress::Line(5),
3034                },
3035                crate::tags::TagEntry {
3036                    file: std::path::PathBuf::from("/y"),
3037                    address: crate::tags::TagAddress::Line(6),
3038                },
3039            ],
3040        );
3041        let active = s.active.as_ref().unwrap();
3042        assert_eq!(active.name, "bar");
3043        assert_eq!(active.matches.len(), 2);
3044        assert_eq!(active.cursor, 0);
3045    }
3046
3047    #[test]
3048    fn writer_emits_color_for_red_cell() {
3049        let cells = vec![Cell::Char {
3050            ch: 'h',
3051            width: 1,
3052            style: crate::ansi::Style {
3053                fg: Some(crate::ansi::Color::Ansi(1)),
3054                ..Default::default()
3055            },
3056            hyperlink: None,
3057        }];
3058        let mut buf: Vec<u8> = Vec::new();
3059        write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3060        let s = String::from_utf8_lossy(&buf);
3061        assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
3062        assert!(s.contains('h'));
3063    }
3064
3065    #[test]
3066    fn writer_emits_osc8_for_hyperlink_cell() {
3067        let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
3068        let cells = vec![Cell::Char {
3069            ch: 'c',
3070            width: 1,
3071            style: crate::ansi::Style::default(),
3072            hyperlink: Some(link),
3073        }];
3074        let mut buf: Vec<u8> = Vec::new();
3075        write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3076        let s = String::from_utf8_lossy(&buf);
3077        assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
3078    }
3079}