be5dffa4f0
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.
197 lines
8.1 KiB
Python
197 lines
8.1 KiB
Python
from unittest.mock import patch, MagicMock
|
|
from src.md_renderer_py import MarkdownRenderer, MdOptions
|
|
|
|
|
|
def _mock_imgui(mock_imgui):
|
|
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
|
|
mock_imgui.TableColumnFlags_ = type("C", (), {"width_stretch": 8})()
|
|
mock_imgui.Col_ = type("C", (), {
|
|
"text": 0, "text_disabled": 1, "frame_bg": 2, "button": 3,
|
|
"button_hovered": 4, "button_active": 5,
|
|
})()
|
|
mock_imgui.begin_table = MagicMock(return_value=True)
|
|
mock_imgui.end_table = MagicMock()
|
|
mock_imgui.table_setup_column = MagicMock()
|
|
mock_imgui.table_next_row = MagicMock()
|
|
mock_imgui.table_next_column = MagicMock()
|
|
mock_imgui.text = MagicMock()
|
|
mock_imgui.text_wrapped = MagicMock()
|
|
mock_imgui.bullet = MagicMock()
|
|
mock_imgui.same_line = MagicMock()
|
|
mock_imgui.new_line = MagicMock()
|
|
mock_imgui.indent = MagicMock()
|
|
mock_imgui.unindent = MagicMock()
|
|
mock_imgui.spacing = MagicMock()
|
|
mock_imgui.separator = MagicMock()
|
|
mock_imgui.push_style_color = MagicMock()
|
|
mock_imgui.pop_style_color = MagicMock()
|
|
mock_imgui.push_font = MagicMock()
|
|
mock_imgui.pop_font = MagicMock()
|
|
mock_imgui.small_button = MagicMock()
|
|
mock_imgui.get_style = MagicMock(return_value=MagicMock(
|
|
color_=MagicMock(side_effect=lambda col: MagicMock(x=1, y=1, z=1, w=1))
|
|
))
|
|
mock_imgui.get_io = MagicMock(return_value=MagicMock(fonts=MagicMock(fonts=[None])))
|
|
mock_imgui.calc_text_size = MagicMock(return_value=MagicMock(x=50, y=20))
|
|
mock_imgui.get_item_rect_min = MagicMock(return_value=MagicMock(x=0, y=0))
|
|
mock_imgui.get_window_draw_list = MagicMock(return_value=MagicMock(
|
|
add_line=MagicMock()
|
|
))
|
|
mock_imgui.is_item_hovered = MagicMock(return_value=False)
|
|
mock_imgui.is_mouse_released = MagicMock(return_value=False)
|
|
return mock_imgui
|
|
|
|
|
|
def test_renderer_parses_simple_paragraph():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("Hello, world.")
|
|
assert mock_imgui.text_wrapped.called
|
|
|
|
def test_renderer_renders_h1_with_separator():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("# Heading 1")
|
|
assert mock_imgui.separator.called, "h1/h2 should render a separator after the heading"
|
|
|
|
def test_renderer_renders_bullet_list_with_bullets():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("- one\n- two\n- three")
|
|
assert mock_imgui.bullet.call_count == 3, f"expected 3 bullets, got {mock_imgui.bullet.call_count}"
|
|
assert mock_imgui.indent.call_count == 3
|
|
assert mock_imgui.unindent.call_count == 3
|
|
|
|
def test_renderer_renders_ordered_list_with_numbers():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("1. first\n2. second\n3. third")
|
|
assert mock_imgui.bullet.call_count == 0, "ordered list should not use bullets"
|
|
assert mock_imgui.text.called, "ordered list should render the number+delim text"
|
|
for call in mock_imgui.text.call_args_list:
|
|
args, _ = call
|
|
assert any(s in str(args[0]) for s in ["1.", "2.", "3."]), f"got {args[0]!r}"
|
|
|
|
def test_renderer_renders_emphasis_with_dim_color():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("This is *emphasized* text.")
|
|
assert mock_imgui.push_style_color.call_count >= 2, "em should push 2 style colors"
|
|
assert mock_imgui.pop_style_color.call_count >= 2, "em should pop 2 style colors"
|
|
assert mock_imgui.text_wrapped.called
|
|
|
|
def test_renderer_renders_strong_with_bright_color():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("This is **strong** text.")
|
|
assert mock_imgui.push_style_color.call_count >= 2, "strong should push 2 style colors"
|
|
assert mock_imgui.pop_style_color.call_count >= 2
|
|
|
|
def test_renderer_renders_inline_code_with_button():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("Use `code` inline.")
|
|
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()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
md = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
|
|
r.render(md)
|
|
assert mock_imgui.begin_table.called, "table should call begin_table"
|
|
assert mock_imgui.end_table.called, "table should call end_table"
|
|
assert mock_imgui.table_setup_column.call_count == 2, f"expected 2 columns, got {mock_imgui.table_setup_column.call_count}"
|
|
assert mock_imgui.table_next_row.call_count == 3, f"expected 3 rows (1 header + 2 body), got {mock_imgui.table_next_row.call_count}"
|
|
|
|
def test_renderer_renders_link_with_underline():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("Click [here](https://example.com).")
|
|
assert mock_imgui.push_style_color.called
|
|
assert mock_imgui.text_wrapped.called
|
|
|
|
def test_renderer_link_callback_fires_on_click():
|
|
callback = MagicMock()
|
|
opts = MdOptions()
|
|
opts.callbacks.on_open_link = callback
|
|
r = MarkdownRenderer(opts)
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
mock_imgui = _mock_imgui(mock_imgui)
|
|
mock_imgui.is_item_hovered = MagicMock(return_value=True)
|
|
mock_imgui.is_mouse_released = MagicMock(return_value=True)
|
|
r.render("[link](https://example.com)")
|
|
assert callback.called
|
|
args, _ = callback.call_args
|
|
assert "https://example.com" in args
|
|
|
|
def test_renderer_renders_code_block_via_external_handler():
|
|
r = MarkdownRenderer()
|
|
handler = MagicMock()
|
|
r.set_external_code_block_handler(handler)
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("```python\nprint('hi')\n```")
|
|
assert handler.called
|
|
args, _ = handler.call_args
|
|
assert args[0] == "print('hi')\n", f"expected code content, got {args[0]!r}"
|
|
assert args[1] == "python", f"expected lang, got {args[1]!r}"
|
|
|
|
def test_renderer_explicit_newline_between_list_paragraphs():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("- first\n\n continuation paragraph")
|
|
assert mock_imgui.new_line.call_count >= 3, f"expected at least 3 new_lines (one before bullet, one between paragraphs, one after list), got {mock_imgui.new_line.call_count}"
|
|
|
|
def test_renderer_handles_empty_input():
|
|
r = MarkdownRenderer()
|
|
r.render("")
|
|
r.render(" \n \n")
|
|
|
|
def test_renderer_handles_nested_lists():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("- outer\n - inner1\n - inner2\n- outer2")
|
|
assert mock_imgui.bullet.call_count == 4, f"expected 4 bullets (1 outer + 2 inner + 1 outer2), got {mock_imgui.bullet.call_count}"
|
|
assert mock_imgui.unindent.call_count == 4
|
|
|
|
def test_renderer_renders_horizontal_rule():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_mock_imgui(mock_imgui)
|
|
r.render("Above\n\n---\n\nBelow")
|
|
assert mock_imgui.separator.called
|
|
|
|
def test_renderer_render_unindented_strips_common_indent():
|
|
r = MarkdownRenderer()
|
|
with patch("src.md_renderer_py.imgui") as mock_imgui:
|
|
_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)}"
|