Skip to main content

diffs_rs/
render.rs

1use ratatui::{
2    Frame,
3    buffer::Buffer,
4    layout::Rect,
5    style::{Color, Modifier, Style},
6    widgets::{Block, Borders, Clear, Paragraph, Wrap},
7};
8use unicode_segmentation::UnicodeSegmentation;
9use unicode_width::UnicodeWidthStr;
10
11mod sidebar;
12
13use crate::{
14    app::{App, Position},
15    model::{Kind, RowKind},
16    view::LineId,
17};
18
19fn code_width(app: &App, file: usize, pane: usize) -> usize {
20    let digits = if app.numbers {
21        app.line_digits(file)
22    } else {
23        0
24    };
25    pane.saturating_sub(if app.split() {
26        digits + 3
27    } else {
28        digits * 2 + usize::from(app.numbers) + 3
29    })
30    .max(1)
31}
32
33fn pane_widths(width: u16) -> (usize, usize) {
34    let left = width.saturating_sub(1) as usize / 2;
35    (left, width as usize - left - 1)
36}
37
38fn source_pane(app: &App, id: LineId, old: bool) -> (usize, usize) {
39    let (left, right) = pane_widths(app.width);
40    let (x, width) = if !app.split() {
41        (0, app.width as usize)
42    } else if old {
43        (0, left)
44    } else {
45        (left + 1, right)
46    };
47    let file = app.line_file(id).id;
48    let available = code_width(app, file, width);
49    (x + width - available, available)
50}
51
52pub fn source_anchor_byte(
53    app: &App,
54    id: LineId,
55    part: usize,
56    old: bool,
57    clicked_column: Option<u16>,
58) -> anyhow::Result<usize> {
59    let (code_start, width) = source_pane(app, id, old);
60    let line = app.line_geometry(id, app.wrap.then_some(width))?;
61    let offset = if app.wrap {
62        line.start_column(part).unwrap_or(usize::MAX)
63    } else {
64        app.horizontal
65    };
66    let column = offset.saturating_add(clicked_column.map_or(0, |x| {
67        (x as usize).saturating_sub(code_start).min(width - 1)
68    }));
69    Ok(line.byte_at_column(column))
70}
71
72pub(crate) fn source_has_part(
73    app: &App,
74    id: LineId,
75    part: usize,
76    old: bool,
77) -> anyhow::Result<bool> {
78    let (_, width) = source_pane(app, id, old);
79    Ok(part < app.line_geometry(id, app.wrap.then_some(width))?.height())
80}
81
82pub fn anchored_position(
83    app: &App,
84    id: LineId,
85    byte: usize,
86    old: bool,
87) -> anyhow::Result<(usize, usize)> {
88    let (_, width) = source_pane(app, id, old);
89    let line = app.line_geometry(id, app.wrap.then_some(width))?;
90    let (column, glyph_width) = line.column_at_byte(byte);
91    if app.wrap {
92        Ok((line.part_for_column(column), app.horizontal))
93    } else {
94        let offset = if column < app.horizontal
95            || column.saturating_add(glyph_width) > app.horizontal.saturating_add(width)
96        {
97            column
98        } else {
99            app.horizontal
100        };
101        Ok((0, offset))
102    }
103}
104
105pub fn row_height(app: &App, index: usize) -> anyhow::Result<usize> {
106    if !app.wrap {
107        return Ok(1);
108    }
109    let Some(row) = app.row(index) else {
110        return Ok(1);
111    };
112    let RowKind::Code { left, right } = row.kind else {
113        return Ok(1);
114    };
115    let (l, r) = if app.split() {
116        pane_widths(app.width)
117    } else {
118        (app.width as usize, app.width as usize)
119    };
120    let mut height = 1;
121    for (id, pane) in [(left, l), (right, r)] {
122        if let Some(id) = id {
123            height = height.max(
124                app.line_geometry(id, Some(code_width(app, row.file, pane)))?
125                    .height(),
126            );
127        }
128    }
129    Ok(height)
130}
131
132pub(crate) fn horizontal_limit(app: &App) -> anyhow::Result<usize> {
133    let sticky_header = app
134        .row(app.top.row)
135        .is_some_and(|row| row.kind != RowKind::Header);
136    let rows = app.height.saturating_sub(2 + u16::from(sticky_header));
137    let end = app.top.row.saturating_add(rows as usize).min(app.row_len());
138    let (left_width, right_width) = pane_widths(app.width);
139    let mut limit = 0;
140    for index in app.top.row..end {
141        let Some(row) = app.row(index) else {
142            break;
143        };
144        let RowKind::Code { left, right } = row.kind else {
145            continue;
146        };
147        let panes = if app.split() {
148            [(left, left_width), (right, right_width)]
149        } else {
150            [(right.or(left), app.width as usize), (None, 0)]
151        };
152        for (id, width) in panes {
153            if let Some(id) = id {
154                let line = app.line_geometry(id, None)?;
155                limit = limit.max(
156                    line.columns()
157                        .saturating_sub(code_width(app, row.file, width)),
158                );
159            }
160        }
161    }
162    Ok(limit)
163}
164
165pub fn draw(frame: &mut Frame<'_>, app: &mut App, input_error: Option<&str>) -> anyhow::Result<()> {
166    draw_in(frame, app, frame.area(), input_error)
167}
168
169pub fn draw_in(
170    frame: &mut Frame<'_>,
171    app: &mut App,
172    area: Rect,
173    input_error: Option<&str>,
174) -> anyhow::Result<()> {
175    draw_with_controls(frame, app, area, input_error, None)
176}
177
178pub(crate) fn draw_with_controls(
179    frame: &mut Frame<'_>,
180    app: &mut App,
181    area: Rect,
182    input_error: Option<&str>,
183    controls: Option<&crate::engine::ReadingControls<'_>>,
184) -> anyhow::Result<()> {
185    let area = area.intersection(frame.area());
186    app.prepare_view(area)?;
187    let colors = app.colors.clone();
188    frame.render_widget(
189        Block::new().style(Style::new().bg(colors.bg).fg(colors.fg)),
190        area,
191    );
192    app.visible.clear();
193    if area.width < 40 || area.height < 10 {
194        app.protect_visible_styles();
195        frame.render_widget(
196            Paragraph::new("Enlarge terminal to at least 40 columns × 10 rows\nq quit")
197                .wrap(Wrap { trim: true })
198                .style(Style::new().fg(colors.fg)),
199            area,
200        );
201        return Ok(());
202    }
203    if !app.wrap && app.horizontal != 0 {
204        app.horizontal = app.horizontal.min(horizontal_limit(app)?);
205    }
206    let layout = if app.split() { "SPLIT" } else { "UNIFIED" };
207    let title = format!(" diffs  /  {}", app.label);
208    let right = format!(
209        "{} files  ·  {layout}  ·  {} ",
210        app.doc.files.len(),
211        app.appearance.name()
212    );
213    let right_width = right.width().min(area.width as usize) as u16;
214    // Keep a visible gap so a long label never runs into the file count.
215    let title_width = area.width.saturating_sub(right_width + 2);
216    let buf = frame.buffer_mut();
217    fill(buf, Rect::new(area.x, area.y, area.width, 1), colors.chrome);
218    label(
219        buf,
220        area.x,
221        area.y,
222        title_width,
223        &ellipsize(&title, title_width.into()),
224        colors.fg,
225        colors.chrome,
226        false,
227    );
228    label(
229        buf,
230        area.right().saturating_sub(right_width),
231        area.y,
232        right_width,
233        &right,
234        colors.dim,
235        colors.chrome,
236        false,
237    );
238    sidebar::draw(buf, app);
239    draw_diff(buf, app, app.sidebar.diff)?;
240    app.protect_visible_styles();
241    if let Some(index) = app.visible.iter().position(|(p, _)| *p == app.cursor) {
242        app.cursor_screen_row = index;
243    }
244    let mut pending = false;
245    let mut fallback = false;
246    for (p, _) in &app.visible {
247        if let Some(crate::model::Row {
248            kind: RowKind::Code { left, right },
249            ..
250        }) = app.row(p.row)
251        {
252            for id in left.into_iter().chain(right) {
253                pending |= app.cache.get(id).is_none();
254                fallback |= app.cache.get(id).is_some_and(|s| s.fallback);
255            }
256        }
257    }
258    let failure = input_error.map(|error| format!("INCOMPLETE · {error}"));
259    let source_status = app.source_status();
260    let status = if let Some(failure) = &failure {
261        failure.as_str()
262    } else if app.loading && pending {
263        "Loading patch… · Highlighting visible code…"
264    } else if app.loading {
265        "Loading patch… · completed files available"
266    } else if let Some(status) = &source_status {
267        &status.text
268    } else if pending {
269        "Highlighting visible code…"
270    } else if fallback {
271        "Plain styling fallback"
272    } else {
273        app.sidebar
274            .notice
275            .unwrap_or("Patch view · syntax context may be incomplete")
276    };
277    let status = match controls.and_then(|controls| controls.status) {
278        Some(notice) if failure.is_none() && !app.loading => {
279            if source_status
280                .as_ref()
281                .is_some_and(|status| status.attention)
282                || pending
283                || fallback
284            {
285                std::borrow::Cow::Owned(format!("{status} · {notice}"))
286            } else {
287                std::borrow::Cow::Borrowed(notice)
288            }
289        }
290        _ => std::borrow::Cow::Borrowed(status),
291    };
292    let hint = if failure.is_some() || app.sidebar.notice.is_some() || app.loading {
293        " q quit "
294    } else if app.sidebar.focused() && area.width < 100 {
295        " ↑↓ move  Enter open  f hide  ? help  q quit "
296    } else if app.sidebar.focused() {
297        " ↑↓ move  ←→ folders  Enter open  Esc diff  f hide  ? help  q quit "
298    } else if area.width < 100 {
299        " f files  ? help  q quit "
300    } else {
301        " f files  u layout  w wrap  t theme  ? help  q quit "
302    };
303    let hint = if controls.is_some() && area.width < 60 {
304        " ? help  q quit "
305    } else {
306        hint
307    };
308    let hint = controls.map_or_else(
309        || hint.to_owned(),
310        |controls| {
311            if controls.status.is_some() {
312                if area.width < 60 {
313                    " ?".into()
314                } else if app.sidebar.focused() {
315                    hint.to_owned()
316                } else {
317                    " ? help  q quit ".into()
318                }
319            } else {
320                format!("{}  {} ", hint.trim_end(), controls.hint)
321            }
322        },
323    );
324    let hint = if failure.is_none() && !app.loading && app.sidebar.notice.is_none() {
325        footer_hint(hint, &status, area.width, app.sidebar.focused(), controls)
326    } else {
327        hint
328    };
329    let hint_width = hint.width().min(area.width as usize) as u16;
330    let status_width = area.width.saturating_sub(hint_width + 1);
331    fill(
332        buf,
333        Rect::new(area.x, area.bottom() - 1, area.width, 1),
334        colors.chrome,
335    );
336    label(
337        buf,
338        area.x + 1,
339        area.bottom() - 1,
340        status_width,
341        &ellipsize(&status, usize::from(status_width.saturating_sub(1))),
342        colors.dim,
343        colors.chrome,
344        false,
345    );
346    label(
347        buf,
348        area.right() - hint_width,
349        area.bottom() - 1,
350        hint_width,
351        &hint,
352        colors.dim,
353        colors.chrome,
354        false,
355    );
356    if app.help {
357        let help = if app.sidebar.focused() {
358            sidebar::HELP
359        } else {
360            "\n  DIFF\n  j/k, ↑/↓       Move one display row\n  PgUp/PgDn      Move one page\n  g/G            First / last source line\n  n/N            Next / previous file (patch order)\n  [ / ]          Previous / next hunk\n  ←/→            Horizontal scroll\n  Tab            Switch active side\n  u / w          Layout / wrap\n  t / l          Theme / line numbers\n  Enter / click  Reveal up to 20 lines at a context gap\n  f              Show and focus Files\n  FILES\n  ↑/↓, j/k       Move; PgUp/PgDn page; Home/End bounds\n  ←/→            Collapse / expand folders\n  Enter          Open file or toggle folder; Space toggle\n  Esc, Tab / f   Return to diff / hide Files\n  *              Current diff file; > keyboard cursor\n  Wheel          Scroll the pane under the pointer\n  Esc, ?         Close help; q, Ctrl-C quit / cancel"
361        };
362        let mut help = help.to_owned();
363        if !app.sidebar.focused() {
364            if let Some(controls) = controls {
365                help = format!("{}\n{help}", controls.help);
366            }
367            if help.lines().count() > usize::from(area.height.saturating_sub(2)) {
368                help = " j/k ↑↓ move; PgUp/PgDn page\n g/G first/last; n/N next/prev file\n ←/→ scroll; Tab switch side\n u layout; w wrap; t theme\n f files; Enter reveal context\n".into();
369                if let Some(controls) = controls {
370                    help.push(' ');
371                    help.push_str(controls.hint);
372                    help.push('\n');
373                }
374                help.push_str(" Esc, ? close help\n q, Ctrl-C quit / cancel");
375            }
376        }
377        let height = help
378            .lines()
379            .count()
380            .saturating_add(2)
381            .min(area.height as usize) as u16;
382        let rect = Rect::new(
383            area.x + (area.width.saturating_sub(68)) / 2,
384            area.y + (area.height.saturating_sub(height)) / 2,
385            68.min(area.width),
386            height,
387        );
388        frame.render_widget(Clear, rect);
389        frame.render_widget(
390            Paragraph::new(help)
391                .block(
392                    Block::new()
393                        .title(" Reading controls ")
394                        .borders(Borders::ALL),
395                )
396                .style(Style::new().bg(colors.chrome).fg(colors.fg)),
397            rect,
398        );
399    }
400    Ok(())
401}
402
403/// Shortens the footer hint until the status fits beside it.
404///
405/// The status describes the current reading state, so the engine's optional hints give way
406/// first: the view toggles, then the Files hint. `? help  q quit` and caller hints remain;
407/// callers already choose their hint for the available width. A status that still does not
408/// fit is truncated by the caller.
409fn footer_hint(
410    hint: String,
411    status: &str,
412    width: u16,
413    sidebar_focused: bool,
414    controls: Option<&crate::engine::ReadingControls<'_>>,
415) -> String {
416    if sidebar_focused || controls.is_some_and(|controls| controls.status.is_some()) {
417        return hint;
418    }
419    // One leading cell, the status, and at least one cell before the hint.
420    let fits = |hint: &str| status.width() + hint.width() + 2 <= usize::from(width);
421    if fits(&hint) {
422        return hint;
423    }
424    let with_controls = |base: &str| match controls {
425        Some(controls) => format!("{}  {} ", base.trim_end(), controls.hint),
426        None => base.to_owned(),
427    };
428    let shortest = with_controls(" ? help  q quit ");
429    [with_controls(" f files  ? help  q quit ")]
430        .into_iter()
431        .find(|candidate| candidate.width() < hint.width() && fits(candidate))
432        .unwrap_or(if shortest.width() < hint.width() {
433            shortest
434        } else {
435            hint
436        })
437}
438
439/// Truncates `text` to `width` display cells, marking the cut with an ellipsis.
440fn ellipsize(text: &str, width: usize) -> String {
441    if text.width() <= width {
442        return text.into();
443    }
444    let mut result = String::new();
445    let mut used = 0;
446    for grapheme in text.graphemes(true) {
447        let next = grapheme.width();
448        if used + next >= width {
449            break;
450        }
451        result.push_str(grapheme);
452        used += next;
453    }
454    if width > 0 {
455        result.push('…');
456    }
457    result
458}
459
460fn draw_diff(buf: &mut Buffer, app: &mut App, area: Rect) -> anyhow::Result<()> {
461    let mut y = area.y;
462    if let Some(row) = app.row(app.top.row)
463        && row.kind != RowKind::Header
464    {
465        file_header(buf, app, row.file, area.x, y);
466        y += 1;
467    }
468    let mut p = app.top;
469    while y < area.bottom() {
470        let Some(row) = app.row(p.row) else {
471            break;
472        };
473        draw_row(buf, app, p, (area.x, y), row.file, row.kind)?;
474        app.visible.push((p, y - area.y + 1));
475        y += 1;
476        let next = app.next(p)?;
477        if next == p {
478            break;
479        }
480        p = next;
481    }
482    if app.doc.files.is_empty() {
483        label(
484            buf,
485            area.x + 2,
486            area.y + 2,
487            area.width - 4,
488            if app.loading {
489                "Loading patch…"
490            } else {
491                "No changes"
492            },
493            app.colors.dim,
494            app.colors.bg,
495            false,
496        );
497    }
498    Ok(())
499}
500
501fn file_header(buf: &mut Buffer, app: &App, file: usize, x: u16, y: u16) {
502    let f = &app.doc.files[file];
503    let c = &app.colors;
504    fill(buf, Rect::new(x, y, app.width, 1), c.chrome);
505    let path = if f.old_path != f.path {
506        format!(" {} → {}", f.old_path, f.path)
507    } else {
508        format!(" {}", f.path)
509    };
510    let summary = format!(" {}   +{} −{} ", f.status, f.additions, f.deletions);
511    let summary_width = summary.width().min(app.width as usize) as u16;
512    label(
513        buf,
514        x,
515        y,
516        app.width.saturating_sub(summary_width),
517        &path,
518        c.fg,
519        c.chrome,
520        true,
521    );
522    label(
523        buf,
524        x + app.width.saturating_sub(summary_width),
525        y,
526        summary_width,
527        &summary,
528        c.dim,
529        c.chrome,
530        false,
531    );
532}
533
534fn draw_row(
535    buf: &mut Buffer,
536    app: &App,
537    p: Position,
538    origin: (u16, u16),
539    file: usize,
540    kind: RowKind<LineId>,
541) -> anyhow::Result<()> {
542    let c = &app.colors;
543    let (x, y) = origin;
544    match kind {
545        RowKind::Header => file_header(buf, app, file, x, y),
546        RowKind::Hunk(h) => {
547            fill(buf, Rect::new(x, y, app.width, 1), c.separator);
548            label(
549                buf,
550                x + 1,
551                y,
552                app.width - 2,
553                &app.doc.files[file].hunks[h].header,
554                c.dim,
555                c.separator,
556                false,
557            );
558        }
559        RowKind::Gap => {
560            let text = if let Some((_, _, count)) = app.view.gap(&app.doc, p.row, app.split()) {
561                format!("{count} unchanged lines · Show {}", count.min(20))
562            } else {
563                "···  Context not included in patch".into()
564            };
565            label(
566                buf,
567                x + 2,
568                y,
569                app.width - 4,
570                &text,
571                if p == app.cursor { c.accent } else { c.dim },
572                c.bg,
573                false,
574            );
575        }
576        RowKind::Metadata => {
577            let f = &app.doc.files[file];
578            let text = format!(
579                " {}  ·  mode {} → {}  ·  no textual hunks",
580                f.status, f.old_mode, f.new_mode
581            );
582            label(buf, x + 1, y, app.width - 2, &text, c.dim, c.bg, false);
583        }
584        RowKind::Code { left, right } => {
585            if app.split() {
586                let (lw, rw) = pane_widths(app.width);
587                code(
588                    buf,
589                    app,
590                    CodeCell {
591                        x,
592                        y,
593                        width: lw,
594                        file,
595                        id: left,
596                        other: None,
597                        part: p.part,
598                        active: p == app.cursor && app.old_side,
599                        old: true,
600                    },
601                )?;
602                label(buf, x + lw as u16, y, 1, "│", c.separator, c.bg, false);
603                code(
604                    buf,
605                    app,
606                    CodeCell {
607                        x: x + lw as u16 + 1,
608                        y,
609                        width: rw,
610                        file,
611                        id: right,
612                        other: None,
613                        part: p.part,
614                        active: p == app.cursor && !app.old_side,
615                        old: false,
616                    },
617                )?;
618            } else {
619                code(
620                    buf,
621                    app,
622                    CodeCell {
623                        x,
624                        y,
625                        width: app.width as usize,
626                        file,
627                        id: right.or(left),
628                        other: left,
629                        part: p.part,
630                        active: p == app.cursor,
631                        old: right.is_none(),
632                    },
633                )?;
634            }
635        }
636        RowKind::NoNewline { left, right } => {
637            let note = " No newline at end of file";
638            if app.split() {
639                let (lw, rw) = pane_widths(app.width);
640                if left {
641                    label(buf, x, y, lw as u16, note, c.dim, c.bg, false);
642                }
643                if right {
644                    label(
645                        buf,
646                        x + lw as u16 + 1,
647                        y,
648                        rw as u16,
649                        note,
650                        c.dim,
651                        c.bg,
652                        false,
653                    );
654                }
655            } else {
656                label(buf, x + 1, y, app.width - 2, note, c.dim, c.bg, false);
657            }
658        }
659    }
660    Ok(())
661}
662
663struct CodeCell {
664    x: u16,
665    y: u16,
666    width: usize,
667    file: usize,
668    id: Option<LineId>,
669    other: Option<LineId>,
670    part: usize,
671    active: bool,
672    old: bool,
673}
674
675fn code(buf: &mut Buffer, app: &App, cell: CodeCell) -> anyhow::Result<()> {
676    let CodeCell {
677        x,
678        y,
679        width,
680        file,
681        id,
682        other,
683        part,
684        active,
685        old,
686    } = cell;
687    let c = &app.colors;
688    let Some(id) = id else {
689        fill(buf, Rect::new(x, y, width as u16, 1), c.empty);
690        return Ok(());
691    };
692    let source = app.line_info(id)?;
693    let selected = app.selection.as_ref().is_some_and(|selection| {
694        let range = selection.range;
695        let range_old = range.side == crate::engine::Side::Old;
696        let number = if range_old { source.old } else { source.new };
697        range.file == file
698            && (!app.split() || range_old == old)
699            && number.is_some_and(|line| range.first <= line && line <= range.last)
700    });
701    let other_info = other.map(|id| app.line_info(id)).transpose()?;
702    let (bg, accent, word, sign) = match source.kind {
703        Kind::Context => (c.context, c.dim, c.context, " "),
704        Kind::Addition => (c.add_bg, c.added, c.add_word, "+"),
705        Kind::Deletion => (c.del_bg, c.deleted, c.del_word, "−"),
706    };
707    fill(buf, Rect::new(x, y, width as u16, 1), bg);
708    let digits = if app.numbers {
709        app.line_digits(file)
710    } else {
711        0
712    };
713    let number = |n: Option<u32>| {
714        if digits == 0 {
715            String::new()
716        } else {
717            n.map(|n| format!("{n:>digits$}"))
718                .unwrap_or_else(|| " ".repeat(digits))
719        }
720    };
721    let gutter = if app.split() {
722        format!(
723            "{}{}{} ",
724            if active {
725                "▎"
726            } else if selected {
727                "┃"
728            } else {
729                " "
730            },
731            number(if part == 0 {
732                if old { source.old } else { source.new }
733            } else {
734                None
735            }),
736            if part == 0 { sign } else { "↪" }
737        )
738    } else {
739        format!(
740            "{}{}{}{}{} ",
741            if active {
742                "▎"
743            } else if selected {
744                "┃"
745            } else {
746                " "
747            },
748            number(if part == 0 {
749                other_info.and_then(|i| i.old).or(source.old)
750            } else {
751                None
752            }),
753            if digits > 0 { " " } else { "" },
754            number(if part == 0 { source.new } else { None }),
755            if part == 0 { sign } else { "↪" }
756        )
757    };
758    let gutter_width = width.saturating_sub(code_width(app, file, width));
759    label(
760        buf,
761        x,
762        y,
763        gutter_width as u16,
764        &gutter,
765        if active { c.accent } else { accent },
766        bg,
767        false,
768    );
769    if selected {
770        buf[(x, y)]
771            .set_fg(c.accent)
772            .set_style(Style::new().add_modifier(Modifier::BOLD));
773    }
774    let available = code_width(app, file, width);
775    let line = app.line_geometry(id, app.wrap.then_some(available))?;
776    let offset = if app.wrap {
777        let Some(offset) = line.start_column(part) else {
778            fill(buf, Rect::new(x, y, width as u16, 1), c.empty);
779            return Ok(());
780        };
781        offset
782    } else {
783        app.horizontal
784    };
785    let style = app.cache.get(id);
786    let current_match = app.search_match.as_ref().filter(|found| {
787        found.hit.source == id
788            && found.check(found.location().review).is_ok()
789            && (!app.split() || (found.location().side == crate::engine::Side::Old) == old)
790    });
791    for glyph in line.glyphs_from_column(offset) {
792        if glyph.column < offset {
793            continue;
794        }
795        let pos = glyph.column - offset;
796        if pos + glyph.width > available {
797            break;
798        }
799        let fg = style
800            .and_then(|s| {
801                let tokens = s.tokens_for(old);
802                tokens
803                    .get(tokens.partition_point(|t| t.range.end <= glyph.byte))
804                    .filter(|t| t.range.contains(&glyph.byte))
805            })
806            .map(|t| t.color)
807            .unwrap_or(c.fg);
808        let emphasized = style.is_some_and(|s| {
809            s.changed
810                .get(s.changed.partition_point(|range| range.end <= glyph.byte))
811                .is_some_and(|range| range.start < glyph.byte + glyph.text.len())
812        });
813        let matched = current_match.is_some_and(|found| {
814            found.display.start < glyph.byte + glyph.text.len() && glyph.byte < found.display.end
815        });
816        let mut style = Style::new().fg(fg).bg(if emphasized { word } else { bg });
817        if selected {
818            style = style.add_modifier(Modifier::UNDERLINED);
819        }
820        if matched {
821            style = style.add_modifier(Modifier::UNDERLINED | Modifier::BOLD);
822        }
823        let col = x + gutter_width as u16 + pos as u16;
824        if glyph.text == "\t" {
825            for n in 0..glyph.width {
826                buf[(col + n as u16, y)].set_symbol(" ").set_style(style);
827            }
828        } else {
829            buf.set_stringn(col, y, glyph.text, glyph.width, style);
830        }
831    }
832    Ok(())
833}
834
835fn fill(buf: &mut Buffer, rect: Rect, bg: Color) {
836    for y in rect.top()..rect.bottom() {
837        for x in rect.left()..rect.right() {
838            buf[(x, y)].reset();
839            buf[(x, y)].set_bg(bg);
840        }
841    }
842}
843
844#[expect(
845    clippy::too_many_arguments,
846    reason = "Small drawing primitive with explicit cell geometry and colors"
847)]
848fn label(
849    buf: &mut Buffer,
850    x: u16,
851    y: u16,
852    width: u16,
853    text: &str,
854    fg: Color,
855    bg: Color,
856    bold: bool,
857) {
858    let style = Style::new().fg(fg).bg(bg).add_modifier(if bold {
859        Modifier::BOLD
860    } else {
861        Modifier::empty()
862    });
863    buf.set_stringn(x, y, text, width as usize, style);
864}
865
866#[cfg(test)]
867mod tests;