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