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)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user