Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
},
"ui": {
"actions_on_right_click": true,
"lifo_paste_enabled": true
"lifo_paste_enabled": false
}
}
35 changes: 21 additions & 14 deletions copyboard/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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__":
Expand Down
38 changes: 38 additions & 0 deletions copyboard/adapters/stackpastecontroller.py
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 42 additions & 1 deletion copyboard/adapters/ui/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,17 +39,21 @@ 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
# QWidget subclass; without it the translucent gradient frame is never drawn.
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()
Expand All @@ -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()

Expand Down
66 changes: 66 additions & 0 deletions tests/adapters/test_stackpastecontroller.py
Original file line number Diff line number Diff line change
@@ -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"]
51 changes: 50 additions & 1 deletion tests/ui/test_mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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


Expand Down Expand Up @@ -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.
Expand Down
Loading