# src/markdown_helper.py from __future__ import annotations from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed import webbrowser import os import re from pathlib import Path from typing import Optional, Dict, Callable class MarkdownRenderer: """ Hybrid Markdown renderer that uses imgui_md for text/headers and ImGuiColorTextEdit for syntax-highlighted code blocks. """ def __init__(self): 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 = 16.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] = {} 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 # Language mapping for ImGuiColorTextEdit self._lang_map = { "python": ed.TextEditor.LanguageDefinitionId.python, "py": ed.TextEditor.LanguageDefinitionId.python, "json": ed.TextEditor.LanguageDefinitionId.json, "cpp": ed.TextEditor.LanguageDefinitionId.cpp, "c++": ed.TextEditor.LanguageDefinitionId.cpp, "c": ed.TextEditor.LanguageDefinitionId.c, "lua": ed.TextEditor.LanguageDefinitionId.lua, "sql": ed.TextEditor.LanguageDefinitionId.sql, "cs": ed.TextEditor.LanguageDefinitionId.cs, "c#": ed.TextEditor.LanguageDefinitionId.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.""" if not text: return # Split into markdown and code blocks parts = re.split(r'(```[\s\S]*?```)', text) block_idx = 0 for part in parts: if part.startswith('```') and part.endswith('```'): self._render_code_block(part, context_id, block_idx) block_idx += 1 elif part.strip(): imgui_md.render(part) def render_unindented(self, text: str) -> None: """Render Markdown text with automatic unindentation.""" imgui_md.render_unindented(text) 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 _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() 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 editor = self._editor_cache[cache_key] # Sync text and language lang_id = self._lang_map.get(lang_tag, ed.TextEditor.LanguageDefinitionId.none) target_text = code + "\n" if editor.get_text() != target_text: editor.set_text(code) editor.set_language_definition(lang_id) elif editor.get_language_definition_name().lower() != lang_tag: # get_language_definition_name might not match exactly but good enough check editor.set_language_definition(lang_id) # 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 _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: 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)