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 8557bbb..99bad4e 100644 --- a/copyboard/__main__.py +++ b/copyboard/__main__.py @@ -44,6 +44,15 @@ class _GlobalInputBridge(QObject): paste_shortcut_released = 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(): @@ -108,11 +117,23 @@ def main() -> int: viewer_hotkey.start() paste_observer.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: - paste_observer.stop() - viewer_hotkey.stop() + hotkey.stop() + if paste_hotkey is not None: + paste_hotkey.stop() if __name__ == "__main__": diff --git a/copyboard/adapters/ui/apptheme.py b/copyboard/adapters/ui/apptheme.py index e02e810..ef2f09f 100644 --- a/copyboard/adapters/ui/apptheme.py +++ b/copyboard/adapters/ui/apptheme.py @@ -18,9 +18,17 @@ _GLASS_STYLESHEET = ( "MainWindow {" - " background-color: rgba(30, 30, 30, 245);" - " border: 1px solid rgba(80, 80, 80, 240);" - " border-radius: 4px;" + " background: qlineargradient(x1:0, y1:0, x2:0, y2:1," + " stop:0.000 rgba(160, 200, 255, 100)," # sky-blue glint — top rim in full light + " 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) + " 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);" + " border-bottom: 1px solid rgba( 55, 85, 185, 55);" + " border-radius: 7px;" "}" "QScrollArea { background: transparent; border: none; }" "QScrollArea > QWidget > QWidget { background: transparent; }" diff --git a/copyboard/adapters/ui/clippingwidget.py b/copyboard/adapters/ui/clippingwidget.py index fcaabc3..93a1659 100644 --- a/copyboard/adapters/ui/clippingwidget.py +++ b/copyboard/adapters/ui/clippingwidget.py @@ -30,9 +30,8 @@ _THUMBNAIL_WIDTH = 160 _THUMBNAIL_HEIGHT = 90 -_CURRENT_CLIPBOARD_BORDER_STYLESHEET = ( - "#currentClipboardClipping { border: 2px solid palette(highlight); }" -) +_WIDE_THUMBNAIL_WIDTH = 340 +_WIDE_THUMBNAIL_HEIGHT = 200 class ClippingWidget(QWidget): @@ -44,7 +43,7 @@ def __init__( on_recopy: Callable[[str], None], on_delete: Callable[[str], None], actions_on_right_click: bool = True, - is_current_clipboard_item: bool = False, + wide: bool = False, ) -> None: super().__init__() # Required for Qt to paint a stylesheet `background` on a plain QWidget subclass. @@ -59,6 +58,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) @@ -126,10 +126,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 b8e648c..955ed4d 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 QPushButton, 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 @@ -36,7 +37,7 @@ 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) self._stack_paste_button = QPushButton() @@ -48,7 +49,10 @@ def __init__( 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) @@ -103,18 +107,28 @@ def bring_to_front(self) -> None: def _refresh_clipping_list(self) -> None: self._clear_list_layout() - current_clipping_id = self._service.get_current_clipboard_clipping_id() + 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, - is_current_clipboard_item=clipping.id == current_clipping_id, - ) + 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 _synchronize_stack_paste_button(self, enabled: bool) -> None: self._stack_paste_button.setChecked(enabled) diff --git a/copyboard/application/copyboardservice.py b/copyboard/application/copyboardservice.py index 74da0ab..def7eb9 100644 --- a/copyboard/application/copyboardservice.py +++ b/copyboard/application/copyboardservice.py @@ -119,6 +119,20 @@ def consume_prepared_clipping_after_paste(self) -> None: self._observers.notify_all(ClippingRemoved(clipping)) self._prepare_newest_clipping_for_stack_paste(clear_clipboard_if_empty=True) + 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 8dcf309..f75c441 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 0cc6455..83b82a6 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") @@ -67,7 +73,10 @@ def _build_ui_config(section: Any, default: UIConfig) -> UIConfig: 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: @@ -92,4 +101,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 83db8de..18cb68c 100644 --- a/tests/application/test_copyboardservice.py +++ b/tests/application/test_copyboardservice.py @@ -74,6 +74,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)) diff --git a/tests/conftest.py b/tests/conftest.py index 0be8182..9e13952 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,15 @@ def qt_app() -> Iterator[QApplication]: existing = QApplication.instance() app = existing if isinstance(existing, QApplication) else QApplication([]) yield app - for widget in app.topLevelWidgets(): - widget.close() - widget.deleteLater() - app.processEvents() + # 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 e452262..a662d63 100644 --- a/tests/ui/test_mainwindow.py +++ b/tests/ui/test_mainwindow.py @@ -108,6 +108,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: @@ -117,6 +118,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: