Skip to content
Closed
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
6 changes: 4 additions & 2 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
25 changes: 23 additions & 2 deletions copyboard/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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__":
Expand Down
14 changes: 11 additions & 3 deletions copyboard/adapters/ui/apptheme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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; }"
Expand Down
14 changes: 8 additions & 6 deletions copyboard/adapters/ui/clippingwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down
40 changes: 27 additions & 13 deletions copyboard/adapters/ui/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions copyboard/application/copyboardservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion copyboard/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -34,16 +35,18 @@ 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)
class UIConfig:
"""UI behaviour options."""

actions_on_right_click: bool = False
lifo_paste_enabled: bool = True


@dataclass(frozen=True, slots=True)
Expand Down
18 changes: 14 additions & 4 deletions copyboard/config_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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)
33 changes: 33 additions & 0 deletions tests/application/test_copyboardservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 12 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions tests/ui/test_mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading