diff --git a/config.json b/config.json index f529c30..6622cd6 100644 --- a/config.json +++ b/config.json @@ -9,6 +9,6 @@ }, "ui": { "actions_on_right_click": true, - "lifo_paste_enabled": true + "lifo_paste_enabled": false } } diff --git a/copyboard/__main__.py b/copyboard/__main__.py index 45513ad..ed2940a 100644 --- a/copyboard/__main__.py +++ b/copyboard/__main__.py @@ -20,6 +20,7 @@ from copyboard.adapters.processdetach import relaunch_detached, should_relaunch_detached from copyboard.adapters.pynputhotkeybinder import PynputHotkeyBinder from copyboard.adapters.qt.qtclipboard import QtClipboardSink, QtClipboardSource +from copyboard.adapters.stackpastecontroller import StackPasteController from copyboard.adapters.systemclock import SystemClock from copyboard.adapters.tempdirvault import TempDirVault from copyboard.adapters.ui.apptheme import ThemeController @@ -82,7 +83,25 @@ def main() -> int: source = QtClipboardSource(clipboard, echo_guard) source.set_new_content_listener(service.handle_new_clipboard_content) - window = MainWindow(service, config.ui) + # Stack paste is bound before the window so the window's toggle button can start out reflecting + # the configured state, and flip the binding on/off from there. + paste_bridge = _HotkeyToggleBridge() + paste_bridge.triggered.connect( + lambda: _handle_lifo_paste(service), Qt.ConnectionType.QueuedConnection + ) + stack_paste = StackPasteController( + PynputHotkeyBinder( + config.hotkey.pop_and_paste_hotkey, lambda: paste_bridge.triggered.emit() + ), + initially_enabled=config.ui.lifo_paste_enabled, + ) + + window = MainWindow( + service, + config.ui, + on_stack_paste_toggled=stack_paste.set_enabled, + stack_paste_enabled=stack_paste.is_enabled, + ) # ThemeController is created after the window so it can apply WA_TranslucentBackground # before window.show(), which is required on some platforms (notably Windows). theme_controller = ThemeController(app, config.theme, window) @@ -102,23 +121,11 @@ 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() + stack_paste.shutdown() if __name__ == "__main__": diff --git a/copyboard/adapters/stackpastecontroller.py b/copyboard/adapters/stackpastecontroller.py new file mode 100644 index 0000000..11959af --- /dev/null +++ b/copyboard/adapters/stackpastecontroller.py @@ -0,0 +1,38 @@ +"""Runtime on/off switch for stack-paste mode. + +Stack paste is nothing more than a global hotkey binding (``ctrl+v`` by default) that pops the +newest clipping onto the clipboard. Turning the mode off therefore means unbinding that hotkey, so +the user's normal paste behaves normally again. This controller owns that binding's lifetime and +exposes it as a boolean the UI can flip; it depends only on the ``HotkeyBinder`` port, never on Qt. +""" + +from __future__ import annotations + +from copyboard.adapters.pynputhotkeybinder import HotkeyBinder + + +class StackPasteController: + """Binds/unbinds the pop-and-paste hotkey so stack-paste mode can be toggled while running.""" + + def __init__(self, hotkey_binder: HotkeyBinder, initially_enabled: bool = False) -> None: + self._hotkey_binder = hotkey_binder + self._is_enabled = False + self.set_enabled(initially_enabled) + + @property + def is_enabled(self) -> bool: + return self._is_enabled + + def set_enabled(self, enabled: bool) -> None: + """Start or stop the pop-and-paste hotkey binding; re-setting the same state is a no-op.""" + if enabled == self._is_enabled: + return + if enabled: + self._hotkey_binder.start() + else: + self._hotkey_binder.stop() + self._is_enabled = enabled + + def shutdown(self) -> None: + """Release the hotkey binding on application exit.""" + self.set_enabled(False) diff --git a/copyboard/adapters/ui/mainwindow.py b/copyboard/adapters/ui/mainwindow.py index e827475..368c8b8 100644 --- a/copyboard/adapters/ui/mainwindow.py +++ b/copyboard/adapters/ui/mainwindow.py @@ -8,8 +8,17 @@ from __future__ import annotations +from collections.abc import Callable + from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) from copyboard.adapters.ui.clippingwidget import ClippingWidget from copyboard.application.copyboardservice import CopyboardService @@ -30,10 +39,13 @@ def __init__( service: CopyboardService, ui_config: UIConfig | None = None, prune_interval_ms: int = _DEFAULT_PRUNE_INTERVAL_MS, + on_stack_paste_toggled: Callable[[bool], None] | None = None, + stack_paste_enabled: bool = False, ) -> None: super().__init__() self._service = service self._ui_config = ui_config or UIConfig() + self._on_stack_paste_toggled = on_stack_paste_toggled self.setWindowTitle("Copyboard") self.resize(500, 620) # Required for Qt to paint the glass theme's stylesheet `background`/`border` on this plain @@ -41,6 +53,7 @@ def __init__( self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) outer_layout = QVBoxLayout(self) + outer_layout.addLayout(self._build_header_row(stack_paste_enabled)) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) self._list_container = QWidget() @@ -62,6 +75,34 @@ def __init__( self._refresh_clipping_list() + def _build_header_row(self, stack_paste_enabled: bool) -> QHBoxLayout: + """The window's top strip: the stack-paste toggle, right-aligned above the list.""" + header_layout = QHBoxLayout() + header_layout.addStretch(1) + header_layout.addWidget(self._build_stack_paste_button(stack_paste_enabled)) + return header_layout + + def _build_stack_paste_button(self, stack_paste_enabled: bool) -> QPushButton: + button = QPushButton() + button.setCheckable(True) + button.setChecked(stack_paste_enabled) + button.setToolTip( + "While on, the paste hotkey pops the newest clipping off the history (LIFO) " + "instead of pasting the same thing repeatedly." + ) + button.toggled.connect(self._handle_stack_paste_toggled) + self._stack_paste_button = button + self._update_stack_paste_button_text(stack_paste_enabled) + return button + + def _handle_stack_paste_toggled(self, enabled: bool) -> None: + self._update_stack_paste_button_text(enabled) + if self._on_stack_paste_toggled is not None: + self._on_stack_paste_toggled(enabled) + + def _update_stack_paste_button_text(self, enabled: bool) -> None: + self._stack_paste_button.setText(f"Stack paste: {'On' if enabled else 'Off'}") + def on_history_changed(self, event: HistoryChangeEvent) -> None: self._history_changed.emit() diff --git a/tests/adapters/test_stackpastecontroller.py b/tests/adapters/test_stackpastecontroller.py new file mode 100644 index 0000000..3540030 --- /dev/null +++ b/tests/adapters/test_stackpastecontroller.py @@ -0,0 +1,66 @@ +"""The stack-paste controller binds the hotkey only while the mode is on.""" + +from __future__ import annotations + +from copyboard.adapters.stackpastecontroller import StackPasteController + + +class FakeHotkeyBinder: + """Records start/stop calls in order so bindings can be asserted without a real listener.""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def start(self) -> None: + self.calls.append("start") + + def stop(self) -> None: + self.calls.append("stop") + + +def test_disabled_controller_never_binds_the_hotkey() -> None: + binder = FakeHotkeyBinder() + + controller = StackPasteController(binder, initially_enabled=False) + + assert not controller.is_enabled + assert binder.calls == [] + + +def test_enabled_controller_binds_the_hotkey_immediately() -> None: + binder = FakeHotkeyBinder() + + controller = StackPasteController(binder, initially_enabled=True) + + assert controller.is_enabled + assert binder.calls == ["start"] + + +def test_toggling_off_then_on_rebinds_the_hotkey() -> None: + binder = FakeHotkeyBinder() + controller = StackPasteController(binder, initially_enabled=True) + + controller.set_enabled(False) + controller.set_enabled(True) + + assert controller.is_enabled + assert binder.calls == ["start", "stop", "start"] + + +def test_setting_the_same_state_twice_does_nothing() -> None: + binder = FakeHotkeyBinder() + controller = StackPasteController(binder, initially_enabled=True) + + controller.set_enabled(True) + + assert binder.calls == ["start"] + + +def test_shutdown_releases_the_binding() -> None: + binder = FakeHotkeyBinder() + controller = StackPasteController(binder, initially_enabled=True) + + controller.shutdown() + + assert not controller.is_enabled + assert binder.calls == ["start", "stop"] diff --git a/tests/ui/test_mainwindow.py b/tests/ui/test_mainwindow.py index 57b47b5..0d90a4b 100644 --- a/tests/ui/test_mainwindow.py +++ b/tests/ui/test_mainwindow.py @@ -16,7 +16,7 @@ from tests.fakes import FakeClipboardSink, FakeClock, FakeVault -def _build_populated_window() -> tuple[MainWindow, FakeClipboardSink]: +def _build_populated_service() -> tuple[CopyboardService, FakeClipboardSink]: clock = FakeClock(datetime(2026, 1, 1, 12, 0, 0)) sink = FakeClipboardSink() service = CopyboardService( @@ -27,6 +27,11 @@ def _build_populated_window() -> tuple[MainWindow, FakeClipboardSink]: ) service.handle_new_clipboard_content(RawClipboardData(text="hello world")) service.handle_new_clipboard_content(RawClipboardData(image=ImagePayload(b"x", "png"))) + return service, sink + + +def _build_populated_window() -> tuple[MainWindow, FakeClipboardSink]: + service, sink = _build_populated_service() return MainWindow(service, prune_interval_ms=100_000), sink @@ -64,6 +69,50 @@ def test_bring_to_front_shows_a_hidden_window(qt_app: QApplication) -> None: window.hide() # don't leave a visible top-level window alive into teardown +def _find_stack_paste_button(window: MainWindow) -> QPushButton: + buttons = [b for b in window.findChildren(QPushButton) if b.text().startswith("Stack paste:")] + assert len(buttons) == 1 + return buttons[0] + + +def test_stack_paste_button_starts_off_by_default(qt_app: QApplication) -> None: + window, _ = _build_populated_window() + + button = _find_stack_paste_button(window) + + assert not button.isChecked() + assert button.text() == "Stack paste: Off" + + +def test_stack_paste_button_reports_each_toggle(qt_app: QApplication) -> None: + toggles: list[bool] = [] + window = MainWindow( + _build_populated_service()[0], + prune_interval_ms=100_000, + on_stack_paste_toggled=toggles.append, + ) + button = _find_stack_paste_button(window) + + button.click() + button.click() + + assert toggles == [True, False] + assert button.text() == "Stack paste: Off" + + +def test_stack_paste_button_reflects_the_initial_enabled_state(qt_app: QApplication) -> None: + window = MainWindow( + _build_populated_service()[0], + prune_interval_ms=100_000, + stack_paste_enabled=True, + ) + + button = _find_stack_paste_button(window) + + assert button.isChecked() + assert button.text() == "Stack paste: On" + + def test_rows_do_not_label_the_clipping_kind(qt_app: QApplication) -> None: window, _ = _build_populated_window() # The old header was "KIND · HH:MM:SS"; the kind label (and its "·" separator) is now gone.