Skip to content

Commit f7b7310

Browse files
authored
Render GFM Task Lists using emoji, not plain text (#9)
List starting with `[ ]` and `[x]` can be displayed nicer.
1 parent a97ed8b commit f7b7310

13 files changed

Lines changed: 273 additions & 40 deletions

‎crates/reviewq-app/src/config.default.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ theme = "dark"
175175
# draw, or with "" to show the branch name on its own.
176176
# [output.icons]
177177
# branch = "->"
178+
# gfm_task_checked = "✅"
179+
# gfm_task_unchecked = "☐"
180+
# Task-list markers are padded to the wider marker's terminal-cell width so
181+
# their text aligns. Use alternatives such as "[x]" and "[ ]" if your font
182+
# displays the defaults at different widths.
178183

179184
# The glyphs before a GFM alert's heading in a PR description — `> [!NOTE]`
180185
# and its four siblings. Each defaults to a Nerd Font codepoint, which a

‎crates/reviewq-app/src/config.rs‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,14 +371,18 @@ impl Marks {
371371
/// The glyphs that label a fact rather than a decision.
372372
///
373373
/// Separate from [`Marks`], which says what *you* did to a PR; these say what
374-
/// the PR *is*. Configurable for the same reason: the default is a Nerd Font
375-
/// codepoint, and a terminal without a patched font draws a box. Set one to an
374+
/// the PR *is*. Some defaults are Nerd Font codepoints, and a terminal
375+
/// without a patched font draws a box. Set one to an
376376
/// empty string to drop the glyph and keep the value it labels.
377377
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
378378
#[serde(default)]
379379
pub struct Icons {
380380
/// Before the branch a PR would merge into.
381381
pub branch: String,
382+
/// A checked GFM task-list item.
383+
pub gfm_task_checked: String,
384+
/// An unchecked GFM task-list item.
385+
pub gfm_task_unchecked: String,
382386
/// Before a GFM alert's heading. `[output.icons.alert]` in TOML.
383387
pub alert: AlertIcons,
384388
}
@@ -388,6 +392,8 @@ impl Default for Icons {
388392
Self {
389393
// U+F419, Nerd Fonts' git-branch.
390394
branch: "\u{f419}".into(),
395+
gfm_task_checked: "✅".into(),
396+
gfm_task_unchecked: "☐".into(),
391397
alert: AlertIcons::default(),
392398
}
393399
}
@@ -939,6 +945,29 @@ mod tests {
939945
assert_eq!(none.output.icons.branch, "", "dropping it is allowed");
940946
}
941947

948+
#[test]
949+
fn gfm_task_icons_can_be_overridden_independently() {
950+
for (setting, checked, unchecked) in [
951+
("", "✅", "☐"),
952+
("gfm_task_checked = '[x]'", "[x]", "☐"),
953+
("gfm_task_unchecked = '[ ]'", "✅", "[ ]"),
954+
("gfm_task_checked = ''\ngfm_task_unchecked = ''", "", ""),
955+
] {
956+
let config: Config =
957+
toml::from_str(&minimal(&format!("[output.icons]\n{setting}"))).expect("parses");
958+
assert_eq!(config.output.icons.gfm_task_checked, checked);
959+
assert_eq!(config.output.icons.gfm_task_unchecked, unchecked);
960+
}
961+
}
962+
963+
#[test]
964+
fn gfm_task_icons_reject_non_string_values() {
965+
for key in ["gfm_task_checked", "gfm_task_unchecked"] {
966+
let result = toml::from_str::<Config>(&minimal(&format!("[output.icons]\n{key} = 42")));
967+
assert!(result.is_err(), "{key}");
968+
}
969+
}
970+
942971
#[test]
943972
fn each_alert_icon_defaults_to_a_nerd_font_glyph_and_is_replaceable_alone() {
944973
let config: Config = toml::from_str(&minimal("")).expect("parses");

‎crates/reviewq-tui/src/ansi.rs‎

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! needed is a handful of sequences, and the caller may be writing to a pipe, a
1717
//! file, or a string in a test rather than to a terminal at all.
1818
19-
use ratatui::buffer::Buffer;
19+
use ratatui::buffer::{Buffer, Cell, CellWidth as _};
2020
use ratatui::layout::Rect;
2121
use ratatui::style::{Color, Modifier};
2222
use ratatui::text::Line;
@@ -32,8 +32,26 @@ use crate::theme::{Mode, Rgb, Theme};
3232
/// the tables, the bullets and the wrapping are in the characters, not in the
3333
/// styling.
3434
pub fn markdown(md: &str, mode: Mode, width: u16, colour: bool) -> String {
35+
markdown_with_icons(
36+
md,
37+
mode,
38+
width,
39+
colour,
40+
&reviewq_app::config::Icons::default(),
41+
)
42+
}
43+
44+
/// Render Markdown with the configured glyphs for GFM task lists.
45+
pub fn markdown_with_icons(
46+
md: &str,
47+
mode: Mode,
48+
width: u16,
49+
colour: bool,
50+
icons: &reviewq_app::config::Icons,
51+
) -> String {
3552
let theme = Theme::new(mode);
36-
let md = reflow_wide_tables(md, width.max(20));
53+
let md = crate::gfm_task_list::render_markers(md, icons);
54+
let md = reflow_wide_tables(&md, width.max(20));
3755
let lines: Vec<Line<'_>> = tui_markdown::from_str(&md)
3856
.lines
3957
.into_iter()
@@ -325,6 +343,19 @@ pub(crate) fn buffer(buffer: &Buffer, theme: &Theme, colour: bool) -> String {
325343
out
326344
}
327345

346+
pub(crate) fn row_cells(buffer: &Buffer, row: u16) -> impl Iterator<Item = (u16, &Cell)> {
347+
let mut col = 0;
348+
std::iter::from_fn(move || {
349+
if col >= buffer.area.width {
350+
return None;
351+
}
352+
let cell = &buffer[(col, row)];
353+
let current = col;
354+
col += cell.cell_width().max(1);
355+
Some((current, cell))
356+
})
357+
}
358+
328359
/// One row of the buffer, its trailing blank cells dropped — a terminal has no
329360
/// use for a line padded to the full width, and a reader copying the output has
330361
/// less.
@@ -336,8 +367,7 @@ fn push_row(out: &mut String, buffer: &Buffer, row: u16, theme: &Theme, colour:
336367
.map_or(0, |col| col + 1);
337368

338369
let mut open: Option<Style> = None;
339-
for col in 0..last {
340-
let cell = &buffer[(col, row)];
370+
for (_, cell) in row_cells(buffer, row).take_while(|(col, _)| *col < last) {
341371
let style = Style {
342372
fg: match cell.fg {
343373
Color::Rgb(r, g, b) => Rgb { r, g, b },
@@ -505,6 +535,50 @@ mod tests {
505535
assert_eq!(rendered, "Short.\n");
506536
}
507537

538+
#[test]
539+
fn wide_glyphs_preserve_spacing_in_plain_and_coloured_output() {
540+
for input in ["✅done", "✅ done", "✅ done", "✅☐界done", "done✅"] {
541+
for colour in [false, true] {
542+
let rendered = markdown(input, Mode::Dark, 60, colour);
543+
assert_eq!(strip_escapes(&rendered), format!("{input}\n"));
544+
}
545+
}
546+
}
547+
548+
#[test]
549+
fn configured_task_markers_align_the_item_text() {
550+
for (checked, unchecked, expected) in [
551+
("✅", "☐", "- ✅ Done\n- ☐ Todo\n"),
552+
("✓", "界", "- ✓ Done\n- 界 Todo\n"),
553+
("[x]", "[ ]", "- [x] Done\n- [ ] Todo\n"),
554+
("", "", "- Done\n- Todo\n"),
555+
] {
556+
let icons = reviewq_app::config::Icons {
557+
gfm_task_checked: checked.into(),
558+
gfm_task_unchecked: unchecked.into(),
559+
..Default::default()
560+
};
561+
for colour in [false, true] {
562+
let rendered =
563+
markdown_with_icons("- [x] Done\n- [ ] Todo\n", Mode::Dark, 60, colour, &icons);
564+
assert_eq!(strip_escapes(&rendered), expected);
565+
}
566+
}
567+
}
568+
569+
#[test]
570+
fn github_task_lists_render_as_status_glyphs() {
571+
let input = "- [ ] Waiting\n- [x] Done lower\n- [X] Done upper\n- [x]ylophone\n\n```text\n- [x] literal\n```\n";
572+
573+
let rendered = markdown(input, Mode::Dark, 60, false);
574+
575+
assert!(rendered.contains("- ☐ Waiting"), "{rendered:?}");
576+
assert!(rendered.contains("- ✅ Done lower"), "{rendered:?}");
577+
assert!(rendered.contains("- ✅ Done upper"), "{rendered:?}");
578+
assert!(rendered.contains("- [x]ylophone"), "{rendered:?}");
579+
assert!(rendered.contains("- [x] literal"), "{rendered:?}");
580+
}
581+
508582
#[test]
509583
fn a_table_too_wide_for_the_page_becomes_a_list() {
510584
// 183 columns of two-cell prose is not a table anybody can read in a

‎crates/reviewq-tui/src/app.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3902,8 +3902,8 @@ mod loop_tests {
39023902
let area = buffer.area();
39033903
(0..area.height)
39043904
.map(|y| {
3905-
(0..area.width)
3906-
.map(|x| buffer[(x, y)].symbol())
3905+
crate::ansi::row_cells(buffer, y)
3906+
.map(|(_, cell)| cell.symbol())
39073907
.collect::<String>()
39083908
.trim_end()
39093909
.to_string()

‎crates/reviewq-tui/src/docs.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ mod tests {
7777
let (buffer, _) = draw(shot, WIDTH, Mode::Dark);
7878
println!("\n=== {} ===", shot.name);
7979
for y in 0..shot.height {
80-
let row: String = (0..WIDTH)
81-
.map(|x| buffer[(x, y)].symbol())
80+
let row: String = crate::ansi::row_cells(&buffer, y)
81+
.map(|(_, cell)| cell.symbol())
8282
.collect::<String>()
8383
.trim_end()
8484
.to_string();
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
use ratatui::buffer::CellWidth as _;
2+
use reviewq_app::config::Icons;
3+
4+
pub(crate) fn render_markers(markdown: &str, icons: &Icons) -> String {
5+
let mut rendered = String::with_capacity(markdown.len());
6+
let mut fence = None;
7+
let width = icons
8+
.gfm_task_checked
9+
.cell_width()
10+
.max(icons.gfm_task_unchecked.cell_width());
11+
12+
for line in markdown.split_inclusive('\n') {
13+
let trimmed = line.trim_start();
14+
if let Some((character, length)) = fence {
15+
rendered.push_str(line);
16+
if closes_fence(trimmed, character, length) {
17+
fence = None;
18+
}
19+
continue;
20+
}
21+
22+
if let Some(opened) = opens_fence(trimmed) {
23+
fence = Some(opened);
24+
rendered.push_str(line);
25+
continue;
26+
}
27+
28+
let indent = line.len() - trimmed.len();
29+
let remainder = trimmed.get(5..).unwrap_or_default();
30+
let marker = if remainder.chars().next().is_none_or(char::is_whitespace) {
31+
match trimmed.get(..5) {
32+
Some("- [ ]") => Some(&icons.gfm_task_unchecked),
33+
Some(candidate) if candidate.eq_ignore_ascii_case("- [x]") => {
34+
Some(&icons.gfm_task_checked)
35+
}
36+
_ => None,
37+
}
38+
} else {
39+
None
40+
};
41+
if let Some(marker) = marker {
42+
rendered.push_str(&line[..indent]);
43+
rendered.push_str("- ");
44+
rendered.push_str(marker);
45+
rendered.extend(std::iter::repeat_n(
46+
' ',
47+
usize::from(width - marker.cell_width()),
48+
));
49+
rendered.push_str(&trimmed[5..]);
50+
} else {
51+
rendered.push_str(line);
52+
}
53+
}
54+
55+
rendered
56+
}
57+
58+
fn opens_fence(line: &str) -> Option<(char, usize)> {
59+
let character = line.chars().next()?;
60+
if !matches!(character, '`' | '~') {
61+
return None;
62+
}
63+
let length = line
64+
.chars()
65+
.take_while(|candidate| *candidate == character)
66+
.count();
67+
(length >= 3).then_some((character, length))
68+
}
69+
70+
fn closes_fence(line: &str, character: char, opening_length: usize) -> bool {
71+
let length = line
72+
.chars()
73+
.take_while(|candidate| *candidate == character)
74+
.count();
75+
length >= opening_length && line[length..].trim().is_empty()
76+
}

‎crates/reviewq-tui/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod app;
1414
#[cfg(test)]
1515
mod docs;
1616
mod fixture;
17+
mod gfm_task_list;
1718
mod mouse;
1819
mod svg;
1920
mod ui;
@@ -37,6 +38,7 @@ use ratatui::backend::CrosstermBackend;
3738
use reviewq_app::config::Config;
3839

3940
pub use ansi::markdown as markdown_to_ansi;
41+
pub use ansi::markdown_with_icons as markdown_to_ansi_with_icons;
4042

4143
/// Draw one of the documentation's screens, as text a terminal can print.
4244
///

‎crates/reviewq-tui/src/snapshots/reviewq_tui__ui__tests__the_description_renders_as_markdown_with_template_comments_stripped.snap‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ reviewq 2 on the queue
2121
│ ││ █
2222
│ ││ Adds a deferrable flag to S3KeySensor. █
2323
│ ││ █
24-
│ ││ - [x] Tests added █
25-
│ ││ - [ ] Docs updated █
24+
│ ││ - ✅ Tests added █
25+
│ ││ - ☐ Docs updated █
2626
│ ││ █
2727
│ ││ ```python █
2828
│ ││ sensor = S3KeySensor(deferrable=True) █

‎crates/reviewq-tui/src/snapshots/reviewq_tui__ui__tests__the_detail_pane_shows_five_recent_activity_events.snap‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/reviewq-tui/src/ui.rs
3+
assertion_line: 2057
34
expression: shown
45
---
56
reviewq 9 on the queue
@@ -32,12 +33,12 @@ reviewq 9 on the queue
3233
│ ││ too, gated on the new flag rather than a second █
3334
│ ││ operator class. █
3435
│ ││ █
35-
│ ││ - [x] Trigger and sensor share one poke █
36+
│ ││ - ✅ Trigger and sensor share one poke █
3637
│ ││ implementation █
37-
│ ││ - [x] Tests for both modes █
38-
│ ││ - [x] Backport label added — this lands on 3.1 as █
38+
│ ││ - ✅ Tests for both modes █
39+
│ ││ - ✅ Backport label added — this lands on 3.1 as █
3940
│ ││ well as main █
40-
│ ││ - [ ] Docs updated — waiting on #70344 █
41+
│ ││ - ☐ Docs updated — waiting on #70344 █
4142
│ ││ █
4243
│ ││ Follow-up to the executor work in #69982. █
4344
│ ││ █

‎crates/reviewq-tui/src/snapshots/reviewq_tui__ui__tests__the_queue_and_the_selected_prs_detail_both_render.snap‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ reviewq 2 on the queue
2121
│ ││ ║
2222
│ ││ Adds a deferrable flag to S3KeySensor. ║
2323
│ ││ ║
24-
│ ││ - [x] Tests added ║
24+
│ ││ - ✅ Tests added ║
2525
╰───────────────────────────────────────────╯╰─────────────────────────────────────────────────────╯
2626
jk move ⏎ review d done z snooze ? / h keys Esc quit

0 commit comments

Comments
 (0)