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