ok
This commit is contained in:
@@ -1,46 +1,155 @@
|
||||
# src/markdown_helper.py
|
||||
from __future__ import annotations
|
||||
from imgui_bundle import imgui_md, imgui, immapp
|
||||
from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed
|
||||
import webbrowser
|
||||
import os
|
||||
from typing import Optional
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Callable
|
||||
|
||||
class MarkdownRenderer:
|
||||
"""
|
||||
Wrapper for imgui_md to manage styling, callbacks, and specialized rendering
|
||||
(like syntax highlighting integration).
|
||||
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()
|
||||
# Use Inter as the base font for Markdown (matches professional theme)
|
||||
# It expects fonts like Inter-Regular.ttf, Inter-Bold.ttf, etc. in the assets folder
|
||||
# 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
|
||||
|
||||
# Note: Syntax highlighting will be integrated in Phase 2
|
||||
# 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 it's a URL, open in browser
|
||||
if url.startswith("http"):
|
||||
webbrowser.open(url)
|
||||
else:
|
||||
# Handle local files or internal links
|
||||
# For now, just print. Could integrate with app_controller in Phase 4.
|
||||
print(f"Clicked local link: {url}")
|
||||
# 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) -> None:
|
||||
"""Render Markdown text using imgui_md."""
|
||||
imgui_md.render(text)
|
||||
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)
|
||||
|
||||
# Global instance for easy access
|
||||
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:
|
||||
@@ -49,8 +158,11 @@ def get_renderer() -> MarkdownRenderer:
|
||||
_renderer = MarkdownRenderer()
|
||||
return _renderer
|
||||
|
||||
def render(text: str) -> None:
|
||||
get_renderer().render(text)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user