From 9d3ce9ca53cb3b660323903a1db1ea6a31c1a03e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 24 Aug 2026 10:38:51 +0200 Subject: feat: pad code blocks, make the copy link a hover button The copy link was a word-plus-glyph sitting above the block, always at full strength, with no feedback once clicked. It is now an icon-only button under the block, since Qt cannot float it over the tint. Qt draws almost no box properties on
: padding and vertical margins
are ignored, so the inset is margin-left/right and the room above and
below is a tinted spacer line. The glyph alone renders small whatever
font-size says, so the button is a tinted pill instead: grey at rest,
the highlight colour under the pointer, and a tick on green once used.
Qt rich text has no :hover, so hovering is QTextBrowser.highlighted()
driving a re-render, the same way the toggles already re-render.

The tick reverts after a moment rather than standing indefinitely, which
would claim the clipboard still holds that block long after it stopped
being true. Copying a second block first cancels the first one's revert.
---
 llamachat/ui.py   | 99 ++++++++++++++++++++++++++++++++++++++++++++++---------
 test_llamachat.py | 48 +++++++++++++++++++++++++++
 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 
 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 
: 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'
 
' + _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
 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"(?:]*>.*?
\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'
' - f'⧉ copy
' + f'
' + f' {glyph} 
' ) - 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"") @@ -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", "
") 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("
") + # 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(", 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") -- cgit v1.2.3