markdown
This commit is contained in:
145
cht/ui/markdown.py
Normal file
145
cht/ui/markdown.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Markdown → Gtk.TextBuffer renderer.
|
||||||
|
|
||||||
|
Pure functions — no app state, just TextBuffer + TextIter operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tags(buf: Gtk.TextBuffer) -> None:
|
||||||
|
"""Create the standard set of text tags used by render()."""
|
||||||
|
buf.create_tag("h1", weight=700, scale=1.4)
|
||||||
|
buf.create_tag("h2", weight=700, scale=1.2)
|
||||||
|
buf.create_tag("h3", weight=700, scale=1.05)
|
||||||
|
buf.create_tag("bold", weight=700)
|
||||||
|
buf.create_tag("italic", style=2)
|
||||||
|
buf.create_tag("code", family="monospace", background="#2a2a2a", foreground="#e8e8e8")
|
||||||
|
buf.create_tag("codeblock", family="monospace", background="#1e1e1e",
|
||||||
|
foreground="#e8e8e8", left_margin=16, right_margin=16,
|
||||||
|
pixels_above_lines=4, pixels_below_lines=4)
|
||||||
|
buf.create_tag("bullet", left_margin=16)
|
||||||
|
|
||||||
|
|
||||||
|
def render(buf: Gtk.TextBuffer, insert_iter: Gtk.TextIter, text: str) -> None:
|
||||||
|
"""Parse *text* as lightweight Markdown and insert into *buf* at *insert_iter*."""
|
||||||
|
lines = text.split("\n")
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
if line.startswith("```"):
|
||||||
|
lang = line[3:].strip()
|
||||||
|
block_lines = []
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and not lines[i].startswith("```"):
|
||||||
|
block_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
code = "\n".join(block_lines) + "\n"
|
||||||
|
block_mark = buf.create_mark(None, insert_iter, True)
|
||||||
|
_insert_highlighted_code(buf, insert_iter, code, lang)
|
||||||
|
buf.apply_tag_by_name("codeblock", buf.get_iter_at_mark(block_mark), insert_iter)
|
||||||
|
buf.delete_mark(block_mark)
|
||||||
|
buf.insert(insert_iter, "\n")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
header_match = re.match(r"^(#{1,3})\s+(.*)", line)
|
||||||
|
if header_match:
|
||||||
|
level = len(header_match.group(1))
|
||||||
|
content = header_match.group(2)
|
||||||
|
tag = f"h{level}"
|
||||||
|
mark = buf.create_mark(None, insert_iter, True)
|
||||||
|
buf.insert(insert_iter, content + "\n")
|
||||||
|
buf.apply_tag_by_name(tag, buf.get_iter_at_mark(mark), insert_iter)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
bullet_match = re.match(r"^[\-\*]\s+(.*)", line)
|
||||||
|
if bullet_match:
|
||||||
|
_insert_inline(buf, insert_iter, "• " + bullet_match.group(1), "bullet")
|
||||||
|
buf.insert(insert_iter, "\n")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
_insert_inline_line(buf, insert_iter, line)
|
||||||
|
buf.insert(insert_iter, "\n")
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- private helpers --
|
||||||
|
|
||||||
|
_INLINE_RE = re.compile(
|
||||||
|
r"(\*\*(.+?)\*\*|__(.+?)__|"
|
||||||
|
r"\*(.+?)\*|_(.+?)_|"
|
||||||
|
r"`([^`]+?)`)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_inline(buf, it, text: str, outer_tag: str | None = None):
|
||||||
|
mark = buf.create_mark(None, it, True)
|
||||||
|
_insert_inline_line(buf, it, text)
|
||||||
|
if outer_tag:
|
||||||
|
buf.apply_tag_by_name(outer_tag, buf.get_iter_at_mark(mark), it)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_inline_line(buf, it, text: str):
|
||||||
|
pos = 0
|
||||||
|
for m in _INLINE_RE.finditer(text):
|
||||||
|
if m.start() > pos:
|
||||||
|
buf.insert(it, text[pos:m.start()])
|
||||||
|
full = m.group(0)
|
||||||
|
if full.startswith("**") or full.startswith("__"):
|
||||||
|
inner = m.group(2) or m.group(3)
|
||||||
|
mark = buf.create_mark(None, it, True)
|
||||||
|
buf.insert(it, inner)
|
||||||
|
buf.apply_tag_by_name("bold", buf.get_iter_at_mark(mark), it)
|
||||||
|
elif full.startswith("*") or full.startswith("_"):
|
||||||
|
inner = m.group(4) or m.group(5)
|
||||||
|
mark = buf.create_mark(None, it, True)
|
||||||
|
buf.insert(it, inner)
|
||||||
|
buf.apply_tag_by_name("italic", buf.get_iter_at_mark(mark), it)
|
||||||
|
elif full.startswith("`"):
|
||||||
|
inner = m.group(6)
|
||||||
|
mark = buf.create_mark(None, it, True)
|
||||||
|
buf.insert(it, inner)
|
||||||
|
buf.apply_tag_by_name("code", buf.get_iter_at_mark(mark), it)
|
||||||
|
pos = m.end()
|
||||||
|
if pos < len(text):
|
||||||
|
buf.insert(it, text[pos:])
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_highlighted_code(buf, it, code: str, lang: str):
|
||||||
|
try:
|
||||||
|
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
|
except ImportError:
|
||||||
|
buf.insert(it, code)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lexer = get_lexer_by_name(lang, stripall=False) if lang else guess_lexer(code)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
lexer = guess_lexer(code)
|
||||||
|
except Exception:
|
||||||
|
buf.insert(it, code)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
style = get_style_by_name("monokai")
|
||||||
|
except Exception:
|
||||||
|
buf.insert(it, code)
|
||||||
|
return
|
||||||
|
tag_table = buf.get_tag_table()
|
||||||
|
for ttype, value in lexer.get_tokens(code):
|
||||||
|
tag_name = f"pyg_{ttype}"
|
||||||
|
tag = tag_table.lookup(tag_name)
|
||||||
|
if tag is None:
|
||||||
|
sf = style.style_for_token(ttype)
|
||||||
|
tag = buf.create_tag(tag_name, family="monospace")
|
||||||
|
if sf.get("color"):
|
||||||
|
tag.set_property("foreground", f"#{sf['color']}")
|
||||||
|
if sf.get("bold"):
|
||||||
|
tag.set_property("weight", 700)
|
||||||
|
if sf.get("italic"):
|
||||||
|
tag.set_property("style", 2)
|
||||||
|
mark = buf.create_mark(None, it, True)
|
||||||
|
buf.insert(it, value)
|
||||||
|
buf.apply_tag(tag, buf.get_iter_at_mark(mark), it)
|
||||||
|
buf.delete_mark(mark)
|
||||||
133
cht/window.py
133
cht/window.py
@@ -19,6 +19,7 @@ from cht.ui.waveform import WaveformWidget
|
|||||||
from cht.ui.frames_panel import FramesPanel
|
from cht.ui.frames_panel import FramesPanel
|
||||||
from cht.ui.transcript_panel import TranscriptPanel
|
from cht.ui.transcript_panel import TranscriptPanel
|
||||||
from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE
|
from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE
|
||||||
|
from cht.ui import markdown
|
||||||
from cht.audio.waveform import WaveformEngine
|
from cht.audio.waveform import WaveformEngine
|
||||||
from cht.transcriber.engine import TranscriberEngine, LANGUAGES
|
from cht.transcriber.engine import TranscriberEngine, LANGUAGES
|
||||||
from cht.stream.manager import StreamManager, list_sessions, delete_sessions
|
from cht.stream.manager import StreamManager, list_sessions, delete_sessions
|
||||||
@@ -497,7 +498,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._agent_output_view.set_cursor_visible(False)
|
self._agent_output_view.set_cursor_visible(False)
|
||||||
self._agent_output_view.set_left_margin(8)
|
self._agent_output_view.set_left_margin(8)
|
||||||
self._agent_output_view.set_right_margin(8)
|
self._agent_output_view.set_right_margin(8)
|
||||||
self._setup_md_tags(self._agent_output_view.get_buffer())
|
markdown.setup_tags(self._agent_output_view.get_buffer())
|
||||||
|
|
||||||
scroll = Gtk.ScrolledWindow()
|
scroll = Gtk.ScrolledWindow()
|
||||||
scroll.set_vexpand(True)
|
scroll.set_vexpand(True)
|
||||||
@@ -660,138 +661,10 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
start = buf.get_iter_at_mark(self._response_start_mark)
|
start = buf.get_iter_at_mark(self._response_start_mark)
|
||||||
end = buf.get_end_iter()
|
end = buf.get_end_iter()
|
||||||
buf.delete(start, end)
|
buf.delete(start, end)
|
||||||
self._render_md(buf, start, "".join(self._response_accum))
|
markdown.render(buf, start, "".join(self._response_accum))
|
||||||
buf.delete_mark(self._response_start_mark)
|
buf.delete_mark(self._response_start_mark)
|
||||||
self._append_agent_output("\n")
|
self._append_agent_output("\n")
|
||||||
|
|
||||||
# -- Markdown rendering --
|
|
||||||
|
|
||||||
def _setup_md_tags(self, buf):
|
|
||||||
buf.create_tag("h1", weight=700, scale=1.4)
|
|
||||||
buf.create_tag("h2", weight=700, scale=1.2)
|
|
||||||
buf.create_tag("h3", weight=700, scale=1.05)
|
|
||||||
buf.create_tag("bold", weight=700)
|
|
||||||
buf.create_tag("italic", style=2)
|
|
||||||
buf.create_tag("code", family="monospace", background="#2a2a2a", foreground="#e8e8e8")
|
|
||||||
buf.create_tag("codeblock", family="monospace", background="#1e1e1e",
|
|
||||||
foreground="#e8e8e8", left_margin=16, right_margin=16,
|
|
||||||
pixels_above_lines=4, pixels_below_lines=4)
|
|
||||||
buf.create_tag("bullet", left_margin=16)
|
|
||||||
|
|
||||||
def _render_md(self, buf, insert_iter, text: str):
|
|
||||||
import re
|
|
||||||
lines = text.split("\n")
|
|
||||||
i = 0
|
|
||||||
while i < len(lines):
|
|
||||||
line = lines[i]
|
|
||||||
if line.startswith("```"):
|
|
||||||
lang = line[3:].strip()
|
|
||||||
block_lines = []
|
|
||||||
i += 1
|
|
||||||
while i < len(lines) and not lines[i].startswith("```"):
|
|
||||||
block_lines.append(lines[i])
|
|
||||||
i += 1
|
|
||||||
code = "\n".join(block_lines) + "\n"
|
|
||||||
block_mark = buf.create_mark(None, insert_iter, True)
|
|
||||||
self._insert_highlighted_code(buf, insert_iter, code, lang)
|
|
||||||
buf.apply_tag_by_name("codeblock", buf.get_iter_at_mark(block_mark), insert_iter)
|
|
||||||
buf.delete_mark(block_mark)
|
|
||||||
buf.insert(insert_iter, "\n")
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
header_match = re.match(r"^(#{1,3})\s+(.*)", line)
|
|
||||||
if header_match:
|
|
||||||
level = len(header_match.group(1))
|
|
||||||
content = header_match.group(2)
|
|
||||||
tag = f"h{level}"
|
|
||||||
mark = buf.create_mark(None, insert_iter, True)
|
|
||||||
buf.insert(insert_iter, content + "\n")
|
|
||||||
buf.apply_tag_by_name(tag, buf.get_iter_at_mark(mark), insert_iter)
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
bullet_match = re.match(r"^[\-\*]\s+(.*)", line)
|
|
||||||
if bullet_match:
|
|
||||||
self._insert_inline(buf, insert_iter, "• " + bullet_match.group(1), "bullet")
|
|
||||||
buf.insert(insert_iter, "\n")
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
self._insert_inline_line(buf, insert_iter, line)
|
|
||||||
buf.insert(insert_iter, "\n")
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
def _insert_inline(self, buf, it, text: str, outer_tag: str | None = None):
|
|
||||||
mark = buf.create_mark(None, it, True)
|
|
||||||
self._insert_inline_line(buf, it, text)
|
|
||||||
if outer_tag:
|
|
||||||
buf.apply_tag_by_name(outer_tag, buf.get_iter_at_mark(mark), it)
|
|
||||||
|
|
||||||
def _insert_inline_line(self, buf, it, text: str):
|
|
||||||
import re
|
|
||||||
pattern = re.compile(r"(\*\*(.+?)\*\*|__(.+?)__|"
|
|
||||||
r"\*(.+?)\*|_(.+?)_|"
|
|
||||||
r"`([^`]+?)`)")
|
|
||||||
pos = 0
|
|
||||||
for m in pattern.finditer(text):
|
|
||||||
if m.start() > pos:
|
|
||||||
buf.insert(it, text[pos:m.start()])
|
|
||||||
full = m.group(0)
|
|
||||||
if full.startswith("**") or full.startswith("__"):
|
|
||||||
inner = m.group(2) or m.group(3)
|
|
||||||
mark = buf.create_mark(None, it, True)
|
|
||||||
buf.insert(it, inner)
|
|
||||||
buf.apply_tag_by_name("bold", buf.get_iter_at_mark(mark), it)
|
|
||||||
elif full.startswith("*") or full.startswith("_"):
|
|
||||||
inner = m.group(4) or m.group(5)
|
|
||||||
mark = buf.create_mark(None, it, True)
|
|
||||||
buf.insert(it, inner)
|
|
||||||
buf.apply_tag_by_name("italic", buf.get_iter_at_mark(mark), it)
|
|
||||||
elif full.startswith("`"):
|
|
||||||
inner = m.group(6)
|
|
||||||
mark = buf.create_mark(None, it, True)
|
|
||||||
buf.insert(it, inner)
|
|
||||||
buf.apply_tag_by_name("code", buf.get_iter_at_mark(mark), it)
|
|
||||||
pos = m.end()
|
|
||||||
if pos < len(text):
|
|
||||||
buf.insert(it, text[pos:])
|
|
||||||
|
|
||||||
def _insert_highlighted_code(self, buf, it, code: str, lang: str):
|
|
||||||
try:
|
|
||||||
from pygments.lexers import get_lexer_by_name, guess_lexer
|
|
||||||
from pygments.styles import get_style_by_name
|
|
||||||
except ImportError:
|
|
||||||
buf.insert(it, code)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
lexer = get_lexer_by_name(lang, stripall=False) if lang else guess_lexer(code)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
lexer = guess_lexer(code)
|
|
||||||
except Exception:
|
|
||||||
buf.insert(it, code)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
style = get_style_by_name("monokai")
|
|
||||||
except Exception:
|
|
||||||
buf.insert(it, code)
|
|
||||||
return
|
|
||||||
tag_table = buf.get_tag_table()
|
|
||||||
for ttype, value in lexer.get_tokens(code):
|
|
||||||
tag_name = f"pyg_{ttype}"
|
|
||||||
tag = tag_table.lookup(tag_name)
|
|
||||||
if tag is None:
|
|
||||||
sf = style.style_for_token(ttype)
|
|
||||||
tag = buf.create_tag(tag_name, family="monospace")
|
|
||||||
if sf.get("color"):
|
|
||||||
tag.set_property("foreground", f"#{sf['color']}")
|
|
||||||
if sf.get("bold"):
|
|
||||||
tag.set_property("weight", 700)
|
|
||||||
if sf.get("italic"):
|
|
||||||
tag.set_property("style", 2)
|
|
||||||
mark = buf.create_mark(None, it, True)
|
|
||||||
buf.insert(it, value)
|
|
||||||
buf.apply_tag(tag, buf.get_iter_at_mark(mark), it)
|
|
||||||
buf.delete_mark(mark)
|
|
||||||
|
|
||||||
# -- Settings callbacks --
|
# -- Settings callbacks --
|
||||||
|
|
||||||
def _on_clear_agent_output(self, _button):
|
def _on_clear_agent_output(self, _button):
|
||||||
|
|||||||
Reference in New Issue
Block a user