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