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 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
403fn 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 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
439fn 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;