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