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, 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}
62
63#[derive(Debug, Clone, PartialEq)]
64enum ColonCommand {
65    Next,
66    Prev,
67    Edit(std::path::PathBuf),
68    ShowFile,
69    Quit,
70    Delete,
71    First,
72    Last,
73}
74
75#[derive(Debug, Clone, PartialEq)]
76enum ColonParseError {
77    UnknownCommand(String),
78    MissingPath,
79}
80
81impl std::fmt::Display for ColonParseError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
85            ColonParseError::MissingPath => write!(f, ":e requires a path"),
86        }
87    }
88}
89
90fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
91    let buf = buf.trim();
92    if buf.is_empty() {
93        return Err(ColonParseError::UnknownCommand(String::new()));
94    }
95    let mut parts = buf.splitn(2, char::is_whitespace);
96    let cmd = parts.next().unwrap();
97    let rest = parts.next().unwrap_or("").trim();
98    match cmd {
99        "n" | "next" => Ok(ColonCommand::Next),
100        "p" | "prev" => Ok(ColonCommand::Prev),
101        "e" | "edit" => {
102            if rest.is_empty() {
103                Err(ColonParseError::MissingPath)
104            } else {
105                // Tilde expansion.
106                let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
107                    if let Some(home) = std::env::var_os("HOME") {
108                        let mut p = std::path::PathBuf::from(home);
109                        p.push(stripped);
110                        p
111                    } else {
112                        std::path::PathBuf::from(rest)
113                    }
114                } else {
115                    std::path::PathBuf::from(rest)
116                };
117                Ok(ColonCommand::Edit(expanded))
118            }
119        }
120        "f" => Ok(ColonCommand::ShowFile),
121        "q" | "quit" => Ok(ColonCommand::Quit),
122        "d" | "delete" => Ok(ColonCommand::Delete),
123        "x" | "first" => Ok(ColonCommand::First),
124        "t" | "last" => Ok(ColonCommand::Last),
125        other => Err(ColonParseError::UnknownCommand(other.to_string())),
126    }
127}
128
129enum ColonOutcome {
130    Continue(Option<String>),  // Some(msg) = transient status to show
131    Quit,
132}
133
134#[allow(clippy::too_many_arguments)]
135fn switch_file(
136    new_path: &std::path::Path,
137    new_file_index: usize,
138    total_files: usize,
139    args: &crate::cli::Args,
140    preprocessor: Option<&crate::preprocess::Preprocessor>,
141    viewport: &mut crate::viewport::Viewport,
142    src: &mut Box<dyn crate::source::Source>,
143    idx: &mut crate::line_index::LineIndex,
144    record_start_regex: Option<&regex::bytes::Regex>,
145) -> crate::error::Result<()> {
146    let (new_src, new_label, new_failure) =
147        crate::open::open_source_for_path(new_path, args, preprocessor)?;
148
149    *src = new_src;
150    let mut new_idx = crate::line_index::LineIndex::new();
151    if let Some(re) = record_start_regex {
152        new_idx.set_record_start(re.clone());
153    }
154    *idx = new_idx;
155
156    viewport.set_source_label(new_label);
157    viewport.set_file_index(new_file_index, total_files);
158    viewport.set_preprocess_failure(new_failure);
159    viewport.goto_top();
160
161    Ok(())
162}
163
164#[allow(clippy::too_many_arguments)]
165fn dispatch_colon_command(
166    cmd: ColonCommand,
167    file_set: &mut crate::file_set::FileSet,
168    current_file_index: &mut usize,
169    args: &crate::cli::Args,
170    preprocessor: Option<&crate::preprocess::Preprocessor>,
171    record_start_regex: Option<&regex::bytes::Regex>,
172    viewport: &mut crate::viewport::Viewport,
173    src: &mut Box<dyn crate::source::Source>,
174    idx: &mut crate::line_index::LineIndex,
175) -> ColonOutcome {
176    match cmd {
177        ColonCommand::Next => {
178            match file_set.next() {
179                Ok(path) => {
180                    let path = path.to_path_buf();
181                    let new_idx_val = file_set.current_index();
182                    if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
183                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
184                    } else {
185                        *current_file_index = new_idx_val;
186                        ColonOutcome::Continue(None)
187                    }
188                }
189                Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
190            }
191        }
192        ColonCommand::Prev => {
193            match file_set.prev() {
194                Ok(path) => {
195                    let path = path.to_path_buf();
196                    let new_idx_val = file_set.current_index();
197                    if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
198                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
199                    } else {
200                        *current_file_index = new_idx_val;
201                        ColonOutcome::Continue(None)
202                    }
203                }
204                Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
205            }
206        }
207        ColonCommand::Edit(path) => {
208            // Try to open first; if successful, append + switch.
209            match crate::open::open_source_for_path(&path, args, preprocessor) {
210                Ok(_) => {
211                    // Successful open; commit to the FileSet.
212                    let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
213                    let new_idx_val = file_set.current_index();
214                    if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
215                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
216                    } else {
217                        *current_file_index = new_idx_val;
218                        ColonOutcome::Continue(None)
219                    }
220                }
221                Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
222            }
223        }
224        ColonCommand::ShowFile => {
225            let label = viewport.source_label_clone();
226            let cur = file_set.current_index() + 1;
227            let total = file_set.len();
228            let top = viewport.top_line() + 1;
229            let total_lines = idx.line_count();
230            let msg = if total > 1 {
231                format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
232            } else {
233                format!("{label}: line {top}/{total_lines}")
234            };
235            ColonOutcome::Continue(Some(msg))
236        }
237        ColonCommand::Quit => ColonOutcome::Quit,
238        ColonCommand::Delete => {
239            match file_set.delete_current() {
240                Ok(path) => {
241                    let path = path.to_path_buf();
242                    let new_idx_val = file_set.current_index();
243                    if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
244                        ColonOutcome::Continue(Some(format!("[open: {e}]")))
245                    } else {
246                        *current_file_index = new_idx_val;
247                        ColonOutcome::Continue(None)
248                    }
249                }
250                Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
251            }
252        }
253        ColonCommand::First => {
254            if file_set.current_index() == 0 {
255                ColonOutcome::Continue(None)  // silent no-op
256            } else if let Some(path) = file_set.first() {
257                let path = path.to_path_buf();
258                let new_idx_val = file_set.current_index();
259                if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
260                    ColonOutcome::Continue(Some(format!("[open: {e}]")))
261                } else {
262                    *current_file_index = new_idx_val;
263                    ColonOutcome::Continue(None)
264                }
265            } else {
266                ColonOutcome::Continue(None)
267            }
268        }
269        ColonCommand::Last => {
270            if file_set.current_index() + 1 == file_set.len() {
271                ColonOutcome::Continue(None)
272            } else if let Some(path) = file_set.last() {
273                let path = path.to_path_buf();
274                let new_idx_val = file_set.current_index();
275                if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
276                    ColonOutcome::Continue(Some(format!("[open: {e}]")))
277                } else {
278                    *current_file_index = new_idx_val;
279                    ColonOutcome::Continue(None)
280                }
281            } else {
282                ColonOutcome::Continue(None)
283            }
284        }
285    }
286}
287
288#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
289pub fn run(
290    mut src: Box<dyn Source>,
291    mut viewport: Viewport,
292    mut idx: LineIndex,
293    sigterm: Arc<AtomicBool>,
294    rebuild_spec: RebuildSpec,
295    keymap: crate::keys::KeyMap,
296    mut file_set: crate::file_set::FileSet,
297    record_start_regex: Option<regex::bytes::Regex>,
298    args: crate::cli::Args,
299    preprocessor: Option<crate::preprocess::Preprocessor>,
300) -> Result<()> {
301    let (mut cols, mut rows) = size().unwrap_or((80, 24));
302    viewport.resize(cols, rows);
303
304    let mut stdout = io::stdout();
305    let timeout = Duration::from_millis(250);
306    let mut last_revision = src.revision();
307
308    // If hide-mode filtering is active (--filter or --grep without --dim),
309    // we need to scan the whole source up front to find matching lines.
310    // Without any predicate this is intentionally skipped — lazy indexing
311    // keeps `tess` fast on huge files.
312    if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
313        idx.extend_to_end(src.as_ref());
314        viewport.extend_visible_lines(&idx, src.as_ref());
315    }
316
317    // If follow mode is on at startup, snap to the bottom of the (possibly
318    // filtered) source so the user sees the newest content (tail-style).
319    if viewport.follow_mode() {
320        src.pump();
321        viewport.extend_visible_lines(&idx, src.as_ref());
322        viewport.goto_bottom(src.as_ref(), &mut idx);
323    }
324
325    // Always draw the initial frame before entering the event loop.
326    let mut needs_redraw = true;
327    let mut mode = InputMode::Normal;
328    let mut numeric_prefix: Option<usize> = None;
329    let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
330    let mut previous_position: Option<(usize, usize)> = None;
331    let mut current_file_index: usize = file_set.current_index();
332    let mut transient_status: Option<String> = None;
333
334    loop {
335        if sigterm.load(Ordering::SeqCst) {
336            break;
337        }
338
339        if needs_redraw {
340            let mut frame = viewport.frame(src.as_ref(), &mut idx);
341            // Override the status row when we're in an interactive prompt OR
342            // when a transient status message is pending.
343            match &mode {
344                InputMode::SearchPrompt { direction, buffer, error } => {
345                    let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
346                    frame.status = match error {
347                        Some(e) => format!("{prefix}{buffer}  [error: {e}]"),
348                        None => format!("{prefix}{buffer}"),
349                    };
350                }
351                InputMode::ShellPrompt { buffer, error } => {
352                    frame.status = match error {
353                        Some(e) => format!("!{buffer}  [error: {e}]"),
354                        None => format!("!{buffer}"),
355                    };
356                }
357                InputMode::ColonPrompt { buffer, error } => {
358                    frame.status = match error {
359                        Some(e) => format!(":{buffer}  [error: {e}]"),
360                        None => format!(":{buffer}"),
361                    };
362                }
363                _ => {
364                    if let Some(msg) = transient_status.take() {
365                        frame.status = msg;
366                    }
367                }
368            }
369            write_frame(&mut stdout, &frame, cols, rows)
370                .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
371            needs_redraw = false;
372        }
373
374        // Poll with timeout so stdin sources can be re-checked.
375        match poll(timeout) {
376            Ok(true) => {
377                let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
378                // Modal input handling: the search prompt and option prefix
379                // intercept keys before they're translated to commands.
380                match &mut mode {
381                    InputMode::SearchPrompt { direction, buffer, error } => {
382                        if let Event::Key(KeyEvent { code, .. }) = event {
383                            match code {
384                                KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
385                                KeyCode::Enter => {
386                                    if buffer.is_empty() {
387                                        // Empty buffer: repeat the last search in the
388                                        // newly-typed direction (less compat). If no
389                                        // prior search exists, just dismiss.
390                                        if viewport.search_active() {
391                                            let reverse = !matches!(
392                                                (viewport.search_direction(), *direction),
393                                                (SearchDirection::Forward, SearchDirection::Forward)
394                                                | (SearchDirection::Backward, SearchDirection::Backward)
395                                            );
396                                            update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
397                                            viewport.search_repeat(src.as_ref(), &mut idx, reverse);
398                                        }
399                                        mode = InputMode::Normal;
400                                    } else {
401                                        match viewport.set_search(buffer.clone(), *direction) {
402                                            Ok(()) => {
403                                                update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
404                                                viewport.search_repeat(src.as_ref(), &mut idx, false);
405                                                mode = InputMode::Normal;
406                                            }
407                                            Err(e) => { *error = Some(e); }
408                                        }
409                                    }
410                                    needs_redraw = true;
411                                }
412                                KeyCode::Backspace => {
413                                    buffer.pop();
414                                    *error = None;
415                                    needs_redraw = true;
416                                }
417                                KeyCode::Char(c) => {
418                                    buffer.push(c);
419                                    *error = None;
420                                    needs_redraw = true;
421                                }
422                                _ => {}
423                            }
424                        }
425                        continue;
426                    }
427                    InputMode::OptionPrefix => {
428                        if let Event::Key(KeyEvent { code, .. }) = event {
429                            match code {
430                                KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
431                                KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
432                                KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
433                                KeyCode::Char('P') | KeyCode::Char('p') => {
434                                    // Two-key prefix: `-P` then a letter for the mode.
435                                    mode = InputMode::PrettifyPrefix;
436                                    needs_redraw = true;
437                                    continue;
438                                }
439                                _ => {}
440                            }
441                        }
442                        mode = InputMode::Normal;
443                        needs_redraw = true;
444                        continue;
445                    }
446                    InputMode::PrettifyPrefix => {
447                        if let Event::Key(KeyEvent { code, .. }) = event {
448                            let target: Option<PrettifyTarget> = match code {
449                                KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
450                                KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
451                                KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
452                                KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
453                                KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
454                                KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
455                                KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
456                                KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
457                                _ => None,
458                            };
459                            if let Some(t) = target {
460                                apply_prettify(
461                                    src.as_ref(),
462                                    &mut viewport,
463                                    &mut idx,
464                                    rebuild_spec,
465                                    t,
466                                );
467                                last_revision = src.revision();
468                            }
469                        }
470                        mode = InputMode::Normal;
471                        needs_redraw = true;
472                        continue;
473                    }
474                    InputMode::MarkSetPending => {
475                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
476                            if is_valid_mark_name(c) {
477                                mark_set(&mut marks, c, current_file_index, viewport.top_line());
478                            }
479                        }
480                        mode = InputMode::Normal;
481                        continue;
482                    }
483                    InputMode::MarkJumpPending => {
484                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
485                            if is_valid_mark_name(c) {
486                                match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
487                                    Some(MarkTarget::SameFile { line }) => {
488                                        let clamped = line.min(idx.line_count().saturating_sub(1));
489                                        viewport.goto_line(clamped, src.as_ref(), &mut idx);
490                                        needs_redraw = true;
491                                    }
492                                    Some(MarkTarget::OtherFile { file_index, line }) => {
493                                        if file_index < file_set.len() {
494                                            file_set.set_current_index(file_index);
495                                            let path = file_set.current().unwrap().to_path_buf();
496                                            if let Err(e) = switch_file(
497                                                &path, file_index, file_set.len(),
498                                                &args, preprocessor.as_ref(),
499                                                &mut viewport, &mut src, &mut idx,
500                                                record_start_regex.as_ref(),
501                                            ) {
502                                                transient_status = Some(format!("[open: {e}]"));
503                                            } else {
504                                                let clamped = line.min(idx.line_count().saturating_sub(1));
505                                                viewport.goto_line(clamped, src.as_ref(), &mut idx);
506                                                current_file_index = file_index;
507                                                needs_redraw = true;
508                                            }
509                                        }
510                                    }
511                                    None => {}
512                                }
513                            }
514                        }
515                        mode = InputMode::Normal;
516                        continue;
517                    }
518                    InputMode::ShellPrompt { buffer, error } => {
519                        if let Event::Key(KeyEvent { code, .. }) = event {
520                            match code {
521                                KeyCode::Esc => {
522                                    mode = InputMode::Normal;
523                                    needs_redraw = true;
524                                }
525                                KeyCode::Enter => {
526                                    if buffer.is_empty() {
527                                        mode = InputMode::Normal;
528                                    } else {
529                                        match crate::shell::run_shell_command(buffer) {
530                                            Ok(()) => {
531                                                mode = InputMode::Normal;
532                                            }
533                                            Err(e) => {
534                                                *error = Some(e.to_string());
535                                            }
536                                        }
537                                    }
538                                    needs_redraw = true;
539                                }
540                                KeyCode::Backspace => {
541                                    buffer.pop();
542                                    *error = None;
543                                    needs_redraw = true;
544                                }
545                                KeyCode::Char(c) => {
546                                    buffer.push(c);
547                                    *error = None;
548                                    needs_redraw = true;
549                                }
550                                _ => {}
551                            }
552                        }
553                        continue;
554                    }
555                    InputMode::CtrlXPending => {
556                        let is_ctrl_x = matches!(
557                            event,
558                            Event::Key(KeyEvent {
559                                code: KeyCode::Char('x'),
560                                modifiers: KeyModifiers::CONTROL,
561                                ..
562                            })
563                        );
564                        if is_ctrl_x {
565                            match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
566                                Some(MarkTarget::SameFile { line }) => {
567                                    let clamped = line.min(idx.line_count().saturating_sub(1));
568                                    viewport.goto_line(clamped, src.as_ref(), &mut idx);
569                                    needs_redraw = true;
570                                }
571                                Some(MarkTarget::OtherFile { file_index, line }) => {
572                                    if file_index < file_set.len() {
573                                        file_set.set_current_index(file_index);
574                                        let path = file_set.current().unwrap().to_path_buf();
575                                        if let Err(e) = switch_file(
576                                            &path, file_index, file_set.len(),
577                                            &args, preprocessor.as_ref(),
578                                            &mut viewport, &mut src, &mut idx,
579                                            record_start_regex.as_ref(),
580                                        ) {
581                                            transient_status = Some(format!("[open: {e}]"));
582                                        } else {
583                                            let clamped = line.min(idx.line_count().saturating_sub(1));
584                                            viewport.goto_line(clamped, src.as_ref(), &mut idx);
585                                            current_file_index = file_index;
586                                            needs_redraw = true;
587                                        }
588                                    }
589                                }
590                                None => {}
591                            }
592                            mode = InputMode::Normal;
593                            continue;
594                        }
595                        // Anything else: cancel and fall through to normal dispatch.
596                        mode = InputMode::Normal;
597                        // Don't `continue` — let the event fall through.
598                    }
599                    InputMode::ColonPrompt { buffer, error } => {
600                        if let Event::Key(KeyEvent { code, .. }) = event {
601                            match code {
602                                KeyCode::Esc => {
603                                    mode = InputMode::Normal;
604                                    needs_redraw = true;
605                                }
606                                KeyCode::Enter => {
607                                    if buffer.is_empty() {
608                                        mode = InputMode::Normal;
609                                    } else {
610                                        match parse_colon_command(buffer) {
611                                            Ok(cmd) => {
612                                                let outcome = dispatch_colon_command(
613                                                    cmd,
614                                                    &mut file_set,
615                                                    &mut current_file_index,
616                                                    &args,
617                                                    preprocessor.as_ref(),
618                                                    record_start_regex.as_ref(),
619                                                    &mut viewport,
620                                                    &mut src,
621                                                    &mut idx,
622                                                );
623                                                match outcome {
624                                                    ColonOutcome::Continue(msg) => {
625                                                        transient_status = msg;
626                                                    }
627                                                    ColonOutcome::Quit => break,
628                                                }
629                                                mode = InputMode::Normal;
630                                            }
631                                            Err(e) => {
632                                                *error = Some(e.to_string());
633                                            }
634                                        }
635                                    }
636                                    needs_redraw = true;
637                                }
638                                KeyCode::Backspace => {
639                                    buffer.pop();
640                                    *error = None;
641                                    needs_redraw = true;
642                                }
643                                KeyCode::Char(c) => {
644                                    buffer.push(c);
645                                    *error = None;
646                                    needs_redraw = true;
647                                }
648                                _ => {}
649                            }
650                        }
651                        continue;
652                    }
653                    InputMode::Normal => {}
654                }
655                // Pre-translate keymap interception. Only consult the keymap
656                // when in Normal mode (not inside a search/option/prettify/
657                // shell prompt).
658                let mut cmd: Option<Command> = None;
659                if let InputMode::Normal = mode {
660                    if let Event::Key(ke) = &event {
661                        if let Some(target) = keymap.lookup(ke) {
662                            match target {
663                                crate::keys::BindingTarget::Shell(cmd_text) => {
664                                    let cmd_text = cmd_text.clone();
665                                    if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
666                                        let _ = writeln!(std::io::stderr(),
667                                            "[shell: {e}]");
668                                    }
669                                    needs_redraw = true;
670                                    continue;
671                                }
672                                crate::keys::BindingTarget::Command(c) => {
673                                    cmd = Some(c.clone());
674                                }
675                            }
676                        }
677                    }
678                }
679                let cmd = cmd.unwrap_or_else(|| translate(event));
680                // Consume the numeric prefix at the top of each dispatch so
681                // commands that don't need it drop it implicitly.
682                let prefix_at_cmd = numeric_prefix.take();
683                match cmd {
684                    Command::Digit(d) => {
685                        let cur = prefix_at_cmd.unwrap_or(0);
686                        let next = cur.saturating_mul(10).saturating_add(d as usize);
687                        if next <= 99_999_999 {
688                            numeric_prefix = Some(next);
689                        } else {
690                            // Overflow: keep previous prefix, ignore this digit.
691                            numeric_prefix = prefix_at_cmd;
692                        }
693                        continue;
694                    }
695                    Command::Cancel => {
696                        // prefix_at_cmd already consumed; nothing else to do.
697                        continue;
698                    }
699                    Command::GotoLine => {
700                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
701                        match prefix_at_cmd {
702                            Some(line) if line > 0 => viewport.goto_line(line - 1, src.as_ref(), &mut idx),
703                            _ => viewport.goto_top(),
704                        }
705                        needs_redraw = true;
706                    }
707                    Command::GotoRecord => {
708                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
709                        match prefix_at_cmd {
710                            Some(rec) if rec > 0 => viewport.goto_record(rec - 1, src.as_ref(), &mut idx),
711                            _ => viewport.goto_bottom(src.as_ref(), &mut idx),
712                        }
713                        needs_redraw = true;
714                    }
715                    Command::GotoPercent => {
716                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
717                        match prefix_at_cmd {
718                            Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
719                            _ => viewport.goto_top(),
720                        }
721                        needs_redraw = true;
722                    }
723                    Command::Quit => break,
724                    Command::Resize(c, r) => {
725                        cols = c; rows = r;
726                        viewport.resize(c, r);
727                        needs_redraw = true;
728                    }
729                    Command::ScrollLines(n) => {
730                        viewport.scroll_lines(n, src.as_ref(), &mut idx);
731                        needs_redraw = true;
732                    }
733                    Command::ScrollLogicalLines(n) => {
734                        viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
735                        needs_redraw = true;
736                    }
737                    Command::PageDown => {
738                        viewport.page_down(src.as_ref(), &mut idx);
739                        needs_redraw = true;
740                    }
741                    Command::PageUp => {
742                        viewport.page_up(src.as_ref(), &mut idx);
743                        needs_redraw = true;
744                    }
745                    Command::HalfPageDown => {
746                        viewport.half_page_down(src.as_ref(), &mut idx);
747                        needs_redraw = true;
748                    }
749                    Command::HalfPageUp => {
750                        viewport.half_page_up(src.as_ref(), &mut idx);
751                        needs_redraw = true;
752                    }
753                    Command::Refresh => {
754                        needs_redraw = true;
755                    }
756                    Command::Reload => {
757                        // Force a stat+reread now (only meaningful for live
758                        // sources; static FileSource::pump() is a no-op).
759                        src.pump();
760                        if src.revision() != last_revision {
761                            rebuild_after_replace(
762                                src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
763                            );
764                            last_revision = src.revision();
765                            needs_redraw = true;
766                        }
767                    }
768                    Command::TogglePrettify => {
769                        apply_prettify(
770                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
771                            PrettifyTarget::Toggle,
772                        );
773                        last_revision = src.revision();
774                        needs_redraw = true;
775                    }
776                    Command::SetPrettifyMode(m) => {
777                        apply_prettify(
778                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
779                            PrettifyTarget::Mode(m),
780                        );
781                        last_revision = src.revision();
782                        needs_redraw = true;
783                    }
784                    Command::RedetectPrettify => {
785                        apply_prettify(
786                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
787                            PrettifyTarget::Auto,
788                        );
789                        last_revision = src.revision();
790                        needs_redraw = true;
791                    }
792                    Command::ToggleLineNumbers => {
793                        viewport.toggle_line_numbers();
794                        needs_redraw = true;
795                    }
796                    Command::ToggleChop => {
797                        viewport.toggle_chop();
798                        needs_redraw = true;
799                    }
800                    Command::ToggleFollow => {
801                        viewport.toggle_follow();
802                        if viewport.follow_mode() {
803                            // Re-engaging: pump any pending bytes and snap to bottom.
804                            src.pump();
805                            idx.notice_new_bytes(src.as_ref());
806                            viewport.goto_bottom(src.as_ref(), &mut idx);
807                        }
808                        needs_redraw = true;
809                    }
810                    Command::SearchForward => {
811                        mode = InputMode::SearchPrompt {
812                            direction: SearchDirection::Forward,
813                            buffer: String::new(),
814                            error: None,
815                        };
816                        needs_redraw = true;
817                    }
818                    Command::SearchBackward => {
819                        mode = InputMode::SearchPrompt {
820                            direction: SearchDirection::Backward,
821                            buffer: String::new(),
822                            error: None,
823                        };
824                        needs_redraw = true;
825                    }
826                    Command::ShellEscape => {
827                        mode = InputMode::ShellPrompt {
828                            buffer: String::new(),
829                            error: None,
830                        };
831                        needs_redraw = true;
832                    }
833                    Command::ColonPrompt => {
834                        mode = InputMode::ColonPrompt {
835                            buffer: String::new(),
836                            error: None,
837                        };
838                        needs_redraw = true;
839                    }
840                    Command::NextMatch => {
841                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
842                        if viewport.search_repeat(src.as_ref(), &mut idx, false) {
843                            needs_redraw = true;
844                        }
845                    }
846                    Command::PreviousMatch => {
847                        update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
848                        if viewport.search_repeat(src.as_ref(), &mut idx, true) {
849                            needs_redraw = true;
850                        }
851                    }
852                    Command::OptionPrefix => {
853                        mode = InputMode::OptionPrefix;
854                    }
855                    Command::MarkSet => {
856                        mode = InputMode::MarkSetPending;
857                    }
858                    Command::MarkJump => {
859                        mode = InputMode::MarkJumpPending;
860                    }
861                    Command::CtrlXPrefix => {
862                        mode = InputMode::CtrlXPending;
863                    }
864                    Command::JumpPrevious => {
865                        // Resolved inside the CtrlXPending mode intercept; this
866                        // arm is defensive and should never fire.
867                    }
868                    Command::Noop => {}
869                }
870            }
871            Ok(false) => {
872                // Timeout — check whether the source has grown or been rewritten.
873                if viewport.live_mode() {
874                    let was_at_bottom = viewport.is_at_bottom(&idx);
875                    src.pump();
876                    if src.revision() != last_revision {
877                        rebuild_after_replace(
878                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
879                        );
880                        if was_at_bottom {
881                            viewport.goto_bottom(src.as_ref(), &mut idx);
882                        }
883                        last_revision = src.revision();
884                        needs_redraw = true;
885                    }
886                } else if viewport.follow_mode() {
887                    let was_at_bottom = viewport.is_at_bottom(&idx);
888                    src.pump();
889                    let lines_before = idx.line_count();
890                    idx.notice_new_bytes(src.as_ref());
891                    viewport.extend_visible_lines(&idx, src.as_ref());
892                    if idx.line_count() != lines_before {
893                        needs_redraw = true;
894                        if was_at_bottom {
895                            viewport.goto_bottom(src.as_ref(), &mut idx);
896                        }
897                    }
898                } else if !src.is_complete() {
899                    // Streaming stdin without follow mode: still keep the index
900                    // up-to-date so line counts stay accurate, but don't auto-scroll.
901                    let lines_before = idx.line_count();
902                    idx.notice_new_bytes(src.as_ref());
903                    viewport.extend_visible_lines(&idx, src.as_ref());
904                    if idx.line_count() != lines_before {
905                        needs_redraw = true;
906                    }
907                }
908            }
909            Err(_) => {
910                // poll() error — sleep the timeout duration to avoid tight-spinning.
911                std::thread::sleep(timeout);
912            }
913        }
914    }
915    Ok(())
916}
917
918/// What `apply_prettify` should do to the source's prettify state.
919#[derive(Debug, Clone, Copy)]
920enum PrettifyTarget {
921    /// Set a specific mode (including `Off` for "raw").
922    Mode(PrettifyMode),
923    /// Flip between current mode and last-active mode.
924    Toggle,
925    /// Re-run byte-based content detection and apply the result.
926    Auto,
927}
928
929/// Apply a prettify-state change to the source and propagate any visible
930/// effects (line index rebuild, viewport label, scroll clamp). No-op if the
931/// source isn't a `TransformingSource` (i.e. `prettify_mode()` is `None`).
932fn apply_prettify(
933    src: &dyn Source,
934    viewport: &mut Viewport,
935    idx: &mut LineIndex,
936    spec: RebuildSpec,
937    target: PrettifyTarget,
938) {
939    // Sources without a wrapper return None — nothing to do.
940    if src.prettify_mode().is_none() {
941        return;
942    }
943    match target {
944        PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
945        PrettifyTarget::Toggle => src.toggle_prettify(),
946        PrettifyTarget::Auto => src.redetect_prettify(),
947    }
948    rebuild_after_replace(src, viewport, idx, spec);
949    viewport.set_prettify_label(src.prettify_label());
950}
951
952/// Rebuild line index and visible-line cache after the source content has
953/// been replaced wholesale (e.g. an editor saved over the file). Re-applies
954/// `--head`/`--tail` caps from the original CLI args; clamps `top_line` so the
955/// user stays roughly where they were rather than jumping. Auto snap-to-bottom
956/// (when the user *was* at the bottom) is the caller's responsibility.
957fn rebuild_after_replace(
958    src: &dyn Source,
959    viewport: &mut Viewport,
960    idx: &mut LineIndex,
961    spec: RebuildSpec,
962) {
963    let new_off = match spec.tail {
964        Some(n) => find_tail_offset(src, n),
965        None => 0,
966    };
967    *idx = LineIndex::new_starting_at(new_off);
968    if let Some(n) = spec.head {
969        idx.set_head_cap(n);
970    }
971    viewport.invalidate_filter_cache();
972    idx.notice_new_bytes(src);
973    viewport.extend_visible_lines(idx, src);
974    viewport.clamp_top_line(idx.line_count());
975}
976
977fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16) -> io::Result<()> {
978    // Reset attributes once before clear so the cleared cells inherit a
979    // clean state (some terminals fill cleared cells with the current
980    // attribute, which caused reverse-video bleed in earlier versions).
981    out.queue(SetAttribute(Attribute::Reset))?;
982    out.queue(ResetColor)?;
983    out.queue(Clear(ClearType::All))?;
984    for (i, row) in frame.body.iter().enumerate() {
985        out.queue(MoveTo(0, i as u16))?;
986        // Defensive: every row begins with a full attribute reset, so a
987        // mis-handled reset on the previous row can't bleed forward.
988        out.queue(SetAttribute(Attribute::Reset))?;
989        let style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
990        if matches!(style, RowStyle::Dim) {
991            out.queue(SetAttribute(Attribute::Dim))?;
992        }
993        let no_highlights = Vec::new();
994        let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
995        write_row_with_highlights(out, row, cols, highlights)?;
996        out.queue(SetAttribute(Attribute::Reset))?;
997    }
998    // Status row
999    out.queue(MoveTo(0, rows.saturating_sub(1)))?;
1000    out.queue(SetAttribute(Attribute::Reverse))?;
1001    let mut status = frame.status.clone();
1002    if status.len() > cols as usize {
1003        status.truncate(cols as usize);
1004    } else {
1005        let pad = cols as usize - status.len();
1006        status.push_str(&" ".repeat(pad));
1007    }
1008    out.queue(Print(status))?;
1009    out.queue(ResetColor)?;
1010    out.queue(SetAttribute(Attribute::Reset))?;
1011    out.flush()
1012}
1013
1014fn cells_to_string(row: &[Cell], cols: u16) -> String {
1015    let mut s = String::with_capacity(cols as usize);
1016    for cell in row.iter().take(cols as usize) {
1017        match cell {
1018            Cell::Char { ch, .. } => s.push(*ch),
1019            Cell::Continuation => { /* width-2 char already pushed */ }
1020            Cell::Empty => s.push(' '),
1021        }
1022    }
1023    s
1024}
1025
1026/// Emit a single row with per-substring reverse-video highlights. Highlight
1027/// ranges are in cell columns; any segment outside a highlight prints with
1028/// the row's already-applied base attribute. Reverse is toggled on/off
1029/// segment-by-segment with explicit `NoReverse` so a base attribute like
1030/// `Dim` stays in effect for un-highlighted text.
1031fn write_row_with_highlights(
1032    out: &mut impl Write,
1033    row: &[Cell],
1034    cols: u16,
1035    highlights: &[std::ops::Range<usize>],
1036) -> io::Result<()> {
1037    let cols_usize = cols as usize;
1038    if highlights.is_empty() {
1039        out.queue(Print(cells_to_string(row, cols)))?;
1040        return Ok(());
1041    }
1042    // Sort and clamp; assume non-overlapping (viewport produces them this way).
1043    let mut ranges: Vec<std::ops::Range<usize>> = highlights
1044        .iter()
1045        .filter_map(|r| {
1046            let s = r.start.min(cols_usize);
1047            let e = r.end.min(cols_usize);
1048            if e > s { Some(s..e) } else { None }
1049        })
1050        .collect();
1051    ranges.sort_by_key(|r| r.start);
1052
1053    let mut col = 0usize;
1054    let mut i = 0usize;
1055    while col < cols_usize && i < row.len() {
1056        // Find which range (if any) covers this column.
1057        let active = ranges.iter().find(|r| r.start <= col && col < r.end);
1058        let (segment_end, reversed) = match active {
1059            Some(r) => (r.end.min(cols_usize), true),
1060            None => {
1061                // Plain segment until the next highlight or row end.
1062                let next = ranges.iter().find(|r| r.start > col).map(|r| r.start);
1063                (next.unwrap_or(cols_usize), false)
1064            }
1065        };
1066        if reversed { out.queue(SetAttribute(Attribute::Reverse))?; }
1067        // Collect cells for this segment from `col` to `segment_end`.
1068        let mut s = String::new();
1069        while col < segment_end && i < row.len() {
1070            match &row[i] {
1071                Cell::Char { ch, width } => {
1072                    s.push(*ch);
1073                    col += *width as usize;
1074                }
1075                Cell::Continuation => {
1076                    // Already accounted for by the preceding wide char's width.
1077                }
1078                Cell::Empty => {
1079                    s.push(' ');
1080                    col += 1;
1081                }
1082            }
1083            i += 1;
1084        }
1085        out.queue(Print(s))?;
1086        if reversed { out.queue(SetAttribute(Attribute::NoReverse))?; }
1087    }
1088    Ok(())
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    #[test]
1096    fn parse_colon_n() {
1097        assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
1098        assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
1099    }
1100
1101    #[test]
1102    fn parse_colon_p() {
1103        assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
1104        assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
1105    }
1106
1107    #[test]
1108    fn parse_colon_e_with_path() {
1109        match parse_colon_command("e /tmp/foo.log").unwrap() {
1110            ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
1111            other => panic!("expected Edit, got {other:?}"),
1112        }
1113    }
1114
1115    #[test]
1116    fn parse_colon_e_with_tilde() {
1117        std::env::set_var("HOME", "/home/user");
1118        match parse_colon_command("e ~/foo.log").unwrap() {
1119            ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
1120            other => panic!("expected Edit, got {other:?}"),
1121        }
1122    }
1123
1124    #[test]
1125    fn parse_colon_e_missing_path_errors() {
1126        assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
1127        assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
1128    }
1129
1130    #[test]
1131    fn parse_colon_f_q_d_x_t() {
1132        assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
1133        assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
1134        assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
1135        assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
1136        assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
1137    }
1138
1139    #[test]
1140    fn parse_unknown_command_errors() {
1141        let err = parse_colon_command("bogus").unwrap_err();
1142        match err {
1143            ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
1144            other => panic!("expected UnknownCommand, got {other:?}"),
1145        }
1146    }
1147
1148    #[test]
1149    fn parse_handles_whitespace() {
1150        // Trailing whitespace OK.
1151        assert_eq!(parse_colon_command("n  ").unwrap(), ColonCommand::Next);
1152        assert_eq!(parse_colon_command("  n").unwrap(), ColonCommand::Next);
1153    }
1154}