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
98 changes: 76 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,105 @@
# Copyboard

A small tray app that shows a live view of your recent
clipboard clippings so you can glance back and re-copy anything you copied earlier, not just the last item.
A cross-platform system-tray app that keeps a live, scrollable history of everything you copy — text, URLs, paths, images, JSON, Markdown, shell commands — so you can glance back and re-copy anything, not just the last item.

## Features

**Automatic capture**
Every clipboard change is recorded in the background. The app runs silently in the system tray; there is nothing to configure before it starts working.

**Smart content classification**
Each clipping is automatically classified so you can tell what you're looking at at a glance:

| Kind | Detected when |
|------|--------------|
| URL | `http://`, `https://`, `ftp://`, `ftps://` with a valid host |
| Path | Windows drive (`C:\`), UNC (`\\server\share`), POSIX absolute (`/foo`), or home-relative (`~/…`) |
| JSON | Parses as a valid JSON object `{}` or array `[]` |
| Markdown | Contains headings, fenced code blocks, blockquotes, inline links, bold text, or two or more list items |
| Command | First word is a known CLI tool (`git`, `docker`, `kubectl`, `npm`, `uv`, `ssh`, `curl`, …) or line starts with `$`, `>`, `#`, `PS ` |
| Image | Bitmap copied from any app; stored in a temp file and shown as a thumbnail |
| Text | Everything else |

**Viewer window**
A scrollable list (newest first) with a timestamp and preview for each item. Images render as 160 × 90 thumbnails. Each row has:
- **Copy** — puts the item back on the system clipboard
- **Delete** — removes it from history immediately
- **Drag** — drag any item directly into another app (text, URLs, paths, or image files)

**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.

**System-tray icon**
Click the tray icon to toggle the viewer. Right-click for the menu:
- Show / hide viewer
- 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.

**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.

**No echo duplicates**
When you re-copy a clipping, the resulting clipboard change is suppressed so it doesn't create a new duplicate entry.

**Detaches from the terminal**
Launching `uv run copyboard` immediately returns your shell prompt; the app continues running in the background.

## Install & run

Requires [uv](https://docs.astral.sh/uv/) and Python 3.10+.

```
uv sync # create .venv and install dependencies
uv run copyboard # launch the tray app
uv run copyboard # launch the tray app (detaches from terminal automatically)
```

The app lives in the **system tray**. Click the tray icon or press the global hotkey
(**Ctrl+Shift+H** by default) to show/hide the viewer. In the viewer, **Copy** puts an item back on
the clipboard and **Delete** removes it.
**Edit config…**.

## Configuration

Settings load from `config.json` at the repo root (missing/partial files fall back to defaults):
Settings load from `config.json` at the repo root. Missing or partial files fall back to defaults.

```json
{
"retention": { "max_items": 30, "max_age_minutes": 20 },
"hotkey": { "toggle_viewer_hotkey": "ctrl+shift+h" },
"retention": {
"max_items": 30,
"max_age_minutes": 20
},
"hotkey": {
"toggle_viewer_hotkey": "ctrl+shift+h"
},
"theme": "dark"
}
```

Retention keeps at most `max_items` clippings **and** drops anything older than `max_age_minutes`.
`theme` is `dark` (default), `light`, or `system` (follow the OS)
| Setting | Default | Description |
|---------|---------|-------------|
| `retention.max_items` | `30` | Maximum number of clippings to keep |
| `retention.max_age_minutes` | `20` | Drop clippings older than this many minutes |
| `hotkey.toggle_viewer_hotkey` | `ctrl+shift+h` | Global show/hide hotkey |
| `theme` | `dark` | `dark`, `light`, or `system` (follow the OS) |

Docs: [SPEC.md](SPEC.md) (scope) · [ARCHITECTURE.md](ARCHITECTURE.md) (design) ·
[TASKS.md](TASKS.md) (progress) · [PLAN.md](PLAN.md) (phased plan).
You can open the config file directly from the tray menu via **Edit config…**

## Platform notes

| Platform | Notes |
|----------|-------|
| Linux | The global hotkey requires **X11**. On native Wayland, the hotkey won't bind, but the tray icon still works. |
| macOS | `pynput` needs **Accessibility permission** (System Settings → Privacy → Accessibility). Without it the hotkey is skipped, but the app runs normally. |
| Windows | Works out of the box. |

If the hotkey fails to bind, the app still runs — use the tray icon to open the viewer.

## Development

```
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy . # type-check (src + tests, disallow untyped defs)
uv run pytest # tests
uv run mypy . # type-check (src + tests, disallow_untyped_defs)
uv run pytest # run the test suite
```

## Platform notes

The global hotkey uses `pynput`, which 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.
Docs: [SPEC.md](SPEC.md) (scope) · [ARCHITECTURE.md](ARCHITECTURE.md) (design) · [TASKS.md](TASKS.md) (progress) · [PLAN.md](PLAN.md) (phased plan)
6 changes: 4 additions & 2 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"retention": {
"max_items": 30,
"max_age_minutes": 20
"max_items": 30
},
"hotkey": {
"toggle_viewer_hotkey": "ctrl+shift+h"
},
"ui": {
"actions_on_right_click": true
}
}
6 changes: 4 additions & 2 deletions copyboard/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ def main() -> int:

config_path = Path(DEFAULT_CONFIG_FILENAME)
config = load_app_config_from_json(config_path)
theme_controller = ThemeController(app, config.theme)

clock = SystemClock()
classifier = ClippingClassifier(vault=TempDirVault(), clock=clock)
Expand All @@ -74,7 +73,10 @@ def main() -> int:
source = QtClipboardSource(clipboard, echo_guard)
source.set_new_content_listener(service.handle_new_clipboard_content)

window = MainWindow(service)
window = MainWindow(service, config.ui)
# 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)
window.show()

tray = TrayIcon(
Expand Down
102 changes: 91 additions & 11 deletions copyboard/adapters/ui/apptheme.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,97 @@
"""Apply a light/dark colour theme to the Qt application.
"""Apply a light/dark/glass colour theme to the Qt application.

Uses the Fusion style plus an explicit :class:`QPalette` so the look is identical on every platform
(Windows, Linux, macOS). ``Theme.SYSTEM`` leaves Qt's native palette untouched. A
:class:`ThemeController` keeps the current choice so the tray can flip it live.
(Windows, Linux, macOS). ``Theme.SYSTEM`` leaves Qt's native palette untouched. ``Theme.GLASS``
additionally sets ``WA_TranslucentBackground`` on the viewer window and applies an RGBA stylesheet
so the desktop shows through the window background. A :class:`ThemeController` keeps the current
choice so the tray can flip it live.
"""

from __future__ import annotations

from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QWidget

from copyboard.config import Theme

_FUSION_STYLE = "Fusion"

# Glass theme stylesheet applied to the viewer window.
#
# The illusion rests on three layers:
# 1. Window gradient — a blue-white glint at the very top rim that transitions into a frosted-
# white body, making the window visible even when the list is empty.
# 2. Directional borders — bright on top/left (light source), dim on right/bottom (shadow side).
# 3. Row tiles — each ClippingWidget uses a more opaque frosted gradient so it floats above the
# window body with its own bright top-edge highlight.
_GLASS_STYLESHEET = (
"MainWindow {"
" 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; }"
# Row tiles — more opaque than the window body so they visibly float above it.
"ClippingWidget {"
" background: qlineargradient(x1:0, y1:0, x2:0, y2:1,"
" stop:0.0 rgba(255, 255, 255, 42),"
" stop:0.4 rgba(255, 255, 255, 22),"
" stop:1.0 rgba(255, 255, 255, 10));"
" border-top: 1px solid rgba(255, 255, 255, 70);" # top highlight — catches light
" border-left: 1px solid rgba(255, 255, 255, 35);"
" border-right: 1px solid rgba(0, 0, 0, 20);"
" border-bottom: 1px solid rgba(0, 0, 0, 28);"
" border-radius: 4px;"
" margin: 1px 2px;"
"}"
)

_THEME_CYCLE: dict[Theme, Theme] = {
Theme.DARK: Theme.LIGHT,
Theme.LIGHT: Theme.GLASS,
Theme.GLASS: Theme.DARK,
}


def next_theme(current: Theme) -> Theme:
"""The theme a light/dark toggle switches to (``SYSTEM`` resolves toward dark first)."""
return Theme.LIGHT if current is Theme.DARK else Theme.DARK
"""The theme a toggle switches to; ``SYSTEM`` resolves toward dark first."""
return _THEME_CYCLE.get(current, Theme.DARK)


def apply_theme(app: QApplication, theme: Theme) -> None:
"""Switch the whole application to ``theme``; a no-op for ``Theme.SYSTEM``."""
"""Switch the whole application colour palette to ``theme``; a no-op for ``Theme.SYSTEM``."""
if theme is Theme.SYSTEM:
return
app.setStyle(_FUSION_STYLE)
app.setPalette(_dark_palette() if theme is Theme.DARK else app.style().standardPalette())
if theme is Theme.LIGHT:
app.setPalette(app.style().standardPalette())
else:
# DARK and GLASS share the same dark palette; GLASS adds window-level transparency.
app.setPalette(_dark_palette())


def apply_glass_window_effect(window: QWidget, enable: bool) -> None:
"""Enable or disable the translucent-background (glass) effect on the viewer window.

Sets ``WA_TranslucentBackground`` so the compositor can show what's beneath the window, then
applies an RGBA stylesheet so the window draws a semi-opaque dark tint instead of a solid fill.
Clearing the stylesheet and the attribute restores normal opaque rendering.

Note: on X11 without a compositor the transparency will not show through — the window will
simply look darker than normal because the attribute is set but not composited.
"""
window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enable)
window.setStyleSheet(_GLASS_STYLESHEET if enable else "")


def _dark_palette() -> QPalette:
Expand Down Expand Up @@ -59,12 +125,24 @@ def _dark_palette() -> QPalette:


class ThemeController:
"""Holds the active theme and re-applies it to the application when toggled."""

def __init__(self, app: QApplication, initial_theme: Theme) -> None:
"""Holds the active theme and re-applies it to the application when toggled.

If a ``window`` is provided, toggling to/from ``Theme.GLASS`` also applies or removes the
translucent-background effect on that window.
"""

def __init__(
self,
app: QApplication,
initial_theme: Theme,
window: QWidget | None = None,
) -> None:
self._app = app
self._theme = initial_theme
self._window = window
apply_theme(app, initial_theme)
if window is not None:
apply_glass_window_effect(window, initial_theme is Theme.GLASS)

@property
def theme(self) -> Theme:
Expand All @@ -73,3 +151,5 @@ def theme(self) -> Theme:
def toggle(self) -> None:
self._theme = next_theme(self._theme)
apply_theme(self._app, self._theme)
if self._window is not None:
apply_glass_window_effect(self._window, self._theme is Theme.GLASS)
44 changes: 32 additions & 12 deletions copyboard/adapters/ui/clippingwidget.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
"""A single row in the viewer: a clipping's preview plus Copy / Delete actions.

Pure view code. It shows the clipping's ``build_preview_text`` (or, for images, a thumbnail rendered
from the temp-file path) under a muted timestamp; the kind is deliberately not labelled. User
actions are forwarded through injected callbacks so the widget never touches the service or domain
logic directly.
from the temp-file path) under a muted timestamp. User actions are forwarded through injected
callbacks so the widget never touches the service or domain logic directly.

Two interaction modes are supported (chosen at construction time):
- **Inline buttons** (default): Copy and Delete buttons appear to the right of each row.
- **Right-click menu**: no buttons are shown; a context menu appears on right-click instead.
"""

from __future__ import annotations

from collections.abc import Callable

from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QDrag, QMouseEvent, QPixmap
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from PySide6.QtGui import QContextMenuEvent, QDrag, QMouseEvent, QPixmap
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QMenu, QPushButton, QVBoxLayout, QWidget

from copyboard.adapters.qt.clippingdragdata import build_drag_mime_data
from copyboard.domain.clipping import Clipping, ImageClipping
Expand All @@ -29,22 +32,25 @@ def __init__(
clipping: Clipping,
on_recopy: Callable[[str], None],
on_delete: Callable[[str], None],
actions_on_right_click: bool = True,
) -> None:
super().__init__()
# Required for Qt to paint a stylesheet `background` on a plain QWidget subclass.
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._clipping_id = clipping.id
self._clipboard_payload = clipping.to_clipboard_payload()
self._on_recopy = on_recopy
self._on_delete = on_delete
self._actions_on_right_click = actions_on_right_click
self._drag_start_position: QPoint | None = None

row = QHBoxLayout(self)
row.addLayout(self._build_content_column(clipping), stretch=1)
row.addWidget(self._build_copy_button())
row.addWidget(self._build_delete_button())
if not actions_on_right_click:
row.addWidget(self._build_copy_button())
row.addWidget(self._build_delete_button())

def mousePressEvent(self, event: QMouseEvent) -> None:
# Presses that land on the Copy/Delete buttons are consumed by them and never reach here, so
# a drag only ever begins from the preview/content area.
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start_position = event.position().toPoint()

Expand All @@ -59,16 +65,30 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:
if moved_far_enough:
self._start_drag()

def mouseReleaseEvent(self, event: QMouseEvent) -> None:
# _start_drag clears _drag_start_position; if it is still set here the press never became
# a drag, so this is a plain click — copy in right-click mode.
if (
event.button() == Qt.MouseButton.LeftButton
and self._drag_start_position is not None
and self._actions_on_right_click
):
self._drag_start_position = None
self._request_recopy()

def contextMenuEvent(self, event: QContextMenuEvent) -> None:
menu = QMenu(self)
menu.addAction("Copy").triggered.connect(self._request_recopy)
menu.addAction("Delete").triggered.connect(self._request_delete)
menu.exec(event.globalPos())

def _start_drag(self) -> None:
self._drag_start_position = None
drag = QDrag(self)
drag.setMimeData(build_drag_mime_data(self._clipboard_payload))
# CopyAction: dragging out never removes the clipping from history.
drag.exec(Qt.DropAction.CopyAction)

def _build_content_column(self, clipping: Clipping) -> QVBoxLayout:
# The kind (url/path/…) is intentionally not shown — the user can tell at a glance, so the
# row stays uncluttered. A muted timestamp is the only chrome above the content.
column = QVBoxLayout()
timestamp = QLabel(f"{clipping.created_at:%H:%M:%S}")
timestamp.setStyleSheet("color: gray; font-size: 11px;")
Expand Down
Loading
Loading