394 lines
14 KiB
Python
394 lines
14 KiB
Python
# src/markdown_helper.py
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import webbrowser
|
|
|
|
from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed
|
|
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Callable
|
|
|
|
from src import theme_2
|
|
|
|
from src.markdown_table import parse_tables, render_table
|
|
|
|
|
|
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
|
|
# Prefer the newer API (1.92.801+) which uses factory functions.
|
|
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()
|
|
# Fall back to the older API (1.92.5) which exposes an enum.
|
|
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 Markdown renderer that uses imgui_md for text/headers
|
|
and ImGuiColorTextEdit for syntax-highlighted code blocks.
|
|
"""
|
|
def __init__(self):
|
|
"""
|
|
[C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__]
|
|
"""
|
|
self.options = imgui_md.MarkdownOptions()
|
|
# Base path for fonts (Inter family)
|
|
self.options.font_options.font_base_path = "fonts/Inter"
|
|
self.options.font_options.regular_size = 18.0
|
|
|
|
# Configure callbacks
|
|
self.options.callbacks.on_open_link = self._on_open_link
|
|
|
|
# Cache for TextEditor instances to maintain state
|
|
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._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
|
|
|
|
# Apply the current theme's syntax palette on construction so new
|
|
# editors we create pick up the right colors. The renderer is re-created
|
|
# when the theme changes (see theme_2 module-load behavior).
|
|
palette_id = theme_2.get_syntax_palette_for_theme(theme_2.get_current_palette())
|
|
theme_2.apply_syntax_palette(palette_id)
|
|
|
|
# Language mapping for ImGuiColorTextEdit
|
|
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)
|
|
else:
|
|
# Try to handle as a local file path
|
|
try:
|
|
p = Path(url)
|
|
if p.exists():
|
|
if self.on_local_link:
|
|
self.on_local_link(str(p.absolute()))
|
|
else:
|
|
# Fallback to OS default handler
|
|
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 render(self, text: str, context_id: str = "default") -> None:
|
|
"""
|
|
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: return
|
|
text = self._normalize_bullet_delimiters(text)
|
|
text = self._normalize_nested_list_endings(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]
|
|
"""
|
|
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]
|
|
"""
|
|
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]
|
|
"""
|
|
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:
|
|
"""Render Markdown text with automatic unindentation."""
|
|
imgui_md.render_unindented(text)
|
|
|
|
def _render_code_block(self, block: str, context_id: str, block_idx: int) -> None:
|
|
"""Render a code block using TextEditor for syntax highlighting."""
|
|
lines = block.strip('`').split('\n')
|
|
lang_tag = lines[0].strip().lower() if lines else ""
|
|
|
|
# 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):
|
|
lang_tag = ""
|
|
code = '\n'.join(lines)
|
|
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:
|
|
# Simple LRU-ish: just clear it all if it gets too big
|
|
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)
|
|
|
|
# Explicitly set palette on instance to be sure
|
|
from src import theme_2
|
|
p_name = theme_2.get_syntax_palette_for_theme(theme_2.get_current_palette())
|
|
if hasattr(ed.TextEditor, "PaletteId"):
|
|
p_id = getattr(ed.TextEditor.PaletteId, p_name, None)
|
|
if p_id is not None:
|
|
editor.set_palette(p_id)
|
|
|
|
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]
|
|
|
|
# Sync text and language. None means "no language set" (skip the call).
|
|
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()
|
|
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
|
|
|
|
# Dynamic height calculation
|
|
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."""
|
|
# Wrap in fake markdown markers for the internal renderer
|
|
self._render_code_block(f"```{lang}\n{code}```", 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()
|
|
|
|
# Global instance
|
|
_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: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)
|
|
|
|
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)
|