Skip to content

Commit 24cba7e

Browse files
committed
Tidy up android bullet rendering & fix ffi list model
2 parents 935404c + e8cc108 commit 24cba7e

File tree

2 files changed

+136
-51
lines changed
  • android/app/src/main/java/co/rustworkshop/markdownneuraxis/ui/screens
  • crates/markdown-neuraxis-ffi/src

2 files changed

+136
-51
lines changed

android/app/src/main/java/co/rustworkshop/markdownneuraxis/ui/screens/FileViewScreen.kt

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ fun FileViewScreen(
120120

121121
/**
122122
* Recursively render a list of blocks and their children.
123+
* List blocks handle their own children (for proper index-based markers).
123124
*/
124125
@Composable
125126
private fun RenderBlockTree(
@@ -129,7 +130,8 @@ private fun RenderBlockTree(
129130
) {
130131
for (block in blocks) {
131132
RenderBlock(block, depth, onWikiLinkClick)
132-
if (block.children.isNotEmpty()) {
133+
// List blocks handle their own children internally
134+
if (block.kind != "list" && block.children.isNotEmpty()) {
133135
RenderBlockTree(block.children, depth + 1, onWikiLinkClick)
134136
}
135137
}
@@ -205,8 +207,6 @@ private fun RenderSegments(
205207

206208
@Composable
207209
private fun RenderBlock(block: BlockDto, depth: Int, onWikiLinkClick: (String) -> Unit) {
208-
val indent = (depth * 16).dp
209-
210210
when (block.kind) {
211211
"heading" -> {
212212
val style = when (block.headingLevel.toInt()) {
@@ -224,18 +224,37 @@ private fun RenderBlock(block: BlockDto, depth: Int, onWikiLinkClick: (String) -
224224
onWikiLinkClick = onWikiLinkClick
225225
)
226226
}
227-
"list_item" -> {
228-
Row(modifier = Modifier.padding(start = indent, top = 4.dp, bottom = 4.dp)) {
229-
Text(
230-
text = block.listMarker ?: "-",
231-
modifier = Modifier.width(24.dp)
232-
)
233-
RenderSegments(
234-
segments = block.segments,
235-
onWikiLinkClick = onWikiLinkClick
236-
)
227+
"list" -> {
228+
// List container renders its children with index-generated markers
229+
// Nesting indent comes from marker width - nested content is after marker
230+
val ordered = block.listOrdered == true
231+
Column {
232+
block.children.forEachIndexed { index, item ->
233+
val marker = if (ordered) "${index + 1}." else ""
234+
val markerWidth = if (ordered) 24.dp else 16.dp
235+
Row(modifier = Modifier.padding(top = 4.dp, bottom = 4.dp)) {
236+
Text(
237+
text = marker,
238+
modifier = Modifier.width(markerWidth)
239+
)
240+
Column {
241+
RenderSegments(
242+
segments = item.segments,
243+
onWikiLinkClick = onWikiLinkClick
244+
)
245+
// Render nested content (e.g., nested lists)
246+
if (item.children.isNotEmpty()) {
247+
RenderBlockTree(item.children, depth + 1, onWikiLinkClick)
248+
}
249+
}
250+
}
251+
}
237252
}
238253
}
254+
"list_item" -> {
255+
// Standalone list_item should never happen - list container handles its items
256+
error("list_item rendered outside of list container - invalid block structure")
257+
}
239258
"paragraph" -> {
240259
RenderSegments(
241260
segments = block.segments,

crates/markdown-neuraxis-ffi/src/lib.rs

Lines changed: 104 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -98,29 +98,28 @@ fn convert_blocks(blocks: &[Block]) -> Vec<BlockDto> {
9898
}
9999

100100
/// Convert a single engine block to DTOs, appending to the result vector.
101-
/// Some blocks (List, Root) are "unwrapped" and their children are added directly.
101+
/// Root blocks are "unwrapped" and their children are added directly.
102+
/// List blocks are preserved with their ordered flag.
102103
fn convert_block_into(block: &Block, result: &mut Vec<BlockDto>) {
103-
match &block.kind {
104-
BlockKind::Root | BlockKind::List { .. } => {
105-
// Unwrap containers: add children directly to result
106-
if let BlockContent::Children(children) = &block.content {
107-
for child in children {
108-
convert_block_into(child, result);
109-
}
104+
if block.kind == BlockKind::Root {
105+
// Unwrap root container: add children directly to result
106+
if let BlockContent::Children(children) = &block.content {
107+
for child in children {
108+
convert_block_into(child, result);
110109
}
111-
return;
112110
}
113-
_ => {}
111+
return;
114112
}
115113

116-
let (kind, heading_level, list_marker) = match &block.kind {
117-
BlockKind::Root | BlockKind::List { .. } => unreachable!(), // Handled above
118-
BlockKind::Paragraph => ("paragraph".to_string(), 0, None),
119-
BlockKind::Heading { level } => ("heading".to_string(), *level, None),
120-
BlockKind::ListItem { marker } => ("list_item".to_string(), 0, Some(marker.clone())),
121-
BlockKind::FencedCode { .. } => ("code_fence".to_string(), 0, None),
122-
BlockKind::ThematicBreak => ("thematic_break".to_string(), 0, None),
123-
BlockKind::BlockQuote => ("block_quote".to_string(), 0, None),
114+
let (kind, heading_level, list_marker, list_ordered) = match &block.kind {
115+
BlockKind::Root => unreachable!(), // Handled above
116+
BlockKind::Paragraph => ("paragraph".to_string(), 0, None, None),
117+
BlockKind::Heading { level } => ("heading".to_string(), *level, None, None),
118+
BlockKind::List { ordered } => ("list".to_string(), 0, None, Some(*ordered)),
119+
BlockKind::ListItem { marker } => ("list_item".to_string(), 0, Some(marker.clone()), None),
120+
BlockKind::FencedCode { .. } => ("code_fence".to_string(), 0, None, None),
121+
BlockKind::ThematicBreak => ("thematic_break".to_string(), 0, None, None),
122+
BlockKind::BlockQuote => ("block_quote".to_string(), 0, None, None),
124123
};
125124

126125
// Convert engine segments to DTOs (engine now provides flat segments)
@@ -142,6 +141,7 @@ fn convert_block_into(block: &Block, result: &mut Vec<BlockDto>) {
142141
kind,
143142
heading_level,
144143
list_marker,
144+
list_ordered,
145145
segments,
146146
children,
147147
});
@@ -152,12 +152,14 @@ fn convert_block_into(block: &Block, result: &mut Vec<BlockDto>) {
152152
pub struct BlockDto {
153153
/// Stable identifier for this block (persists across edits)
154154
pub id: String,
155-
/// Block type (e.g., "heading", "list_item", "paragraph")
155+
/// Block type (e.g., "heading", "list_item", "paragraph", "list")
156156
pub kind: String,
157157
/// Heading level (1-6) if this is a heading, 0 otherwise
158158
pub heading_level: u8,
159159
/// List marker if this is a list item
160160
pub list_marker: Option<String>,
161+
/// Whether this is an ordered list (only set for kind="list")
162+
pub list_ordered: Option<bool>,
161163
/// Parsed inline segments (wiki-links, URLs, plain text)
162164
pub segments: Vec<TextSegmentDto>,
163165
/// Child blocks (e.g., nested list items)
@@ -417,52 +419,56 @@ mod tests {
417419

418420
#[test]
419421
fn test_nested_list_tree_structure() {
420-
// Verify nested lists produce a proper tree, not a flat list
422+
// Verify nested lists produce a proper tree with list containers preserved
421423
let content = "- parent\n - child 1\n - child 2\n - grandchild";
422424
let doc = DocumentHandle::from_string(content.to_string()).unwrap();
423425
let snapshot = doc.get_snapshot();
424426

425-
// Top level should have the parent list item
427+
// Top level should be a list container
426428
assert_eq!(snapshot.blocks.len(), 1);
427-
let parent = &snapshot.blocks[0];
429+
let list = &snapshot.blocks[0];
430+
assert_eq!(list.kind, "list");
431+
assert_eq!(list.list_ordered, Some(false));
432+
433+
// List should contain the parent list_item
434+
assert_eq!(list.children.len(), 1);
435+
let parent = &list.children[0];
428436
assert_eq!(parent.kind, "list_item");
429-
// Content is now extracted from segments
430437
assert!(segments_to_text(&parent.segments).contains("parent"));
431438

432-
// Parent should have nested items
439+
// Parent should have a nested list container
433440
assert!(
434441
!parent.children.is_empty(),
435-
"Parent should have nested items"
442+
"Parent should have nested content"
436443
);
444+
let nested_list = &parent.children[0];
445+
assert_eq!(nested_list.kind, "list");
437446

438-
// Count total nested list items (child 1, child 2, grandchild)
439-
let all_nested = collect_all_blocks(&parent.children);
440-
let nested_list_items: Vec<_> = all_nested
447+
// Count total list items in tree (parent, child 1, child 2, grandchild)
448+
let all_blocks = collect_all_blocks(&snapshot.blocks);
449+
let all_list_items: Vec<_> = all_blocks
441450
.iter()
442451
.filter(|b| b.kind == "list_item")
443452
.collect();
444453
assert_eq!(
445-
nested_list_items.len(),
446-
3,
447-
"Should have 3 nested items total (child 1, child 2, grandchild)"
454+
all_list_items.len(),
455+
4,
456+
"Should have 4 list items total (parent, child 1, child 2, grandchild)"
448457
);
449458

450-
// Verify we can traverse to find specific items
451-
// FFI layer "unwraps" List containers, so list items are direct children.
452-
// Correct structure: parent.children = [child1, child2] where child2.children = [grandchild]
453-
// child1 and child2 are siblings in parent.children, not nested under each other
454-
let nested_items: Vec<_> = parent
459+
// Nested list should have child 1 and child 2 as direct children
460+
let nested_items: Vec<_> = nested_list
455461
.children
456462
.iter()
457463
.filter(|b| b.kind == "list_item")
458464
.collect();
459465
assert_eq!(
460466
nested_items.len(),
461467
2,
462-
"Parent should have 2 direct child items (child 1, child 2)"
468+
"Nested list should have 2 direct child items (child 1, child 2)"
463469
);
464470

465-
// child 2 should have the grandchild
471+
// child 2 should have a nested list with the grandchild
466472
let child2 = nested_items
467473
.iter()
468474
.find(|b| segments_to_text(&b.segments).contains("child 2"));
@@ -485,4 +491,64 @@ mod tests {
485491
// Content is now extracted from segments
486492
assert_eq!(segments_to_text(&quote.segments), "This is a quote");
487493
}
494+
495+
#[test]
496+
fn test_list_container_preserved() {
497+
// Verify list containers are preserved with ordered flag
498+
let content = "- item 1\n- item 2";
499+
let doc = DocumentHandle::from_string(content.to_string()).unwrap();
500+
let snapshot = doc.get_snapshot();
501+
502+
// Top level should be a list, not a list_item
503+
assert_eq!(snapshot.blocks.len(), 1);
504+
assert_eq!(snapshot.blocks[0].kind, "list");
505+
assert_eq!(snapshot.blocks[0].list_ordered, Some(false));
506+
507+
// List should contain the list items as children
508+
assert_eq!(snapshot.blocks[0].children.len(), 2);
509+
let item1 = &snapshot.blocks[0].children[0];
510+
let item2 = &snapshot.blocks[0].children[1];
511+
assert_eq!(item1.kind, "list_item");
512+
assert_eq!(item2.kind, "list_item");
513+
514+
// Segments should NOT contain the marker (no duplication)
515+
assert_eq!(segments_to_text(&item1.segments), "item 1");
516+
assert_eq!(segments_to_text(&item2.segments), "item 2");
517+
518+
// Marker should be separate
519+
assert_eq!(item1.list_marker, Some("- ".to_string()));
520+
}
521+
522+
#[test]
523+
fn test_ordered_list_container() {
524+
// Verify ordered lists have list_ordered = true
525+
let content = "1. first\n2. second";
526+
let doc = DocumentHandle::from_string(content.to_string()).unwrap();
527+
let snapshot = doc.get_snapshot();
528+
529+
assert_eq!(snapshot.blocks.len(), 1);
530+
assert_eq!(snapshot.blocks[0].kind, "list");
531+
assert_eq!(snapshot.blocks[0].list_ordered, Some(true));
532+
}
533+
534+
#[test]
535+
fn test_mixed_ordered_unordered_lists() {
536+
// Verify document with both ordered and unordered lists
537+
let content = "# Header\n\n- bullet 1\n- bullet 2\n\n1. numbered 1\n2. numbered 2";
538+
let doc = DocumentHandle::from_string(content.to_string()).unwrap();
539+
let snapshot = doc.get_snapshot();
540+
541+
// Should have: heading, unordered list, ordered list
542+
assert_eq!(snapshot.blocks.len(), 3);
543+
544+
assert_eq!(snapshot.blocks[0].kind, "heading");
545+
546+
assert_eq!(snapshot.blocks[1].kind, "list");
547+
assert_eq!(snapshot.blocks[1].list_ordered, Some(false));
548+
assert_eq!(snapshot.blocks[1].children.len(), 2);
549+
550+
assert_eq!(snapshot.blocks[2].kind, "list");
551+
assert_eq!(snapshot.blocks[2].list_ordered, Some(true));
552+
assert_eq!(snapshot.blocks[2].children.len(), 2);
553+
}
488554
}

0 commit comments

Comments
 (0)