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