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