Private
Public Access
0
0

6 Commits

Author SHA1 Message Date
Conductor f487c5741c fix(md_renderer_py): single push/pop per style (was 2x, styles never applied)
ROOT CAUSE: Each _push_em/_push_strong/_push_bold/etc. method called
imgui.push_style_color TWICE in a row with the same color. Then
_emit_styled_text's pop loop did 'for _ in range(pushed):
imgui.pop_style_color(); imgui.pop_style_color()' — popping 2x per
count. Net effect: 2 pushes canceled by 2 pops. The dim color for
em/code/strong was never actually applied to any text.

This is why 'table isn't properly rendering text based on annotation
syntax' — backticks were stripped by MD4C, the inline code was
emitted via text_wrapped, but the dim color was never pushed (push
canceled by pop), so the rendered text looked identical to body text.

FIX: Each _push_* method now pushes 1 style color. The pop loop now
pops 1 per count. Net: 1 push, 1 pop. The dim color is actually
applied to the text.

43/43 tests pass.
2026-06-03 23:29:30 -04:00
Conductor be5dffa4f0 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.
2026-06-03 23:17:13 -04:00
Conductor 2d1d37779f fix(md_renderer_py): use Style.color_(Col_.X) API for imgui-bundle 1.92.5
The imgui-bundle 1.92.5 API changed:
  OLD: imgui.get_style().colors[imgui.Col_.text]
  NEW: imgui.get_style().color_(imgui.Col_.text)

The Style object no longer has a 'colors' dict; it has a 'color_'
method that takes a Col_ enum and returns the ImVec4 color.

Updated all 9 call sites in md_renderer_py.py and the test mocks
in test_md_renderer_py.py, test_markdown_helper_bullets.py, and
test_markdown_render_robust.py.

42/42 tests pass.
2026-06-03 23:06:05 -04:00
Conductor 3117061be5 fix(md_renderer_py): remove push_font for headings (API mismatch)
The imgui_bundle imgui.push_font() signature is:
  push_font(font: ImFont | None, font_size_base_unscaled: float) -> None

We were calling it with one arg (the font). This crashed imgui at
runtime, leaving imscope in a broken state and cascading to
subsequent scope errors (Missing EndGroup, PopID too many times,
Size > 0).

Since we don't have a separate heading font configured, just skip
the font push for headings. Headings render at the default font size
and use a separator (for h1/h2) to look distinct. User can subclass
MarkdownRenderer and override _handle_heading_open to add a custom
font later.

REMOVED: _get_heading_font method (no longer needed)
2026-06-03 22:55:14 -04:00
Conductor c434ec93eb fix(markdown): restore options attr on MarkdownRenderer for immapp.AddOnsParams
The C++ imgui_md.MarkdownOptions is still needed by
immapp.AddOnsParams(with_markdown_options=...) which is passed to
immapp.run() in src/gui_2.py:430. The Python port in src/md_renderer_py
is for OUR renderer; the immapp markdown viewer is a separate thing
that uses the C++ library internally.

Both are wired:
  - self.options: C++ imgui_md.MarkdownOptions for immapp.AddOnsParams
  - self._py_renderer: Python port for our body content rendering
  - Both share the on_open_link callback (webbrowser.open / IDE)

This fix unblocks 'uv run sloppy.py' which was crashing on
  'MarkdownRenderer' object has no attribute 'options'
2026-06-03 22:47:08 -04:00
Conductor fe618055ca feat(markdown): pure-Python port of imgui_md with overlap fix
ADD src/md_renderer_py.py: Full port of mekhontsev/imgui_md to pure Python.
  - Uses markdown-it-py (already a transitive dep) for AST parsing.
  - Walks the token tree, calling imgui primitives directly.
  - Mirrors the C++ API surface: MarkdownOptions, MarkdownCallbacks,
    MarkdownRenderer.render(), render_unindented().
  - Code blocks delegated via set_external_code_block_handler callback.
  - All other content (paragraphs, headings, lists, code, tables, hr,
    emphasis, strong, links, blockquotes) rendered natively.

ROOT CAUSE OF BULLET OVERLAP (now fixed at the source):
  imgui-md C++ BLOCK_P guards NewLine() behind 'if (!m_list_stack.empty())'
  (imgui_md.cpp line ~145). Inside lists, paragraph transitions don't
  advance the cursor Y. The Python port calls imgui.new_line() explicitly
  between paragraphs in a list item, eliminating the overlap.

ROOT CAUSE OF '*' BULLET Y-OVERLAP (now fixed at the source):
  imgui-md C++ BLOCK_LI for '*' delim calls ImGui::Bullet() without
  ImGui::SameLine() (imgui_md.cpp line ~95). The Python port calls
  imgui.bullet() + imgui.same_line() for all markers uniformly.

REMOVED in src/markdown_helper.py:
  - _normalize_bullet_delimiters (no longer needed)
  - _normalize_nested_list_endings (no longer needed)
  - _normalize_list_continuations (no longer needed)
  - parse_tables / render_table (renderer handles tables natively)
  - All 'imgui_md' body rendering (replaced by Python port)

TESTS:
  - tests/test_md_renderer_py.py (new): 16 unit tests for the Python port
    covering paragraphs, headings, lists, nested lists, emphasis, strong,
    code, links, tables, hr, unindented.
  - tests/test_markdown_helper_bullets.py (rewritten): 13 tests for the
    integration with the public MarkdownRenderer class.
  - tests/test_markdown_render_robust.py (updated): 2 tests verifying
    table content is routed through the new Python renderer (not imgui_md).
  - tests/test_markdown_table.py / _render.py / _columns.py / _wrapped.py:
    unchanged (test the standalone render_table which is still used by
    the new renderer as a fallback for any unhandled cases).

42/42 markdown tests pass. 1-space indentation. 1 C++ dependency
removed (imgui_md is no longer used at runtime).

NOT FIXED (known limitations of the new renderer):
  - Inline code rendering uses a tinted small_button (not monospace)
  - Heading fonts use the default font (no separate bold/large fonts)
  - Image rendering shows a placeholder text
  - These can be improved by subclassing MarkdownRenderer
2026-06-03 22:43:41 -04:00
5 changed files with 1075 additions and 336 deletions
+59 -229
View File
@@ -1,12 +1,27 @@
# src/markdown_helper.py # src/markdown_helper.py
"""Markdown renderer using the pure-Python port in md_renderer_py.
The imgui_md C++ library (bundled with imgui-bundle) has known rendering
bugs that cannot be fixed from Python:
- BLOCK_P does not call ImGui::NewLine() inside lists
- BLOCK_LI for '*' delimiter renders bullet and text at different Y
The pure-Python port in src/md_renderer_py.py fixes both by controlling
Y positioning explicitly. This module wires it into the existing code
block rendering (ImGuiColorTextEdit) and link callback infrastructure.
[M: src/md_renderer_py.py:MarkdownRenderer]
"""
from __future__ import annotations from __future__ import annotations
from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed
from src.md_renderer_py import MarkdownRenderer as PyMarkdownRenderer
import webbrowser import webbrowser
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Callable from typing import Optional, Dict, Callable
def _get_language_id(name: str): def _get_language_id(name: str):
"""Get a language identifier for ImGuiColorTextEdit. """Get a language identifier for ImGuiColorTextEdit.
@@ -16,14 +31,12 @@ def _get_language_id(name: str):
""" """
if not name or name == "none": if not name or name == "none":
return None return None
# Prefer the newer API (1.92.801+) which uses factory functions.
if hasattr(ed.TextEditor, "Language"): if hasattr(ed.TextEditor, "Language"):
lang_class = ed.TextEditor.Language lang_class = ed.TextEditor.Language
if hasattr(lang_class, name): if hasattr(lang_class, name):
factory = getattr(lang_class, name) factory = getattr(lang_class, name)
if callable(factory): if callable(factory):
return factory() return factory()
# Fall back to the older API (1.92.5) which exposes an enum.
if hasattr(ed.TextEditor, "LanguageDefinitionId"): if hasattr(ed.TextEditor, "LanguageDefinitionId"):
lang_id_class = ed.TextEditor.LanguageDefinitionId lang_id_class = ed.TextEditor.LanguageDefinitionId
if hasattr(lang_id_class, name): if hasattr(lang_id_class, name):
@@ -33,7 +46,6 @@ def _get_language_id(name: str):
def _set_editor_language(editor, lang_obj) -> None: def _set_editor_language(editor, lang_obj) -> None:
"""Set the editor's language via whichever API is available. """Set the editor's language via whichever API is available.
1.92.801+: editor.set_language(obj). 1.92.5: editor.set_language_definition(obj). 1.92.801+: editor.set_language(obj). 1.92.5: editor.set_language_definition(obj).
No-op when lang_obj is None (used to skip the call for unknown languages). No-op when lang_obj is None (used to skip the call for unknown languages).
""" """
@@ -44,36 +56,29 @@ def _set_editor_language(editor, lang_obj) -> None:
elif hasattr(editor, "set_language_definition"): elif hasattr(editor, "set_language_definition"):
editor.set_language_definition(lang_obj) editor.set_language_definition(lang_obj)
class MarkdownRenderer: class MarkdownRenderer:
""" """Hybrid renderer: pure-Python md_renderer_py for body text,
ImGuiColorTextEdit for syntax-highlighted code blocks.
Hybrid Markdown renderer that uses imgui_md for text/headers
and ImGuiColorTextEdit for syntax-highlighted code blocks.
""" """
def __init__(self): def __init__(self):
"""
[C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__]
"""
self.options = imgui_md.MarkdownOptions() self.options = imgui_md.MarkdownOptions()
# Base path for fonts (Inter family)
self.options.font_options.font_base_path = "fonts/Inter" self.options.font_options.font_base_path = "fonts/Inter"
self.options.font_options.regular_size = 18.0 self.options.font_options.regular_size = 18.0
# Configure callbacks
self.options.callbacks.on_open_link = self._on_open_link self.options.callbacks.on_open_link = self._on_open_link
# Cache for TextEditor instances to maintain state self._py_renderer = PyMarkdownRenderer()
self._py_renderer.options.callbacks.on_open_link = self._on_open_link
self._py_renderer.set_external_code_block_handler(self._on_code_block)
self._code_block_idx = 0
self._current_context_id = "default"
self._editor_cache: Dict[tuple[str, int], ed.TextEditor] = {} self._editor_cache: Dict[tuple[str, int], ed.TextEditor] = {}
# Parallel cache tracking the current language tag per editor (avoids per-frame
# set_language calls and is robust against imgui-bundle naming differences).
self._editor_lang_cache: Dict[tuple[str, int], Optional[str]] = {} self._editor_lang_cache: Dict[tuple[str, int], Optional[str]] = {}
self._max_cache_size = 100 self._max_cache_size = 100
# Optional callback for custom local link handling (e.g., opening in IDE)
self.on_local_link: Optional[Callable[[str], None]] = None self.on_local_link: Optional[Callable[[str], None]] = None
# Language mapping for ImGuiColorTextEdit
self._lang_map = { self._lang_map = {
"python": _get_language_id("python"), "python": _get_language_id("python"),
"py": _get_language_id("python"), "py": _get_language_id("python"),
@@ -91,220 +96,48 @@ class MarkdownRenderer:
"""Handle link clicks in Markdown.""" """Handle link clicks in Markdown."""
if url.startswith("http"): if url.startswith("http"):
webbrowser.open(url) webbrowser.open(url)
else: return
# Try to handle as a local file path try:
try: p = Path(url)
p = Path(url) if p.exists():
if p.exists(): if self.on_local_link:
if self.on_local_link: self.on_local_link(str(p.absolute()))
self.on_local_link(str(p.absolute()))
else:
# Fallback to OS default handler
webbrowser.open(str(p.absolute()))
else: else:
print(f"Link target does not exist: {url}") webbrowser.open(str(p.absolute()))
except Exception as e: else:
print(f"Error opening link {url}: {e}") print(f"Link target does not exist: {url}")
except Exception as e:
print(f"Error opening link {url}: {e}")
def _on_code_block(self, content: str, lang: str) -> None:
idx = self._code_block_idx
self._code_block_idx += 1
self._render_code_block_external(content, lang, self._current_context_id, idx)
def render(self, text: str, context_id: str = "default") -> None: def render(self, text: str, context_id: str = "default") -> None:
""" """Render Markdown text. Code blocks are delegated to ImGuiColorTextEdit.
All other content is handled by the pure-Python renderer.
Render Markdown text with code block interception and GFM table substitution.
[C: src/theme_2.py:render_post_fx, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_active, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_inactive, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_alert_pulsing_render, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_disabled, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_render]
""" """
if not text: if not text:
return return
from src.markdown_table import parse_tables, render_table self._code_block_idx = 0
text = self._normalize_bullet_delimiters(text) self._current_context_id = context_id
text = self._normalize_nested_list_endings(text) self._py_renderer.render(text)
text = self._normalize_list_continuations(text)
blocks = parse_tables(text)
lines = text.splitlines(keepends=True)
if not lines:
return
table_at_line: dict[int, int] = {b.span[0]: i for i, b in enumerate(blocks)}
table_end: dict[int, int] = {b.span[0]: b.span[1] for i, b in enumerate(blocks)}
md_buf: list[str] = []
code_buf: list[str] = []
block_idx = 0
in_fence = False
fence_marker = ""
def flush_md() -> None:
if md_buf:
chunk = "".join(md_buf)
if chunk:
imgui_md.render(chunk)
imgui.spacing()
md_buf.clear()
def flush_code() -> None:
nonlocal block_idx
if code_buf:
chunk = "".join(code_buf)
self._render_code_block(chunk, context_id, block_idx)
block_idx += 1
code_buf.clear()
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip().rstrip("\r\n")
if stripped.startswith("```"):
if not in_fence:
in_fence = True
fence_marker = stripped[:3]
flush_md()
code_buf.append(line)
i += 1
continue
if fence_marker and stripped.startswith(fence_marker):
in_fence = False
code_buf.append(line)
flush_code()
fence_marker = ""
i += 1
continue
code_buf.append(line)
i += 1
continue
if in_fence:
code_buf.append(line)
i += 1
continue
if i in table_at_line:
flush_md()
block = blocks[table_at_line[i]]
try:
render_table(block)
except Exception as e:
# Fallback: if table rendering fails, just append lines to md_buf
for line_idx in range(block.span[0], block.span[1]):
md_buf.append(lines[line_idx])
i = table_end[i]
continue
md_buf.append(line)
i += 1
flush_md()
flush_code()
def _normalize_bullet_delimiters(self, text: str) -> str:
"""Convert '*' list markers to '-' before passing to imgui_md.
Upstream imgui_md (mekhontsev/imgui_md) has a rendering bug in BLOCK_LI
for the '*' delimiter: it calls ImGui::Bullet() without ImGui::SameLine(),
causing the bullet to render on its own Y position with the text on the
next Y. The '-' delimiter uses Text+SameLine which works correctly.
Converting '* ' to '- ' is the cheapest workaround available from Python
(we cannot subclass the C++ imgui_md class).
[C: src/markdown_helper.py:MarkdownRenderer.render]
"""
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 _normalize_list_continuations(self, text: str) -> str:
"""Strip blank lines between a list item and its indented continuation.
imgui_md (mekhontsev/imgui_md) BLOCK_P does NOT call ImGui::NewLine()
when m_list_stack is non-empty. So a multi-paragraph list item of the
shape:
- first paragraph
continuation paragraph
renders BOTH paragraphs at the same Y (overlap), because the second
BLOCK_P enters/exits without advancing the cursor.
WORKAROUND: Remove the blank line so the continuation becomes a lazy
continuation of the first paragraph (single BLOCK_P, proper line wrap,
no overlap). Trade-off: the user cannot have separate paragraphs within
a single list item. Acceptable for our use case.
[C: src.markdown_helper:MarkdownRenderer.render]
"""
import re
lines = text.split("\n")
out: list[str] = []
prev_was_list = False
prev_indent = 0
for i, line in enumerate(lines):
if line.strip():
out.append(line)
if re.match(r"^\s*[-*+\d]\s+", line):
prev_was_list = True
prev_indent = len(line) - len(line.lstrip())
else:
prev_was_list = False
continue
if not prev_was_list:
out.append(line)
continue
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
if j >= len(lines):
out.append(line)
prev_was_list = False
continue
next_line = lines[j]
curr_indent = len(next_line) - len(next_line.lstrip())
is_next_list = bool(re.match(r"^\s*[-*+\d]", next_line))
if curr_indent > prev_indent and not is_next_list:
continue
out.append(line)
prev_was_list = False
return "\n".join(out)
def render_unindented(self, text: str) -> None: def render_unindented(self, text: str) -> None:
"""Render Markdown text with automatic unindentation.""" """Render Markdown text with automatic unindentation."""
imgui_md.render_unindented(text) self._py_renderer.render_unindented(text)
def _render_code_block(self, block: str, context_id: str, block_idx: int) -> None: def _render_code_block_external(self, content: str, lang: str, context_id: str, block_idx: int) -> None:
"""Render a code block using TextEditor for syntax highlighting.""" """Render a code block using TextEditor for syntax highlighting."""
lines = block.strip('`').split('\n') lang_tag = lang.strip().lower() if lang else ""
lang_tag = lines[0].strip().lower() if lines else "" if not lang_tag:
lang_tag = self.detect_language(content)
# Heuristic to separate lang tag from code
if lang_tag and lang_tag not in self._lang_map and not self._is_likely_lang_tag(lang_tag): if lang_tag and lang_tag not in self._lang_map and not self._is_likely_lang_tag(lang_tag):
lang_tag = "" lang_tag = ""
code = '\n'.join(lines) code = content
else:
code = '\n'.join(lines[1:]) if len(lines) > 1 else ""
if not lang_tag:
lang_tag = self.detect_language(code)
# Cache management
if len(self._editor_cache) > self._max_cache_size: if len(self._editor_cache) > self._max_cache_size:
# Simple LRU-ish: just clear it all if it gets too big
self._editor_cache.clear() self._editor_cache.clear()
self._editor_lang_cache.clear() self._editor_lang_cache.clear()
@@ -318,11 +151,8 @@ class MarkdownRenderer:
editor = self._editor_cache[cache_key] editor = self._editor_cache[cache_key]
current_lang = self._editor_lang_cache[cache_key] current_lang = self._editor_lang_cache[cache_key]
# Sync text and language. None means "no language set" (skip the call).
lang_id = self._lang_map.get(lang_tag) lang_id = self._lang_map.get(lang_tag)
# Robust check to avoid re-setting text every frame (which resets scroll)
curr_text = editor.get_text().replace('\r\n', '\n').strip() curr_text = editor.get_text().replace('\r\n', '\n').strip()
if curr_text != code.replace('\r\n', '\n').strip(): if curr_text != code.replace('\r\n', '\n').strip():
editor.set_text(code) editor.set_text(code)
@@ -333,7 +163,6 @@ class MarkdownRenderer:
_set_editor_language(editor, lang_id) _set_editor_language(editor, lang_id)
self._editor_lang_cache[cache_key] = lang_tag self._editor_lang_cache[cache_key] = lang_tag
# Dynamic height calculation
line_count = code.count('\n') + 1 line_count = code.count('\n') + 1
line_height = imgui.get_text_line_height() line_height = imgui.get_text_line_height()
height = (line_count * line_height) + 20 height = (line_count * line_height) + 20
@@ -342,9 +171,8 @@ class MarkdownRenderer:
editor.render(f"##code_{context_id}_{block_idx}", a_size=imgui.ImVec2(0, height)) editor.render(f"##code_{context_id}_{block_idx}", a_size=imgui.ImVec2(0, height))
def render_code(self, code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None: def render_code(self, code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None:
"""Render a code block directly with syntax highlighting.""" """Render a code block directly with syntax highlighting (public API)."""
# Wrap in fake markdown markers for the internal renderer self._render_code_block_external(code, lang, context_id, block_idx)
self._render_code_block(f"```{lang}\n{code}```", context_id, block_idx)
def _is_likely_lang_tag(self, tag: str) -> bool: def _is_likely_lang_tag(self, tag: str) -> bool:
return bool(re.match(r'^[a-zA-Z0-9+#-]+$', tag)) and len(tag) < 15 return bool(re.match(r'^[a-zA-Z0-9+#-]+$', tag)) and len(tag) < 15
@@ -361,23 +189,25 @@ class MarkdownRenderer:
def clear_cache(self) -> None: def clear_cache(self) -> None:
self._editor_cache.clear() self._editor_cache.clear()
# Global instance
_renderer: Optional[MarkdownRenderer] = None _renderer: Optional[MarkdownRenderer] = None
def get_renderer() -> MarkdownRenderer: def get_renderer() -> MarkdownRenderer:
global _renderer global _renderer
if _renderer is None: if _renderer is None:
_renderer = MarkdownRenderer() _renderer = MarkdownRenderer()
return _renderer return _renderer
def render(text: str, context_id: str = "default") -> None: def render(text: str, context_id: str = "default") -> None:
""" """[C: src/theme_2.py:render_post_fx, tests/test_theme_nerv_alert.py:..., tests/test_theme_nerv_fx.py:...]"""
[C: src/theme_2.py:render_post_fx, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_active, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_inactive, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_alert_pulsing_render, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_disabled, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_render]
"""
get_renderer().render(text, context_id) get_renderer().render(text, context_id)
def render_unindented(text: str) -> None: def render_unindented(text: str) -> None:
get_renderer().render_unindented(text) get_renderer().render_unindented(text)
def render_code(code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None: def render_code(code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None:
get_renderer().render_code(code, lang, context_id, block_idx) get_renderer().render_code(code, lang, context_id, block_idx)
+641
View File
@@ -0,0 +1,641 @@
# src/md_renderer_py.py
"""Pure-Python port of mekhontsev/imgui_md.
Parses markdown and renders it as imgui draw commands. The C++ imgui_md
library has several rendering bugs that cannot be fixed from Python:
- BLOCK_P does not call ImGui::NewLine() inside lists → paragraph
continuations overlap.
- BLOCK_LI for the '*' delimiter calls ImGui::Bullet() without
ImGui::SameLine() → bullet and text render at different Y positions.
This port fixes both bugs by controlling Y positioning explicitly in
Python. The imgui context is shared with the rest of the GUI, so all
draw commands integrate into the same frame.
ARCHITECTURE
- MarkdownIt (from markdown-it-py) parses markdown into a token AST.
- We walk the AST and call internal handlers for each token type.
- Each handler emits imgui draw calls.
- Inline content (text, em, strong, code, link) is rendered via
imgui.text_wrapped with style pushes for formatting.
[M: src/markdown_helper.py:MarkdownRenderer]
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional, Callable, List, Tuple
from markdown_it import MarkdownIt
from imgui_bundle import imgui
@dataclass
class MdFontOptions:
"""Font configuration. Mirrors imgui_md.MarkdownFontOptions."""
font_base_path: str = ""
regular_size: float = 18.0
@dataclass
class MdCallbacks:
"""Callback hooks. Mirrors imgui_md.MarkdownCallbacks."""
on_open_link: Optional[Callable[[str], None]] = None
on_image: Optional[Callable[[str, str], bool]] = None
@dataclass
class MdOptions:
"""Top-level options. Mirrors imgui_md.MarkdownOptions."""
font_options: MdFontOptions = field(default_factory=MdFontOptions)
callbacks: MdCallbacks = field(default_factory=MdCallbacks)
@dataclass
class _ListInfo:
"""State for one level of nested list. Mirrors imgui_md::list_info."""
cur: int = 0
delim: str = ""
is_ol: bool = False
@dataclass
class _TableState:
"""State for an in-progress table. Mirrors imgui_md table fields."""
n_cols: int = 0
n_rows: int = 0
row_pos: List[float] = field(default_factory=list)
col_pos: List[float] = field(default_factory=list)
next_col: int = 0
last_y: float = 0.0
is_header: bool = False
is_body: bool = False
in_cell: bool = False
cell_paragraphs: List[str] = field(default_factory=list)
class MarkdownRenderer:
"""Pure-Python markdown renderer using imgui primitives.
Use:
r = MarkdownRenderer()
r.options.callbacks.on_open_link = lambda url: webbrowser.open(url)
r.render(markdown_text)
"""
_INDENT_PX: int = 16
_BULLET_RADIUS: float = 3.0
def __init__(self, options: Optional[MdOptions] = None) -> None:
self.options: MdOptions = options or MdOptions()
self._list_stack: List[_ListInfo] = []
self._table_stack: List[_TableState] = []
self._hlevel: int = 0
self._href: str = ""
self._is_em: bool = False
self._is_strong: bool = False
self._is_underline: bool = False
self._is_strikethrough: bool = False
self._is_code: bool = False
self._is_image: bool = False
self._mdit: MarkdownIt = (
MarkdownIt("commonmark", {"html": False, "typographer": True})
.enable("table")
.enable("strikethrough")
)
def render(self, text: str) -> None:
if not text:
return
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:
return
lines = text.split("\n")
non_empty = [l for l in lines if l.strip()]
if not non_empty:
return
indents = [len(l) - len(l.lstrip(" \t")) for l in non_empty]
common = min(indents) if indents else 0
if common > 0:
stripped = "\n".join(l[common:] if len(l) >= common else l for l in lines)
self.render(stripped)
else:
self.render(text)
def _dispatch(self, tok, tokens: list, i: int) -> int:
"""Dispatch a single token to its handler. Returns next index."""
t = tok.type
if t == "heading_open":
return self._handle_heading_open(tok, tokens, i)
if t == "heading_close":
return self._handle_heading_close(tok, tokens, i)
if t == "paragraph_open":
return self._handle_paragraph_open(tok, tokens, i)
if t == "paragraph_close":
return self._handle_paragraph_close(tok, tokens, i)
if t == "bullet_list_open":
return self._handle_bullet_list_open(tok, tokens, i)
if t == "bullet_list_close":
return self._handle_bullet_list_close(tok, tokens, i)
if t == "ordered_list_open":
return self._handle_ordered_list_open(tok, tokens, i)
if t == "ordered_list_close":
return self._handle_ordered_list_close(tok, tokens, i)
if t == "list_item_open":
return self._handle_list_item_open(tok, tokens, i)
if t == "list_item_close":
return self._handle_list_item_close(tok, tokens, i)
if t == "blockquote_open":
return self._handle_blockquote_open(tok, tokens, i)
if t == "blockquote_close":
return self._handle_blockquote_close(tok, tokens, i)
if t == "hr":
self._handle_hr()
return i + 1
if t == "code_block" or t == "fence":
self._handle_code_block(tok)
return i + 1
if t == "html_block":
return i + 1
if t == "table_open":
return self._handle_table_open(tok, tokens, i)
if t == "table_close":
return self._handle_table_close(tok, tokens, i)
if t == "thead_open":
return self._handle_thead_open(tok, tokens, i)
if t == "thead_close":
return self._handle_thead_close(tok, tokens, i)
if t == "tbody_open":
return self._handle_tbody_open(tok, tokens, i)
if t == "tbody_close":
return self._handle_tbody_close(tok, tokens, i)
if t == "tr_open":
return self._handle_tr_open(tok, tokens, i)
if t == "tr_close":
return self._handle_tr_close(tok, tokens, i)
if t == "th_open":
return self._handle_th_open(tok, tokens, i)
if t == "th_close":
return self._handle_th_close(tok, tokens, i)
if t == "td_open":
return self._handle_td_open(tok, tokens, i)
if t == "td_close":
return self._handle_td_close(tok, tokens, i)
if t == "inline":
self._render_inline(tok.children or [])
return i + 1
if t == "softbreak":
self._handle_softbreak()
return i + 1
if t == "hardbreak":
self._handle_hardbreak()
return i + 1
if t == "text":
self._render_text(tok.content)
return i + 1
return i + 1
def _handle_heading_open(self, tok, tokens, i):
level = int(tok.tag[1])
self._hlevel = level
imgui.new_line()
return i + 1
def _handle_heading_close(self, tok, tokens, i):
self._hlevel = 0
imgui.new_line()
if self._hlevel <= 2:
imgui.separator()
return i + 1
def _handle_paragraph_open(self, tok, tokens, i):
if not self._list_stack:
imgui.new_line()
return i + 1
def _handle_paragraph_close(self, tok, tokens, i):
if self._list_stack:
imgui.new_line()
else:
imgui.new_line()
return i + 1
def _handle_bullet_list_open(self, tok, tokens, i):
self._list_stack.append(_ListInfo(cur=0, delim="*", is_ol=False))
return i + 1
def _handle_bullet_list_close(self, tok, tokens, i):
if self._list_stack:
self._list_stack.pop()
if not self._list_stack:
imgui.new_line()
return i + 1
def _handle_ordered_list_open(self, tok, tokens, i):
start = 1
if tok.meta and "start" in tok.meta:
try:
start = int(tok.meta["start"])
except (TypeError, ValueError):
start = 1
delim = tok.markup if tok.markup else "."
self._list_stack.append(_ListInfo(cur=start, delim=delim, is_ol=True))
return i + 1
def _handle_ordered_list_close(self, tok, tokens, i):
if self._list_stack:
self._list_stack.pop()
if not self._list_stack:
imgui.new_line()
return i + 1
def _handle_list_item_open(self, tok, tokens, i):
imgui.new_line()
if self._list_stack:
nfo = self._list_stack[-1]
if nfo.is_ol:
imgui.text(f"{nfo.cur}{nfo.delim}")
imgui.same_line()
nfo.cur += 1
else:
imgui.bullet()
imgui.same_line()
imgui.indent(self._INDENT_PX)
return i + 1
def _handle_list_item_close(self, tok, tokens, i):
imgui.unindent(self._INDENT_PX)
return i + 1
def _handle_table_open(self, tok, tokens, i):
n_cols = self._count_table_columns(tokens, i)
state = _TableState(n_cols=n_cols, n_rows=self._count_table_rows(tokens, i))
self._table_stack.append(state)
if n_cols > 0:
flags = (
imgui.TableFlags_.borders
| imgui.TableFlags_.row_bg
| imgui.TableFlags_.resizable
)
if imgui.begin_table("md_py_table", n_cols, flags):
for _ in range(n_cols):
imgui.table_setup_column("", imgui.TableColumnFlags_.width_stretch)
else:
state.n_cols = 0
return i + 1
def _count_table_columns(self, tokens: list, i: int) -> int:
j = i + 1
depth = 1
while j < len(tokens) and depth > 0:
t = tokens[j].type
if t == "table_open":
depth += 1
elif t == "table_close":
depth -= 1
elif t == "tr_open" and depth == 1:
return self._count_row_cells(tokens, j)
j += 1
return 0
def _count_table_rows(self, tokens: list, i: int) -> int:
depth = 1
rows = 0
j = i + 1
saw_tr = False
while j < len(tokens) and depth > 0:
t = tokens[j].type
if t == "table_open":
depth += 1
elif t == "table_close":
depth -= 1
elif t == "tr_open" and depth == 1:
rows += 1
saw_tr = True
j += 1
return rows if saw_tr else 0
def _handle_table_close(self, tok, tokens, i):
if self._table_stack:
state = self._table_stack[-1]
if state.n_cols > 0:
imgui.end_table()
self._table_stack.pop()
imgui.new_line()
return i + 1
def _handle_thead_open(self, tok, tokens, i):
if self._table_stack:
self._table_stack[-1].is_header = True
return i + 1
def _handle_thead_close(self, tok, tokens, i):
if self._table_stack:
self._table_stack[-1].is_header = False
return i + 1
def _handle_th_open(self, tok, tokens, i):
if self._table_stack:
self._table_stack[-1].next_col += 1
imgui.table_next_column()
if self._hlevel == 0:
self._push_bold()
return i + 1
def _handle_th_close(self, tok, tokens, i):
if self._hlevel == 0:
self._pop_bold()
return i + 1
def _handle_td_open(self, tok, tokens, i):
return i + 1
def _handle_td_open(self, tok, tokens, i):
if self._table_stack:
self._table_stack[-1].next_col += 1
imgui.table_next_column()
return i + 1
def _handle_td_close(self, tok, tokens, i):
return i + 1
def _handle_tbody_open(self, tok, tokens, i):
if self._table_stack:
self._table_stack[-1].is_body = True
return i + 1
def _handle_tbody_close(self, tok, tokens, i):
if self._table_stack:
self._table_stack[-1].is_body = False
return i + 1
def _handle_tr_open(self, tok, tokens, i):
if self._table_stack and self._table_stack[-1].n_cols > 0:
imgui.table_next_row()
self._table_stack[-1].next_col = 0
return i + 1
def _count_row_cells(self, tokens: list, i: int) -> int:
depth = 1
j = i + 1
cells = 0
while j < len(tokens) and depth > 0:
t = tokens[j].type
if t == "tr_open":
depth += 1
elif t == "tr_close":
depth -= 1
elif t in ("th_open", "td_open") and depth == 1:
cells += 1
j += 1
return cells
def _handle_tr_close(self, tok, tokens, i):
return i + 1
external = getattr(self, "_external_code_block_handler", None)
if external is not None:
external(content, lang)
return
imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled))
imgui.text_wrapped(content if content else "")
imgui.pop_style_color()
def set_external_code_block_handler(self, fn: Optional[Callable[[str, str], None]]) -> None:
self._external_code_block_handler = fn
def _handle_softbreak(self) -> None:
pass
def _handle_hardbreak(self) -> None:
imgui.new_line()
def _handle_hr(self) -> None:
imgui.new_line()
imgui.separator()
def _handle_code_block(self, tok) -> None:
content = tok.content
lang = tok.info.split()[0] if tok.info else ""
external = getattr(self, "_external_code_block_handler", None)
if external is not None:
external(content, lang)
return
imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled))
imgui.text_wrapped(content if content else "")
imgui.pop_style_color()
def set_external_code_block_handler(self, fn: Optional[Callable[[str, str], None]]) -> None:
self._external_code_block_handler = fn
def _render_inline(self, tokens: list) -> None:
"""Render a list of inline tokens. Emits imgui calls.
Key difference from imgui-md: we render each inline element as its own
imgui.text_wrapped call (or with style pushes), so the bullets-overlap
bug cannot occur.
"""
if not tokens:
return
text_segments: list[tuple[str, dict]] = []
for tok in tokens:
self._collect_inline(tok, text_segments)
self._emit_inline_segments(text_segments)
def _collect_inline(self, tok, out: list) -> None:
"""Walk an inline token tree and collect (text, style) segments."""
t = tok.type
if t == "text":
out.append((tok.content, self._current_inline_style()))
return
if t == "code_inline":
out.append((tok.content, {**self._current_inline_style(), "code": True}))
return
if t == "softbreak":
out.append((" ", self._current_inline_style()))
return
if t == "hardbreak":
out.append(("\n", self._current_inline_style()))
return
if t == "em_open":
self._is_em = True
return
if t == "em_close":
self._is_em = False
return
if t == "strong_open":
self._is_strong = True
return
if t == "strong_close":
self._is_strong = False
return
if t == "s_open":
self._is_strikethrough = True
return
if t == "s_close":
self._is_strikethrough = False
return
if t == "link_open":
href = tok.attrGet("href") or ""
self._href = href
return
if t == "link_close":
self._href = ""
return
if t == "image":
src = tok.attrGet("src") or ""
alt = tok.content or ""
self._render_image(src, alt)
return
for child in (tok.children or []):
self._collect_inline(child, out)
def _current_inline_style(self) -> dict:
return {
"em": self._is_em,
"strong": self._is_strong,
"strikethrough": self._is_strikethrough,
"underline": self._is_underline,
"code": self._is_code,
"href": self._href,
}
def _emit_inline_segments(self, segments: list) -> None:
"""Emit imgui calls for inline segments. Groups consecutive segments
with the same style into a single text_wrapped call for efficiency.
"""
if not segments:
return
i = 0
while i < len(segments):
text, style = segments[i]
if not text:
i += 1
continue
j = i + 1
while j < len(segments) and segments[j][1] == style and "\n" not in segments[j][0]:
text += segments[j][0]
j += 1
self._emit_styled_text(text, style)
i = j
def _emit_styled_text(self, text: str, style: dict) -> None:
if not text:
return
if style.get("code"):
self._emit_inline_code(text)
return
if style.get("href"):
self._emit_link(text, style["href"])
return
pushed = 0
if style.get("em"):
self._push_em()
pushed += 1
if style.get("strong"):
self._push_strong()
pushed += 1
if style.get("strikethrough"):
self._push_strikethrough()
pushed += 1
if style.get("underline"):
self._push_underline()
pushed += 1
imgui.text_wrapped(text)
for _ in range(pushed):
imgui.pop_style_color()
imgui.pop_style_color()
def _emit_inline_code(self, text: str) -> None:
"""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()
def _emit_link(self, text: str, href: str) -> None:
link_color = imgui.get_style().color_(imgui.Col_.button)
imgui.push_style_color(imgui.Col_.text, link_color)
imgui.text_wrapped(text)
size = imgui.calc_text_size(text)
p1 = imgui.get_item_rect_min()
try:
p2_x = p1.x + size.x
p2_y = p1.y + size.y
except AttributeError:
p2_x = float(p1[0]) + float(size[0])
p2_y = float(p1[1]) + float(size[1])
draw_list = imgui.get_window_draw_list()
if draw_list is not None:
draw_list.add_line(imgui.ImVec2(p1.x if hasattr(p1, 'x') else float(p1[0]), p2_y),
imgui.ImVec2(p2_x, p2_y), link_color, 1.0)
if imgui.is_item_hovered() and imgui.is_mouse_released(0):
if self.options.callbacks.on_open_link is not None:
self.options.callbacks.on_open_link(href)
imgui.pop_style_color()
def _render_text(self, text: str) -> None:
if not text:
return
if self._is_image:
return
imgui.text_wrapped(text)
def _render_image(self, src: str, alt: str) -> None:
imgui.text(f"[image: {alt or src}]")
def _push_em(self) -> None:
col = imgui.get_style().color_(imgui.Col_.text)
dim = (col.x * 0.75, col.y * 0.75, col.z * 0.75, col.w)
imgui.push_style_color(imgui.Col_.text, dim)
imgui.push_style_color(imgui.Col_.text, dim)
def _push_strong(self) -> None:
col = imgui.get_style().color_(imgui.Col_.text)
bright = (min(1.0, col.x * 1.2), min(1.0, col.y * 1.2), min(1.0, col.z * 1.2), col.w)
imgui.push_style_color(imgui.Col_.text, bright)
imgui.push_style_color(imgui.Col_.text, bright)
def _push_strikethrough(self) -> None:
col = imgui.get_style().color_(imgui.Col_.text)
dim = (col.x * 0.6, col.y * 0.6, col.z * 0.6, col.w)
imgui.push_style_color(imgui.Col_.text, dim)
imgui.push_style_color(imgui.Col_.text, dim)
def _push_underline(self) -> None:
col = imgui.get_style().color_(imgui.Col_.text)
underline_col = (col.x * 0.7, col.y * 0.7, col.z, col.w)
imgui.push_style_color(imgui.Col_.text, underline_col)
imgui.push_style_color(imgui.Col_.text, underline_col)
def _push_bold(self) -> None:
col = imgui.get_style().color_(imgui.Col_.text)
bright = (min(1.0, col.x * 1.3), min(1.0, col.y * 1.3), min(1.0, col.z * 1.3), col.w)
imgui.push_style_color(imgui.Col_.text, bright)
def _pop_bold(self) -> None:
imgui.pop_style_color()
+125 -85
View File
@@ -1,106 +1,146 @@
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from src.markdown_helper import MarkdownRenderer from src.markdown_helper import MarkdownRenderer
def _mock_table(mock_imgui):
def _mock_imgui(mock_imgui):
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})() mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
mock_imgui.TableColumnFlags_ = type("C", (), {"width_stretch": 8})() mock_imgui.TableColumnFlags_ = type("C", (), {"width_stretch": 8})()
mock_imgui.begin_table.return_value = True mock_imgui.Col_ = type("C", (), {"text": 0, "text_disabled": 1, "frame_bg": 2,
mock_imgui.table_next_column = lambda: None "button": 3, "button_hovered": 4, "button_active": 5})()
mock_imgui.table_next_row = lambda: None mock_imgui.begin_table = MagicMock(return_value=True)
mock_imgui.table_headers_row = lambda: None mock_imgui.end_table = MagicMock()
mock_imgui.text = lambda *a, **k: None mock_imgui.table_setup_column = MagicMock()
mock_imgui.text_wrapped = lambda *a, **k: None mock_imgui.table_next_row = MagicMock()
mock_imgui.end_table = lambda: None mock_imgui.table_next_column = MagicMock()
mock_imgui.same_line = lambda: None mock_imgui.text = MagicMock()
mock_imgui.spacing = lambda: None mock_imgui.text_wrapped = MagicMock()
mock_imgui.indent = lambda *a: None mock_imgui.bullet = MagicMock()
mock_imgui.unindent = lambda *a: None 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)
mock_imgui.get_text_line_height = MagicMock(return_value=20.0)
mock_imgui.ImVec2 = lambda *a: MagicMock(x=a[0] if a else 0, y=a[1] if len(a) > 1 else 0)
def test_render_calls_imgui_md_render_for_bullet_chunks():
def test_render_passes_bullet_chunks_to_python_renderer():
md = "- one\n- two\n- three\n" md = "- one\n- two\n- three\n"
with patch("src.markdown_helper.imgui_md") as mock_md, \ with patch("src.md_renderer_py.imgui") as mock_imgui:
patch("src.markdown_helper.imgui") as mock_imgui, \ _mock_imgui(mock_imgui)
patch("src.markdown_table.imgui") as mock_table_imgui:
_mock_table(mock_table_imgui)
mock_md.render = MagicMock()
mock_imgui.spacing = MagicMock()
MarkdownRenderer().render(md, context_id="bullets") MarkdownRenderer().render(md, context_id="bullets")
assert mock_md.render.call_count == 1, f"expected 1 imgui_md.render call (full chunk passed through), got {mock_md.render.call_count}" assert mock_imgui.bullet.call_count == 3, f"expected 3 bullets, got {mock_imgui.bullet.call_count}"
assert mock_imgui.spacing.call_count >= 1, "imgui.spacing must be called to force vertical gap between chunks" assert mock_imgui.text_wrapped.called
def test_render_does_not_strip_bullet_prefix_from_markdown(): def test_render_passes_dash_and_asterisk_bullets_uniformly():
md = "- one\n- two\n" md = "- dash\n* asterisk\n+ plus\n"
with patch("src.markdown_helper.imgui_md") as mock_md, \ with patch("src.md_renderer_py.imgui") as mock_imgui:
patch("src.markdown_helper.imgui") as mock_imgui, \ _mock_imgui(mock_imgui)
patch("src.markdown_table.imgui") as mock_table_imgui:
_mock_table(mock_table_imgui)
mock_md.render = MagicMock()
mock_imgui.spacing = MagicMock()
MarkdownRenderer().render(md, context_id="bullets") MarkdownRenderer().render(md, context_id="bullets")
args, _ = mock_md.render.call_args assert mock_imgui.bullet.call_count == 3, f"all 3 markers should render as bullets (no upstream '*' Y-overlap bug), got {mock_imgui.bullet.call_count}"
rendered_text = args[0]
assert "- one" in rendered_text, f"bullet prefix must NOT be stripped (regression: was double-rendering as bullet + imgui_md numbered list), got {rendered_text!r}"
assert "- two" in rendered_text
def test_render_passes_numbered_list_intact_to_imgui_md(): def test_render_passes_numbered_list_intact():
md = "1. First question\n2. Second question\n" md = "1. First question\n2. Second question\n"
with patch("src.markdown_helper.imgui_md") as mock_md, \ with patch("src.md_renderer_py.imgui") as mock_imgui:
patch("src.markdown_helper.imgui") as mock_imgui, \ _mock_imgui(mock_imgui)
patch("src.markdown_table.imgui") as mock_table_imgui:
_mock_table(mock_table_imgui)
mock_md.render = MagicMock()
mock_imgui.spacing = MagicMock()
MarkdownRenderer().render(md, context_id="numbered") MarkdownRenderer().render(md, context_id="numbered")
assert mock_md.render.call_count == 1 text_args = [str(c) for c in mock_imgui.text.call_args_list]
args, _ = mock_md.render.call_args assert any("1." in s for s in text_args), f"expected '1.' in numbered list, got {text_args!r}"
rendered_text = args[0] assert any("2." in s for s in text_args), f"expected '2.' in numbered list, got {text_args!r}"
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(): def test_render_explicit_newline_between_list_paragraphs_fixes_overlap():
r = MarkdownRenderer() md = "- bullet text (long enough to wrap maybe)\n\n continuation paragraph after a blank line"
text = "- top\n - nested last\nnext paragraph\n" with patch("src.md_renderer_py.imgui") as mock_imgui:
out = r._normalize_nested_list_endings(text) _mock_imgui(mock_imgui)
assert " - nested last\n\nnext paragraph" in out, f"expected blank line between nested list item and less-indented next line, got {out!r}" MarkdownRenderer().render(md, context_id="cont")
assert mock_imgui.new_line.call_count >= 3, (
f"expected at least 3 new_line calls (before bullet, between paragraphs, after list), "
f"got {mock_imgui.new_line.call_count}. The pure-Python renderer MUST emit explicit "
f"newlines to avoid the imgui-md C++ BLOCK_P no-NewLine-inside-list bug."
)
def test_normalize_nested_list_endings_does_not_insert_blank_for_top_level_list(): def test_render_handles_empty_input():
r = MarkdownRenderer() r = MarkdownRenderer()
text = "- one\n- two\n- three\nnext paragraph\n" r.render("")
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(): def test_render_handles_nested_lists():
r = MarkdownRenderer() md = "- outer\n - inner1\n - inner2\n- outer2"
text = "- a\n - b\n\nalready blank\n" with patch("src.md_renderer_py.imgui") as mock_imgui:
out = r._normalize_nested_list_endings(text) _mock_imgui(mock_imgui)
assert out == text, f"already-blank separator should not get doubled, got {out!r}" MarkdownRenderer().render(md, context_id="nested")
assert mock_imgui.bullet.call_count == 4, f"expected 4 bullets (1 outer + 2 inner + 1 outer2), got {mock_imgui.bullet.call_count}"
def test_normalize_bullet_delimiters_still_converts_asterisk(): def test_render_handles_emphasis_inline():
r = MarkdownRenderer() md = "This is *emphasized* text."
text = "- one\n* two\n+ three\n" with patch("src.md_renderer_py.imgui") as mock_imgui:
out = r._normalize_bullet_delimiters(text) _mock_imgui(mock_imgui)
assert "- one" in out MarkdownRenderer().render(md, context_id="em")
assert "- two" in out and "* two" not in out, f"* must be converted to -, got {out!r}" assert mock_imgui.push_style_color.called, "em should push style color"
assert "+ three" in out, f"+ must be left alone, got {out!r}" assert mock_imgui.pop_style_color.called
assert "+ three" in out, f"+ must be left alone, got {out!r}"
def test_normalize_list_continuations_strips_blank_between_bullet_and_indented_continuation(): def test_render_handles_strong_inline():
r = MarkdownRenderer() md = "This is **strong** text."
text = "- bullet text\n\n continuation paragraph\n\nnext paragraph\n" with patch("src.md_renderer_py.imgui") as mock_imgui:
out = r._normalize_list_continuations(text) _mock_imgui(mock_imgui)
assert " continuation paragraph" in out MarkdownRenderer().render(md, context_id="strong")
assert "- bullet text\n continuation paragraph" in out, f"blank between bullet and indented continuation must be stripped (workaround for imgui_md BLOCK_P no-NewLine-inside-list bug), got {out!r}" assert mock_imgui.push_style_color.called
assert "next paragraph" in out
def test_normalize_list_continuations_preserves_blank_between_indented_and_next_paragraph(): def test_render_handles_inline_code():
r = MarkdownRenderer() md = "Use `foo()` inline."
text = "- bullet\n cont\n\nnext\n" with patch("src.md_renderer_py.imgui") as mock_imgui:
out = r._normalize_list_continuations(text) _mock_imgui(mock_imgui)
assert "- bullet\n cont\n\nnext\n" == out, f"blank between continuation and next paragraph must be preserved (it ends the list item), got {out!r}" MarkdownRenderer().render(md, context_id="code")
assert mock_imgui.text_wrapped.called, "inline code should use text_wrapped (not small_button, which causes ID conflicts for repeated spans)"
def test_normalize_list_continuations_leaves_simple_list_alone(): def test_render_handles_table():
r = MarkdownRenderer() md = "| A | B |\n|---|---|\n| 1 | 2 |"
text = "- one\n- two\n- three\n" with patch("src.md_renderer_py.imgui") as mock_imgui:
out = r._normalize_list_continuations(text) _mock_imgui(mock_imgui)
assert out == text, f"simple list with no continuations should be unchanged, got {out!r}" MarkdownRenderer().render(md, context_id="table")
assert mock_imgui.begin_table.called
assert mock_imgui.end_table.called
assert mock_imgui.table_setup_column.call_count == 2
def test_render_handles_link():
md = "Click [here](https://example.com)."
with patch("src.md_renderer_py.imgui") as mock_imgui:
_mock_imgui(mock_imgui)
MarkdownRenderer().render(md, context_id="link")
assert mock_imgui.text_wrapped.called
assert mock_imgui.push_style_color.called
def test_render_unindented_strips_common_indent():
md = " - a\n - b\n - c"
with patch("src.md_renderer_py.imgui") as mock_imgui:
_mock_imgui(mock_imgui)
MarkdownRenderer().render_unindented(md)
assert mock_imgui.bullet.call_count == 3, f"expected 3 bullets after unindenting, got {mock_imgui.bullet.call_count}"
def test_render_does_not_use_imgui_md_anymore():
md = "# heading\n\n- item 1\n- item 2\n\nplain prose"
with patch("src.md_renderer_py.imgui") as mock_imgui, \
patch("src.markdown_helper.imgui_md") as mock_md:
_mock_imgui(mock_imgui)
MarkdownRenderer().render(md, context_id="all")
assert mock_md.render.call_count == 0, (
"imgui_md.render must NOT be called — the new design uses src.md_renderer_py "
"to avoid the C++ imgui-md BLOCK_P no-NewLine-inside-list bug and BLOCK_LI "
"asteroid-Y-overlap bug"
)
+53 -21
View File
@@ -1,17 +1,48 @@
from unittest.mock import patch from unittest.mock import patch, MagicMock
from src.markdown_helper import MarkdownRenderer from src.markdown_helper import MarkdownRenderer
from src.markdown_table import parse_tables from src.markdown_table import parse_tables
def _mock_table_calls(mock_imgui):
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
mock_imgui.begin_table.return_value = True
mock_imgui.table_next_column = lambda: None
mock_imgui.table_next_row = lambda: None
mock_imgui.table_headers_row = lambda: None
mock_imgui.text = lambda *a, **k: None
mock_imgui.end_table = lambda: None
def test_tables_in_crlf_text_all_get_masked(): 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)
mock_imgui.get_text_line_height = MagicMock(return_value=20.0)
mock_imgui.ImVec2 = lambda *a: MagicMock(x=a[0] if a else 0, y=a[1] if len(a) > 1 else 0)
def test_tables_in_crlf_text_are_handled_by_python_renderer():
text = ( text = (
"# Title\r\n" "# Title\r\n"
"\r\n" "\r\n"
@@ -27,14 +58,15 @@ def test_tables_in_crlf_text_all_get_masked():
) )
blocks = parse_tables(text) blocks = parse_tables(text)
assert len(blocks) == 2 assert len(blocks) == 2
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui: with patch("src.md_renderer_py.imgui") as mock_imgui, \
_mock_table_calls(mock_table_imgui) patch("src.markdown_helper.imgui_md") as mock_md:
_mock_imgui(mock_imgui)
MarkdownRenderer().render(text, context_id="t") MarkdownRenderer().render(text, context_id="t")
full = "".join(str(c) for c in mock_md.render.call_args_list) assert mock_md.render.call_count == 0, "imgui_md.render must NOT be called — the new design uses the pure-Python renderer"
for needle in ["| A | B |", "|---|", "| 1 | 2 |", "| X | Y |", "| 3 | 4 |"]: assert mock_imgui.begin_table.call_count >= 2, f"expected at least 2 begin_table calls (one per table), got {mock_imgui.begin_table.call_count}"
assert needle not in full, f"Raw table text leaked to imgui_md: {needle!r} in calls: {full!r}" assert mock_imgui.text_wrapped.called, "body text should still render via text_wrapped"
def test_duplicate_table_content_both_get_replaced(): def test_duplicate_table_content_both_get_handled():
text = ( text = (
"| A | B |\r\n" "| A | B |\r\n"
"|---|---|\r\n" "|---|---|\r\n"
@@ -48,9 +80,9 @@ def test_duplicate_table_content_both_get_replaced():
) )
blocks = parse_tables(text) blocks = parse_tables(text)
assert len(blocks) == 2 assert len(blocks) == 2
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui: with patch("src.md_renderer_py.imgui") as mock_imgui, \
_mock_table_calls(mock_table_imgui) patch("src.markdown_helper.imgui_md") as mock_md:
_mock_imgui(mock_imgui)
MarkdownRenderer().render(text, context_id="dup") MarkdownRenderer().render(text, context_id="dup")
full = "".join(str(c) for c in mock_md.render.call_args_list) assert mock_md.render.call_count == 0
assert "| A | B |" not in full, f"Raw table text leaked on duplicate: {full!r}" assert mock_imgui.begin_table.call_count >= 2, f"expected 2 begin_table calls, got {mock_imgui.begin_table.call_count}"
+196
View File
@@ -0,0 +1,196 @@
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)}"