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: 0 additions & 1 deletion .pytest-ui-refresh/test_full_config_is_loaded0/config.json

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

13 changes: 0 additions & 13 deletions .pytest-ui-refresh/test_written_default_config_ro0/config.json

This file was deleted.

19 changes: 0 additions & 19 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,6 @@ 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: 0 additions & 1,164 deletions COPYBOARD_STUDY_GUIDE.md

This file was deleted.

9 changes: 0 additions & 9 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,6 @@ 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: 1 addition & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,18 @@ 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 themes from the tray menu — no restart needed. Colors use flat, neutral boxes without
gradients; dark is the default.
Switch between dark, light, and system themes from the tray menu — no restart needed.

**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 @@ -108,10 +95,6 @@ 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: 0 additions & 12 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ 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 @@ -38,13 +36,6 @@ 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 @@ -76,9 +67,6 @@ 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: 0 additions & 8 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ 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
40 changes: 9 additions & 31 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 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.
(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.
"""

from __future__ import annotations
Expand All @@ -19,7 +19,6 @@
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 @@ -37,15 +36,8 @@
from copyboard.domain.clippinghistory import ClippingHistory


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

viewer_toggle_requested = Signal()
paste_shortcut_released = Signal()


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

triggered = Signal()

Expand Down Expand Up @@ -99,29 +91,16 @@ 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 = _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()
bridge = _HotkeyToggleBridge()
bridge.triggered.connect(window.toggle_visibility, Qt.ConnectionType.QueuedConnection)
hotkey = PynputHotkeyBinder(config.hotkey.toggle_viewer_hotkey, lambda: bridge.triggered.emit())
hotkey.start()

paste_hotkey: PynputHotkeyBinder | None = None
if config.ui.lifo_paste_enabled:
Expand All @@ -137,8 +116,7 @@ def main() -> int:
try:
return app.exec()
finally:
viewer_hotkey.stop()
paste_observer.stop()
hotkey.stop()
if paste_hotkey is not None:
paste_hotkey.stop()

Expand Down
94 changes: 0 additions & 94 deletions copyboard/adapters/pynputpasteobserver.py

This file was deleted.

9 changes: 3 additions & 6 deletions copyboard/adapters/qt/qtclipboard.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""Qt-backed clipboard adapters: capture changes and write clippings back.

``QtClipboardSource`` implements the ``ClipboardSource`` port and drives the service on every
clipboard change, including an external clear; ``QtClipboardSink`` implements the ``ClipboardSink``
port. Both share a
clipboard change; ``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 @@ -58,6 +57,8 @@ 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 @@ -97,7 +98,3 @@ 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