aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/ui.py99
-rwxr-xr-xtest_llamachat.py48
2 files changed, 131 insertions, 16 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 7c2e113..210c515 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -22,7 +22,7 @@ import re
from pathlib import Path
from PySide6.QtCore import (
- QObject, QRect, QSettings, Qt, QThread, Signal, Slot,
+ QObject, QRect, QSettings, Qt, QThread, QTimer, Signal, Slot,
)
from PySide6.QtGui import (
QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument,
@@ -480,6 +480,10 @@ class ChatWindow(QMainWindow):
self.bubbles: list[dict] = []
self.expanded: set[int] = set()
self.expanded_searches: set[int] = set()
+ # Which copy link the pointer is on, and which one was last used.
+ # Qt rich text has no :hover, so both are re-render inputs.
+ self._hovered_copy = ""
+ self._copied_block = ""
self.setWindowTitle("llamachat")
self.resize(1000, 700)
@@ -589,6 +593,9 @@ class ChatWindow(QMainWindow):
# thinking block, anything else opens in the browser.
self.transcript.setOpenLinks(False)
self.transcript.anchorClicked.connect(self._on_anchor_clicked)
+ # Qt has no :hover for rich text, but it does say which anchor the
+ # pointer is over; an empty url means it left.
+ self.transcript.highlighted.connect(self._on_anchor_hovered)
right_layout.addWidget(self.transcript, 1)
self.attach_label = QLabel()
@@ -1546,6 +1553,8 @@ class ChatWindow(QMainWindow):
live=streaming and not bubble["text"],
searches=bubble.get("searches") or [],
searches_expanded=i in self.expanded_searches,
+ hovered=self._hovered_copy,
+ copied=self._copied_block,
)
)
self.transcript.setHtml("".join(parts))
@@ -1572,6 +1581,19 @@ class ChatWindow(QMainWindow):
if url.scheme() in ("http", "https", "mailto"):
QDesktopServices.openUrl(url)
+ def _on_anchor_hovered(self, url) -> None:
+ """Light the copy link under the pointer, dim it again on leave."""
+ target = url.toString()
+ hovered = (
+ target[len(COPY_SCHEME) :]
+ if target.startswith(COPY_SCHEME)
+ else ""
+ )
+ if hovered == self._hovered_copy:
+ return # same link, or still on nothing; no repaint needed
+ self._hovered_copy = hovered
+ self._render(live=self.thread is not None)
+
def _copy_code(self, raw: str) -> None:
"""Copy one code block of one bubble to the clipboard."""
bubble_index, _, block_index = raw.partition(".")
@@ -1581,7 +1603,18 @@ class ChatWindow(QMainWindow):
except (ValueError, IndexError, KeyError):
return # a stale link from a re-render; nothing to copy
QApplication.clipboard().setText(block)
- self.show_status("Code block copied.")
+ # The tick is the confirmation, so the status bar stays quiet. It
+ # reverts on its own; a tick left standing would claim the clipboard
+ # still holds this block long after it stopped being true.
+ self._copied_block = raw
+ self._render(live=self.thread is not None)
+ QTimer.singleShot(1500, lambda: self._clear_copied(raw))
+
+ def _clear_copied(self, raw: str) -> None:
+ if self._copied_block != raw:
+ return # another block was copied since; leave its tick alone
+ self._copied_block = ""
+ self._render(live=self.thread is not None)
def _toggle(self, which: set[int], raw: str) -> None:
try:
@@ -1757,14 +1790,20 @@ MARKDOWN_FLAGS = (
# Qt renders fenced code as one <pre> per line with no background of its
# own. Giving those a tint is what makes a code block read as a block.
+# Qt honours almost no box properties on <pre>: padding and vertical
+# margins are ignored outright, so the inset is margin-left/right and the
+# room above and below is a tinted spacer line (_PRE_SPACER).
_PRE_STYLE = (
- "background:rgba(127,127,127,0.18);padding:1px 6px;margin:0;"
+ "background:rgba(127,127,127,0.18);margin:0 8px;"
# Qt's own markdown stylesheet wraps p and li but not pre, and that
# stylesheet is dropped when only the body fragment is kept. Without
# this a long line widens the whole transcript instead of wrapping.
"font-family:monospace;white-space:pre-wrap"
)
+# A short tinted line standing in for the padding Qt will not draw.
+_PRE_SPACER = f'<pre style="{_PRE_STYLE};font-size:4pt">&nbsp;</pre>'
+
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})", re.MULTILINE)
@@ -1812,7 +1851,12 @@ def _code_blocks(text: str) -> list[str]:
return [m.group("body").rstrip("\n") for m in _BLOCK_RE.finditer(text)]
-def _markdown_to_fragment(text: str, index: int | None = None) -> str:
+def _markdown_to_fragment(
+ text: str,
+ index: int | None = None,
+ hovered: str = "",
+ copied: str = "",
+) -> str:
"""Render markdown to an HTML fragment safe to splice into the page.
QTextDocument does the parsing, so there is no markdown dependency. Its
@@ -1846,7 +1890,7 @@ def _markdown_to_fragment(text: str, index: int | None = None) -> str:
fragment = _PRE_RE.sub(_tint_pre, fragment)
if index is None:
return fragment
- return _add_copy_links(fragment, index)
+ return _add_copy_links(fragment, index, hovered, copied)
# The first <pre> of a run is where a copy header goes; the rest are the
@@ -1854,23 +1898,44 @@ def _markdown_to_fragment(text: str, index: int | None = None) -> str:
_PRE_RUN_RE = re.compile(r"(?:<pre\b[^>]*>.*?</pre>\s*)+", re.DOTALL)
-def _add_copy_links(fragment: str, index: int) -> str:
- """Put a copy link above each code block.
+def _add_copy_links(
+ fragment: str, index: int, hovered: str = "", copied: str = ""
+) -> str:
+ """Pad each code block and put a copy link under it.
- Qt rich text has no widgets, so the button is an anchor in the existing
- scheme-dispatch, the same mechanism the thinking and search toggles use.
+ Qt rich text has no widgets and no :hover, so the button is an anchor in
+ the existing scheme-dispatch and "hover" is a re-render driven by
+ QTextBrowser.highlighted(). The link sits below the block because Qt
+ cannot float it over the tint, and it is dim until pointed at so it does
+ not compete with the code itself.
"""
counter = itertools.count()
- def header(match: re.Match) -> str:
+ def block(match: re.Match) -> str:
+ target = f"{index}.{next(counter)}"
+ # A tinted pill rather than a bare glyph: the icon alone renders
+ # small whatever font-size says, and reads as a speck instead of a
+ # control. Grey until pointed at, so a transcript full of code is
+ # not a wall of buttons.
+ if target == copied:
+ glyph, background, colour = "✓", "#2e9e4f", "#ffffff"
+ tip = "Copied"
+ elif target == hovered:
+ glyph, background, colour = "⧉", "palette(highlight)", "#ffffff"
+ tip = "Copy this code block"
+ else:
+ glyph = "⧉"
+ background, colour = "rgba(127,127,127,0.28)", "palette(text)"
+ tip = "Copy this code block"
link = (
- f'<div style="margin:4px 0 0 0;text-align:right">'
- f'<a href="{COPY_SCHEME}{index}.{next(counter)}" '
- f'style="color:palette(mid);text-decoration:none">⧉ copy</a></div>'
+ f'<div style="margin:0 8px 6px 0;text-align:right">'
+ f'<a href="{COPY_SCHEME}{target}" title="{tip}" '
+ f'style="background:{background};color:{colour};'
+ f'text-decoration:none">&nbsp;{glyph}&nbsp;</a></div>'
)
- return link + match.group(0)
+ return _PRE_SPACER + match.group(0) + _PRE_SPACER + link
- return _PRE_RUN_RE.sub(header, fragment)
+ return _PRE_RUN_RE.sub(block, fragment)
_PRE_RE = re.compile(r"<pre(\s+style=\"([^\"]*)\")?\s*>")
@@ -2025,6 +2090,8 @@ def _bubble_html(
live: bool = False,
searches: list[dict] | None = None,
searches_expanded: bool = False,
+ hovered: str = "",
+ copied: str = "",
) -> str:
"""One transcript entry as HTML."""
label = {"user": "You", "assistant": "Model"}.get(role, role)
@@ -2033,7 +2100,7 @@ def _bubble_html(
# Replies are markdown; what the user typed is shown as typed, so an
# attached file's contents cannot be reflowed into headings and lists.
if role == "assistant":
- body = _markdown_to_fragment(text, index)
+ body = _markdown_to_fragment(text, index, hovered, copied)
else:
body = html.escape(text).replace("\n", "<br>")
diff --git a/test_llamachat.py b/test_llamachat.py
index 7a84608..407c5dc 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -501,6 +501,10 @@ def test_code_block_copy_links():
fragment = _markdown_to_fragment(markdown, 3)
assert _re.findall(r"x-llamachat-copy:([0-9.]+)", fragment) == ["3.0", "3.1"]
+ # Icon only, and under its block: Qt cannot float it over the tint.
+ assert "⧉" in fragment and "copy<" not in fragment
+ assert fragment.rindex("⧉") > fragment.rindex("</pre>")
+
# The indices the links carry must select the blocks the parser found.
blocks = _code_blocks(_close_fences(markdown))
assert blocks == ["AAA", "BBB"], blocks
@@ -518,6 +522,49 @@ def test_code_block_copy_links():
print("ok code block copy links")
+def test_code_block_copy_states():
+ """The copy link dims, lights on hover, and ticks once used."""
+ import os
+
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat.ui import _PRE_SPACER, _markdown_to_fragment
+
+ app = QApplication.instance() or QApplication([])
+ assert app is not None
+
+ markdown = "```py\nAAA\n```\n"
+
+ def link_style(fragment: str, glyph: str) -> str:
+ return fragment.split(glyph)[0].rsplit("<a", 1)[1]
+
+ # Grey by default, so a transcript full of code is not a wall of
+ # buttons. The pill is what makes it read as a control at all.
+ plain = _markdown_to_fragment(markdown, 0)
+ assert "rgba(127,127,127,0.28)" in link_style(plain, "⧉")
+
+ # Qt rich text has no :hover, so hovering is a re-render with the
+ # highlighted anchor passed back in.
+ hovered = _markdown_to_fragment(markdown, 0, hovered="0.0")
+ assert "palette(highlight)" in link_style(hovered, "⧉")
+
+ # A different link being hovered must not light this one.
+ other = _markdown_to_fragment(markdown, 0, hovered="0.1")
+ assert "rgba(127,127,127,0.28)" in link_style(other, "⧉")
+
+ # After copying, the icon becomes a tick on green, so the confirmation
+ # is visible without a status message.
+ copied = _markdown_to_fragment(markdown, 0, copied="0.0")
+ assert "✓" in copied and "⧉" not in copied
+ assert "#2e9e4f" in link_style(copied, "✓")
+
+ # Qt draws no padding on <pre>, so a tinted spacer stands in above and
+ # below; without it the tint sits flush against the code.
+ assert plain.count(_PRE_SPACER) == 2
+ print("ok code block copy states")
+
+
def test_system_qt_theme_guard():
"""The plugin path is only borrowed when the Qt versions agree."""
import os
@@ -3339,4 +3386,5 @@ if __name__ == "__main__":
test_title_prompt()
test_code_block_wrapping()
test_code_block_copy_links()
+ test_code_block_copy_states()
print("\nall checks passed")