diff --git a/src/markdown_helper.py b/src/markdown_helper.py index c2cf6d2a..8307e03e 100644 --- a/src/markdown_helper.py +++ b/src/markdown_helper.py @@ -117,6 +117,7 @@ class MarkdownRenderer: from src.markdown_table import parse_tables, render_table text = self._normalize_bullet_delimiters(text) text = self._normalize_nested_list_endings(text) + text = self._normalize_list_continuations(text) blocks = parse_tables(text) lines = text.splitlines(keepends=True) if not lines: @@ -231,6 +232,57 @@ class MarkdownRenderer: out.append(line) return "\n".join(out) + def _normalize_list_continuations(self, text: str) -> str: + """Strip blank lines between a list item and its indented continuation. + + imgui_md (mekhontsev/imgui_md) BLOCK_P does NOT call ImGui::NewLine() + when m_list_stack is non-empty. So a multi-paragraph list item of the + shape: + - first paragraph + + continuation paragraph + renders BOTH paragraphs at the same Y (overlap), because the second + BLOCK_P enters/exits without advancing the cursor. + + WORKAROUND: Remove the blank line so the continuation becomes a lazy + continuation of the first paragraph (single BLOCK_P, proper line wrap, + no overlap). Trade-off: the user cannot have separate paragraphs within + a single list item. Acceptable for our use case. + [C: src.markdown_helper:MarkdownRenderer.render] + """ + import re + lines = text.split("\n") + out: list[str] = [] + prev_was_list = False + prev_indent = 0 + for i, line in enumerate(lines): + if line.strip(): + out.append(line) + if re.match(r"^\s*[-*+\d]\s+", line): + prev_was_list = True + prev_indent = len(line) - len(line.lstrip()) + else: + prev_was_list = False + continue + if not prev_was_list: + out.append(line) + continue + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j >= len(lines): + out.append(line) + prev_was_list = False + continue + next_line = lines[j] + curr_indent = len(next_line) - len(next_line.lstrip()) + is_next_list = bool(re.match(r"^\s*[-*+\d]", next_line)) + if curr_indent > prev_indent and not is_next_list: + continue + out.append(line) + prev_was_list = False + return "\n".join(out) + def render_unindented(self, text: str) -> None: """Render Markdown text with automatic unindentation.""" imgui_md.render_unindented(text) diff --git a/tests/test_markdown_helper_bullets.py b/tests/test_markdown_helper_bullets.py index dc6a83b9..2ef19a4b 100644 --- a/tests/test_markdown_helper_bullets.py +++ b/tests/test_markdown_helper_bullets.py @@ -83,3 +83,24 @@ def test_normalize_bullet_delimiters_still_converts_asterisk(): assert "- one" in out assert "- two" in out and "* two" not in out, f"* must be converted to -, got {out!r}" assert "+ three" in out, f"+ must be left alone, got {out!r}" + assert "+ three" in out, f"+ must be left alone, got {out!r}" + +def test_normalize_list_continuations_strips_blank_between_bullet_and_indented_continuation(): + r = MarkdownRenderer() + text = "- bullet text\n\n continuation paragraph\n\nnext paragraph\n" + out = r._normalize_list_continuations(text) + assert " continuation paragraph" in out + assert "- bullet text\n continuation paragraph" in out, f"blank between bullet and indented continuation must be stripped (workaround for imgui_md BLOCK_P no-NewLine-inside-list bug), got {out!r}" + assert "next paragraph" in out + +def test_normalize_list_continuations_preserves_blank_between_indented_and_next_paragraph(): + r = MarkdownRenderer() + text = "- bullet\n cont\n\nnext\n" + out = r._normalize_list_continuations(text) + assert "- bullet\n cont\n\nnext\n" == out, f"blank between continuation and next paragraph must be preserved (it ends the list item), got {out!r}" + +def test_normalize_list_continuations_leaves_simple_list_alone(): + r = MarkdownRenderer() + text = "- one\n- two\n- three\n" + out = r._normalize_list_continuations(text) + assert out == text, f"simple list with no continuations should be unchanged, got {out!r}"