From 0d8fb04f535809dac095b6dcb26fef95fbce5f00 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Thu, 30 Jul 2026 14:48:55 +0200 Subject: [PATCH 1/5] move from list to space --- copyboard/adapters/ui/clippingwidget.py | 10 +++++-- copyboard/adapters/ui/mainwindow.py | 38 ++++++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/copyboard/adapters/ui/clippingwidget.py b/copyboard/adapters/ui/clippingwidget.py index 2dc7ba7..364a5c8 100644 --- a/copyboard/adapters/ui/clippingwidget.py +++ b/copyboard/adapters/ui/clippingwidget.py @@ -22,6 +22,8 @@ _THUMBNAIL_WIDTH = 160 _THUMBNAIL_HEIGHT = 90 +_WIDE_THUMBNAIL_WIDTH = 340 +_WIDE_THUMBNAIL_HEIGHT = 200 class ClippingWidget(QWidget): @@ -33,6 +35,7 @@ def __init__( on_recopy: Callable[[str], None], on_delete: Callable[[str], None], actions_on_right_click: bool = True, + wide: bool = False, ) -> None: super().__init__() # Required for Qt to paint a stylesheet `background` on a plain QWidget subclass. @@ -42,6 +45,7 @@ def __init__( self._on_recopy = on_recopy self._on_delete = on_delete self._actions_on_right_click = actions_on_right_click + self._wide = wide self._drag_start_position: QPoint | None = None row = QHBoxLayout(self) @@ -109,10 +113,12 @@ def _build_thumbnail_label(self, clipping: ImageClipping) -> QLabel: if pixmap.isNull(): label.setText(clipping.build_preview_text()) return label + tw = _WIDE_THUMBNAIL_WIDTH if self._wide else _THUMBNAIL_WIDTH + th = _WIDE_THUMBNAIL_HEIGHT if self._wide else _THUMBNAIL_HEIGHT label.setPixmap( pixmap.scaled( - _THUMBNAIL_WIDTH, - _THUMBNAIL_HEIGHT, + tw, + th, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) diff --git a/copyboard/adapters/ui/mainwindow.py b/copyboard/adapters/ui/mainwindow.py index abe865f..bf94013 100644 --- a/copyboard/adapters/ui/mainwindow.py +++ b/copyboard/adapters/ui/mainwindow.py @@ -9,12 +9,13 @@ from __future__ import annotations from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import QScrollArea, QVBoxLayout, QWidget +from PySide6.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget from copyboard.adapters.ui.clippingwidget import ClippingWidget from copyboard.application.copyboardservice import CopyboardService from copyboard.application.events import HistoryChangeEvent from copyboard.config import UIConfig +from copyboard.domain.clipping import ImageClipping _DEFAULT_PRUNE_INTERVAL_MS = 1000 @@ -34,13 +35,16 @@ def __init__( self._service = service self._ui_config = ui_config or UIConfig() self.setWindowTitle("Copyboard") - self.resize(420, 560) + self.resize(500, 620) outer_layout = QVBoxLayout(self) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) self._list_container = QWidget() - self._list_layout = QVBoxLayout(self._list_container) + self._list_layout = QGridLayout(self._list_container) + self._list_layout.setSpacing(6) + self._list_layout.setColumnStretch(0, 1) + self._list_layout.setColumnStretch(1, 1) scroll_area.setWidget(self._list_container) outer_layout.addWidget(scroll_area) @@ -80,16 +84,28 @@ def bring_to_front(self) -> None: def _refresh_clipping_list(self) -> None: self._clear_list_layout() + row, col = 0, 0 for clipping in self._service.list_clippings_newest_first(): - self._list_layout.addWidget( - ClippingWidget( - clipping, - self._service.recopy_clipping_by_id, - self._service.delete_clipping_by_id, - actions_on_right_click=self._ui_config.actions_on_right_click, - ) + wide = isinstance(clipping, ImageClipping) + widget = ClippingWidget( + clipping, + self._service.recopy_clipping_by_id, + self._service.delete_clipping_by_id, + actions_on_right_click=self._ui_config.actions_on_right_click, + wide=wide, ) - self._list_layout.addStretch(1) + if wide: + if col == 1: # flush the half-filled row before spanning + row += 1 + col = 0 + self._list_layout.addWidget(widget, row, 0, 1, 2) + row += 1 + else: + self._list_layout.addWidget(widget, row, col) + col += 1 + if col == 2: + col = 0 + row += 1 def _clear_list_layout(self) -> None: while self._list_layout.count(): From 962c59aab1a26d110b15b6a87f93763bb9b92b45 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Thu, 30 Jul 2026 15:55:10 +0200 Subject: [PATCH 2/5] stack copy --- config.json | 6 ++-- copyboard/__main__.py | 22 +++++++++++++++ copyboard/application/copyboardservice.py | 14 +++++++++ copyboard/config.py | 5 +++- copyboard/config_loading.py | 16 ++++++++--- tests/application/test_copyboardservice.py | 33 ++++++++++++++++++++++ 6 files changed, 89 insertions(+), 7 deletions(-) diff --git a/config.json b/config.json index d4bf084..29630c5 100644 --- a/config.json +++ b/config.json @@ -3,9 +3,11 @@ "max_items": 30 }, "hotkey": { - "toggle_viewer_hotkey": "ctrl+shift+h" + "toggle_viewer_hotkey": "ctrl+shift+h", + "pop_and_paste_hotkey": "ctrl+v" }, "ui": { - "actions_on_right_click": true + "actions_on_right_click": true, + "lifo_paste_enabled": true } } diff --git a/copyboard/__main__.py b/copyboard/__main__.py index e2d5723..45513ad 100644 --- a/copyboard/__main__.py +++ b/copyboard/__main__.py @@ -42,6 +42,15 @@ class _HotkeyToggleBridge(QObject): triggered = Signal() +def _handle_lifo_paste(service: CopyboardService) -> None: + """Pop the top clipping onto the clipboard. + + The original Ctrl+V keystroke that triggered this is still dispatched by the OS, so no + synthetic paste is needed — the focused app will paste the swapped clipboard content. + """ + service.pop_and_recopy_top_clipping() + + def _open_config_in_editor(config_path: Path, config: AppConfig) -> None: """Open ``config.json`` in the OS default editor, seeding it with defaults if absent.""" if not config_path.is_file(): @@ -93,10 +102,23 @@ def main() -> int: hotkey = PynputHotkeyBinder(config.hotkey.toggle_viewer_hotkey, lambda: bridge.triggered.emit()) hotkey.start() + paste_hotkey: PynputHotkeyBinder | None = None + if config.ui.lifo_paste_enabled: + paste_bridge = _HotkeyToggleBridge() + paste_bridge.triggered.connect( + lambda: _handle_lifo_paste(service), Qt.ConnectionType.QueuedConnection + ) + paste_hotkey = PynputHotkeyBinder( + config.hotkey.pop_and_paste_hotkey, lambda: paste_bridge.triggered.emit() + ) + paste_hotkey.start() + try: return app.exec() finally: hotkey.stop() + if paste_hotkey is not None: + paste_hotkey.stop() if __name__ == "__main__": diff --git a/copyboard/application/copyboardservice.py b/copyboard/application/copyboardservice.py index 3720b32..07c044f 100644 --- a/copyboard/application/copyboardservice.py +++ b/copyboard/application/copyboardservice.py @@ -66,5 +66,19 @@ def delete_clipping_by_id(self, clipping_id: str) -> None: if clipping is not None and self._history.remove_clipping_by_id(clipping_id): self._observers.notify_all(ClippingRemoved(clipping)) + def pop_and_recopy_top_clipping(self) -> bool: + """Copy the newest clipping to the system clipboard and remove it from history. + + Returns True if there was an item to pop, False if the history was empty. + """ + clippings = self._history.list_clippings_newest_first() + if not clippings: + return False + top = clippings[0] + self._sink.copy_clipping_to_system_clipboard(top) + self._history.remove_clipping_by_id(top.id) + self._observers.notify_all(ClippingRemoved(top)) + return True + def list_clippings_newest_first(self) -> list[Clipping]: return self._history.list_clippings_newest_first() diff --git a/copyboard/config.py b/copyboard/config.py index faaff87..007c70a 100644 --- a/copyboard/config.py +++ b/copyboard/config.py @@ -13,6 +13,7 @@ _DEFAULT_MAX_ITEMS = 30 _DEFAULT_MAX_AGE = timedelta.max # no age limit unless the user sets max_age_minutes _DEFAULT_TOGGLE_HOTKEY = "ctrl+shift+h" +_DEFAULT_POP_AND_PASTE_HOTKEY = "ctrl+v" class Theme(Enum): @@ -34,9 +35,10 @@ class RetentionPolicy: @dataclass(frozen=True, slots=True) class HotkeyConfig: - """The global hotkey that shows/hides the viewer window.""" + """The global hotkeys that control viewer and clipboard-stack behaviour.""" toggle_viewer_hotkey: str = _DEFAULT_TOGGLE_HOTKEY + pop_and_paste_hotkey: str = _DEFAULT_POP_AND_PASTE_HOTKEY @dataclass(frozen=True, slots=True) @@ -44,6 +46,7 @@ class UIConfig: """UI behaviour options.""" actions_on_right_click: bool = False + lifo_paste_enabled: bool = True @dataclass(frozen=True, slots=True) diff --git a/copyboard/config_loading.py b/copyboard/config_loading.py index f1bd7ad..3e60597 100644 --- a/copyboard/config_loading.py +++ b/copyboard/config_loading.py @@ -27,8 +27,14 @@ def write_default_config_file(config_path: Path, config: AppConfig) -> None: else config.retention.max_age.total_seconds() / 60 ), }, - "hotkey": {"toggle_viewer_hotkey": config.hotkey.toggle_viewer_hotkey}, - "ui": {"actions_on_right_click": config.ui.actions_on_right_click}, + "hotkey": { + "toggle_viewer_hotkey": config.hotkey.toggle_viewer_hotkey, + "pop_and_paste_hotkey": config.hotkey.pop_and_paste_hotkey, + }, + "ui": { + "actions_on_right_click": config.ui.actions_on_right_click, + "lifo_paste_enabled": config.ui.lifo_paste_enabled, + }, "theme": config.theme.value, } config_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") @@ -65,7 +71,8 @@ def _build_ui_config(section: Any, default: UIConfig) -> UIConfig: if not isinstance(section, dict): return default actions_on_right_click = bool(section.get("actions_on_right_click", default.actions_on_right_click)) - return UIConfig(actions_on_right_click=actions_on_right_click) + lifo_paste_enabled = bool(section.get("lifo_paste_enabled", default.lifo_paste_enabled)) + return UIConfig(actions_on_right_click=actions_on_right_click, lifo_paste_enabled=lifo_paste_enabled) def _build_theme(value: Any, default: Theme) -> Theme: @@ -90,4 +97,5 @@ def _build_hotkey_config(section: Any, default: HotkeyConfig) -> HotkeyConfig: if not isinstance(section, dict): return default toggle_hotkey = str(section.get("toggle_viewer_hotkey", default.toggle_viewer_hotkey)) - return HotkeyConfig(toggle_viewer_hotkey=toggle_hotkey) + pop_hotkey = str(section.get("pop_and_paste_hotkey", default.pop_and_paste_hotkey)) + return HotkeyConfig(toggle_viewer_hotkey=toggle_hotkey, pop_and_paste_hotkey=pop_hotkey) diff --git a/tests/application/test_copyboardservice.py b/tests/application/test_copyboardservice.py index 3b61c7c..c543b97 100644 --- a/tests/application/test_copyboardservice.py +++ b/tests/application/test_copyboardservice.py @@ -67,6 +67,39 @@ def test_delete_removes_the_clipping_and_notifies() -> None: assert any(isinstance(event, ClippingRemoved) for event in observer.events) +def test_pop_and_recopy_returns_false_when_history_is_empty() -> None: + service, _, _, _ = _build_service() + + result = service.pop_and_recopy_top_clipping() + + assert result is False + + +def test_pop_and_recopy_writes_top_clipping_to_sink_and_removes_it() -> None: + service, sink, _, _ = _build_service() + service.handle_new_clipboard_content(RawClipboardData(text="first")) + service.handle_new_clipboard_content(RawClipboardData(text="second")) + + result = service.pop_and_recopy_top_clipping() + + assert result is True + assert len(sink.copied_clippings) == 1 + assert sink.copied_clippings[0].build_preview_text() == "second" + remaining = [c.build_preview_text() for c in service.list_clippings_newest_first()] + assert remaining == ["first"] + + +def test_pop_and_recopy_notifies_observers_with_clipping_removed() -> None: + service, _, observer, _ = _build_service() + service.handle_new_clipboard_content(RawClipboardData(text="hello")) + observer.events.clear() + + service.pop_and_recopy_top_clipping() + + assert len(observer.events) == 1 + assert isinstance(observer.events[0], ClippingRemoved) + + def test_expired_clippings_are_pruned_when_new_content_arrives() -> None: service, _, observer, clock = _build_service( RetentionPolicy(max_items=100, max_age=timedelta(minutes=20)) From 3f447d707cd2c9533c48355a699cb61595b98a0f Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Thu, 30 Jul 2026 16:04:33 +0200 Subject: [PATCH 3/5] lint check pass --- copyboard/adapters/ui/clippingwidget.py | 10 +++++++++- copyboard/config_loading.py | 6 ++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/copyboard/adapters/ui/clippingwidget.py b/copyboard/adapters/ui/clippingwidget.py index 364a5c8..09827c9 100644 --- a/copyboard/adapters/ui/clippingwidget.py +++ b/copyboard/adapters/ui/clippingwidget.py @@ -15,7 +15,15 @@ from PySide6.QtCore import QPoint, Qt from PySide6.QtGui import QContextMenuEvent, QDrag, QMouseEvent, QPixmap -from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QMenu, QPushButton, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QMenu, + QPushButton, + QVBoxLayout, + QWidget, +) from copyboard.adapters.qt.clippingdragdata import build_drag_mime_data from copyboard.domain.clipping import Clipping, ImageClipping diff --git a/copyboard/config_loading.py b/copyboard/config_loading.py index 3e60597..631943c 100644 --- a/copyboard/config_loading.py +++ b/copyboard/config_loading.py @@ -70,9 +70,11 @@ def _build_app_config_from_document(document: dict[str, Any]) -> AppConfig: def _build_ui_config(section: Any, default: UIConfig) -> UIConfig: if not isinstance(section, dict): return default - actions_on_right_click = bool(section.get("actions_on_right_click", default.actions_on_right_click)) + actions_on_right_click = bool(section.get("actions_on_right_click" + , default.actions_on_right_click)) lifo_paste_enabled = bool(section.get("lifo_paste_enabled", default.lifo_paste_enabled)) - return UIConfig(actions_on_right_click=actions_on_right_click, lifo_paste_enabled=lifo_paste_enabled) + return UIConfig(actions_on_right_click=actions_on_right_click, + lifo_paste_enabled=lifo_paste_enabled) def _build_theme(value: Any, default: Theme) -> Theme: From b8feebd3fbef93aefc5a1a9f609f4ec9d3f010c2 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Thu, 30 Jul 2026 16:09:43 +0200 Subject: [PATCH 4/5] format --- copyboard/adapters/ui/apptheme.py | 2 +- copyboard/config_loading.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/copyboard/adapters/ui/apptheme.py b/copyboard/adapters/ui/apptheme.py index 6d64fb0..4fc022b 100644 --- a/copyboard/adapters/ui/apptheme.py +++ b/copyboard/adapters/ui/apptheme.py @@ -32,7 +32,7 @@ " stop:0.040 rgba(100, 145, 235, 55)," # glow fades " stop:0.085 rgba(255, 255, 255, 28)," # transitions to frosted-white body " stop:0.500 rgba(255, 255, 255, 18)," # mid-body - " stop:1.000 rgba(255, 255, 255, 10));" # slight lift toward the bottom (reflected table) + " stop:1.000 rgba(255, 255, 255, 10));" # slight lift toward the bottom (reflected table) " border-top: 1px solid rgba(200, 225, 255, 160);" # lit rim " border-left: 1px solid rgba(150, 185, 255, 90);" " border-right: 1px solid rgba( 80, 115, 210, 60);" diff --git a/copyboard/config_loading.py b/copyboard/config_loading.py index 631943c..83b82a6 100644 --- a/copyboard/config_loading.py +++ b/copyboard/config_loading.py @@ -70,11 +70,13 @@ def _build_app_config_from_document(document: dict[str, Any]) -> AppConfig: def _build_ui_config(section: Any, default: UIConfig) -> UIConfig: if not isinstance(section, dict): return default - actions_on_right_click = bool(section.get("actions_on_right_click" - , default.actions_on_right_click)) + actions_on_right_click = bool( + section.get("actions_on_right_click", default.actions_on_right_click) + ) lifo_paste_enabled = bool(section.get("lifo_paste_enabled", default.lifo_paste_enabled)) - return UIConfig(actions_on_right_click=actions_on_right_click, - lifo_paste_enabled=lifo_paste_enabled) + return UIConfig( + actions_on_right_click=actions_on_right_click, lifo_paste_enabled=lifo_paste_enabled + ) def _build_theme(value: Any, default: Theme) -> Theme: From 729958aef356a62eedad7c4516021b9cf57a27c1 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Thu, 30 Jul 2026 16:29:43 +0200 Subject: [PATCH 5/5] test fix --- tests/conftest.py | 12 ++++++++++++ tests/ui/test_mainwindow.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 1de7a63..9e13952 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,15 @@ def qt_app() -> Iterator[QApplication]: existing = QApplication.instance() app = existing if isinstance(existing, QApplication) else QApplication([]) yield app + # Explicitly destroy all top-level widgets before Python's shutdown GC runs. + # Tests that create MainWindow objects leave a window↔service reference cycle + # that Python 3.11/3.12 GC collects only during interpreter shutdown, when Qt's + # X11/compositor state is partially torn down — causing a SIGSEGV. Calling + # shiboken6.delete() here destroys the C++ objects while QApplication is still + # fully alive, so the Python wrappers become safe no-op shells for the GC. + import shiboken6 + + for widget in list(app.topLevelWidgets()): + if shiboken6.isValid(widget): + widget.close() + shiboken6.delete(widget) diff --git a/tests/ui/test_mainwindow.py b/tests/ui/test_mainwindow.py index d14ebff..57b47b5 100644 --- a/tests/ui/test_mainwindow.py +++ b/tests/ui/test_mainwindow.py @@ -51,6 +51,7 @@ def test_toggle_from_hidden_brings_window_to_front(qt_app: QApplication) -> None window.toggle_visibility() assert window.isVisible() + window.hide() # don't leave a visible top-level window alive into teardown def test_bring_to_front_shows_a_hidden_window(qt_app: QApplication) -> None: @@ -60,6 +61,7 @@ def test_bring_to_front_shows_a_hidden_window(qt_app: QApplication) -> None: window.bring_to_front() assert window.isVisible() + window.hide() # don't leave a visible top-level window alive into teardown def test_rows_do_not_label_the_clipping_kind(qt_app: QApplication) -> None: