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};
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    /// Set-mark prefix: the next keystroke names the mark to set.
50    MarkSetPending,
51    /// Jump-to-mark prefix: the next keystroke names the mark to jump to.
52    MarkJumpPending,
53    /// First half of the Ctrl-X Ctrl-X chord.
54    CtrlXPending,
55}
56
57pub fn run(
58    src: Box<dyn Source>,
59    mut viewport: Viewport,
60    mut idx: LineIndex,
61    sigterm: Arc<AtomicBool>,
62    rebuild_spec: RebuildSpec,
63) -> Result<()> {
64    let (mut cols, mut rows) = size().unwrap_or((80, 24));
65    viewport.resize(cols, rows);
66
67    let mut stdout = io::stdout();
68    let timeout = Duration::from_millis(250);
69    let mut last_revision = src.revision();
70
71    // If hide-mode filtering is active (--filter or --grep without --dim),
72    // we need to scan the whole source up front to find matching lines.
73    // Without any predicate this is intentionally skipped — lazy indexing
74    // keeps `tess` fast on huge files.
75    if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
76        idx.extend_to_end(src.as_ref());
77        viewport.extend_visible_lines(&idx, src.as_ref());
78    }
79
80    // If follow mode is on at startup, snap to the bottom of the (possibly
81    // filtered) source so the user sees the newest content (tail-style).
82    if viewport.follow_mode() {
83        src.pump();
84        viewport.extend_visible_lines(&idx, src.as_ref());
85        viewport.goto_bottom(src.as_ref(), &mut idx);
86    }
87
88    // Always draw the initial frame before entering the event loop.
89    let mut needs_redraw = true;
90    let mut mode = InputMode::Normal;
91    let mut numeric_prefix: Option<usize> = None;
92    let mut marks: HashMap<char, usize> = HashMap::new();
93    let mut previous_position: Option<usize> = None;
94
95    loop {
96        if sigterm.load(Ordering::SeqCst) {
97            break;
98        }
99
100        if needs_redraw {
101            let mut frame = viewport.frame(src.as_ref(), &mut idx);
102            // Override the status row when we're in an interactive prompt.
103            if let InputMode::SearchPrompt { direction, buffer, error } = &mode {
104                let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
105                frame.status = match error {
106                    Some(e) => format!("{prefix}{buffer}  [error: {e}]"),
107                    None => format!("{prefix}{buffer}"),
108                };
109            }
110            write_frame(&mut stdout, &frame, cols, rows)
111                .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
112            needs_redraw = false;
113        }
114
115        // Poll with timeout so stdin sources can be re-checked.
116        match poll(timeout) {
117            Ok(true) => {
118                let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
119                // Modal input handling: the search prompt and option prefix
120                // intercept keys before they're translated to commands.
121                match &mut mode {
122                    InputMode::SearchPrompt { direction, buffer, error } => {
123                        if let Event::Key(KeyEvent { code, .. }) = event {
124                            match code {
125                                KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
126                                KeyCode::Enter => {
127                                    if buffer.is_empty() {
128                                        // Empty buffer: repeat the last search in the
129                                        // newly-typed direction (less compat). If no
130                                        // prior search exists, just dismiss.
131                                        if viewport.search_active() {
132                                            let reverse = !matches!(
133                                                (viewport.search_direction(), *direction),
134                                                (SearchDirection::Forward, SearchDirection::Forward)
135                                                | (SearchDirection::Backward, SearchDirection::Backward)
136                                            );
137                                            update_prev_position(&mut previous_position, viewport.top_line());
138                                            viewport.search_repeat(src.as_ref(), &mut idx, reverse);
139                                        }
140                                        mode = InputMode::Normal;
141                                    } else {
142                                        match viewport.set_search(buffer.clone(), *direction) {
143                                            Ok(()) => {
144                                                update_prev_position(&mut previous_position, viewport.top_line());
145                                                viewport.search_repeat(src.as_ref(), &mut idx, false);
146                                                mode = InputMode::Normal;
147                                            }
148                                            Err(e) => { *error = Some(e); }
149                                        }
150                                    }
151                                    needs_redraw = true;
152                                }
153                                KeyCode::Backspace => {
154                                    buffer.pop();
155                                    *error = None;
156                                    needs_redraw = true;
157                                }
158                                KeyCode::Char(c) => {
159                                    buffer.push(c);
160                                    *error = None;
161                                    needs_redraw = true;
162                                }
163                                _ => {}
164                            }
165                        }
166                        continue;
167                    }
168                    InputMode::OptionPrefix => {
169                        if let Event::Key(KeyEvent { code, .. }) = event {
170                            match code {
171                                KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
172                                KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
173                                KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
174                                KeyCode::Char('P') | KeyCode::Char('p') => {
175                                    // Two-key prefix: `-P` then a letter for the mode.
176                                    mode = InputMode::PrettifyPrefix;
177                                    needs_redraw = true;
178                                    continue;
179                                }
180                                _ => {}
181                            }
182                        }
183                        mode = InputMode::Normal;
184                        needs_redraw = true;
185                        continue;
186                    }
187                    InputMode::PrettifyPrefix => {
188                        if let Event::Key(KeyEvent { code, .. }) = event {
189                            let target: Option<PrettifyTarget> = match code {
190                                KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
191                                KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
192                                KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
193                                KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
194                                KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
195                                KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
196                                KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
197                                KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
198                                _ => None,
199                            };
200                            if let Some(t) = target {
201                                apply_prettify(
202                                    src.as_ref(),
203                                    &mut viewport,
204                                    &mut idx,
205                                    rebuild_spec,
206                                    t,
207                                );
208                                last_revision = src.revision();
209                            }
210                        }
211                        mode = InputMode::Normal;
212                        needs_redraw = true;
213                        continue;
214                    }
215                    InputMode::MarkSetPending => {
216                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
217                            if is_valid_mark_name(c) {
218                                mark_set(&mut marks, c, viewport.top_line());
219                            }
220                        }
221                        mode = InputMode::Normal;
222                        continue;
223                    }
224                    InputMode::MarkJumpPending => {
225                        if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
226                            if is_valid_mark_name(c) {
227                                if let Some(line) = mark_jump(
228                                    &marks, c, idx.line_count(),
229                                    &mut previous_position, viewport.top_line(),
230                                ) {
231                                    viewport.goto_line(line, src.as_ref(), &mut idx);
232                                    needs_redraw = true;
233                                }
234                            }
235                        }
236                        mode = InputMode::Normal;
237                        continue;
238                    }
239                    InputMode::CtrlXPending => {
240                        let is_ctrl_x = matches!(
241                            event,
242                            Event::Key(KeyEvent {
243                                code: KeyCode::Char('x'),
244                                modifiers: KeyModifiers::CONTROL,
245                                ..
246                            })
247                        );
248                        if is_ctrl_x {
249                            if let Some(line) = jump_previous(
250                                &mut previous_position, viewport.top_line(),
251                            ) {
252                                let clamped = line.min(idx.line_count().saturating_sub(1));
253                                viewport.goto_line(clamped, src.as_ref(), &mut idx);
254                                needs_redraw = true;
255                            }
256                            mode = InputMode::Normal;
257                            continue;
258                        }
259                        // Anything else: cancel and fall through to normal dispatch.
260                        mode = InputMode::Normal;
261                        // Don't `continue` — let the event fall through.
262                    }
263                    InputMode::Normal => {}
264                }
265                let cmd = translate(event);
266                // Consume the numeric prefix at the top of each dispatch so
267                // commands that don't need it drop it implicitly.
268                let prefix_at_cmd = numeric_prefix.take();
269                match cmd {
270                    Command::Digit(d) => {
271                        let cur = prefix_at_cmd.unwrap_or(0);
272                        let next = cur.saturating_mul(10).saturating_add(d as usize);
273                        if next <= 99_999_999 {
274                            numeric_prefix = Some(next);
275                        } else {
276                            // Overflow: keep previous prefix, ignore this digit.
277                            numeric_prefix = prefix_at_cmd;
278                        }
279                        continue;
280                    }
281                    Command::Cancel => {
282                        // prefix_at_cmd already consumed; nothing else to do.
283                        continue;
284                    }
285                    Command::GotoLine => {
286                        update_prev_position(&mut previous_position, viewport.top_line());
287                        match prefix_at_cmd {
288                            Some(line) if line > 0 => viewport.goto_line(line - 1, src.as_ref(), &mut idx),
289                            _ => viewport.goto_top(),
290                        }
291                        needs_redraw = true;
292                    }
293                    Command::GotoRecord => {
294                        update_prev_position(&mut previous_position, viewport.top_line());
295                        match prefix_at_cmd {
296                            Some(rec) if rec > 0 => viewport.goto_record(rec - 1, src.as_ref(), &mut idx),
297                            _ => viewport.goto_bottom(src.as_ref(), &mut idx),
298                        }
299                        needs_redraw = true;
300                    }
301                    Command::GotoPercent => {
302                        update_prev_position(&mut previous_position, viewport.top_line());
303                        match prefix_at_cmd {
304                            Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
305                            _ => viewport.goto_top(),
306                        }
307                        needs_redraw = true;
308                    }
309                    Command::Quit => break,
310                    Command::Resize(c, r) => {
311                        cols = c; rows = r;
312                        viewport.resize(c, r);
313                        needs_redraw = true;
314                    }
315                    Command::ScrollLines(n) => {
316                        viewport.scroll_lines(n, src.as_ref(), &mut idx);
317                        needs_redraw = true;
318                    }
319                    Command::ScrollLogicalLines(n) => {
320                        viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
321                        needs_redraw = true;
322                    }
323                    Command::PageDown => {
324                        viewport.page_down(src.as_ref(), &mut idx);
325                        needs_redraw = true;
326                    }
327                    Command::PageUp => {
328                        viewport.page_up(src.as_ref(), &mut idx);
329                        needs_redraw = true;
330                    }
331                    Command::HalfPageDown => {
332                        viewport.half_page_down(src.as_ref(), &mut idx);
333                        needs_redraw = true;
334                    }
335                    Command::HalfPageUp => {
336                        viewport.half_page_up(src.as_ref(), &mut idx);
337                        needs_redraw = true;
338                    }
339                    Command::Refresh => {
340                        needs_redraw = true;
341                    }
342                    Command::Reload => {
343                        // Force a stat+reread now (only meaningful for live
344                        // sources; static FileSource::pump() is a no-op).
345                        src.pump();
346                        if src.revision() != last_revision {
347                            rebuild_after_replace(
348                                src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
349                            );
350                            last_revision = src.revision();
351                            needs_redraw = true;
352                        }
353                    }
354                    Command::TogglePrettify => {
355                        apply_prettify(
356                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
357                            PrettifyTarget::Toggle,
358                        );
359                        last_revision = src.revision();
360                        needs_redraw = true;
361                    }
362                    Command::SetPrettifyMode(m) => {
363                        apply_prettify(
364                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
365                            PrettifyTarget::Mode(m),
366                        );
367                        last_revision = src.revision();
368                        needs_redraw = true;
369                    }
370                    Command::RedetectPrettify => {
371                        apply_prettify(
372                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
373                            PrettifyTarget::Auto,
374                        );
375                        last_revision = src.revision();
376                        needs_redraw = true;
377                    }
378                    Command::ToggleLineNumbers => {
379                        viewport.toggle_line_numbers();
380                        needs_redraw = true;
381                    }
382                    Command::ToggleChop => {
383                        viewport.toggle_chop();
384                        needs_redraw = true;
385                    }
386                    Command::ToggleFollow => {
387                        viewport.toggle_follow();
388                        if viewport.follow_mode() {
389                            // Re-engaging: pump any pending bytes and snap to bottom.
390                            src.pump();
391                            idx.notice_new_bytes(src.as_ref());
392                            viewport.goto_bottom(src.as_ref(), &mut idx);
393                        }
394                        needs_redraw = true;
395                    }
396                    Command::SearchForward => {
397                        mode = InputMode::SearchPrompt {
398                            direction: SearchDirection::Forward,
399                            buffer: String::new(),
400                            error: None,
401                        };
402                        needs_redraw = true;
403                    }
404                    Command::SearchBackward => {
405                        mode = InputMode::SearchPrompt {
406                            direction: SearchDirection::Backward,
407                            buffer: String::new(),
408                            error: None,
409                        };
410                        needs_redraw = true;
411                    }
412                    Command::NextMatch => {
413                        update_prev_position(&mut previous_position, viewport.top_line());
414                        if viewport.search_repeat(src.as_ref(), &mut idx, false) {
415                            needs_redraw = true;
416                        }
417                    }
418                    Command::PreviousMatch => {
419                        update_prev_position(&mut previous_position, viewport.top_line());
420                        if viewport.search_repeat(src.as_ref(), &mut idx, true) {
421                            needs_redraw = true;
422                        }
423                    }
424                    Command::OptionPrefix => {
425                        mode = InputMode::OptionPrefix;
426                    }
427                    Command::MarkSet => {
428                        mode = InputMode::MarkSetPending;
429                    }
430                    Command::MarkJump => {
431                        mode = InputMode::MarkJumpPending;
432                    }
433                    Command::CtrlXPrefix => {
434                        mode = InputMode::CtrlXPending;
435                    }
436                    Command::JumpPrevious => {
437                        // Resolved inside the CtrlXPending mode intercept; this
438                        // arm is defensive and should never fire.
439                    }
440                    Command::Noop => {}
441                }
442            }
443            Ok(false) => {
444                // Timeout — check whether the source has grown or been rewritten.
445                if viewport.live_mode() {
446                    let was_at_bottom = viewport.is_at_bottom(&idx);
447                    src.pump();
448                    if src.revision() != last_revision {
449                        rebuild_after_replace(
450                            src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
451                        );
452                        if was_at_bottom {
453                            viewport.goto_bottom(src.as_ref(), &mut idx);
454                        }
455                        last_revision = src.revision();
456                        needs_redraw = true;
457                    }
458                } else if viewport.follow_mode() {
459                    let was_at_bottom = viewport.is_at_bottom(&idx);
460                    src.pump();
461                    let lines_before = idx.line_count();
462                    idx.notice_new_bytes(src.as_ref());
463                    viewport.extend_visible_lines(&idx, src.as_ref());
464                    if idx.line_count() != lines_before {
465                        needs_redraw = true;
466                        if was_at_bottom {
467                            viewport.goto_bottom(src.as_ref(), &mut idx);
468                        }
469                    }
470                } else if !src.is_complete() {
471                    // Streaming stdin without follow mode: still keep the index
472                    // up-to-date so line counts stay accurate, but don't auto-scroll.
473                    let lines_before = idx.line_count();
474                    idx.notice_new_bytes(src.as_ref());
475                    viewport.extend_visible_lines(&idx, src.as_ref());
476                    if idx.line_count() != lines_before {
477                        needs_redraw = true;
478                    }
479                }
480            }
481            Err(_) => {
482                // poll() error — sleep the timeout duration to avoid tight-spinning.
483                std::thread::sleep(timeout);
484            }
485        }
486    }
487    Ok(())
488}
489
490/// What `apply_prettify` should do to the source's prettify state.
491#[derive(Debug, Clone, Copy)]
492enum PrettifyTarget {
493    /// Set a specific mode (including `Off` for "raw").
494    Mode(PrettifyMode),
495    /// Flip between current mode and last-active mode.
496    Toggle,
497    /// Re-run byte-based content detection and apply the result.
498    Auto,
499}
500
501/// Apply a prettify-state change to the source and propagate any visible
502/// effects (line index rebuild, viewport label, scroll clamp). No-op if the
503/// source isn't a `TransformingSource` (i.e. `prettify_mode()` is `None`).
504fn apply_prettify(
505    src: &dyn Source,
506    viewport: &mut Viewport,
507    idx: &mut LineIndex,
508    spec: RebuildSpec,
509    target: PrettifyTarget,
510) {
511    // Sources without a wrapper return None — nothing to do.
512    if src.prettify_mode().is_none() {
513        return;
514    }
515    match target {
516        PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
517        PrettifyTarget::Toggle => src.toggle_prettify(),
518        PrettifyTarget::Auto => src.redetect_prettify(),
519    }
520    rebuild_after_replace(src, viewport, idx, spec);
521    viewport.set_prettify_label(src.prettify_label());
522}
523
524/// Rebuild line index and visible-line cache after the source content has
525/// been replaced wholesale (e.g. an editor saved over the file). Re-applies
526/// `--head`/`--tail` caps from the original CLI args; clamps `top_line` so the
527/// user stays roughly where they were rather than jumping. Auto snap-to-bottom
528/// (when the user *was* at the bottom) is the caller's responsibility.
529fn rebuild_after_replace(
530    src: &dyn Source,
531    viewport: &mut Viewport,
532    idx: &mut LineIndex,
533    spec: RebuildSpec,
534) {
535    let new_off = match spec.tail {
536        Some(n) => find_tail_offset(src, n),
537        None => 0,
538    };
539    *idx = LineIndex::new_starting_at(new_off);
540    if let Some(n) = spec.head {
541        idx.set_head_cap(n);
542    }
543    viewport.invalidate_filter_cache();
544    idx.notice_new_bytes(src);
545    viewport.extend_visible_lines(idx, src);
546    viewport.clamp_top_line(idx.line_count());
547}
548
549fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16) -> io::Result<()> {
550    // Reset attributes once before clear so the cleared cells inherit a
551    // clean state (some terminals fill cleared cells with the current
552    // attribute, which caused reverse-video bleed in earlier versions).
553    out.queue(SetAttribute(Attribute::Reset))?;
554    out.queue(ResetColor)?;
555    out.queue(Clear(ClearType::All))?;
556    for (i, row) in frame.body.iter().enumerate() {
557        out.queue(MoveTo(0, i as u16))?;
558        // Defensive: every row begins with a full attribute reset, so a
559        // mis-handled reset on the previous row can't bleed forward.
560        out.queue(SetAttribute(Attribute::Reset))?;
561        let style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
562        if matches!(style, RowStyle::Dim) {
563            out.queue(SetAttribute(Attribute::Dim))?;
564        }
565        let no_highlights = Vec::new();
566        let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
567        write_row_with_highlights(out, row, cols, highlights)?;
568        out.queue(SetAttribute(Attribute::Reset))?;
569    }
570    // Status row
571    out.queue(MoveTo(0, rows.saturating_sub(1)))?;
572    out.queue(SetAttribute(Attribute::Reverse))?;
573    let mut status = frame.status.clone();
574    if status.len() > cols as usize {
575        status.truncate(cols as usize);
576    } else {
577        let pad = cols as usize - status.len();
578        status.push_str(&" ".repeat(pad));
579    }
580    out.queue(Print(status))?;
581    out.queue(ResetColor)?;
582    out.queue(SetAttribute(Attribute::Reset))?;
583    out.flush()
584}
585
586fn cells_to_string(row: &[Cell], cols: u16) -> String {
587    let mut s = String::with_capacity(cols as usize);
588    for cell in row.iter().take(cols as usize) {
589        match cell {
590            Cell::Char { ch, .. } => s.push(*ch),
591            Cell::Continuation => { /* width-2 char already pushed */ }
592            Cell::Empty => s.push(' '),
593        }
594    }
595    s
596}
597
598/// Emit a single row with per-substring reverse-video highlights. Highlight
599/// ranges are in cell columns; any segment outside a highlight prints with
600/// the row's already-applied base attribute. Reverse is toggled on/off
601/// segment-by-segment with explicit `NoReverse` so a base attribute like
602/// `Dim` stays in effect for un-highlighted text.
603fn write_row_with_highlights(
604    out: &mut impl Write,
605    row: &[Cell],
606    cols: u16,
607    highlights: &[std::ops::Range<usize>],
608) -> io::Result<()> {
609    let cols_usize = cols as usize;
610    if highlights.is_empty() {
611        out.queue(Print(cells_to_string(row, cols)))?;
612        return Ok(());
613    }
614    // Sort and clamp; assume non-overlapping (viewport produces them this way).
615    let mut ranges: Vec<std::ops::Range<usize>> = highlights
616        .iter()
617        .filter_map(|r| {
618            let s = r.start.min(cols_usize);
619            let e = r.end.min(cols_usize);
620            if e > s { Some(s..e) } else { None }
621        })
622        .collect();
623    ranges.sort_by_key(|r| r.start);
624
625    let mut col = 0usize;
626    let mut i = 0usize;
627    while col < cols_usize && i < row.len() {
628        // Find which range (if any) covers this column.
629        let active = ranges.iter().find(|r| r.start <= col && col < r.end);
630        let (segment_end, reversed) = match active {
631            Some(r) => (r.end.min(cols_usize), true),
632            None => {
633                // Plain segment until the next highlight or row end.
634                let next = ranges.iter().find(|r| r.start > col).map(|r| r.start);
635                (next.unwrap_or(cols_usize), false)
636            }
637        };
638        if reversed { out.queue(SetAttribute(Attribute::Reverse))?; }
639        // Collect cells for this segment from `col` to `segment_end`.
640        let mut s = String::new();
641        while col < segment_end && i < row.len() {
642            match &row[i] {
643                Cell::Char { ch, width } => {
644                    s.push(*ch);
645                    col += *width as usize;
646                }
647                Cell::Continuation => {
648                    // Already accounted for by the preceding wide char's width.
649                }
650                Cell::Empty => {
651                    s.push(' ');
652                    col += 1;
653                }
654            }
655            i += 1;
656        }
657        out.queue(Print(s))?;
658        if reversed { out.queue(SetAttribute(Attribute::NoReverse))?; }
659    }
660    Ok(())
661}