Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f487c5741c | |||
| be5dffa4f0 | |||
| 2d1d37779f | |||
| 3117061be5 | |||
| c434ec93eb | |||
| fe618055ca |
+60
-230
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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)}"
|
||||||
Reference in New Issue
Block a user