Private
Public Access
0
0
Files
manual_slop/src/markdown_helper.py
T
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

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)