fe618055ca
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
209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
# 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 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 os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Callable
|
|
|
|
|
|
def _get_language_id(name: str):
|
|
"""Get a language identifier for ImGuiColorTextEdit.
|
|
|
|
Compatible with both imgui-bundle 1.92.5 (LanguageDefinitionId enum)
|
|
and 1.92.801+ (Language factory functions returning a Language object).
|
|
Returns None for "no language" or unknown names.
|
|
"""
|
|
if not name or name == "none":
|
|
return None
|
|
if hasattr(ed.TextEditor, "Language"):
|
|
lang_class = ed.TextEditor.Language
|
|
if hasattr(lang_class, name):
|
|
factory = getattr(lang_class, name)
|
|
if callable(factory):
|
|
return factory()
|
|
if hasattr(ed.TextEditor, "LanguageDefinitionId"):
|
|
lang_id_class = ed.TextEditor.LanguageDefinitionId
|
|
if hasattr(lang_id_class, name):
|
|
return getattr(lang_id_class, name)
|
|
return None
|
|
|
|
|
|
def _set_editor_language(editor, lang_obj) -> None:
|
|
"""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).
|
|
No-op when lang_obj is None (used to skip the call for unknown languages).
|
|
"""
|
|
if lang_obj is None:
|
|
return
|
|
if hasattr(editor, "set_language"):
|
|
editor.set_language(lang_obj)
|
|
elif hasattr(editor, "set_language_definition"):
|
|
editor.set_language_definition(lang_obj)
|
|
|
|
|
|
class MarkdownRenderer:
|
|
"""Hybrid renderer: pure-Python md_renderer_py for body text,
|
|
ImGuiColorTextEdit for syntax-highlighted code blocks.
|
|
"""
|
|
def __init__(self):
|
|
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_lang_cache: Dict[tuple[str, int], Optional[str]] = {}
|
|
self._max_cache_size = 100
|
|
|
|
self.on_local_link: Optional[Callable[[str], None]] = None
|
|
|
|
self._lang_map = {
|
|
"python": _get_language_id("python"),
|
|
"py": _get_language_id("python"),
|
|
"json": _get_language_id("json"),
|
|
"cpp": _get_language_id("cpp"),
|
|
"c++": _get_language_id("cpp"),
|
|
"c": _get_language_id("c"),
|
|
"lua": _get_language_id("lua"),
|
|
"sql": _get_language_id("sql"),
|
|
"cs": _get_language_id("cs"),
|
|
"c#": _get_language_id("cs"),
|
|
}
|
|
|
|
def _on_open_link(self, url: str) -> None:
|
|
"""Handle link clicks in Markdown."""
|
|
if url.startswith("http"):
|
|
webbrowser.open(url)
|
|
return
|
|
try:
|
|
p = Path(url)
|
|
if p.exists():
|
|
if self.on_local_link:
|
|
self.on_local_link(str(p.absolute()))
|
|
else:
|
|
webbrowser.open(str(p.absolute()))
|
|
else:
|
|
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:
|
|
"""Render Markdown text. Code blocks are delegated to ImGuiColorTextEdit.
|
|
All other content is handled by the pure-Python renderer.
|
|
"""
|
|
if not text:
|
|
return
|
|
self._code_block_idx = 0
|
|
self._current_context_id = context_id
|
|
self._py_renderer.render(text)
|
|
|
|
def render_unindented(self, text: str) -> None:
|
|
"""Render Markdown text with automatic unindentation."""
|
|
self._py_renderer.render_unindented(text)
|
|
|
|
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."""
|
|
lang_tag = lang.strip().lower() if lang else ""
|
|
if not lang_tag:
|
|
lang_tag = self.detect_language(content)
|
|
if lang_tag and lang_tag not in self._lang_map and not self._is_likely_lang_tag(lang_tag):
|
|
lang_tag = ""
|
|
code = content
|
|
|
|
if len(self._editor_cache) > self._max_cache_size:
|
|
self._editor_cache.clear()
|
|
self._editor_lang_cache.clear()
|
|
|
|
cache_key = (context_id, block_idx)
|
|
if cache_key not in self._editor_cache:
|
|
editor = ed.TextEditor()
|
|
editor.set_read_only_enabled(True)
|
|
editor.set_show_line_numbers_enabled(True)
|
|
self._editor_cache[cache_key] = editor
|
|
self._editor_lang_cache[cache_key] = None
|
|
|
|
editor = self._editor_cache[cache_key]
|
|
current_lang = self._editor_lang_cache[cache_key]
|
|
lang_id = self._lang_map.get(lang_tag)
|
|
|
|
curr_text = editor.get_text().replace('\r\n', '\n').strip()
|
|
if curr_text != code.replace('\r\n', '\n').strip():
|
|
editor.set_text(code)
|
|
if current_lang != lang_tag:
|
|
_set_editor_language(editor, lang_id)
|
|
self._editor_lang_cache[cache_key] = lang_tag
|
|
elif current_lang != lang_tag:
|
|
_set_editor_language(editor, lang_id)
|
|
self._editor_lang_cache[cache_key] = lang_tag
|
|
|
|
line_count = code.count('\n') + 1
|
|
line_height = imgui.get_text_line_height()
|
|
height = (line_count * line_height) + 20
|
|
height = min(max(height, 40), 500)
|
|
|
|
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:
|
|
"""Render a code block directly with syntax highlighting (public API)."""
|
|
self._render_code_block_external(code, lang, context_id, block_idx)
|
|
|
|
def _is_likely_lang_tag(self, tag: str) -> bool:
|
|
return bool(re.match(r'^[a-zA-Z0-9+#-]+$', tag)) and len(tag) < 15
|
|
|
|
def detect_language(self, code: str) -> str:
|
|
if "def " in code or "import " in code:
|
|
return "python"
|
|
if "{" in code and '"' in code and ":" in code:
|
|
return "json"
|
|
if "$" in code and ("{" in code or "if" in code):
|
|
return "powershell"
|
|
return ""
|
|
|
|
def clear_cache(self) -> None:
|
|
self._editor_cache.clear()
|
|
|
|
|
|
_renderer: Optional[MarkdownRenderer] = None
|
|
|
|
|
|
def get_renderer() -> MarkdownRenderer:
|
|
global _renderer
|
|
if _renderer is None:
|
|
_renderer = MarkdownRenderer()
|
|
return _renderer
|
|
|
|
|
|
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:...]"""
|
|
get_renderer().render(text, context_id)
|
|
|
|
|
|
def render_unindented(text: str) -> None:
|
|
get_renderer().render_unindented(text)
|
|
|
|
|
|
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)
|