# 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 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). from src import theme_2 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 from src.markdown_table import parse_tables, render_table 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] """ 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: """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)