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