Skip to content

Commit b48e0cb

Browse files
committed
fix(md): fixed checklist rendering
test(markdown): add UI tests for fixed list rendering
1 parent afa6404 commit b48e0cb

5 files changed

Lines changed: 234 additions & 11 deletions

src/ui/components/issue_conversation.rs

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,11 +2497,56 @@ struct MarkdownRenderer {
24972497
in_code_block: bool,
24982498
code_block_lang: Option<String>,
24992499
code_block_buf: String,
2500-
list_prefix: Option<String>,
2500+
item_prefix: Option<ListPrefix>,
25012501
pending_space: bool,
25022502
active_link_url: Option<String>,
25032503
}
25042504

2505+
#[derive(Debug, Clone)]
2506+
struct ListPrefix {
2507+
first_line: String,
2508+
continuation: String,
2509+
first_line_pending: bool,
2510+
}
2511+
2512+
impl ListPrefix {
2513+
fn bullet() -> Self {
2514+
Self::new("• ".to_string())
2515+
}
2516+
2517+
fn task(checked: bool) -> Self {
2518+
let prefix = if checked { "[x] " } else { "[ ] " };
2519+
Self::new(prefix.to_string())
2520+
}
2521+
2522+
fn new(first_line: String) -> Self {
2523+
let continuation = " ".repeat(display_width(&first_line));
2524+
Self {
2525+
first_line,
2526+
continuation,
2527+
first_line_pending: true,
2528+
}
2529+
}
2530+
2531+
fn current_text(&self) -> &str {
2532+
if self.first_line_pending {
2533+
&self.first_line
2534+
} else {
2535+
&self.continuation
2536+
}
2537+
}
2538+
2539+
fn current_width(&self) -> usize {
2540+
display_width(self.current_text())
2541+
}
2542+
2543+
fn take_for_line(&mut self) -> String {
2544+
let prefix = self.current_text().to_string();
2545+
self.first_line_pending = false;
2546+
prefix
2547+
}
2548+
}
2549+
25052550
#[derive(Clone, Copy)]
25062551
struct AdmonitionStyle {
25072552
marker: &'static str,
@@ -2564,7 +2609,7 @@ impl MarkdownRenderer {
25642609
in_code_block: false,
25652610
code_block_lang: None,
25662611
code_block_buf: String::new(),
2567-
list_prefix: None,
2612+
item_prefix: None,
25682613
pending_space: false,
25692614
active_link_url: None,
25702615
}
@@ -2604,7 +2649,7 @@ impl MarkdownRenderer {
26042649
}
26052650
Tag::Item => {
26062651
self.flush_line();
2607-
self.list_prefix = Some("• ".to_string());
2652+
self.item_prefix = Some(ListPrefix::bullet());
26082653
}
26092654
_ => {}
26102655
}
@@ -2644,7 +2689,7 @@ impl MarkdownRenderer {
26442689
}
26452690
TagEnd::Item => {
26462691
self.flush_line();
2647-
self.list_prefix = None;
2692+
self.item_prefix = None;
26482693
}
26492694
TagEnd::Paragraph => {
26502695
self.flush_line();
@@ -2716,8 +2761,8 @@ impl MarkdownRenderer {
27162761

27172762
fn task_list_marker(&mut self, checked: bool) {
27182763
self.ensure_admonition_header();
2719-
let marker = if checked { "[x] " } else { "[ ] " };
2720-
self.push_text(marker, self.current_style);
2764+
self.item_prefix = Some(ListPrefix::task(checked));
2765+
self.pending_space = false;
27212766
}
27222767

27232768
fn rule(&mut self) {
@@ -2923,9 +2968,10 @@ impl MarkdownRenderer {
29232968
.unwrap_or_else(|| Style::new().fg(Color::DarkGray));
29242969
self.current_line.push(Span::styled("│ ", border_style));
29252970
}
2926-
if let Some(prefix) = &self.list_prefix {
2927-
self.current_width += display_width(prefix);
2928-
self.current_line.push(Span::raw(prefix.clone()));
2971+
if let Some(prefix) = self.item_prefix.as_mut() {
2972+
let prefix = prefix.take_for_line();
2973+
self.current_width += display_width(&prefix);
2974+
self.current_line.push(Span::raw(prefix));
29292975
}
29302976
}
29312977

@@ -2934,8 +2980,8 @@ impl MarkdownRenderer {
29342980
if self.in_block_quote {
29352981
width += 2;
29362982
}
2937-
if let Some(prefix) = &self.list_prefix {
2938-
width += display_width(prefix);
2983+
if let Some(prefix) = &self.item_prefix {
2984+
width += prefix.current_width();
29392985
}
29402986
width
29412987
}
@@ -3090,6 +3136,19 @@ mod tests {
30903136
.collect()
30913137
}
30923138

3139+
fn all_line_text(rendered: &super::MarkdownRender) -> Vec<String> {
3140+
rendered
3141+
.lines
3142+
.iter()
3143+
.map(|line| {
3144+
line.spans
3145+
.iter()
3146+
.map(|span| span.content.as_ref())
3147+
.collect()
3148+
})
3149+
.collect()
3150+
}
3151+
30933152
#[test]
30943153
fn extracts_link_segments_with_urls() {
30953154
let rendered = render_markdown("Go to [ratatui docs](https://github.com/ratatui/).", 80, 0);
@@ -3122,4 +3181,35 @@ mod tests {
31223181
.all(|link| !link.label.starts_with(' ') && !link.label.ends_with(' '))
31233182
);
31243183
}
3184+
3185+
#[test]
3186+
fn renders_unchecked_checklist_without_bullet_prefix() {
3187+
let rendered = render_markdown("- [ ] todo", 80, 0);
3188+
3189+
assert_eq!(all_line_text(&rendered), vec!["[ ] todo"]);
3190+
}
3191+
3192+
#[test]
3193+
fn renders_checked_checklist_without_bullet_prefix() {
3194+
let rendered = render_markdown("- [x] done", 80, 0);
3195+
3196+
assert_eq!(all_line_text(&rendered), vec!["[x] done"]);
3197+
}
3198+
3199+
#[test]
3200+
fn wraps_checklist_items_with_aligned_continuation() {
3201+
let rendered = render_markdown("- [ ] hello world", 10, 0);
3202+
3203+
assert_eq!(all_line_text(&rendered), vec!["[ ] hello", " world"]);
3204+
}
3205+
3206+
#[test]
3207+
fn keeps_bullets_for_non_task_list_items() {
3208+
let rendered = render_markdown("- bullet\n- [x] done\n- [ ] todo", 80, 0);
3209+
3210+
assert_eq!(
3211+
all_line_text(&rendered),
3212+
vec!["• bullet", "[x] done", "[ ] todo"]
3213+
);
3214+
}
31253215
}

tests/markdown_checklists.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
mod support;
2+
3+
use crate::support::buffer_to_string;
4+
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
5+
use futures::executor::block_on;
6+
use gitv_tui::ui::components::{Component, issue_create::IssueCreate};
7+
use gitv_tui::ui::issue_data::UiIssuePool;
8+
use gitv_tui::ui::{Action, AppState};
9+
use insta::assert_snapshot;
10+
use ratatui::buffer::Buffer;
11+
use ratatui::layout::Rect;
12+
use std::sync::{Arc, RwLock};
13+
use tokio::sync::mpsc;
14+
15+
fn render_issue_create_preview(body: &str, width: u16, height: u16) -> String {
16+
let area = Rect::new(0, 0, width, height);
17+
let layout = gitv_tui::ui::layout::Layout::fullscreen(area);
18+
let mut buf = Buffer::empty(area);
19+
let issue_pool = Arc::new(RwLock::new(UiIssuePool::default()));
20+
let mut issue_create = IssueCreate::new(
21+
AppState::new("repo".to_string(), "owner".to_string(), "user".to_string()),
22+
issue_pool,
23+
);
24+
let (tx, _rx) = mpsc::channel(8);
25+
issue_create.register_action_tx(tx);
26+
27+
block_on(async {
28+
issue_create
29+
.handle_event(Action::EnterIssueCreate)
30+
.await
31+
.expect("enter issue create should succeed");
32+
issue_create
33+
.handle_event(Action::AppEvent(Event::Paste(body.to_string())))
34+
.await
35+
.expect("pasting markdown body should succeed");
36+
issue_create
37+
.handle_event(Action::AppEvent(Event::Key(KeyEvent::new(
38+
KeyCode::Char('p'),
39+
KeyModifiers::CONTROL,
40+
))))
41+
.await
42+
.expect("toggling preview should succeed");
43+
});
44+
45+
issue_create.render(layout, &mut buf);
46+
buffer_to_string(&buf)
47+
}
48+
49+
#[test]
50+
fn markdown_preview_renders_ascii_checklists() {
51+
let result = render_issue_create_preview(
52+
"## Tasks\n\n- [ ] write docs\n- [x] add tests\n- plain bullet",
53+
44,
54+
16,
55+
);
56+
57+
assert_snapshot!(result);
58+
}
59+
60+
#[test]
61+
fn markdown_preview_wraps_checklist_continuations() {
62+
let result = render_issue_create_preview(
63+
"- [ ] a very long checklist item that should wrap onto the next rendered line",
64+
34,
65+
14,
66+
);
67+
68+
assert_snapshot!(result);
69+
}
70+
71+
#[test]
72+
fn markdown_preview_keeps_checked_and_unchecked_prefixes() {
73+
let result = render_issue_create_preview(
74+
"- [x] completed task\n- [ ] pending task\n- [x] reviewed",
75+
38,
76+
14,
77+
);
78+
79+
assert_snapshot!(result);
80+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: tests/markdown_checklists.rs
3+
expression: result
4+
---
5+
╭[0] Title───────────────────────────╮
6+
│ │
7+
╰────────────────────────────────────╯
8+
Labels (comma-separated)────────────╮
9+
│ │
10+
╰────────────────────────────────────╯
11+
Assignees (comma-separated)─────────╮
12+
│ │
13+
╰────────────────────────────────────╯
14+
Preview (Ctrl+P: Edit | Ctrl+Enter: Cr
15+
[x] completed task
16+
[ ] pending task
17+
[x] reviewed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: tests/markdown_checklists.rs
3+
expression: result
4+
---
5+
╭[0] Title─────────────────────────────────╮
6+
│ │
7+
╰──────────────────────────────────────────╯
8+
Labels (comma-separated)──────────────────╮
9+
│ │
10+
╰──────────────────────────────────────────╯
11+
Assignees (comma-separated)───────────────╮
12+
│ │
13+
╰──────────────────────────────────────────╯
14+
Preview (Ctrl+P: Edit | Ctrl+Enter: Create)─
15+
Tasks
16+
[ ] write docs
17+
[x] add tests
18+
plain bullet
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: tests/markdown_checklists.rs
3+
expression: result
4+
---
5+
╭[0] Title───────────────────────╮
6+
│ │
7+
╰────────────────────────────────╯
8+
Labels (comma-separated)────────╮
9+
│ │
10+
╰────────────────────────────────╯
11+
Assignees (comma-separated)─────╮
12+
│ │
13+
╰────────────────────────────────╯
14+
Preview (Ctrl+P: Edit | Ctrl+Enter
15+
[ ] a very long checklist
16+
item that should wrap
17+
onto the next rendered
18+
line

0 commit comments

Comments
 (0)