diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-24 10:38:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-24 10:38:51 +0200 |
| commit | 9d3ce9ca53cb3b660323903a1db1ea6a31c1a03e (patch) | |
| tree | a9f9537a3273a9f8b0987d553600f4341d35e803 | |
| parent | 84249d5a74321deca3d37d2994473e08c5f5df28 (diff) | |
| download | llamachat-9d3ce9ca53cb3b660323903a1db1ea6a31c1a03e.tar.gz llamachat-9d3ce9ca53cb3b660323903a1db1ea6a31c1a03e.zip | |
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 <pre>: 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.
| -rw-r--r-- | llamachat/ui.py | 99 | ||||
| -rwxr-xr-x | 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 <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"> </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"> {glyph} </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") |
