Skip to main content

tess/
app.rs

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