Skip to main content

tess/
app.rs

1use std::io::{self, Write};
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::Duration;
5
6use crossterm::cursor::MoveTo;
7use crossterm::event::{poll, read, Event, KeyCode, KeyEvent};
8use crossterm::style::{Print, ResetColor, SetAttribute, Attribute};
9use crossterm::terminal::{Clear, ClearType, size};
10use crossterm::QueueableCommand;
11
12use crate::error::Result;
13use crate::input::{translate, Command};
14use crate::line_index::LineIndex;
15use crate::prettify::PrettifyMode;
16use crate::render::Cell;
17use crate::source::{find_tail_offset, Source};
18use crate::viewport::{Frame, RowStyle, SearchDirection, Viewport};
19
20/// Constraints to re-apply when the source content has been replaced wholesale
21/// (`--live`). The line index is rebuilt from scratch each time, so caps that
22/// were originally honored at startup need to be reasserted.
23#[derive(Default, Clone, Copy)]
24pub struct RebuildSpec {
25    pub head: Option<usize>,
26    pub tail: Option<usize>,
27}
28
29/// Per-keystroke modes the app event loop can be in.
30#[derive(Debug, Clone)]
31enum InputMode {
32    Normal,
33    /// User pressed `-`; the next keystroke selects an option to toggle.
34    OptionPrefix,
35    /// User pressed `-P`; the next keystroke chooses a prettify mode
36    /// (`j`/`y`/`t`/`x`/`h`/`c`/`a`/`r`).
37    PrettifyPrefix,
38    /// User pressed `/` or `?`; subsequent characters accumulate into a
39    /// search pattern until Enter (commit) or Esc (cancel).
40    SearchPrompt {
41        direction: SearchDirection,
42        buffer: String,
43        /// If a search compile error occurred, show this in place of the
44        /// buffer until the next keystroke.
45        error: Option<String>,
46    },
47}
48
49pub fn run(
50    src: Box<dyn Source>,
51    mut viewport: Viewport,
52    mut idx: LineIndex,
53    sigterm: Arc<AtomicBool>,
54    rebuild_spec: RebuildSpec,
55) -> Result<()> {
56    let (mut cols, mut rows) = size().unwrap_or((80, 24));
57    viewport.resize(cols, rows);
58
59    let mut stdout = io::stdout();
60    let timeout = Duration::from_millis(250);
61    let mut last_revision = src.revision();
62
63    // If a filter is active in hide mode, we need to scan the whole source
64    // up front to find matching lines. Without a filter this is intentionally
65    // skipped — lazy indexing keeps `tess` fast on huge files.
66    if viewport.filter_active() && !viewport.dim_mode() {
67        idx.extend_to_end(src.as_ref());
68        viewport.extend_visible_lines(&idx, src.as_ref());
69    }
70
71    // If follow mode is on at startup, snap to the bottom of the (possibly
72    // filtered) source so the user sees the newest content (tail-style).
73    if viewport.follow_mode() {
74        src.pump();
75        viewport.extend_visible_lines(&idx, src.as_ref());
76        viewport.goto_bottom(src.as_ref(), &mut idx);
77    }
78
79    // Always draw the initial frame before entering the event loop.
80    let mut needs_redraw = true;
81    let mut mode = InputMode::Normal;
82
83    loop {
84        if sigterm.load(Ordering::SeqCst) {
85            break;
86        }
87
88        if needs_redraw {
89            let mut frame = viewport.frame(src.as_ref(), &mut idx);
90            // Override the status row when we're in an interactive prompt.
91            if let InputMode::SearchPrompt { direction, buffer, error } = &mode {
92                let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
93                frame.status = match error {
94                    Some(e) => format!("{prefix}{buffer}  [error: {e}]"),
95                    None => format!("{prefix}{buffer}"),
96                };
97            }
98            write_frame(&mut stdout, &frame, cols, rows)
99                .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
100            needs_redraw = false;
101        }
102
103        // Poll with timeout so stdin sources can be re-checked.
104        match poll(timeout) {
105            Ok(true) => {
106                let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
107                // Modal input handling: the search prompt and option prefix
108                // intercept keys before they're translated to commands.
109                match &mut mode {
110                    InputMode::SearchPrompt { direction, buffer, error } => {
111                        if let Event::Key(KeyEvent { code, .. }) = event {
112                            match code {
113                                KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
114                                KeyCode::Enter => {
115                                    if buffer.is_empty() {
116                                        // Empty buffer: repeat the last search in the
117                                        // newly-typed direction (less compat). If no
118                                        // prior search exists, just dismiss.
119                                        if viewport.search_active() {
120                                            let reverse = !matches!(
121                                                (viewport.search_direction(), *direction),
122                                                (SearchDirection::Forward, SearchDirection::Forward)
123                                                | (SearchDirection::Backward, SearchDirection::Backward)
124                                            );
125                                            viewport.search_repeat(src.as_ref(), &mut idx, reverse);
126                                        }
127                                        mode = InputMode::Normal;
128                                    } else {
129                                        match viewport.set_search(buffer.clone(), *direction) {
130                                            Ok(()) => {
131                                                viewport.search_repeat(src.as_ref(), &mut idx, false);
132                                                mode = InputMode::Normal;
133                                            }
134                                            Err(e) => { *error = Some(e); }
135                                        }
136                                    }
137                                    needs_redraw = true;
138                                }
139                                KeyCode::Backspace => {
140                                    buffer.pop();
141                                    *error = None;
142                                    needs_redraw = true;
143                                }
144                                KeyCode::Char(c) => {
145                                    buffer.push(c);
146                                    *error = None;
147                                    needs_redraw = true;
148                                }
149                                _ => {}
150                            }
151                        }
152                        continue;
153                    }
154                    InputMode::OptionPrefix => {
155                        if let Event::Key(KeyEvent { code, .. }) = event {
156                            match code {
157                                KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
158                                KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
159                                KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
160                                KeyCode::Char('P') | KeyCode::Char('p') => {
161                                    // Two-key prefix: `-P` then a letter for the mode.
162                                    mode = InputMode::PrettifyPrefix;
163                                    needs_redraw = true;
164                                    continue;
165                                }
166                                _ => {}
167                            }
168                        }
169                        mode = InputMode::Normal;
170                        needs_redraw = true;
171                        continue;
172                    }
173                    InputMode::PrettifyPrefix => {
174                        if let Event::Key(KeyEvent { code, .. }) = event {
175                            let target: Option<PrettifyTarget> = match code {
176                                KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
177                                KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
178                                KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
179                                KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
180                                KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
181                                KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
182                                KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
183                                KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
184                                _ => None,
185                            };
186                            if let Some(t) = target {
187                                apply_prettify(
188                                    src.as_ref(),
189                                    &mut viewport,
190                                    &mut idx,
191                                    rebuild_spec,
192                                    t,
193                                );
194                                last_revision = src.revision();
195                            }
196                        }
197                        mode = InputMode::Normal;
198                        needs_redraw = true;
199                        continue;
200                    }
201                    InputMode::Normal => {}
202                }
203                let cmd = translate(event);
204                match cmd {
205                    Command::Quit => break,
206                    Command::Resize(c, r) => {
207                        cols = c; rows = r;
208                        viewport.resize(c, r);
209                        needs_redraw = true;
210                    }
211                    Command::ScrollLines(n) => {
212                        viewport.scroll_lines(n, src.as_ref(), &mut idx);
213                        needs_redraw = true;
214                    }
215                    Command::ScrollLogicalLines(n) => {
216                        viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
217                        needs_redraw = true;
218                    }
219                    Command::PageDown => {
220                        viewport.page_down(src.as_ref(), &mut idx);
221                        needs_redraw = true;
222                    }
223                    Command::PageUp => {
224                        viewport.page_up(src.as_ref(), &mut idx);
225                        needs_redraw = true;
226                    }
227                    Command::HalfPageDown => {
228                        viewport.half_page_down(src.as_ref(), &mut idx);
229                        needs_redraw = true;
230                    }
231                    Command::HalfPageUp => {
232                        viewport.half_page_up(src.as_ref(), &mut idx);
233                        needs_redraw = true;
234                    }
235                    Command::GoTop => {
236                        viewport.goto_top();
237                        needs_redraw = true;
238                    }
239                    Command::GoBottom => {
240                        viewport.goto_bottom(src.as_ref(), &mut idx);
241                        needs_redraw = true;
242                    }
243                    Command::Refresh => {
244                        needs_redraw = true;
245                    }
246                    Command::Reload => {
247                        // Force a stat+reread now (only meaningful for live
248                        // sources; static FileSource::pump() is a no-op).
249                        src.pump();
250                        if src.revision() != last_revision {
251                            rebuild_after_replace(
252                                src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
253                            );
254                            last_revision = src.revision();
255                            needs_redraw = true;
256                        }
257                    }
258                    Command::TogglePrettify => {
259                        apply_prettify(
260                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
261                            PrettifyTarget::Toggle,
262                        );
263                        last_revision = src.revision();
264                        needs_redraw = true;
265                    }
266                    Command::SetPrettifyMode(m) => {
267                        apply_prettify(
268                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
269                            PrettifyTarget::Mode(m),
270                        );
271                        last_revision = src.revision();
272                        needs_redraw = true;
273                    }
274                    Command::RedetectPrettify => {
275                        apply_prettify(
276                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
277                            PrettifyTarget::Auto,
278                        );
279                        last_revision = src.revision();
280                        needs_redraw = true;
281                    }
282                    Command::ToggleLineNumbers => {
283                        viewport.toggle_line_numbers();
284                        needs_redraw = true;
285                    }
286                    Command::ToggleChop => {
287                        viewport.toggle_chop();
288                        needs_redraw = true;
289                    }
290                    Command::ToggleFollow => {
291                        viewport.toggle_follow();
292                        if viewport.follow_mode() {
293                            // Re-engaging: pump any pending bytes and snap to bottom.
294                            src.pump();
295                            idx.notice_new_bytes(src.as_ref());
296                            viewport.goto_bottom(src.as_ref(), &mut idx);
297                        }
298                        needs_redraw = true;
299                    }
300                    Command::SearchForward => {
301                        mode = InputMode::SearchPrompt {
302                            direction: SearchDirection::Forward,
303                            buffer: String::new(),
304                            error: None,
305                        };
306                        needs_redraw = true;
307                    }
308                    Command::SearchBackward => {
309                        mode = InputMode::SearchPrompt {
310                            direction: SearchDirection::Backward,
311                            buffer: String::new(),
312                            error: None,
313                        };
314                        needs_redraw = true;
315                    }
316                    Command::NextMatch => {
317                        if viewport.search_repeat(src.as_ref(), &mut idx, false) {
318                            needs_redraw = true;
319                        }
320                    }
321                    Command::PreviousMatch => {
322                        if viewport.search_repeat(src.as_ref(), &mut idx, true) {
323                            needs_redraw = true;
324                        }
325                    }
326                    Command::OptionPrefix => {
327                        mode = InputMode::OptionPrefix;
328                    }
329                    Command::Noop => {}
330                }
331            }
332            Ok(false) => {
333                // Timeout — check whether the source has grown or been rewritten.
334                if viewport.live_mode() {
335                    let was_at_bottom = viewport.is_at_bottom(&idx);
336                    src.pump();
337                    if src.revision() != last_revision {
338                        rebuild_after_replace(
339                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
340                        );
341                        if was_at_bottom {
342                            viewport.goto_bottom(src.as_ref(), &mut idx);
343                        }
344                        last_revision = src.revision();
345                        needs_redraw = true;
346                    }
347                } else if viewport.follow_mode() {
348                    let was_at_bottom = viewport.is_at_bottom(&idx);
349                    src.pump();
350                    let lines_before = idx.line_count();
351                    idx.notice_new_bytes(src.as_ref());
352                    viewport.extend_visible_lines(&idx, src.as_ref());
353                    if idx.line_count() != lines_before {
354                        needs_redraw = true;
355                        if was_at_bottom {
356                            viewport.goto_bottom(src.as_ref(), &mut idx);
357                        }
358                    }
359                } else if !src.is_complete() {
360                    // Streaming stdin without follow mode: still keep the index
361                    // up-to-date so line counts stay accurate, but don't auto-scroll.
362                    let lines_before = idx.line_count();
363                    idx.notice_new_bytes(src.as_ref());
364                    viewport.extend_visible_lines(&idx, src.as_ref());
365                    if idx.line_count() != lines_before {
366                        needs_redraw = true;
367                    }
368                }
369            }
370            Err(_) => {
371                // poll() error — sleep the timeout duration to avoid tight-spinning.
372                std::thread::sleep(timeout);
373            }
374        }
375    }
376    Ok(())
377}
378
379/// What `apply_prettify` should do to the source's prettify state.
380#[derive(Debug, Clone, Copy)]
381enum PrettifyTarget {
382    /// Set a specific mode (including `Off` for "raw").
383    Mode(PrettifyMode),
384    /// Flip between current mode and last-active mode.
385    Toggle,
386    /// Re-run byte-based content detection and apply the result.
387    Auto,
388}
389
390/// Apply a prettify-state change to the source and propagate any visible
391/// effects (line index rebuild, viewport label, scroll clamp). No-op if the
392/// source isn't a `TransformingSource` (i.e. `prettify_mode()` is `None`).
393fn apply_prettify(
394    src: &dyn Source,
395    viewport: &mut Viewport,
396    idx: &mut LineIndex,
397    spec: RebuildSpec,
398    target: PrettifyTarget,
399) {
400    // Sources without a wrapper return None — nothing to do.
401    if src.prettify_mode().is_none() {
402        return;
403    }
404    match target {
405        PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
406        PrettifyTarget::Toggle => src.toggle_prettify(),
407        PrettifyTarget::Auto => src.redetect_prettify(),
408    }
409    rebuild_after_replace(src, viewport, idx, spec);
410    viewport.set_prettify_label(src.prettify_label());
411}
412
413/// Rebuild line index and visible-line cache after the source content has
414/// been replaced wholesale (e.g. an editor saved over the file). Re-applies
415/// `--head`/`--tail` caps from the original CLI args; clamps `top_line` so the
416/// user stays roughly where they were rather than jumping. Auto snap-to-bottom
417/// (when the user *was* at the bottom) is the caller's responsibility.
418fn rebuild_after_replace(
419    src: &dyn Source,
420    viewport: &mut Viewport,
421    idx: &mut LineIndex,
422    spec: RebuildSpec,
423) {
424    let new_off = match spec.tail {
425        Some(n) => find_tail_offset(src, n),
426        None => 0,
427    };
428    *idx = LineIndex::new_starting_at(new_off);
429    if let Some(n) = spec.head {
430        idx.set_head_cap(n);
431    }
432    viewport.invalidate_filter_cache();
433    idx.notice_new_bytes(src);
434    viewport.extend_visible_lines(idx, src);
435    viewport.clamp_top_line(idx.line_count());
436}
437
438fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16) -> io::Result<()> {
439    // Reset attributes once before clear so the cleared cells inherit a
440    // clean state (some terminals fill cleared cells with the current
441    // attribute, which caused reverse-video bleed in earlier versions).
442    out.queue(SetAttribute(Attribute::Reset))?;
443    out.queue(ResetColor)?;
444    out.queue(Clear(ClearType::All))?;
445    for (i, row) in frame.body.iter().enumerate() {
446        out.queue(MoveTo(0, i as u16))?;
447        // Defensive: every row begins with a full attribute reset, so a
448        // mis-handled reset on the previous row can't bleed forward.
449        out.queue(SetAttribute(Attribute::Reset))?;
450        let style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
451        if matches!(style, RowStyle::Dim) {
452            out.queue(SetAttribute(Attribute::Dim))?;
453        }
454        let no_highlights = Vec::new();
455        let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
456        write_row_with_highlights(out, row, cols, highlights)?;
457        out.queue(SetAttribute(Attribute::Reset))?;
458    }
459    // Status row
460    out.queue(MoveTo(0, rows.saturating_sub(1)))?;
461    out.queue(SetAttribute(Attribute::Reverse))?;
462    let mut status = frame.status.clone();
463    if status.len() > cols as usize {
464        status.truncate(cols as usize);
465    } else {
466        let pad = cols as usize - status.len();
467        status.push_str(&" ".repeat(pad));
468    }
469    out.queue(Print(status))?;
470    out.queue(ResetColor)?;
471    out.queue(SetAttribute(Attribute::Reset))?;
472    out.flush()
473}
474
475fn cells_to_string(row: &[Cell], cols: u16) -> String {
476    let mut s = String::with_capacity(cols as usize);
477    for cell in row.iter().take(cols as usize) {
478        match cell {
479            Cell::Char { ch, .. } => s.push(*ch),
480            Cell::Continuation => { /* width-2 char already pushed */ }
481            Cell::Empty => s.push(' '),
482        }
483    }
484    s
485}
486
487/// Emit a single row with per-substring reverse-video highlights. Highlight
488/// ranges are in cell columns; any segment outside a highlight prints with
489/// the row's already-applied base attribute. Reverse is toggled on/off
490/// segment-by-segment with explicit `NoReverse` so a base attribute like
491/// `Dim` stays in effect for un-highlighted text.
492fn write_row_with_highlights(
493    out: &mut impl Write,
494    row: &[Cell],
495    cols: u16,
496    highlights: &[std::ops::Range<usize>],
497) -> io::Result<()> {
498    let cols_usize = cols as usize;
499    if highlights.is_empty() {
500        out.queue(Print(cells_to_string(row, cols)))?;
501        return Ok(());
502    }
503    // Sort and clamp; assume non-overlapping (viewport produces them this way).
504    let mut ranges: Vec<std::ops::Range<usize>> = highlights
505        .iter()
506        .filter_map(|r| {
507            let s = r.start.min(cols_usize);
508            let e = r.end.min(cols_usize);
509            if e > s { Some(s..e) } else { None }
510        })
511        .collect();
512    ranges.sort_by_key(|r| r.start);
513
514    let mut col = 0usize;
515    let mut i = 0usize;
516    while col < cols_usize && i < row.len() {
517        // Find which range (if any) covers this column.
518        let active = ranges.iter().find(|r| r.start <= col && col < r.end);
519        let (segment_end, reversed) = match active {
520            Some(r) => (r.end.min(cols_usize), true),
521            None => {
522                // Plain segment until the next highlight or row end.
523                let next = ranges.iter().find(|r| r.start > col).map(|r| r.start);
524                (next.unwrap_or(cols_usize), false)
525            }
526        };
527        if reversed { out.queue(SetAttribute(Attribute::Reverse))?; }
528        // Collect cells for this segment from `col` to `segment_end`.
529        let mut s = String::new();
530        while col < segment_end && i < row.len() {
531            match &row[i] {
532                Cell::Char { ch, width } => {
533                    s.push(*ch);
534                    col += *width as usize;
535                }
536                Cell::Continuation => {
537                    // Already accounted for by the preceding wide char's width.
538                }
539                Cell::Empty => {
540                    s.push(' ');
541                    col += 1;
542                }
543            }
544            i += 1;
545        }
546        out.queue(Print(s))?;
547        if reversed { out.queue(SetAttribute(Attribute::NoReverse))?; }
548    }
549    Ok(())
550}