From 443fe4f5ee76ab2c00bfdb4434f100f0da523606 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Mon, 27 Jul 2026 20:41:04 +0200 Subject: [PATCH 1/7] features listed --- README.md | 98 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 23ff77e..1f460a0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,52 @@ # 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 @@ -9,43 +54,52 @@ 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) From 578a87b1572f6c5a701042684bdc1bafb9e60dbe Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Mon, 27 Jul 2026 20:51:55 +0200 Subject: [PATCH 2/7] glass theme --- copyboard/__main__.py | 4 +- copyboard/adapters/ui/apptheme.py | 74 ++++++++++++++++++++++++++----- tests/ui/test_apptheme.py | 12 +++-- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/copyboard/__main__.py b/copyboard/__main__.py index b3a3ad7..c6640a5 100644 --- a/copyboard/__main__.py +++ b/copyboard/__main__.py @@ -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) @@ -75,6 +74,9 @@ def main() -> int: source.set_new_content_listener(service.handle_new_clipboard_content) window = MainWindow(service) + # 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( diff --git a/copyboard/adapters/ui/apptheme.py b/copyboard/adapters/ui/apptheme.py index af41950..e646013 100644 --- a/copyboard/adapters/ui/apptheme.py +++ b/copyboard/adapters/ui/apptheme.py @@ -1,31 +1,69 @@ -"""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_WINDOW_BG = "rgba(18, 20, 35, 212)" +_GLASS_BORDER = "rgba(90, 130, 255, 55)" + +_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) + if enable: + window.setStyleSheet( + f"MainWindow {{ background: {_GLASS_WINDOW_BG};" + f" border: 1px solid {_GLASS_BORDER}; border-radius: 6px; }}" + "QScrollArea { background: transparent; border: none; }" + "QScrollArea > QWidget > QWidget { background: transparent; }" + ) + else: + window.setStyleSheet("") def _dark_palette() -> QPalette: @@ -59,12 +97,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: @@ -73,3 +123,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) diff --git a/tests/ui/test_apptheme.py b/tests/ui/test_apptheme.py index 5672e43..172c5af 100644 --- a/tests/ui/test_apptheme.py +++ b/tests/ui/test_apptheme.py @@ -1,4 +1,4 @@ -"""Theme selection: the dark palette is dark, and toggling flips between light and dark.""" +"""Theme selection: the dark palette is dark, and toggling cycles through dark → light → glass.""" from __future__ import annotations @@ -14,18 +14,22 @@ def test_dark_palette_uses_a_dark_window_colour(qt_app: QApplication) -> None: assert palette.color(QPalette.ColorRole.Window).lightness() < 128 -def test_next_theme_flips_light_and_dark() -> None: +def test_next_theme_cycles_dark_light_glass() -> None: assert next_theme(Theme.DARK) is Theme.LIGHT - assert next_theme(Theme.LIGHT) is Theme.DARK + assert next_theme(Theme.LIGHT) is Theme.GLASS + assert next_theme(Theme.GLASS) is Theme.DARK assert next_theme(Theme.SYSTEM) is Theme.DARK -def test_controller_toggles_between_dark_and_light(qt_app: QApplication) -> None: +def test_controller_cycles_through_all_non_system_themes(qt_app: QApplication) -> None: controller = ThemeController(qt_app, Theme.DARK) assert controller.theme is Theme.DARK controller.toggle() assert controller.theme is Theme.LIGHT + controller.toggle() + assert controller.theme is Theme.GLASS + controller.toggle() assert controller.theme is Theme.DARK From 64a2f3825b97b7f5832045066b178d098ea229c4 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Mon, 27 Jul 2026 21:04:09 +0200 Subject: [PATCH 3/7] actions on right click --- copyboard/__main__.py | 2 +- copyboard/adapters/ui/clippingwidget.py | 30 +++++++++++++++---------- copyboard/adapters/ui/mainwindow.py | 4 ++++ copyboard/config.py | 11 ++++++++- copyboard/config_loading.py | 11 ++++++++- tests/test_config_loading.py | 6 ++--- 6 files changed, 46 insertions(+), 18 deletions(-) diff --git a/copyboard/__main__.py b/copyboard/__main__.py index c6640a5..e2d5723 100644 --- a/copyboard/__main__.py +++ b/copyboard/__main__.py @@ -73,7 +73,7 @@ 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) diff --git a/copyboard/adapters/ui/clippingwidget.py b/copyboard/adapters/ui/clippingwidget.py index 654f7e9..904d6be 100644 --- a/copyboard/adapters/ui/clippingwidget.py +++ b/copyboard/adapters/ui/clippingwidget.py @@ -1,9 +1,12 @@ """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 @@ -11,8 +14,8 @@ 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 @@ -29,6 +32,7 @@ def __init__( clipping: Clipping, on_recopy: Callable[[str], None], on_delete: Callable[[str], None], + actions_on_right_click: bool = True, ) -> None: super().__init__() self._clipping_id = clipping.id @@ -39,12 +43,11 @@ def __init__( 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() @@ -59,16 +62,19 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: if moved_far_enough: self._start_drag() + 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;") diff --git a/copyboard/adapters/ui/mainwindow.py b/copyboard/adapters/ui/mainwindow.py index 6a4baeb..abe865f 100644 --- a/copyboard/adapters/ui/mainwindow.py +++ b/copyboard/adapters/ui/mainwindow.py @@ -14,6 +14,7 @@ from copyboard.adapters.ui.clippingwidget import ClippingWidget from copyboard.application.copyboardservice import CopyboardService from copyboard.application.events import HistoryChangeEvent +from copyboard.config import UIConfig _DEFAULT_PRUNE_INTERVAL_MS = 1000 @@ -26,10 +27,12 @@ class MainWindow(QWidget): def __init__( self, service: CopyboardService, + ui_config: UIConfig | None = None, prune_interval_ms: int = _DEFAULT_PRUNE_INTERVAL_MS, ) -> None: super().__init__() self._service = service + self._ui_config = ui_config or UIConfig() self.setWindowTitle("Copyboard") self.resize(420, 560) @@ -83,6 +86,7 @@ def _refresh_clipping_list(self) -> None: clipping, self._service.recopy_clipping_by_id, self._service.delete_clipping_by_id, + actions_on_right_click=self._ui_config.actions_on_right_click, ) ) self._list_layout.addStretch(1) diff --git a/copyboard/config.py b/copyboard/config.py index 12de87b..9012122 100644 --- a/copyboard/config.py +++ b/copyboard/config.py @@ -21,6 +21,7 @@ class Theme(Enum): DARK = "dark" LIGHT = "light" SYSTEM = "system" + GLASS = "glass" @dataclass(frozen=True, slots=True) @@ -38,10 +39,18 @@ class HotkeyConfig: toggle_viewer_hotkey: str = _DEFAULT_TOGGLE_HOTKEY +@dataclass(frozen=True, slots=True) +class UIConfig: + """UI behaviour options.""" + + actions_on_right_click: bool = False + + @dataclass(frozen=True, slots=True) class AppConfig: """Top-level configuration bundle wired in at the composition root.""" retention: RetentionPolicy = field(default_factory=RetentionPolicy) hotkey: HotkeyConfig = field(default_factory=HotkeyConfig) - theme: Theme = Theme.DARK + ui: UIConfig = field(default_factory=UIConfig) + theme: Theme = Theme.GLASS diff --git a/copyboard/config_loading.py b/copyboard/config_loading.py index f782272..4a2282c 100644 --- a/copyboard/config_loading.py +++ b/copyboard/config_loading.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -from copyboard.config import AppConfig, HotkeyConfig, RetentionPolicy, Theme +from copyboard.config import AppConfig, HotkeyConfig, RetentionPolicy, Theme, UIConfig DEFAULT_CONFIG_FILENAME = "config.json" @@ -24,6 +24,7 @@ def write_default_config_file(config_path: Path, config: AppConfig) -> None: "max_age_minutes": 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}, "theme": config.theme.value, } config_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") @@ -51,10 +52,18 @@ def _build_app_config_from_document(document: dict[str, Any]) -> AppConfig: return AppConfig( retention=_build_retention_policy(document.get("retention"), defaults.retention), hotkey=_build_hotkey_config(document.get("hotkey"), defaults.hotkey), + ui=_build_ui_config(document.get("ui"), defaults.ui), theme=_build_theme(document.get("theme"), defaults.theme), ) +def _build_ui_config(section: Any, default: UIConfig) -> UIConfig: + if not isinstance(section, dict): + return default + 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) + + def _build_theme(value: Any, default: Theme) -> Theme: if not isinstance(value, str): return default diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py index d7248b3..17530f6 100644 --- a/tests/test_config_loading.py +++ b/tests/test_config_loading.py @@ -50,8 +50,8 @@ def test_partial_config_falls_back_to_defaults(tmp_path: Path) -> None: assert config.hotkey == AppConfig().hotkey -def test_default_theme_is_dark() -> None: - assert AppConfig().theme is Theme.DARK +def test_default_theme_is_glass() -> None: + assert AppConfig().theme is Theme.GLASS @pytest.mark.parametrize( @@ -60,7 +60,7 @@ def test_default_theme_is_dark() -> None: ("light", Theme.LIGHT), ("system", Theme.SYSTEM), ("DARK", Theme.DARK), - ("nonsense", Theme.DARK), + ("nonsense", Theme.GLASS), # unknown value falls back to the configured default ], ) def test_theme_is_parsed_case_insensitively_with_fallback( From 6cd4532ff5527902ce09d886278d40f7f362c117 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Tue, 28 Jul 2026 20:07:04 +0200 Subject: [PATCH 4/7] actions_on_right_click conf --- config.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index b41cd6e..163e76f 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,12 @@ { "retention": { "max_items": 30, - "max_age_minutes": 20 + "max_age_minutes": 200000 }, "hotkey": { "toggle_viewer_hotkey": "ctrl+shift+h" + }, + "ui": { + "actions_on_right_click": true } } From d2ad6467163e5d614654c263ef551981d506b0e9 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Tue, 28 Jul 2026 20:18:16 +0200 Subject: [PATCH 5/7] glass theme fix --- copyboard/adapters/ui/apptheme.py | 50 +++++++++++++++++++------ copyboard/adapters/ui/clippingwidget.py | 2 + 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/copyboard/adapters/ui/apptheme.py b/copyboard/adapters/ui/apptheme.py index e646013..6d64fb0 100644 --- a/copyboard/adapters/ui/apptheme.py +++ b/copyboard/adapters/ui/apptheme.py @@ -17,8 +17,44 @@ _FUSION_STYLE = "Fusion" -_GLASS_WINDOW_BG = "rgba(18, 20, 35, 212)" -_GLASS_BORDER = "rgba(90, 130, 255, 55)" +# 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, @@ -55,15 +91,7 @@ def apply_glass_window_effect(window: QWidget, enable: bool) -> None: simply look darker than normal because the attribute is set but not composited. """ window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enable) - if enable: - window.setStyleSheet( - f"MainWindow {{ background: {_GLASS_WINDOW_BG};" - f" border: 1px solid {_GLASS_BORDER}; border-radius: 6px; }}" - "QScrollArea { background: transparent; border: none; }" - "QScrollArea > QWidget > QWidget { background: transparent; }" - ) - else: - window.setStyleSheet("") + window.setStyleSheet(_GLASS_STYLESHEET if enable else "") def _dark_palette() -> QPalette: diff --git a/copyboard/adapters/ui/clippingwidget.py b/copyboard/adapters/ui/clippingwidget.py index 904d6be..7d85c51 100644 --- a/copyboard/adapters/ui/clippingwidget.py +++ b/copyboard/adapters/ui/clippingwidget.py @@ -35,6 +35,8 @@ def __init__( 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 From 3ee682bcd680bfd79968811945ca7635d25bd93e Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Tue, 28 Jul 2026 20:25:38 +0200 Subject: [PATCH 6/7] default time infinite --- config.json | 3 +-- copyboard/config.py | 2 +- copyboard/config_loading.py | 12 ++++++++---- copyboard/domain/clippinghistory.py | 5 ++++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/config.json b/config.json index 163e76f..d4bf084 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,6 @@ { "retention": { - "max_items": 30, - "max_age_minutes": 200000 + "max_items": 30 }, "hotkey": { "toggle_viewer_hotkey": "ctrl+shift+h" diff --git a/copyboard/config.py b/copyboard/config.py index 9012122..faaff87 100644 --- a/copyboard/config.py +++ b/copyboard/config.py @@ -11,7 +11,7 @@ from enum import Enum _DEFAULT_MAX_ITEMS = 30 -_DEFAULT_MAX_AGE = timedelta(minutes=20) +_DEFAULT_MAX_AGE = timedelta.max # no age limit unless the user sets max_age_minutes _DEFAULT_TOGGLE_HOTKEY = "ctrl+shift+h" diff --git a/copyboard/config_loading.py b/copyboard/config_loading.py index 4a2282c..f1bd7ad 100644 --- a/copyboard/config_loading.py +++ b/copyboard/config_loading.py @@ -21,7 +21,11 @@ def write_default_config_file(config_path: Path, config: AppConfig) -> None: document = { "retention": { "max_items": config.retention.max_items, - "max_age_minutes": config.retention.max_age.total_seconds() / 60, + "max_age_minutes": ( + None + if config.retention.max_age == timedelta.max + 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}, @@ -77,9 +81,9 @@ def _build_retention_policy(section: Any, default: RetentionPolicy) -> Retention if not isinstance(section, dict): return default max_items = int(section.get("max_items", default.max_items)) - default_minutes = default.max_age.total_seconds() / 60 - max_age_minutes = float(section.get("max_age_minutes", default_minutes)) - return RetentionPolicy(max_items=max_items, max_age=timedelta(minutes=max_age_minutes)) + raw_age = section.get("max_age_minutes") + max_age = timedelta.max if raw_age is None else timedelta(minutes=float(raw_age)) + return RetentionPolicy(max_items=max_items, max_age=max_age) def _build_hotkey_config(section: Any, default: HotkeyConfig) -> HotkeyConfig: diff --git a/copyboard/domain/clippinghistory.py b/copyboard/domain/clippinghistory.py index 37fdf21..358a57c 100644 --- a/copyboard/domain/clippinghistory.py +++ b/copyboard/domain/clippinghistory.py @@ -27,7 +27,10 @@ def add_clipping(self, clipping: Clipping) -> None: def enforce_retention(self, now: datetime) -> list[Clipping]: """Drop clippings that are too old or beyond the count limit; return the removed ones.""" - cutoff = now - self._policy.max_age + try: + cutoff = now - self._policy.max_age + except OverflowError: + cutoff = datetime.min # max_age is timedelta.max — nothing ever expires by age removed = [clipping for clipping in self._clippings if clipping.created_at < cutoff] self._clippings = [ clipping for clipping in self._clippings if clipping.created_at >= cutoff From 224eb559b9b546d8d1eb7a332b066e787504a2c6 Mon Sep 17 00:00:00 2001 From: fabianhogger Date: Tue, 28 Jul 2026 20:59:30 +0200 Subject: [PATCH 7/7] copy on left click --- copyboard/adapters/ui/clippingwidget.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/copyboard/adapters/ui/clippingwidget.py b/copyboard/adapters/ui/clippingwidget.py index 7d85c51..2dc7ba7 100644 --- a/copyboard/adapters/ui/clippingwidget.py +++ b/copyboard/adapters/ui/clippingwidget.py @@ -41,6 +41,7 @@ def __init__( 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) @@ -64,6 +65,17 @@ 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)