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
1 change: 1 addition & 0 deletions .pytest-ui-refresh/test_full_config_is_loaded0/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"retention": {"max_items": 5, "max_age_minutes": 10}, "hotkey": {"toggle_viewer_hotkey": "ctrl+alt+p"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"retention": {"max_items": 7}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"theme": "light"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"theme": "system"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"theme": "DARK"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"theme": "nonsense"}
13 changes: 13 additions & 0 deletions .pytest-ui-refresh/test_written_default_config_ro0/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"retention": {
"max_items": 30,
"max_age_minutes": null
},
"hotkey": {
"toggle_viewer_hotkey": "ctrl+shift+h"
},
"ui": {
"actions_on_right_click": false
},
"theme": "dark"
}
19 changes: 19 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ copyboard/
`pynput`, running a listener thread; its callback is marshaled onto the Qt GUI thread by a queued
signal. Lives outside the domain (an interaction concern, not business logic).

## Stack paste flow

`CopyboardService` owns the off-by-default stack state and the ID of the exact clipping prepared on
the clipboard. The same current-clipping ID drives the viewer's solid clipboard-item outline during
normal capture and re-copy operations. It notifies UI observers when either state changes, keeping
the row outline, viewer button, and tray action synchronized.
Enabling either control copies the newest clipping. `PynputPasteObserver` watches the
native Ctrl+V shortcut on Windows/Linux or Cmd+V on macOS without suppressing it. On chord release,
the composition root queues a GUI-thread call that removes the prepared clipping, notifies history
observers, and copies the next-newest item through `ClipboardSink`. Exhausting the history calls
`ClipboardSink.clear_system_clipboard()`; the shared `ClipboardEchoGuard` prevents those internal
writes and clears from being captured again.

The keyboard observer remains an adapter: neither `domain/` nor `application/` imports `pynput` or
Qt. Context-menu and mouse paste actions are intentionally outside this portable interaction seam.

UI themes use a shared palette plus flat, uniform box fills. No stylesheet uses color gradients;
dark is the default theme.

## Supporting patterns

**Observer** (service → UI change notifications), **Strategy/polymorphism** (the `Clipping` hierarchy +
Expand Down
1,164 changes: 1,164 additions & 0 deletions COPYBOARD_STUDY_GUIDE.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ subclassing, because time-pruning needs to scan).
- **6.2** Full `uv run ruff check`, `uv run mypy .`, `uv run pytest` green across src + tests.
- **6.3** Backlog notes (config file, thumbnail/image-size caps, persistence, cross-platform clipboard adapter).

### PHASE 7 — Stack paste mode (LIFO)
- **7.1** Extend `CopyboardService` with off-by-default stack state and exact prepared-clipping tracking.
- **7.2** Extend `ClipboardSink`/`QtClipboardSink` with guarded clipboard clearing for stack exhaustion.
- **7.3** Add `PynputPasteObserver`: Ctrl+V on Windows/Linux, Cmd+V on macOS, callback after release.
- **7.4** Add the checkable tray action and marshal paste callbacks onto the Qt GUI thread.
- **7.5** Test LIFO consumption, re-copy/delete interactions, empty clipboard safety, platform mapping,
and tray state; document keyboard-only paste observation.
- **7.6** Add synchronized viewer/tray controls and use flat neutral box styling without gradients.

## Verification

- **Core (automated, no display needed):** `uv run pytest` — classifier picks correct kinds;
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,31 @@ A scrollable list (newest first) with a timestamp and preview for each item. Ima
- **Delete** — removes it from history immediately
- **Drag** — drag any item directly into another app (text, URLs, paths, or image files)

The item currently on the system clipboard has a simple solid outline. The outline follows new
copies, row re-copy actions, and stack-paste advancement, and disappears when the clipboard is clear.

**Global hotkey**
Press **Ctrl+Shift+H** (configurable) from any application to show or raise the viewer. Press again while the viewer is in the foreground to hide it.

**Stack paste mode (LIFO)**

Enable **Stack paste: On/Off** in the viewer or **Stack paste mode (LIFO)** in the tray menu to paste
through history newest-first. Both controls stay synchronized. Copyboard
preloads the newest clipping; after each normal **Ctrl+V** (Windows/Linux) or **Cmd+V** (macOS), that
clipping is removed from history and the next-newest clipping is loaded. The final paste clears the
clipboard. Turning the mode off leaves the current clipboard and remaining history untouched.

**System-tray icon**
Click the tray icon to toggle the viewer. Right-click for the menu:
- Show / hide viewer
- Stack paste mode (LIFO)
- Toggle light / dark theme
- Edit config… (opens `config.json` in your default editor)
- Quit Copyboard

**Live theme toggle**
Switch between dark, light, and system themes from the tray menu — no restart needed.
Switch themes from the tray menu — no restart needed. Colors use flat, neutral boxes without
gradients; dark is the default.

**Retention policy**
Both limits apply together: the history keeps at most `max_items` clippings **and** drops anything older than `max_age_minutes`. Pruning runs every second in the background.
Expand Down Expand Up @@ -95,6 +108,10 @@ If the hotkey fails to bind, the app still runs — use the tray icon to open th

## Development

Stack paste advances only for keyboard paste shortcuts. Context-menu paste, middle-click paste, and
mouse buttons mapped to Paste are not observed. Its keyboard listener has the same X11 requirement on
Linux and Accessibility-permission requirement on macOS as the viewer hotkey.

```
uv run ruff check . # lint
uv run ruff format . # format
Expand Down
12 changes: 12 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ a live view of your recent clippings, so you can glance back and re-copy any of
2. **Live view** — a window listing recent clippings, newest first, each showing a preview
(text snippet, URL, path, or image thumbnail) and its capture time.
3. **Re-copy** — clicking a clipping puts it back on the system clipboard.
The clipping currently represented by the system clipboard is marked with a solid outline in the
viewer.
4. **Delete** — a clipping can be removed from the view.
5. **Retention** — the view keeps only recent clippings, bounded by **both**:
- a maximum **count** (default **30**), and
Expand All @@ -36,6 +38,13 @@ a live view of your recent clippings, so you can glance back and re-copy any of
6. **Background behavior** — the app lives in the **system tray** and captures continuously. A
**global hotkey** (default **Ctrl+Shift+H**) and clicking the tray icon show/hide the viewer.

7. **Stack paste mode (LIFO)** — synchronized checkable controls in the viewer and tray enable
destructive newest-first pasting.
Enabling it loads the newest clipping onto the system clipboard. After each native keyboard paste
(`Ctrl+V` on Windows/Linux, `Cmd+V` on macOS), the prepared clipping is removed from history and
the next-newest clipping is loaded. The final paste clears the clipboard. The mode is off by
default and disabling it leaves the current clipboard and remaining history unchanged.

## Configuration

Settings load from **`config.json`** at the repo root (`copyboard/config_loading.py`). Missing or
Expand Down Expand Up @@ -67,6 +76,9 @@ partial files fall back to defaults (`copyboard/config.py`). Fields:

## Known platform caveats

Stack paste observes keyboard paste shortcuts only. Context-menu paste, middle-click paste, and mouse
buttons mapped to Paste are not detected.

- The `pynput` global hotkey needs **X11** on Linux (not native Wayland) and **Accessibility
permission** on macOS. If the hotkey can't bind, the app still runs — use the tray icon to open the
viewer.
8 changes: 8 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ Phase/step checklist. See [PLAN.md](PLAN.md) for full detail, [SPEC.md](SPEC.md)
- [x] 6.2 Full `ruff` / `mypy` / `pytest` green across src + tests (42 files, 34 tests).
- [x] 6.3 Backlog notes (below).

## Phase 7 — Stack paste mode (LIFO)
- [x] 7.1 Add application-level stack state that prepares, consumes, and removes the exact clipping.
- [x] 7.2 Add clipboard clearing and preserve echo suppression for the final item.
- [x] 7.3 Observe Ctrl+V on Windows/Linux and Cmd+V on macOS after chord release.
- [x] 7.4 Add the checkable tray toggle and GUI-thread signal wiring.
- [x] 7.5 Cover LIFO behavior, platform shortcuts, clipboard clearing, and tray state with tests.
- [x] 7.6 Add a synchronized viewer toggle and replace gradient styling with flat neutral boxes.

## Backlog
- Disk persistence (history surviving restart) — currently in-memory only.
- A web front-end reusing the pure core (the hexagonal seam makes it possible).
Expand Down
55 changes: 45 additions & 10 deletions copyboard/__main__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Composition root — the only place concrete adapters are wired into the service.

Builds the Qt application, the pure core (classifier, history, service) and every adapter
(clock, vault, clipboard source/sink, viewer, tray, global hotkey), connects them, and runs the
event loop. The global hotkey fires on a background thread, so its callback is bounced onto the GUI
thread through a queued Qt signal.
(clock, vault, clipboard source/sink, viewer, tray, global keyboard observers), connects them, and
runs the event loop. Keyboard callbacks fire on background threads, so they are bounced onto the GUI
thread through queued Qt signals.
"""

from __future__ import annotations
Expand All @@ -19,6 +19,7 @@
from copyboard.adapters.clipboardechoguard import ClipboardEchoGuard
from copyboard.adapters.processdetach import relaunch_detached, should_relaunch_detached
from copyboard.adapters.pynputhotkeybinder import PynputHotkeyBinder
from copyboard.adapters.pynputpasteobserver import PynputPasteObserver
from copyboard.adapters.qt.qtclipboard import QtClipboardSink, QtClipboardSource
from copyboard.adapters.systemclock import SystemClock
from copyboard.adapters.tempdirvault import TempDirVault
Expand All @@ -36,10 +37,20 @@
from copyboard.domain.clippinghistory import ClippingHistory


class _HotkeyToggleBridge(QObject):
"""Marshals the background-thread hotkey callback onto the Qt GUI thread."""
class _GlobalInputBridge(QObject):
"""Marshals background-thread keyboard callbacks onto the Qt GUI thread."""

triggered = Signal()
viewer_toggle_requested = Signal()
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 _handle_lifo_paste(service: CopyboardService) -> None:
Expand Down Expand Up @@ -91,16 +102,40 @@ def main() -> int:
tray = TrayIcon(
create_default_tray_icon(),
window.toggle_visibility,
service.set_stack_paste_mode_enabled,
theme_controller.toggle,
lambda: _open_config_in_editor(config_path, config),
app.quit,
)
service.register_stack_paste_mode_observer(tray)
tray.show()

bridge = _HotkeyToggleBridge()
bridge.triggered.connect(window.toggle_visibility, Qt.ConnectionType.QueuedConnection)
hotkey = PynputHotkeyBinder(config.hotkey.toggle_viewer_hotkey, lambda: bridge.triggered.emit())
hotkey.start()
bridge = _GlobalInputBridge()
bridge.viewer_toggle_requested.connect(
window.toggle_visibility, Qt.ConnectionType.QueuedConnection
)
bridge.paste_shortcut_released.connect(
service.consume_prepared_clipping_after_paste,
Qt.ConnectionType.QueuedConnection,
)
viewer_hotkey = PynputHotkeyBinder(
config.hotkey.toggle_viewer_hotkey,
lambda: bridge.viewer_toggle_requested.emit(),
)
paste_observer = PynputPasteObserver(lambda: bridge.paste_shortcut_released.emit())
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()

paste_hotkey: PynputHotkeyBinder | None = None
if config.ui.lifo_paste_enabled:
Expand Down
94 changes: 94 additions & 0 deletions copyboard/adapters/pynputpasteobserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Observe the platform's native keyboard-paste shortcut without suppressing it.

The callback runs after the first key in the active paste chord is released. By then the focused
application has handled the real Ctrl+V or Cmd+V key press, so Copyboard can safely advance its
stack without synthesising input or racing the destination's clipboard read.
"""

from __future__ import annotations

import sys
from collections.abc import Callable
from typing import Any, Protocol

from pynput import keyboard


def system_paste_hotkey_for_platform(platform: str) -> str:
"""Return pynput syntax for the native paste shortcut on ``platform``."""
return "<cmd>+v" if platform == "darwin" else "<ctrl>+v"


class PasteObserver(Protocol):
"""A startable/stoppable observer of completed native keyboard-paste shortcuts."""

def start(self) -> None: ...

def stop(self) -> None: ...


class PasteShortcutReleaseGate:
"""Emit once when a previously activated paste chord begins to be released."""

def __init__(self, on_paste_shortcut_released: Callable[[], None]) -> None:
self._on_paste_shortcut_released = on_paste_shortcut_released
self._paste_shortcut_is_active = False

def record_paste_shortcut_activated(self) -> None:
self._paste_shortcut_is_active = True

def record_key_released(self) -> None:
if not self._paste_shortcut_is_active:
return
self._paste_shortcut_is_active = False
self._on_paste_shortcut_released()

def reset(self) -> None:
self._paste_shortcut_is_active = False


class PynputPasteObserver:
"""Observe Ctrl+V on Windows/Linux and Cmd+V on macOS using ``pynput``."""

def __init__(
self,
on_paste_shortcut_released: Callable[[], None],
platform: str = sys.platform,
) -> None:
self._hotkey_text = system_paste_hotkey_for_platform(platform)
self._release_gate = PasteShortcutReleaseGate(on_paste_shortcut_released)
self._hotkey: Any = None
self._listener: Any = None

def start(self) -> None:
self._release_gate.reset()
self._hotkey = keyboard.HotKey(
keyboard.HotKey.parse(self._hotkey_text),
self._release_gate.record_paste_shortcut_activated,
)
self._listener = keyboard.Listener(
on_press=self._handle_key_pressed,
on_release=self._handle_key_released,
)
self._listener.start()

def stop(self) -> None:
if self._listener is not None:
self._listener.stop()
self._listener = None
self._hotkey = None
self._release_gate.reset()

def _handle_key_pressed(self, key: Any) -> None:
if self._hotkey is not None:
self._hotkey.press(self._canonicalize_key(key))

def _handle_key_released(self, key: Any) -> None:
if self._hotkey is not None:
self._hotkey.release(self._canonicalize_key(key))
self._release_gate.record_key_released()

def _canonicalize_key(self, key: Any) -> Any:
if self._listener is None:
return key
return self._listener.canonical(key)
9 changes: 6 additions & 3 deletions copyboard/adapters/qt/qtclipboard.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Qt-backed clipboard adapters: capture changes and write clippings back.

``QtClipboardSource`` implements the ``ClipboardSource`` port and drives the service on every
clipboard change; ``QtClipboardSink`` implements the ``ClipboardSink`` port. Both share a
clipboard change, including an external clear; ``QtClipboardSink`` implements the ``ClipboardSink``
port. Both share a
:class:`ClipboardEchoGuard` so a re-copy does not get re-captured as a new clipping.
"""

Expand Down Expand Up @@ -57,8 +58,6 @@ def read_current_content(self) -> RawClipboardData:

def _handle_clipboard_data_changed(self) -> None:
content = self.read_current_content()
if content.is_empty():
return
if self._echo_guard.consume_if_armed():
self._last_content = content
return
Expand Down Expand Up @@ -98,3 +97,7 @@ def copy_clipping_to_system_clipboard(self, clipping: Clipping) -> None:
elif payload.text is not None:
self._echo_guard.arm()
self._clipboard.setText(payload.text)

def clear_system_clipboard(self) -> None:
self._echo_guard.arm()
self._clipboard.clear()
Loading
Loading