From fd5f4d0eda00f797eb354c9a159b83bc969c7a49 Mon Sep 17 00:00:00 2001 From: Conductor Date: Wed, 3 Jun 2026 21:33:47 -0400 Subject: [PATCH] fix(markdown): strip backticks in table cells + add nested-list overlap workaround FIX 1 (src/markdown_table.py): Cells now use imgui_md.render(c) instead of imgui.text_wrapped(c). imgui_md uses MD4C which strips backtick-delimited inline code spans BEFORE rendering, so backticks no longer appear as literal characters in cell content. Side benefit: inline emphasis (*foo*, **bar**) now renders in cells too. FIX 2 (src/markdown_helper.py): Added MarkdownRenderer._normalize_nested_list_endings. Upstream imgui_md (mekhontsev/imgui_md) BLOCK_UL exit only calls ImGui::NewLine() for top-level list endings. For nested list endings, no NewLine is emitted, so the next text starts at the same Y as the last list item, causing visual overlap. The preprocessor inserts a blank line before any line that follows a list item with MORE indent than itself, forcing a paragraph break. Cannot fix the C++ from Python. Tests: - test_markdown_table_wrapped.py: updated to assert imgui_md.render is called for cell content (not imgui.text_wrapped). - test_markdown_helper_bullets.py: added 4 tests for the new preprocessors (nested-list blank insertion + bullet delimiter conversion + edge cases). 20/20 markdown unit tests pass. 1-space indentation throughout. KNOWN LIMITATIONS (cannot fix without forking imgui_md C++): - Inline code spans render as plain text (no monospace font in cells) - The ' * ' bullet delimiter has a Y-overlap bug upstream (workaround: pre-convert to '- ' via _normalize_bullet_delimiters) - Nested list ending overlap (workaround: insert blank line via _normalize_nested_list_endings) --- src/markdown_helper.py | 28 +++++++++++++++++++++++ src/markdown_table.py | 4 ++-- tests/test_markdown_helper_bullets.py | 26 ++++++++++++++++++++++ tests/test_markdown_table_wrapped.py | 32 +++++++++++++++++++-------- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/markdown_helper.py b/src/markdown_helper.py index 8afd16a4..c2cf6d2a 100644 --- a/src/markdown_helper.py +++ b/src/markdown_helper.py @@ -116,6 +116,7 @@ class MarkdownRenderer: return from src.markdown_table import parse_tables, render_table text = self._normalize_bullet_delimiters(text) + text = self._normalize_nested_list_endings(text) blocks = parse_tables(text) lines = text.splitlines(keepends=True) if not lines: @@ -203,6 +204,33 @@ class MarkdownRenderer: import re return re.sub(r"(?m)^([ \t]*)\*[ \t]+", r"\1- ", text) + def _normalize_nested_list_endings(self, text: str) -> str: + """Insert blank lines before non-list text that follows a nested list item. + + Workaround for imgui_md (mekhontsev/imgui_md) BLOCK_UL exit which only + calls ImGui::NewLine() for top-level list endings. For nested list + endings, no NewLine is emitted, so the next text starts at the same Y + as the last list item (overlap). Inserting a blank line forces a + paragraph break. Cannot fix the upstream C++ from Python. + [C: src/markdown_helper.py:MarkdownRenderer.render] + """ + import re + lines = text.split("\n") + out: list[str] = [] + for i, line in enumerate(lines): + if ( + line.strip() + and not re.match(r"^\s*[-*+\d]", line) + and i > 0 + and re.match(r"^\s*[-*+\d]", lines[i - 1]) + ): + prev_indent = len(lines[i - 1]) - len(lines[i - 1].lstrip()) + curr_indent = len(line) - len(line.lstrip()) + if curr_indent < prev_indent: + out.append("") + out.append(line) + 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/src/markdown_table.py b/src/markdown_table.py index c4c6cc88..180e604b 100644 --- a/src/markdown_table.py +++ b/src/markdown_table.py @@ -1,6 +1,6 @@ import re from dataclasses import dataclass -from imgui_bundle import imgui +from imgui_bundle import imgui, imgui_md _TABLE_SEPARATOR = re.compile(r"^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$") @@ -19,7 +19,7 @@ def render_table(block: "TableBlock") -> None: imgui.table_next_row() for c in row: imgui.table_next_column() - imgui.text_wrapped(c) + imgui_md.render(c) imgui.end_table() @dataclass(frozen=True) diff --git a/tests/test_markdown_helper_bullets.py b/tests/test_markdown_helper_bullets.py index fa13f706..dc6a83b9 100644 --- a/tests/test_markdown_helper_bullets.py +++ b/tests/test_markdown_helper_bullets.py @@ -57,3 +57,29 @@ def test_render_passes_numbered_list_intact_to_imgui_md(): assert "1. First question" in rendered_text assert "2. Second question" in rendered_text assert not mock_imgui.bullet.called, "no manual imgui.bullet should be added — let imgui_md handle list rendering" + +def test_normalize_nested_list_endings_inserts_blank_after_nested_item(): + r = MarkdownRenderer() + text = "- top\n - nested last\nnext paragraph\n" + out = r._normalize_nested_list_endings(text) + assert " - nested last\n\nnext paragraph" in out, f"expected blank line between nested list item and less-indented next line, got {out!r}" + +def test_normalize_nested_list_endings_does_not_insert_blank_for_top_level_list(): + r = MarkdownRenderer() + text = "- one\n- two\n- three\nnext paragraph\n" + out = r._normalize_nested_list_endings(text) + assert out == text, f"top-level list ending should not trigger blank line insertion, got {out!r}" + +def test_normalize_nested_list_endings_does_not_double_blank(): + r = MarkdownRenderer() + text = "- a\n - b\n\nalready blank\n" + out = r._normalize_nested_list_endings(text) + assert out == text, f"already-blank separator should not get doubled, got {out!r}" + +def test_normalize_bullet_delimiters_still_converts_asterisk(): + r = MarkdownRenderer() + text = "- one\n* two\n+ three\n" + out = r._normalize_bullet_delimiters(text) + 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}" diff --git a/tests/test_markdown_table_wrapped.py b/tests/test_markdown_table_wrapped.py index f63e5cf4..e25c4f9c 100644 --- a/tests/test_markdown_table_wrapped.py +++ b/tests/test_markdown_table_wrapped.py @@ -12,32 +12,44 @@ def _setup_mocks(mock_imgui): mock_imgui.text = MagicMock() mock_imgui.end_table = MagicMock() -def test_render_table_uses_text_wrapped_for_cells(): +def _setup_md_mocks(): + from src.markdown_table import imgui_md as md_mod + md_mod.render = MagicMock() + +def test_render_table_routes_cell_content_through_imgui_md(): block = TableBlock(headers=["A"], rows=[["hello"]], span=(0, 2)) - with patch("src.markdown_table.imgui") as mock_imgui: + with patch("src.markdown_table.imgui") as mock_imgui, \ + patch("src.markdown_table.imgui_md") as mock_md: _setup_mocks(mock_imgui) + _setup_md_mocks() render_table(block) - mock_imgui.text_wrapped.assert_any_call("hello") + mock_md.render.assert_any_call("hello"), "cell content must be routed through imgui_md.render (so MD4C strips backticks and handles inline emphasis)" def test_render_table_uses_table_headers_row_for_headers(): block = TableBlock(headers=["A"], rows=[["hello"]], span=(0, 2)) - with patch("src.markdown_table.imgui") as mock_imgui: + with patch("src.markdown_table.imgui") as mock_imgui, \ + patch("src.markdown_table.imgui_md") as mock_md: _setup_mocks(mock_imgui) + _setup_md_mocks() render_table(block) assert mock_imgui.table_headers_row.called, "table_headers_row must be called exactly once to render the header row" assert mock_imgui.table_headers_row.call_count == 1, f"table_headers_row must be called exactly once (not {mock_imgui.table_headers_row.call_count} — would cause double-header bug)" def test_render_table_does_not_use_text_for_cells(): block = TableBlock(headers=["A"], rows=[["hello"]], span=(0, 2)) - with patch("src.markdown_table.imgui") as mock_imgui: + with patch("src.markdown_table.imgui") as mock_imgui, \ + patch("src.markdown_table.imgui_md") as mock_md: _setup_mocks(mock_imgui) + _setup_md_mocks() render_table(block) assert not mock_imgui.text.called, "imgui.text should not be called for table cells" def test_render_table_uses_width_stretch_for_columns(): block = TableBlock(headers=["A", "B"], rows=[["1", "2"]], span=(0, 3)) - with patch("src.markdown_table.imgui") as mock_imgui: + with patch("src.markdown_table.imgui") as mock_imgui, \ + patch("src.markdown_table.imgui_md") as mock_md: _setup_mocks(mock_imgui) + _setup_md_mocks() render_table(block) assert mock_imgui.table_setup_column.call_count == 2 for call in mock_imgui.table_setup_column.call_args_list: @@ -48,10 +60,12 @@ def imgui_flags_passed(args) -> bool: if len(args) < 2: return False return bool(args[1] & 8) -def test_render_table_text_wrapped_count_matches_cell_count(): +def test_render_table_routes_every_cell_through_imgui_md(): block = TableBlock(headers=["A", "B"], rows=[["1", "2"], ["3", "4"], ["5", "6"]], span=(0, 5)) - with patch("src.markdown_table.imgui") as mock_imgui: + with patch("src.markdown_table.imgui") as mock_imgui, \ + patch("src.markdown_table.imgui_md") as mock_md: _setup_mocks(mock_imgui) + _setup_md_mocks() render_table(block) cell_count = 6 - assert mock_imgui.text_wrapped.call_count == cell_count, f"text_wrapped must be called exactly {cell_count} times (cells only, not headers), got {mock_imgui.text_wrapped.call_count}" + assert mock_md.render.call_count == cell_count, f"imgui_md.render must be called exactly {cell_count} times (one per cell, not headers), got {mock_md.render.call_count}"