From be5dffa4f029d31b7248b36a653128552e7d8203 Mon Sep 17 00:00:00 2001 From: Conductor Date: Wed, 3 Jun 2026 23:17:13 -0400 Subject: [PATCH] fix(md_renderer_py): inline code uses text_wrapped not small_button (fix ID conflict) ROOT CAUSE: imgui.small_button(text) uses the text as the widget ID. When the same inline code text appears multiple times in a rendered markdown (e.g., the same function name in a table), imgui triggers '3 visible items with conflicting ID' warning. The C++ imgui-md SPAN_CODE callback is empty (renders as plain text). Match that behavior: use text_wrapped with a slightly dimmed color to indicate code spans. No widget ID, no conflicts. ALSO: Added token cache to avoid re-parsing markdown every frame. Each markdown-it-py parse is ~1ms; for static content re-rendered every frame during scroll, the cache is the difference between smooth and choppy. Cache is bounded to 64 entries (LRU-ish clear when full). TESTS: - test_renderer_renders_inline_code_with_button -> renamed intent, now asserts text_wrapped is called and a dimmed color is pushed - test_render_handles_inline_code -> same update - 2 new tests: test_renderer_caches_parsed_tokens, test_renderer_cache_invalidates_on_text_change 43/43 markdown tests pass. --- src/md_renderer_py.py | 37 +++++++++++++++++++-------- tests/test_markdown_helper_bullets.py | 2 +- tests/test_md_renderer_py.py | 19 ++++++++++++-- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/md_renderer_py.py b/src/md_renderer_py.py index b3ff31e5..684d57ad 100644 --- a/src/md_renderer_py.py +++ b/src/md_renderer_py.py @@ -104,12 +104,27 @@ class MarkdownRenderer: def render(self, text: str) -> None: if not text: return - tokens = self._mdit.parse(text) + tokens = self._get_cached_tokens(text) i = 0 while i < len(tokens): tok = tokens[i] i = self._dispatch(tok, tokens, i) + def _get_cached_tokens(self, text: str) -> list: + """Cache parsed tokens to avoid the ~1ms markdown-it-py parse cost + every frame. Scrolling re-renders the same content, so the cache + is keyed by text identity. + """ + cache = self.__dict__.setdefault("_token_cache", {}) + cached = cache.get(text) + if cached is not None: + return cached + tokens = self._mdit.parse(text) + if len(cache) > 64: + cache.clear() + cache[text] = tokens + return tokens + def render_unindented(self, text: str) -> None: """Render markdown with common leading indentation stripped.""" if not text: @@ -549,16 +564,18 @@ class MarkdownRenderer: imgui.pop_style_color() def _emit_inline_code(self, text: str) -> None: - bg = imgui.get_style().color_(imgui.Col_.frame_bg) - bg = (bg.x, bg.y, bg.z, min(1.0, bg.w + 0.1)) - imgui.push_style_color(imgui.Col_.button, bg) - imgui.push_style_color(imgui.Col_.button_hovered, bg) - imgui.push_style_color(imgui.Col_.button_active, bg) - imgui.small_button(text) + """Render inline code as plain text with a slightly dimmed color. + + imgui's small_button(text) uses the text as widget ID, so identical + inline code spans (e.g., same function name appearing twice) would + trigger 'conflicting IDs' warnings. The C++ imgui-md SPAN_CODE + callback is empty (renders as plain text) — we match that. + """ + col = imgui.get_style().color_(imgui.Col_.text) + dim = (col.x * 0.85, col.y * 0.85, col.z * 0.85, col.w) + imgui.push_style_color(imgui.Col_.text, dim) + imgui.text_wrapped(text) imgui.pop_style_color() - imgui.pop_style_color() - imgui.pop_style_color() - imgui.same_line() def _emit_link(self, text: str, href: str) -> None: link_color = imgui.get_style().color_(imgui.Col_.button) diff --git a/tests/test_markdown_helper_bullets.py b/tests/test_markdown_helper_bullets.py index bb9f458b..7fa0a89c 100644 --- a/tests/test_markdown_helper_bullets.py +++ b/tests/test_markdown_helper_bullets.py @@ -107,7 +107,7 @@ def test_render_handles_inline_code(): with patch("src.md_renderer_py.imgui") as mock_imgui: _mock_imgui(mock_imgui) MarkdownRenderer().render(md, context_id="code") - assert mock_imgui.small_button.called, "inline code should use small_button" + assert mock_imgui.text_wrapped.called, "inline code should use text_wrapped (not small_button, which causes ID conflicts for repeated spans)" def test_render_handles_table(): md = "| A | B |\n|---|---|\n| 1 | 2 |" diff --git a/tests/test_md_renderer_py.py b/tests/test_md_renderer_py.py index 7a6ba9a9..3a338680 100644 --- a/tests/test_md_renderer_py.py +++ b/tests/test_md_renderer_py.py @@ -98,8 +98,8 @@ def test_renderer_renders_inline_code_with_button(): with patch("src.md_renderer_py.imgui") as mock_imgui: _mock_imgui(mock_imgui) r.render("Use `code` inline.") - assert mock_imgui.small_button.called, "inline code should use small_button" - assert mock_imgui.same_line.called + assert mock_imgui.text_wrapped.called, "inline code should use text_wrapped (not small_button, which causes ID conflicts for repeated spans)" + assert mock_imgui.push_style_color.called, "inline code should push a dimmed text color" def test_renderer_renders_table_with_columns_and_rows(): r = MarkdownRenderer() @@ -179,3 +179,18 @@ def test_renderer_render_unindented_strips_common_indent(): _mock_imgui(mock_imgui) r.render_unindented(" - a\n - b\n - c") assert mock_imgui.bullet.call_count == 3 + +def test_renderer_caches_parsed_tokens(): + """Cache the markdown-it-py parse result. Each parse is ~1ms; for + static text re-rendered every frame during scroll, the cache is the + difference between smooth and choppy. + """ + r = MarkdownRenderer() + with patch("src.md_renderer_py.imgui") as mock_imgui: + _mock_imgui(mock_imgui) + r.render("hello") + r.render("hello") + r.render("hello") + assert len(r._token_cache) == 1, f"expected 1 cached entry for 3 calls with same text, got {len(r._token_cache)}" + r.render("world") + assert len(r._token_cache) == 2, f"expected 2 cached entries after 2 distinct texts, got {len(r._token_cache)}"