From 54fe4a20fc7cc67e247d620381d544b3e403d88a Mon Sep 17 00:00:00 2001 From: Washik Date: Thu, 29 Jan 2026 22:11:47 +0100 Subject: [PATCH 1/4] add clipboard widget --- README.md | 1 + docs/widgets/(Widget)-Clipboard.md | 235 ++++++++ src/core/validation/widgets/yasb/clipboard.py | 127 ++++ src/core/widgets/yasb/clipboard.py | 546 ++++++++++++++++++ 4 files changed, 909 insertions(+) create mode 100644 docs/widgets/(Widget)-Clipboard.md create mode 100644 src/core/validation/widgets/yasb/clipboard.py create mode 100644 src/core/widgets/yasb/clipboard.py diff --git a/README.md b/README.md index d5f93b553..01a8f03e0 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ https://github.com/user-attachments/assets/aab8d8e8-248f-46a1-919c-9b0601236ac1 - **[Cava](https://github.com/amnweb/yasb/wiki/(Widget)-Cava)**: Displays audio visualizer using Cava. - **[Copilot](https://github.com/amnweb/yasb/wiki/(Widget)-Copilot)**: GitHub Copilot usage with a detailed menu showing statistics - **[CPU](https://github.com/amnweb/yasb/wiki/(Widget)-CPU)**: Shows the current CPU usage. +- **[Clipboard](https://github.com/amnweb/yasb/wiki/(Widget)-Clipboard)**: A powerful, persistent clipboard manager for YASB. - **[Clock](https://github.com/amnweb/yasb/wiki/(Widget)-Clock)**: Displays the current time and date. - **[Custom](https://github.com/amnweb/yasb/wiki/(Widget)-Custom)**: Create a custom widget. - **[Github](https://github.com/amnweb/yasb/wiki/(Widget)-Github)**: Shows notifications from GitHub. diff --git a/docs/widgets/(Widget)-Clipboard.md b/docs/widgets/(Widget)-Clipboard.md new file mode 100644 index 000000000..7ed3bf886 --- /dev/null +++ b/docs/widgets/(Widget)-Clipboard.md @@ -0,0 +1,235 @@ +# Clipboard Widget for YASB + +A powerful, persistent clipboard manager for Windows. This widget maintains a history of recent clipboard items (text and images), and allows you to "Pin" items so they are never lost. If you want to you can even enable persistance for all clipboard items, not just the pinned ones. + +## Features +- **History Persistence (Toggleable)**: Option to save history across restarts via a toggle button (Floppy icon). If disabled, history remains in memory only. +- **Image Support**: Full support for copying and previewing images directly in the history list. +- **Pinning**: Pin important items to keep them at the top. Pinned items are always persisted. +- **Management**: Clear entire history or delete individual items using the delete button (recycle bin icon). +- **Search**: Built-in search bar with a persistent clear 'X' button. +- **Dynamic Resizing**: The bar widget grows and shrinks based on the current clipboard content. + +## Options + +| Option | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `type` | `string` | `yasb.clipboard.ClipboardWidget` | The widget class identifier. | +| `label` | `string` | `\udb80\udd4d {clipboard}` | Primary label format. Supports `{clipboard}` token. | +| `label_alt` | `string` | `{clipboard}` | Alternative label format (swapped on right-click). | +| `max_length` | `integer` | `30` | Max characters to display in the bar before truncation. | +| `max_history` | `integer` | `50` | Maximum number of history items to store. | +| `data_path` | `string` | `~/.config/yasb/clipboard.json` | Location where history data is stored. | +| `class_name` | `string` | `""` | Additional CSS class for the widget container. | +| `menu` | `dict` | (See Schema) | Configuration for the popup menu (blur, corners, alignment). | +| `icons` | `dict` | (See Schema) | Custom icons for clipboard, pin, unpin, clear, etc. | + +## Icons Configuration defaults + +| Key | Default | Description | +| :--- | :--- | :--- | +| `clipboard` | `\udb80\udd4d` | Widget icon. | +| `pin` | `\udb81\udc03` | Pin button icon. | +| `unpin` | `\udb82\udd31` | Unpin button icon. | +| `clear` | `\uf1f8` | Clear/Delete icon. | +| `persistent` | `\udb80\udd93` | Persistence ON icon. | +| `temporary` | `\udb85\ude43` | Persistence OFF icon. | +| `search_clear` | `\uf00d` | Search clear 'X' icon. | + +## Callbacks + +| Function | Description | +| :--- | :--- | +| `toggle_menu` | Opens or closes the clipboard history popup. | +| `toggle_label` | Switches display between `label` and `label_alt`. | +| `clear_history` | Wipes all recent clips (Preserves Pinned/Starred items). | +| `do_nothing` | No action. | + +## Configuration Example + +```yaml + clipboard: + type: "yasb.clipboard.ClipboardWidget" + options: + label: "\udb80\udd4d {clipboard}" + label_alt: "CLIPBOARD: {clipboard}" + max_length: 25 + menu: + blur: false + round_corners: false + alignment: "right" + direction: "down" + callbacks: + on_left: "toggle_menu" + on_middle: "do_nothing" + on_right: "toggle_label" +``` + +## Styling + +### Example CSS + +```css +.clipboard-widget .icon { + color: var(--mauve); + font-size: 16px; +} + +.clipboard-widget .label { + padding: 0 4px; +} + +/* Clipboard Menu Popup */ +.clipboard-menu { + background-color: var(--bg-color1); + border-radius: 8px; + border: 1px solid var(--bg-color2); + min-width: 320px; + max-width: 400px; + max-height: 450px; +} + +.clipboard-menu .search-input { + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 8px 12px; + font-size: 12px; + color: var(--text1); + font-family: 'Segoe UI'; +} + +.clipboard-menu .search-input:focus { + border: 1px solid var(--blue); + background-color: rgba(255, 255, 255, 0.08); +} + +/* Search clear button inside wrapper */ +.clipboard-menu .search-wrapper .search-clear-button { + background-color: transparent; + border: none; + color: var(--text4); + border-radius: 50%; +} +.clipboard-menu .search-wrapper .search-clear-button:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--red); +} + +.clipboard-menu .clear-button { + background-color: rgba(243, 139, 168, 0.15); + border: none; + border-radius: 6px; + padding: 8px 12px; + color: var(--red); + font-size: 12px; + font-family: 'Segoe UI'; + font-weight: 600; +} + +.clipboard-menu .clear-button:hover { + background-color: rgba(243, 139, 168, 0.25); +} + +.clipboard-menu .section-header { + font-size: 10px; + font-weight: 700; + color: var(--blue); + padding: 8px 12px 4px 12px; + font-family: 'Segoe UI'; + letter-spacing: 0.5px; +} + +.clipboard-menu .clipboard-item { + background-color: transparent; + border-radius: 6px; + margin: 2px 6px; +} + +.clipboard-menu .clipboard-item:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.clipboard-menu .item-text { + font-size: 12px; + color: var(--text1); + font-family: 'Segoe UI'; +} + +.clipboard-menu .pin-button, +.clipboard-menu .pin-button-active { + background-color: transparent; + border: none; + font-size: 14px; + color: var(--text4); + padding: 4px 8px; + border-radius: 4px; +} + +.clipboard-menu .pin-button:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--yellow); +} + +.clipboard-menu .pin-button-active { + color: var(--yellow); +} + +.clipboard-menu .pin-button-active:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.clipboard-menu .empty-list { + color: var(--text4); + font-size: 12px; + font-family: 'Segoe UI'; + padding: 20px; +} + +.clipboard-menu .scroll-area { + background-color: transparent; + border: none; +} + +/* Delete button for individual items */ +.clipboard-menu .delete-button { + background-color: transparent; + border: none; + font-size: 14px; + color: var(--text4); + padding: 4px 8px; + border-radius: 4px; +} + +.clipboard-menu .delete-button:hover { + background-color: rgba(243, 139, 168, 0.15); + color: var(--red); +} + +/* Persistence toggle button */ +.clipboard-menu .persistence-button, +.clipboard-menu .persistence-button-active { + background-color: transparent; + border-radius: 6px; + border: none; + font-size: 16px; + margin-left: 4px; +} + +.clipboard-menu .persistence-button { + color: var(--text4); +} + +.clipboard-menu .persistence-button:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--mauve); +} + +.clipboard-menu .persistence-button-active { + color: var(--mauve); +} + +.clipboard-menu .persistence-button-active:hover { + background-color: rgba(255, 255, 255, 0.15); +} +``` diff --git a/src/core/validation/widgets/yasb/clipboard.py b/src/core/validation/widgets/yasb/clipboard.py new file mode 100644 index 000000000..933a4b6da --- /dev/null +++ b/src/core/validation/widgets/yasb/clipboard.py @@ -0,0 +1,127 @@ +DEFAULTS = { + "label": "\udb80\udd4d {clipboard}", + "label_alt": "{clipboard}", + "class_name": "", + "max_length": 30, + "max_history": 50, + "data_path": "", + "container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0}, + "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, + "menu": { + "blur": True, + "round_corners": True, + "round_corners_type": "normal", + "border_color": "System", + "alignment": "right", + "direction": "down", + "offset_top": 6, + "offset_left": 0, + "max_item_length": 50, + }, + "icons": { + "clipboard": "\udb80\udd4d", + "pin": "\udb81\udc03", + "unpin": "\udb82\udd31", + "clear": "\uf1f8", + "persistent": "\udb80\udd93", + "temporary": "\udb85\ude43", + "search_clear": "\uf00d", + }, + "callbacks": {"on_left": "toggle_menu", "on_middle": "do_nothing", "on_right": "toggle_label"}, +} + +VALIDATION_SCHEMA = { + "label": {"type": "string", "default": DEFAULTS["label"]}, + "label_alt": {"type": "string", "default": DEFAULTS["label_alt"]}, + "class_name": {"type": "string", "required": False, "default": DEFAULTS["class_name"]}, + "max_length": {"type": "integer", "required": False, "default": DEFAULTS["max_length"], "min": 5, "max": 100}, + "max_history": {"type": "integer", "required": False, "default": DEFAULTS["max_history"], "min": 10, "max": 500}, + "data_path": {"type": "string", "required": False, "default": DEFAULTS["data_path"]}, + "container_padding": { + "type": "dict", + "required": False, + "schema": { + "top": {"type": "integer", "default": DEFAULTS["container_padding"]["top"]}, + "left": {"type": "integer", "default": DEFAULTS["container_padding"]["left"]}, + "bottom": {"type": "integer", "default": DEFAULTS["container_padding"]["bottom"]}, + "right": {"type": "integer", "default": DEFAULTS["container_padding"]["right"]}, + }, + "default": DEFAULTS["container_padding"], + }, + "animation": { + "type": "dict", + "required": False, + "schema": { + "enabled": {"type": "boolean", "default": DEFAULTS["animation"]["enabled"]}, + "type": {"type": "string", "default": DEFAULTS["animation"]["type"]}, + "duration": {"type": "integer", "default": DEFAULTS["animation"]["duration"]}, + }, + "default": DEFAULTS["animation"], + }, + "menu": { + "type": "dict", + "required": False, + "schema": { + "blur": {"type": "boolean", "default": DEFAULTS["menu"]["blur"]}, + "round_corners": {"type": "boolean", "default": DEFAULTS["menu"]["round_corners"]}, + "round_corners_type": { + "type": "string", + "default": DEFAULTS["menu"]["round_corners_type"], + "allowed": ["normal", "small"], + }, + "border_color": {"type": "string", "default": DEFAULTS["menu"]["border_color"]}, + "alignment": {"type": "string", "default": DEFAULTS["menu"]["alignment"]}, + "direction": {"type": "string", "default": DEFAULTS["menu"]["direction"]}, + "offset_top": {"type": "integer", "default": DEFAULTS["menu"]["offset_top"]}, + "offset_left": {"type": "integer", "default": DEFAULTS["menu"]["offset_left"]}, + "max_item_length": {"type": "integer", "default": DEFAULTS["menu"]["max_item_length"]}, + }, + "default": DEFAULTS["menu"], + }, + "icons": { + "type": "dict", + "required": False, + "schema": { + "clipboard": {"type": "string", "default": DEFAULTS["icons"]["clipboard"]}, + "pin": {"type": "string", "default": DEFAULTS["icons"]["pin"]}, + "unpin": {"type": "string", "default": DEFAULTS["icons"]["unpin"]}, + "clear": {"type": "string", "default": DEFAULTS["icons"]["clear"]}, + "persistent": {"type": "string", "default": DEFAULTS["icons"]["persistent"]}, + "temporary": {"type": "string", "default": DEFAULTS["icons"]["temporary"]}, + "search_clear": {"type": "string", "default": DEFAULTS["icons"]["search_clear"]}, + }, + "default": DEFAULTS["icons"], + }, + "label_shadow": { + "type": "dict", + "required": False, + "schema": { + "enabled": {"type": "boolean", "default": False}, + "color": {"type": "string", "default": "black"}, + "offset": {"type": "list", "default": [1, 1]}, + "radius": {"type": "integer", "default": 3}, + }, + "default": {"enabled": False, "color": "black", "offset": [1, 1], "radius": 3}, + }, + "container_shadow": { + "type": "dict", + "required": False, + "schema": { + "enabled": {"type": "boolean", "default": False}, + "color": {"type": "string", "default": "black"}, + "offset": {"type": "list", "default": [1, 1]}, + "radius": {"type": "integer", "default": 3}, + }, + "default": {"enabled": False, "color": "black", "offset": [1, 1], "radius": 3}, + }, + "callbacks": { + "type": "dict", + "required": False, + "schema": { + "on_left": {"type": "string", "default": DEFAULTS["callbacks"]["on_left"]}, + "on_middle": {"type": "string", "default": DEFAULTS["callbacks"]["on_middle"]}, + "on_right": {"type": "string", "default": DEFAULTS["callbacks"]["on_right"]}, + }, + "default": DEFAULTS["callbacks"], + }, +} diff --git a/src/core/widgets/yasb/clipboard.py b/src/core/widgets/yasb/clipboard.py new file mode 100644 index 000000000..6fac2b102 --- /dev/null +++ b/src/core/widgets/yasb/clipboard.py @@ -0,0 +1,546 @@ +import base64 +import json +import logging +import os +import re + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QCursor, QPixmap +from PyQt6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, +) + +from core.config import HOME_CONFIGURATION_DIR +from core.utils.utilities import PopupWidget, add_shadow, build_widget_label +from core.utils.widgets.animation_manager import AnimationManager +from core.validation.widgets.yasb.clipboard import VALIDATION_SCHEMA +from core.widgets.base import BaseWidget +from settings import DEBUG + + +def get_clipboard_text() -> str: + """Get the current text content from clipboard using Qt API.""" + try: + clipboard = QApplication.clipboard() + if clipboard: + return clipboard.text() or "" + except Exception: + pass + return "" + + +class ClipboardWidget(BaseWidget): + """ + A clipboard history widget for yasb. + + Features: + - Monitors Windows clipboard and stores text history + - Pin important items to persist across restarts + - Search through clipboard history + - Clear history functionality + """ + + validation_schema = VALIDATION_SCHEMA + _instances = [] + + def __init__( + self, + label: str, + label_alt: str, + class_name: str, + max_length: int, + max_history: int, + data_path: str, + container_padding: dict, + animation: dict, + menu: dict, + icons: dict, + callbacks: dict, + label_shadow: dict = None, + container_shadow: dict = None, + ): + super().__init__(class_name=f"clipboard-widget {class_name}") + ClipboardWidget._instances.append(self) + + self._show_alt_label = False + self._label_content = label + self._label_alt_content = label_alt + self._max_length = max_length + self._max_history = max_history + self._animation = animation + self._padding = container_padding + self._menu_config = menu + self._icons = icons + self._label_shadow = label_shadow + self._container_shadow = container_shadow + + self._last_clipboard = "" + self._search_query = "" + + # Data storage path + if data_path and data_path.strip(): + self._data_file = os.path.expanduser(data_path) + else: + self._data_file = os.path.join(HOME_CONFIGURATION_DIR, "clipboard.json") + + self._data = self._load_data() + + # Initialize container layout + self._widget_container_layout = QHBoxLayout() + self._widget_container_layout.setSpacing(0) + self._widget_container_layout.setContentsMargins( + self._padding["left"], self._padding["top"], self._padding["right"], self._padding["bottom"] + ) + + # Initialize container widget + self._widget_container = QFrame() + self._widget_container.setLayout(self._widget_container_layout) + self._widget_container.setProperty("class", "widget-container") + add_shadow(self._widget_container, self._container_shadow) + self.widget_layout.addWidget(self._widget_container) + + build_widget_label(self, self._label_content, self._label_alt_content, self._label_shadow) + + # Register callbacks + self.register_callback("toggle_label", self._toggle_label) + self.register_callback("toggle_menu", self._toggle_menu) + self.register_callback("clear_history", self._clear_history) + self.register_callback("update_label", self._update_label) + + self.callback_left = callbacks["on_left"] + self.callback_right = callbacks["on_right"] + self.callback_middle = callbacks["on_middle"] + self.callback_timer = "update_label" + + # Clipboard monitoring timer + self._clipboard_timer = QTimer(self) + self._clipboard_timer.timeout.connect(self._check_clipboard) + self._clipboard_timer.start(1000) # Check every second + + self._update_label() + + def __del__(self): + try: + ClipboardWidget._instances.remove(self) + except ValueError: + pass + + @classmethod + def update_all(cls): + """Update all instances of ClipboardWidget.""" + for instance in cls._instances: + instance._data = instance._load_data() + instance._update_label() + + def _toggle_label(self): + """Toggle between primary and alternate labels.""" + if self._animation["enabled"]: + AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) + + self._show_alt_label = not self._show_alt_label + + for widget in self._widgets: + widget.setVisible(not self._show_alt_label) + + for widget in self._widgets_alt: + widget.setVisible(self._show_alt_label) + + self._update_label() + + def _toggle_menu(self): + """Toggle the clipboard history popup menu.""" + if self._animation["enabled"]: + AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) + self._show_menu() + + def _check_clipboard(self): + """Check clipboard for new content and add to history.""" + current_text = get_clipboard_text() or "" + if current_text and current_text != self._last_clipboard: + self._last_clipboard = current_text + # Only add if not already in pinned or history + if current_text not in self._data["pinned"] and current_text not in self._data["history"]: + self._data["history"].insert(0, current_text) + self._data["history"] = self._data["history"][: self._max_history] + self._save_data() + self._update_label() + + def _update_label(self): + """Update the widget label with current clipboard content.""" + active_widgets = self._widgets_alt if self._show_alt_label else self._widgets + active_label_content = self._label_alt_content if self._show_alt_label else self._label_content + + label_parts = re.split(r"(.*?)", active_label_content) + label_parts = [part for part in label_parts if part] + + # Get current clipboard display text + current_text = self._last_clipboard or get_clipboard_text() or "" + display_text = current_text.replace("\n", " ").replace("\r", "").strip() + if len(display_text) > self._max_length: + display_text = display_text[: self._max_length] + "..." + if not display_text: + display_text = "Empty" + + for widget_index, part in enumerate(label_parts): + if widget_index >= len(active_widgets) or not isinstance(active_widgets[widget_index], QLabel): + continue + + current_widget = active_widgets[widget_index] + + if "" in part: + icon = re.sub(r"|", "", part).strip() + current_widget.setText(icon) + else: + formatted_text = part.format(clipboard=display_text) + current_widget.setText(formatted_text) + + def _show_menu(self): + """Display the clipboard history popup menu.""" + self._menu = PopupWidget( + self, + self._menu_config["blur"], + self._menu_config["round_corners"], + self._menu_config["round_corners_type"], + self._menu_config["border_color"], + ) + self._menu.setProperty("class", "clipboard-menu") + + # Main layout + main_layout = QVBoxLayout(self._menu) + main_layout.setSpacing(0) + main_layout.setContentsMargins(0, 0, 0, 0) + + # Search bar container + search_container = QWidget() + search_layout = QHBoxLayout(search_container) + search_layout.setContentsMargins(8, 8, 8, 8) + search_layout.setSpacing(5) + + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("Search clipboard history...") + self._search_input.setProperty("class", "search-input") + self._search_input.textChanged.connect(self._on_search_changed) + + # Search wrapper to include clear button inside + search_wrapper = QWidget() + search_wrapper.setProperty("class", "search-wrapper") + wrapper_layout = QHBoxLayout(search_wrapper) + wrapper_layout.setContentsMargins(0, 0, 4, 0) + wrapper_layout.setSpacing(0) + + # Inner input needs transparent background in CSS + self._search_input.setProperty("class", "search-input-inner") + + wrapper_layout.addWidget(self._search_input, 1) + + # Clear search button + self._search_clear_btn = QPushButton(self._icons.get("search_clear", "\uf00d")) + self._search_clear_btn.setProperty("class", "search-clear-button") + self._search_clear_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + self._search_clear_btn.setFixedSize(20, 20) + self._search_clear_btn.clicked.connect(self._search_input.clear) + self._search_clear_btn.hide() + + # Show/hide clear button based on text + self._search_input.textChanged.connect(lambda t: self._search_clear_btn.setVisible(bool(t))) + + wrapper_layout.addWidget(self._search_clear_btn) + + search_layout.addWidget(search_wrapper, 1) + + # Clear history button + clear_btn = QPushButton(f"{self._icons['clear']} History") + clear_btn.setProperty("class", "clear-button") + clear_btn.setToolTip("Clear clipboard history (keeps pinned)") + clear_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + clear_btn.clicked.connect(self._clear_history_and_refresh) + search_layout.addWidget(clear_btn) + + # Persistence toggle button + is_persistent = self._data.get("settings", {}).get("persistence", True) + self._persist_btn = QPushButton(self._icons["persistent"] if is_persistent else self._icons["temporary"]) + self._persist_btn.setProperty("class", "persistence-button-active" if is_persistent else "persistence-button") + self._persist_btn.setToolTip(f"History Persistence: {'ON' if is_persistent else 'OFF'}") + self._persist_btn.setFixedSize(28, 28) + self._persist_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + self._persist_btn.clicked.connect(self._toggle_persistence) + search_layout.addWidget(self._persist_btn) + + main_layout.addWidget(search_container) + + # Scroll area for items + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setProperty("class", "scroll-area") + scroll_area.setViewportMargins(0, 0, -4, 0) + scroll_area.setStyleSheet(""" + QScrollBar:vertical { border: none; background:transparent; width: 4px; } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } + QScrollBar::handle:vertical { background: rgba(255, 255, 255, 0.2); min-height: 10px; border-radius: 2px; } + QScrollBar::handle:vertical:hover { background: rgba(255, 255, 255, 0.35); } + QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical { height: 0px; } + """) + + scroll_widget = QWidget() + scroll_widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self._scroll_layout = QVBoxLayout(scroll_widget) + self._scroll_layout.setContentsMargins(0, 0, 0, 0) + self._scroll_layout.setSpacing(0) + + scroll_area.setWidget(scroll_widget) + + self._refresh_list() + + main_layout.addWidget(scroll_area) + + self._menu.adjustSize() + self._menu.setPosition( + alignment=self._menu_config["alignment"], + direction=self._menu_config["direction"], + offset_left=self._menu_config["offset_left"], + offset_top=self._menu_config["offset_top"], + ) + self._menu.show() + self._search_input.setFocus() + + def _refresh_list(self): + """Refresh the items list in the popup menu.""" + # Clear existing items + while self._scroll_layout.count(): + item = self._scroll_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + query = self._search_query.lower() + pinned = [t for t in self._data["pinned"] if query in t.lower()] + history = [t for t in self._data["history"] if query in t.lower()] + + if pinned: + self._add_section_header("PINNED") + for text in pinned: + self._add_item_row(text, is_pinned=True) + + if history: + self._add_section_header("RECENT") + for text in history: + self._add_item_row(text, is_pinned=False) + + if not pinned and not history: + empty_label = QLabel(f"{self._icons['clipboard']} No items found") + empty_label.setProperty("class", "empty-list") + empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._scroll_layout.addWidget(empty_label) + + # Add spacer at bottom + self._scroll_layout.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) + + # Resize menu to fit new content + if hasattr(self, "_menu") and self._menu: + self._menu.adjustSize() + + def _add_section_header(self, text: str): + """Add a section header to the list.""" + header = QLabel(text) + header.setProperty("class", "section-header") + self._scroll_layout.addWidget(header) + + def _get_image_from_base64(self, text: str) -> QPixmap | None: + """Convert base64 text to QPixmap if valid image.""" + if not text.startswith("data:image/") or ";base64," not in text: + return None + + try: + # Extract base64 data + _, data_str = text.split(";base64,") + data = base64.b64decode(data_str) + pixmap = QPixmap() + if pixmap.loadFromData(data): + return pixmap + except Exception: + pass + return None + + def _add_item_row(self, text: str, is_pinned: bool): + """Add an item row to the list.""" + container = QWidget() + container.setProperty("class", "clipboard-item") + container.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + + layout = QHBoxLayout(container) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(8) + + # Content + pixmap = self._get_image_from_base64(text) + + if pixmap: + # Display image + image_label = QLabel() + image_label.setPixmap( + pixmap.scaled(300, 150, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + ) + image_label.setProperty("class", "item-image") + layout.addWidget(image_label, 1) + else: + # Display text + max_len = self._menu_config["max_item_length"] + display_text = text.replace("\n", " ").replace("\r", "").strip() + if len(display_text) > max_len: + display_text = display_text[:max_len] + "..." + + text_label = QLabel(display_text) + text_label.setProperty("class", "item-text") + text_label.setWordWrap(False) + layout.addWidget(text_label, 1) + + # Pin/unpin button + pin_btn = QPushButton(self._icons["pin"] if is_pinned else self._icons["unpin"]) + pin_btn.setProperty("class", "pin-button-active" if is_pinned else "pin-button") + pin_btn.setFixedWidth(28) + pin_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + pin_btn.clicked.connect(lambda _, t=text, p=is_pinned: self._toggle_pin(t, p)) + layout.addWidget(pin_btn) + + # Delete button + del_btn = QPushButton(self._icons["clear"]) + del_btn.setProperty("class", "delete-button") + del_btn.setFixedWidth(28) + del_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + del_btn.clicked.connect(lambda _, t=text, p=is_pinned: self._delete_item(t, p)) + layout.addWidget(del_btn) + + # Click on container to copy + container.mousePressEvent = ( + lambda e, t=text: self._copy_item(t) if e.button() == Qt.MouseButton.LeftButton else None + ) + + self._scroll_layout.addWidget(container) + + def _toggle_pin(self, text: str, is_pinned: bool): + """Toggle pin status of an item.""" + if is_pinned: + if text in self._data["pinned"]: + self._data["pinned"].remove(text) + self._data["history"].insert(0, text) + else: + if text in self._data["history"]: + self._data["history"].remove(text) + self._data["pinned"].insert(0, text) + + self._save_data() + self._refresh_list() + + def _delete_item(self, text: str, is_pinned: bool): + """Delete an item from history or pinned list.""" + if is_pinned: + if text in self._data["pinned"]: + self._data["pinned"].remove(text) + else: + if text in self._data["history"]: + self._data["history"].remove(text) + + self._save_data() + self._refresh_list() + + def _copy_item(self, text: str): + """Copy an item to clipboard and close menu.""" + + pixmap = self._get_image_from_base64(text) + if pixmap: + QApplication.clipboard().setPixmap(pixmap) + else: + QApplication.clipboard().setText(text) + + self._last_clipboard = text + self._update_label() + if hasattr(self, "_menu"): + self._menu.hide() + + def _clear_history(self): + """Clear clipboard history (keeps pinned items).""" + self._data["history"] = [] + self._save_data() + ClipboardWidget.update_all() + + def _clear_history_and_refresh(self): + """Clear history and refresh the menu.""" + self._clear_history() + self._refresh_list() + + def _on_search_changed(self, text: str): + """Handle search input changes.""" + self._search_query = text + self._refresh_list() + + def _toggle_persistence(self): + """Toggle clipboard history persistence.""" + if "settings" not in self._data: + self._data["settings"] = {} + + current = self._data["settings"].get("persistence", True) + new_state = not current + self._data["settings"]["persistence"] = new_state + + # Update button + if hasattr(self, "_persist_btn"): + self._persist_btn.setText(self._icons["persistent"] if new_state else self._icons["temporary"]) + self._persist_btn.setProperty("class", "persistence-button-active" if new_state else "persistence-button") + self._persist_btn.setToolTip(f"History Persistence: {'ON' if new_state else 'OFF'}") + + # Force style refresh + self._persist_btn.style().unpolish(self._persist_btn) + self._persist_btn.style().polish(self._persist_btn) + + self._save_data() + + def _load_data(self) -> dict: + """Load clipboard data from JSON file.""" + try: + if os.path.exists(self._data_file): + if DEBUG: + logging.debug(f"Loading clipboard data from {self._data_file}") + with open(self._data_file, "r", encoding="utf-8") as f: + data = json.load(f) + # Ensure proper structure + if "pinned" not in data: + data["pinned"] = [] + if "history" not in data: + data["history"] = [] + if "settings" not in data: + data["settings"] = {"persistence": True} + return data + except Exception as e: + logging.error(f"Error loading clipboard data: {e}") + + return {"pinned": [], "history": []} + + def _save_data(self): + """Save clipboard data to JSON file.""" + try: + os.makedirs(os.path.dirname(self._data_file), exist_ok=True) + + # Prepare data dump (handle persistence) + persistence = self._data.get("settings", {}).get("persistence", True) + + dump_data = { + "pinned": self._data["pinned"], + "settings": self._data.get("settings", {"persistence": True}), + # Only save history to disk if persistence is ON + "history": self._data["history"] if persistence else [], + } + + with open(self._data_file, "w", encoding="utf-8") as f: + json.dump(dump_data, f, indent=2, ensure_ascii=False) + except Exception as e: + logging.error(f"Error saving clipboard data: {e}") From 34df66ec493f648fc397a37954bf8cfd47f647d8 Mon Sep 17 00:00:00 2001 From: Washik Date: Sat, 31 Jan 2026 04:34:59 +0100 Subject: [PATCH 2/4] switch to official windows api --- docs/widgets/(Widget)-Clipboard.md | 206 ++--- src/core/validation/widgets/yasb/clipboard.py | 32 - src/core/widgets/yasb/clipboard.py | 719 +++++------------- 3 files changed, 258 insertions(+), 699 deletions(-) diff --git a/docs/widgets/(Widget)-Clipboard.md b/docs/widgets/(Widget)-Clipboard.md index 7ed3bf886..1f880ca52 100644 --- a/docs/widgets/(Widget)-Clipboard.md +++ b/docs/widgets/(Widget)-Clipboard.md @@ -1,14 +1,16 @@ # Clipboard Widget for YASB -A powerful, persistent clipboard manager for Windows. This widget maintains a history of recent clipboard items (text and images), and allows you to "Pin" items so they are never lost. If you want to you can even enable persistance for all clipboard items, not just the pinned ones. +A lightweight, modern clipboard manager for YASB that integrates directly with the native Windows Clipboard History (Win + V). This widget provides real-time access to your system's clip buffer without the need for heavy local storage or complex background monitoring. + +**Note for Users:** This widget requires the following Python packages to interact with Windows APIs: +`pip install winrt-Windows.ApplicationModel.DataTransfer winrt-Windows.Foundation` ## Features -- **History Persistence (Toggleable)**: Option to save history across restarts via a toggle button (Floppy icon). If disabled, history remains in memory only. -- **Image Support**: Full support for copying and previewing images directly in the history list. -- **Pinning**: Pin important items to keep them at the top. Pinned items are always persisted. -- **Management**: Clear entire history or delete individual items using the delete button (recycle bin icon). -- **Search**: Built-in search bar with a persistent clear 'X' button. -- **Dynamic Resizing**: The bar widget grows and shrinks based on the current clipboard content. +- **Native Windows Sync**: Syncs in real-time with your official Windows Clipboard History. +- **Search**: Built-in real-time search bar to filter through your text-based history. +- **Image Support**: Full support for previewing and re-copying images directly from the history list. +- **Long text preview**: Hover over a copied long piece of text to display complete text preview. +- **Zero-SDK Footprint**: Uses modular WinRT bindings to keep the installation under 10MB. ## Options @@ -18,33 +20,30 @@ A powerful, persistent clipboard manager for Windows. This widget maintains a hi | `label` | `string` | `\udb80\udd4d {clipboard}` | Primary label format. Supports `{clipboard}` token. | | `label_alt` | `string` | `{clipboard}` | Alternative label format (swapped on right-click). | | `max_length` | `integer` | `30` | Max characters to display in the bar before truncation. | -| `max_history` | `integer` | `50` | Maximum number of history items to store. | -| `data_path` | `string` | `~/.config/yasb/clipboard.json` | Location where history data is stored. | +| `max_history` | `integer` | `50` | Maximum number of history items to fetch from Windows. | | `class_name` | `string` | `""` | Additional CSS class for the widget container. | | `menu` | `dict` | (See Schema) | Configuration for the popup menu (blur, corners, alignment). | -| `icons` | `dict` | (See Schema) | Custom icons for clipboard, pin, unpin, clear, etc. | +| `icons` | `dict` | (See Schema) | Custom icons for clipboard, clear, and search. | -## Icons Configuration defaults +## Icons Configuration Defaults | Key | Default | Description | | :--- | :--- | :--- | -| `clipboard` | `\udb80\udd4d` | Widget icon. | -| `pin` | `\udb81\udc03` | Pin button icon. | -| `unpin` | `\udb82\udd31` | Unpin button icon. | -| `clear` | `\uf1f8` | Clear/Delete icon. | -| `persistent` | `\udb80\udd93` | Persistence ON icon. | -| `temporary` | `\udb85\ude43` | Persistence OFF icon. | -| `search_clear` | `\uf00d` | Search clear 'X' icon. | +| `clipboard` | `\udb80\udd4d` | Main widget icon used on the bar and in the "Copied!" flash. | +| `clear` | `\uf1f8` | Global clear icon (Clears the system's unpinned history). | +| `search_clear` | `\uf00d` | Icon used for general UI elements. | ## Callbacks | Function | Description | | :--- | :--- | -| `toggle_menu` | Opens or closes the clipboard history popup. | -| `toggle_label` | Switches display between `label` and `label_alt`. | -| `clear_history` | Wipes all recent clips (Preserves Pinned/Starred items). | +| `toggle_menu` | Opens/closes the clipboard history popup (Scheduled asynchronously). | +| `toggle_label` | Switches display between `label` and `label_alt` on the bar. | | `do_nothing` | No action. | +--- + + ## Configuration Example ```yaml @@ -70,166 +69,95 @@ A powerful, persistent clipboard manager for Windows. This widget maintains a hi ### Example CSS ```css -.clipboard-widget .icon { - color: var(--mauve); - font-size: 16px; +.clipboard-widget { + font-family: "JetBrainsMono Nerd Font", "Segoe UI Variable"; } - +/* Widget on the bar */ .clipboard-widget .label { - padding: 0 4px; + padding: 0 6px; + color: var(--mauve); } -/* Clipboard Menu Popup */ +/* Main Popup Container */ .clipboard-menu { background-color: var(--bg-color1); - border-radius: 8px; border: 1px solid var(--bg-color2); - min-width: 320px; - max-width: 400px; - max-height: 450px; + border-radius: 10px; + padding: 8px; } +/* Search Bar */ .clipboard-menu .search-input { background-color: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; - padding: 8px 12px; - font-size: 12px; + padding: 6px 10px; + margin-bottom: 6px; color: var(--text1); - font-family: 'Segoe UI'; + font-size: 13px; } .clipboard-menu .search-input:focus { border: 1px solid var(--blue); - background-color: rgba(255, 255, 255, 0.08); -} - -/* Search clear button inside wrapper */ -.clipboard-menu .search-wrapper .search-clear-button { - background-color: transparent; - border: none; - color: var(--text4); - border-radius: 50%; -} -.clipboard-menu .search-wrapper .search-clear-button:hover { - background-color: rgba(255, 255, 255, 0.1); - color: var(--red); } +/* Global Clear Button */ .clipboard-menu .clear-button { - background-color: rgba(243, 139, 168, 0.15); - border: none; - border-radius: 6px; - padding: 8px 12px; + background-color: rgba(243, 139, 168, 0.1); color: var(--red); - font-size: 12px; - font-family: 'Segoe UI'; - font-weight: 600; + border-radius: 6px; + padding: 5px; + margin-bottom: 8px; + font-weight: bold; + font-size: 11px; } .clipboard-menu .clear-button:hover { - background-color: rgba(243, 139, 168, 0.25); + background-color: rgba(243, 139, 168, 0.2); } -.clipboard-menu .section-header { - font-size: 10px; - font-weight: 700; - color: var(--blue); - padding: 8px 12px 4px 12px; - font-family: 'Segoe UI'; - letter-spacing: 0.5px; +/* Scroll Area Styling */ +.clipboard-menu .scroll-area { + background: transparent; + border: none; } +/* Individual Clipboard Items (Buttons) */ .clipboard-menu .clipboard-item { - background-color: transparent; - border-radius: 6px; - margin: 2px 6px; -} - -.clipboard-menu .clipboard-item:hover { - background-color: rgba(255, 255, 255, 0.08); -} - -.clipboard-menu .item-text { - font-size: 12px; + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid transparent; + border-radius: 5px; + padding: 8px; + margin-bottom: 4px; + text-align: left; color: var(--text1); - font-family: 'Segoe UI'; -} - -.clipboard-menu .pin-button, -.clipboard-menu .pin-button-active { - background-color: transparent; - border: none; - font-size: 14px; - color: var(--text4); - padding: 4px 8px; - border-radius: 4px; -} - -.clipboard-menu .pin-button:hover { - background-color: rgba(255, 255, 255, 0.1); - color: var(--yellow); -} - -.clipboard-menu .pin-button-active { - color: var(--yellow); -} - -.clipboard-menu .pin-button-active:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -.clipboard-menu .empty-list { - color: var(--text4); font-size: 12px; - font-family: 'Segoe UI'; - padding: 20px; -} - -.clipboard-menu .scroll-area { - background-color: transparent; - border: none; } -/* Delete button for individual items */ -.clipboard-menu .delete-button { - background-color: transparent; - border: none; - font-size: 14px; - color: var(--text4); - padding: 4px 8px; - border-radius: 4px; +.clipboard-menu .clipboard-item:hover { + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid var(--bg-color2); } -.clipboard-menu .delete-button:hover { - background-color: rgba(243, 139, 168, 0.15); - color: var(--red); +/* Image Item Specifics (if needed) */ +.clipboard-menu .clipboard-item [icon] { + margin-right: 8px; } -/* Persistence toggle button */ -.clipboard-menu .persistence-button, -.clipboard-menu .persistence-button-active { - background-color: transparent; - border-radius: 6px; +/* Scrollbar Styling (Optional but looks better) */ +QScrollBar:vertical { border: none; - font-size: 16px; - margin-left: 4px; + background: transparent; + width: 4px; } -.clipboard-menu .persistence-button { - color: var(--text4); +QScrollBar::handle:vertical { + background: var(--bg-color2); + border-radius: 2px; } -.clipboard-menu .persistence-button:hover { - background-color: rgba(255, 255, 255, 0.1); - color: var(--mauve); -} - -.clipboard-menu .persistence-button-active { - color: var(--mauve); -} - -.clipboard-menu .persistence-button-active:hover { - background-color: rgba(255, 255, 255, 0.15); +/* Styling for the temporary 'Copied!' flash */ +.clipboard-widget .label { + transition: all 0.2s ease-in-out; } ``` diff --git a/src/core/validation/widgets/yasb/clipboard.py b/src/core/validation/widgets/yasb/clipboard.py index 933a4b6da..152973c7d 100644 --- a/src/core/validation/widgets/yasb/clipboard.py +++ b/src/core/validation/widgets/yasb/clipboard.py @@ -4,7 +4,6 @@ "class_name": "", "max_length": 30, "max_history": 50, - "data_path": "", "container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0}, "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, "menu": { @@ -20,11 +19,7 @@ }, "icons": { "clipboard": "\udb80\udd4d", - "pin": "\udb81\udc03", - "unpin": "\udb82\udd31", "clear": "\uf1f8", - "persistent": "\udb80\udd93", - "temporary": "\udb85\ude43", "search_clear": "\uf00d", }, "callbacks": {"on_left": "toggle_menu", "on_middle": "do_nothing", "on_right": "toggle_label"}, @@ -36,7 +31,6 @@ "class_name": {"type": "string", "required": False, "default": DEFAULTS["class_name"]}, "max_length": {"type": "integer", "required": False, "default": DEFAULTS["max_length"], "min": 5, "max": 100}, "max_history": {"type": "integer", "required": False, "default": DEFAULTS["max_history"], "min": 10, "max": 500}, - "data_path": {"type": "string", "required": False, "default": DEFAULTS["data_path"]}, "container_padding": { "type": "dict", "required": False, @@ -83,37 +77,11 @@ "required": False, "schema": { "clipboard": {"type": "string", "default": DEFAULTS["icons"]["clipboard"]}, - "pin": {"type": "string", "default": DEFAULTS["icons"]["pin"]}, - "unpin": {"type": "string", "default": DEFAULTS["icons"]["unpin"]}, "clear": {"type": "string", "default": DEFAULTS["icons"]["clear"]}, - "persistent": {"type": "string", "default": DEFAULTS["icons"]["persistent"]}, - "temporary": {"type": "string", "default": DEFAULTS["icons"]["temporary"]}, "search_clear": {"type": "string", "default": DEFAULTS["icons"]["search_clear"]}, }, "default": DEFAULTS["icons"], }, - "label_shadow": { - "type": "dict", - "required": False, - "schema": { - "enabled": {"type": "boolean", "default": False}, - "color": {"type": "string", "default": "black"}, - "offset": {"type": "list", "default": [1, 1]}, - "radius": {"type": "integer", "default": 3}, - }, - "default": {"enabled": False, "color": "black", "offset": [1, 1], "radius": 3}, - }, - "container_shadow": { - "type": "dict", - "required": False, - "schema": { - "enabled": {"type": "boolean", "default": False}, - "color": {"type": "string", "default": "black"}, - "offset": {"type": "list", "default": [1, 1]}, - "radius": {"type": "integer", "default": 3}, - }, - "default": {"enabled": False, "color": "black", "offset": [1, 1], "radius": 3}, - }, "callbacks": { "type": "dict", "required": False, diff --git a/src/core/widgets/yasb/clipboard.py b/src/core/widgets/yasb/clipboard.py index 6fac2b102..92515f78a 100644 --- a/src/core/widgets/yasb/clipboard.py +++ b/src/core/widgets/yasb/clipboard.py @@ -1,546 +1,209 @@ -import base64 -import json +import asyncio import logging -import os -import re from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QCursor, QPixmap -from PyQt6.QtWidgets import ( - QApplication, - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, - QSizePolicy, - QSpacerItem, - QVBoxLayout, - QWidget, -) - -from core.config import HOME_CONFIGURATION_DIR -from core.utils.utilities import PopupWidget, add_shadow, build_widget_label -from core.utils.widgets.animation_manager import AnimationManager +from PyQt6.QtGui import QIcon, QPixmap +from PyQt6.QtWidgets import QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget +from winrt.windows.applicationmodel.datatransfer import Clipboard, DataPackage, StandardDataFormats + +from core.utils.utilities import PopupWidget, build_widget_label from core.validation.widgets.yasb.clipboard import VALIDATION_SCHEMA from core.widgets.base import BaseWidget -from settings import DEBUG - - -def get_clipboard_text() -> str: - """Get the current text content from clipboard using Qt API.""" - try: - clipboard = QApplication.clipboard() - if clipboard: - return clipboard.text() or "" - except Exception: - pass - return "" class ClipboardWidget(BaseWidget): - """ - A clipboard history widget for yasb. - - Features: - - Monitors Windows clipboard and stores text history - - Pin important items to persist across restarts - - Search through clipboard history - - Clear history functionality - """ - validation_schema = VALIDATION_SCHEMA - _instances = [] - - def __init__( - self, - label: str, - label_alt: str, - class_name: str, - max_length: int, - max_history: int, - data_path: str, - container_padding: dict, - animation: dict, - menu: dict, - icons: dict, - callbacks: dict, - label_shadow: dict = None, - container_shadow: dict = None, - ): - super().__init__(class_name=f"clipboard-widget {class_name}") - ClipboardWidget._instances.append(self) - - self._show_alt_label = False - self._label_content = label - self._label_alt_content = label_alt - self._max_length = max_length - self._max_history = max_history - self._animation = animation - self._padding = container_padding - self._menu_config = menu - self._icons = icons - self._label_shadow = label_shadow - self._container_shadow = container_shadow - - self._last_clipboard = "" - self._search_query = "" - - # Data storage path - if data_path and data_path.strip(): - self._data_file = os.path.expanduser(data_path) - else: - self._data_file = os.path.join(HOME_CONFIGURATION_DIR, "clipboard.json") - - self._data = self._load_data() - - # Initialize container layout - self._widget_container_layout = QHBoxLayout() - self._widget_container_layout.setSpacing(0) - self._widget_container_layout.setContentsMargins( - self._padding["left"], self._padding["top"], self._padding["right"], self._padding["bottom"] - ) - - # Initialize container widget - self._widget_container = QFrame() - self._widget_container.setLayout(self._widget_container_layout) - self._widget_container.setProperty("class", "widget-container") - add_shadow(self._widget_container, self._container_shadow) - self.widget_layout.addWidget(self._widget_container) - - build_widget_label(self, self._label_content, self._label_alt_content, self._label_shadow) - - # Register callbacks - self.register_callback("toggle_label", self._toggle_label) - self.register_callback("toggle_menu", self._toggle_menu) - self.register_callback("clear_history", self._clear_history) - self.register_callback("update_label", self._update_label) - - self.callback_left = callbacks["on_left"] - self.callback_right = callbacks["on_right"] - self.callback_middle = callbacks["on_middle"] - self.callback_timer = "update_label" - - # Clipboard monitoring timer - self._clipboard_timer = QTimer(self) - self._clipboard_timer.timeout.connect(self._check_clipboard) - self._clipboard_timer.start(1000) # Check every second - - self._update_label() - - def __del__(self): - try: - ClipboardWidget._instances.remove(self) - except ValueError: - pass - - @classmethod - def update_all(cls): - """Update all instances of ClipboardWidget.""" - for instance in cls._instances: - instance._data = instance._load_data() - instance._update_label() - - def _toggle_label(self): - """Toggle between primary and alternate labels.""" - if self._animation["enabled"]: - AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) - - self._show_alt_label = not self._show_alt_label - - for widget in self._widgets: - widget.setVisible(not self._show_alt_label) - - for widget in self._widgets_alt: - widget.setVisible(self._show_alt_label) - - self._update_label() - - def _toggle_menu(self): - """Toggle the clipboard history popup menu.""" - if self._animation["enabled"]: - AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) - self._show_menu() - - def _check_clipboard(self): - """Check clipboard for new content and add to history.""" - current_text = get_clipboard_text() or "" - if current_text and current_text != self._last_clipboard: - self._last_clipboard = current_text - # Only add if not already in pinned or history - if current_text not in self._data["pinned"] and current_text not in self._data["history"]: - self._data["history"].insert(0, current_text) - self._data["history"] = self._data["history"][: self._max_history] - self._save_data() - self._update_label() - - def _update_label(self): - """Update the widget label with current clipboard content.""" - active_widgets = self._widgets_alt if self._show_alt_label else self._widgets - active_label_content = self._label_alt_content if self._show_alt_label else self._label_content - - label_parts = re.split(r"(.*?)", active_label_content) - label_parts = [part for part in label_parts if part] - - # Get current clipboard display text - current_text = self._last_clipboard or get_clipboard_text() or "" - display_text = current_text.replace("\n", " ").replace("\r", "").strip() - if len(display_text) > self._max_length: - display_text = display_text[: self._max_length] + "..." - if not display_text: - display_text = "Empty" - - for widget_index, part in enumerate(label_parts): - if widget_index >= len(active_widgets) or not isinstance(active_widgets[widget_index], QLabel): - continue - - current_widget = active_widgets[widget_index] - - if "" in part: - icon = re.sub(r"|", "", part).strip() - current_widget.setText(icon) - else: - formatted_text = part.format(clipboard=display_text) - current_widget.setText(formatted_text) - - def _show_menu(self): - """Display the clipboard history popup menu.""" - self._menu = PopupWidget( - self, - self._menu_config["blur"], - self._menu_config["round_corners"], - self._menu_config["round_corners_type"], - self._menu_config["border_color"], - ) - self._menu.setProperty("class", "clipboard-menu") - - # Main layout - main_layout = QVBoxLayout(self._menu) - main_layout.setSpacing(0) - main_layout.setContentsMargins(0, 0, 0, 0) - - # Search bar container - search_container = QWidget() - search_layout = QHBoxLayout(search_container) - search_layout.setContentsMargins(8, 8, 8, 8) - search_layout.setSpacing(5) - - self._search_input = QLineEdit() - self._search_input.setPlaceholderText("Search clipboard history...") - self._search_input.setProperty("class", "search-input") - self._search_input.textChanged.connect(self._on_search_changed) - - # Search wrapper to include clear button inside - search_wrapper = QWidget() - search_wrapper.setProperty("class", "search-wrapper") - wrapper_layout = QHBoxLayout(search_wrapper) - wrapper_layout.setContentsMargins(0, 0, 4, 0) - wrapper_layout.setSpacing(0) - - # Inner input needs transparent background in CSS - self._search_input.setProperty("class", "search-input-inner") - - wrapper_layout.addWidget(self._search_input, 1) - - # Clear search button - self._search_clear_btn = QPushButton(self._icons.get("search_clear", "\uf00d")) - self._search_clear_btn.setProperty("class", "search-clear-button") - self._search_clear_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - self._search_clear_btn.setFixedSize(20, 20) - self._search_clear_btn.clicked.connect(self._search_input.clear) - self._search_clear_btn.hide() - - # Show/hide clear button based on text - self._search_input.textChanged.connect(lambda t: self._search_clear_btn.setVisible(bool(t))) - - wrapper_layout.addWidget(self._search_clear_btn) - - search_layout.addWidget(search_wrapper, 1) - - # Clear history button - clear_btn = QPushButton(f"{self._icons['clear']} History") - clear_btn.setProperty("class", "clear-button") - clear_btn.setToolTip("Clear clipboard history (keeps pinned)") - clear_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - clear_btn.clicked.connect(self._clear_history_and_refresh) - search_layout.addWidget(clear_btn) - - # Persistence toggle button - is_persistent = self._data.get("settings", {}).get("persistence", True) - self._persist_btn = QPushButton(self._icons["persistent"] if is_persistent else self._icons["temporary"]) - self._persist_btn.setProperty("class", "persistence-button-active" if is_persistent else "persistence-button") - self._persist_btn.setToolTip(f"History Persistence: {'ON' if is_persistent else 'OFF'}") - self._persist_btn.setFixedSize(28, 28) - self._persist_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - self._persist_btn.clicked.connect(self._toggle_persistence) - search_layout.addWidget(self._persist_btn) - - main_layout.addWidget(search_container) - - # Scroll area for items - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll_area.setProperty("class", "scroll-area") - scroll_area.setViewportMargins(0, 0, -4, 0) - scroll_area.setStyleSheet(""" - QScrollBar:vertical { border: none; background:transparent; width: 4px; } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } - QScrollBar::handle:vertical { background: rgba(255, 255, 255, 0.2); min-height: 10px; border-radius: 2px; } - QScrollBar::handle:vertical:hover { background: rgba(255, 255, 255, 0.35); } - QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical { height: 0px; } - """) - - scroll_widget = QWidget() - scroll_widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self._scroll_layout = QVBoxLayout(scroll_widget) - self._scroll_layout.setContentsMargins(0, 0, 0, 0) - self._scroll_layout.setSpacing(0) - - scroll_area.setWidget(scroll_widget) - - self._refresh_list() - - main_layout.addWidget(scroll_area) - - self._menu.adjustSize() - self._menu.setPosition( - alignment=self._menu_config["alignment"], - direction=self._menu_config["direction"], - offset_left=self._menu_config["offset_left"], - offset_top=self._menu_config["offset_top"], - ) - self._menu.show() - self._search_input.setFocus() - - def _refresh_list(self): - """Refresh the items list in the popup menu.""" - # Clear existing items - while self._scroll_layout.count(): - item = self._scroll_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - query = self._search_query.lower() - pinned = [t for t in self._data["pinned"] if query in t.lower()] - history = [t for t in self._data["history"] if query in t.lower()] - - if pinned: - self._add_section_header("PINNED") - for text in pinned: - self._add_item_row(text, is_pinned=True) - - if history: - self._add_section_header("RECENT") - for text in history: - self._add_item_row(text, is_pinned=False) - - if not pinned and not history: - empty_label = QLabel(f"{self._icons['clipboard']} No items found") - empty_label.setProperty("class", "empty-list") - empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._scroll_layout.addWidget(empty_label) - - # Add spacer at bottom - self._scroll_layout.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) - - # Resize menu to fit new content - if hasattr(self, "_menu") and self._menu: - self._menu.adjustSize() - - def _add_section_header(self, text: str): - """Add a section header to the list.""" - header = QLabel(text) - header.setProperty("class", "section-header") - self._scroll_layout.addWidget(header) - - def _get_image_from_base64(self, text: str) -> QPixmap | None: - """Convert base64 text to QPixmap if valid image.""" - if not text.startswith("data:image/") or ";base64," not in text: - return None - try: - # Extract base64 data - _, data_str = text.split(";base64,") - data = base64.b64decode(data_str) - pixmap = QPixmap() - if pixmap.loadFromData(data): - return pixmap - except Exception: - pass - return None + def __init__(self, **kwargs): + super().__init__(class_name=f"clipboard-widget {kwargs.get('class_name', '')}") + self._config = kwargs + self._show_alt = False + self._widget_container_layout = self.layout() + self.widget_label = build_widget_label(self, self._config["label"].format(clipboard="")) + self._menu = None - def _add_item_row(self, text: str, is_pinned: bool): - """Add an item row to the list.""" - container = QWidget() - container.setProperty("class", "clipboard-item") - container.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - - layout = QHBoxLayout(container) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(8) - - # Content - pixmap = self._get_image_from_base64(text) - - if pixmap: - # Display image - image_label = QLabel() - image_label.setPixmap( - pixmap.scaled(300, 150, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - ) - image_label.setProperty("class", "item-image") - layout.addWidget(image_label, 1) - else: - # Display text - max_len = self._menu_config["max_item_length"] - display_text = text.replace("\n", " ").replace("\r", "").strip() - if len(display_text) > max_len: - display_text = display_text[:max_len] + "..." - - text_label = QLabel(display_text) - text_label.setProperty("class", "item-text") - text_label.setWordWrap(False) - layout.addWidget(text_label, 1) - - # Pin/unpin button - pin_btn = QPushButton(self._icons["pin"] if is_pinned else self._icons["unpin"]) - pin_btn.setProperty("class", "pin-button-active" if is_pinned else "pin-button") - pin_btn.setFixedWidth(28) - pin_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - pin_btn.clicked.connect(lambda _, t=text, p=is_pinned: self._toggle_pin(t, p)) - layout.addWidget(pin_btn) - - # Delete button - del_btn = QPushButton(self._icons["clear"]) - del_btn.setProperty("class", "delete-button") - del_btn.setFixedWidth(28) - del_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - del_btn.clicked.connect(lambda _, t=text, p=is_pinned: self._delete_item(t, p)) - layout.addWidget(del_btn) - - # Click on container to copy - container.mousePressEvent = ( - lambda e, t=text: self._copy_item(t) if e.button() == Qt.MouseButton.LeftButton else None - ) - - self._scroll_layout.addWidget(container) - - def _toggle_pin(self, text: str, is_pinned: bool): - """Toggle pin status of an item.""" - if is_pinned: - if text in self._data["pinned"]: - self._data["pinned"].remove(text) - self._data["history"].insert(0, text) - else: - if text in self._data["history"]: - self._data["history"].remove(text) - self._data["pinned"].insert(0, text) - - self._save_data() - self._refresh_list() - - def _delete_item(self, text: str, is_pinned: bool): - """Delete an item from history or pinned list.""" - if is_pinned: - if text in self._data["pinned"]: - self._data["pinned"].remove(text) - else: - if text in self._data["history"]: - self._data["history"].remove(text) - - self._save_data() - self._refresh_list() - - def _copy_item(self, text: str): - """Copy an item to clipboard and close menu.""" - - pixmap = self._get_image_from_base64(text) - if pixmap: - QApplication.clipboard().setPixmap(pixmap) - else: - QApplication.clipboard().setText(text) - - self._last_clipboard = text - self._update_label() - if hasattr(self, "_menu"): - self._menu.hide() + self.register_callback("toggle_menu", self.toggle_menu) + self.register_callback("toggle_label", self.toggle_label) + self.callback_left = "toggle_menu" + self.callback_right = "toggle_label" - def _clear_history(self): - """Clear clipboard history (keeps pinned items).""" - self._data["history"] = [] - self._save_data() - ClipboardWidget.update_all() - - def _clear_history_and_refresh(self): - """Clear history and refresh the menu.""" - self._clear_history() - self._refresh_list() - - def _on_search_changed(self, text: str): - """Handle search input changes.""" - self._search_query = text - self._refresh_list() - - def _toggle_persistence(self): - """Toggle clipboard history persistence.""" - if "settings" not in self._data: - self._data["settings"] = {} - - current = self._data["settings"].get("persistence", True) - new_state = not current - self._data["settings"]["persistence"] = new_state - - # Update button - if hasattr(self, "_persist_btn"): - self._persist_btn.setText(self._icons["persistent"] if new_state else self._icons["temporary"]) - self._persist_btn.setProperty("class", "persistence-button-active" if new_state else "persistence-button") - self._persist_btn.setToolTip(f"History Persistence: {'ON' if new_state else 'OFF'}") - - # Force style refresh - self._persist_btn.style().unpolish(self._persist_btn) - self._persist_btn.style().polish(self._persist_btn) - - self._save_data() - - def _load_data(self) -> dict: - """Load clipboard data from JSON file.""" + async def _fetch_and_show(self): try: - if os.path.exists(self._data_file): - if DEBUG: - logging.debug(f"Loading clipboard data from {self._data_file}") - with open(self._data_file, "r", encoding="utf-8") as f: - data = json.load(f) - # Ensure proper structure - if "pinned" not in data: - data["pinned"] = [] - if "history" not in data: - data["history"] = [] - if "settings" not in data: - data["settings"] = {"persistence": True} - return data + history = await Clipboard.get_history_items_async() + items = [] + if history.status.value == 0: + for item in list(history.items)[: self._config["max_history"]]: + content = item.content + entry = {"id": item.id, "type": "text", "data": None, "raw_item": item} + + if content.contains(StandardDataFormats.text): + entry["data"] = await content.get_text_async() + items.append(entry) + elif content.contains(StandardDataFormats.bitmap): + try: + stream_ref = await content.get_bitmap_async() + stream = await stream_ref.open_read_async() + buffer = bytearray(stream.size) + await stream.read_async(buffer, stream.size, 0) + + pixmap = QPixmap() + pixmap.loadFromData(buffer) + if not pixmap.isNull(): + entry["data"] = pixmap + entry["raw"] = stream_ref + entry["type"] = "image" + items.append(entry) + except Exception: + continue + + self._menu = ClipboardPopup(self, items, self._config) + self._menu.show_menu() except Exception as e: - logging.error(f"Error loading clipboard data: {e}") - - return {"pinned": [], "history": []} + logging.error(f"Async Clipboard Error: {e}") - def _save_data(self): - """Save clipboard data to JSON file.""" + def toggle_menu(self): try: - os.makedirs(os.path.dirname(self._data_file), exist_ok=True) - - # Prepare data dump (handle persistence) - persistence = self._data.get("settings", {}).get("persistence", True) + if self._menu and self._menu.isVisible(): + self._menu.hide() + return + except RuntimeError: + self._menu = None + + asyncio.create_task(self._fetch_and_show()) + + def toggle_label(self): + self._show_alt = not self._show_alt + lbl = self._config["label_alt"] if self._show_alt else self._config["label"] + while self.layout().count(): + child = self.layout().takeAt(0) + if child.widget(): + child.widget().deleteLater() + self.widget_label = build_widget_label(self, lbl.format(clipboard="")) + + def set_system_clipboard(self, item): + # 1. Update the actual Windows Clipboard + dp = DataPackage() + if item["type"] == "text": + dp.set_text(item["data"]) + elif item["type"] == "image": + dp.set_bitmap(item["raw"]) + + Clipboard.set_content(dp) + Clipboard.flush() + + # 2. Provide Visual Feedback on the Bar + self._flash_copied_message() + + if self._menu: + self._menu.hide() - dump_data = { - "pinned": self._data["pinned"], - "settings": self._data.get("settings", {"persistence": True}), - # Only save history to disk if persistence is ON - "history": self._data["history"] if persistence else [], - } + def _find_label_widget(self): + """Helper to find the QLabel created by build_widget_label""" + for i in range(self.layout().count()): + widget = self.layout().itemAt(i).widget() + if isinstance(widget, QLabel): + return widget + return None - with open(self._data_file, "w", encoding="utf-8") as f: - json.dump(dump_data, f, indent=2, ensure_ascii=False) - except Exception as e: - logging.error(f"Error saving clipboard data: {e}") + def _flash_copied_message(self): + label = self._find_label_widget() + if label: + # Use the hex code directly here. + # We add for bolding since the CSS selector is gone. + icon = self._config["icons"]["clipboard"] + flash_text = f"{icon} Copied!" + + label.setText(flash_text) + QTimer.singleShot(1500, self._revert_label) + + def _revert_label(self): + """Returns the bar label to its original state by updating text""" + label = self._find_label_widget() + if label: + lbl = self._config["label_alt"] if self._show_alt else self._config["label"] + label.setText(lbl.format(clipboard="")) + + +class ClipboardPopup(PopupWidget): + def __init__(self, parent_widget, items, config): + super().__init__(parent_widget, config["menu"]) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self._parent_widget = parent_widget + self._all_items = items + self._icons = config["icons"] + self._max_item_len = config["menu"].get("max_item_length", 50) + self._init_ui() + + def _init_ui(self): + self.main_layout = QVBoxLayout(self) + self.setMinimumWidth(320) + + # FIX: Set property on the instances, not the methods + self.setProperty("class", "clipboard-menu") + + self.search_bar = QLineEdit() + self.search_bar.setProperty("class", "search-input") + self.search_bar.setPlaceholderText("Search history...") + self.search_bar.textChanged.connect(self._filter_items) + self.main_layout.addWidget(self.search_bar) + + clear_btn = QPushButton(f"{self._icons['clear']} Clear All History") + clear_btn.setProperty("class", "clear-button") + clear_btn.clicked.connect(lambda: [Clipboard.clear_history(), self.close()]) + self.main_layout.addWidget(clear_btn) + + self.scroll = QScrollArea() + self.scroll.setProperty("class", "scroll-area") + self.scroll.setWidgetResizable(True) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self.container = QWidget() + self.container_layout = QVBoxLayout(self.container) + self.container_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.scroll.setWidget(self.container) + self.main_layout.addWidget(self.scroll) + self._render_items(self._all_items) + + def _render_items(self, items): + while self.container_layout.count(): + child = self.container_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + for item in items: + btn = QPushButton() + btn.setProperty("class", "clipboard-item") + btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + if item["type"] == "text": + clean = item["data"].replace("\n", " ").strip() + display = (clean[: self._max_item_len] + "..") if len(clean) > self._max_item_len else clean + btn.setText(display) + btn.setToolTip(item["data"]) + else: + btn.setIcon(QIcon(item["data"])) + btn.setIconSize(item["data"].size().scaled(200, 80, Qt.AspectRatioMode.KeepAspectRatio)) + btn.setText(" [Image]") + + btn.clicked.connect(lambda _, i=item: self._parent_widget.set_system_clipboard(i)) + self.container_layout.addWidget(btn) + + def _filter_items(self, query): + filtered = [] + for i in self._all_items: + if i["type"] == "image": + filtered.append(i) + elif query.lower() in i["data"].lower(): + filtered.append(i) + self._render_items(filtered) + + def show_menu(self): + self.show() + parent_geo = self._parent_widget.mapToGlobal(self._parent_widget.rect().bottomLeft()) + screen_geo = self.screen().availableGeometry() + x, y = parent_geo.x(), parent_geo.y() + if x + self.width() > screen_geo.right(): + x = screen_geo.right() - self.width() + if y + self.height() > screen_geo.bottom(): + y = parent_geo.y() - self._parent_widget.height() - self.height() + self.move(x, y) From f299d7bd83f5c23ed67caf89e34898ca7eeda8fe Mon Sep 17 00:00:00 2001 From: Washik Date: Sun, 1 Feb 2026 22:13:45 +0100 Subject: [PATCH 3/4] individual-deletion-added --- README.md | 2 +- docs/widgets/(Widget)-Clipboard.md | 4 +-- src/core/widgets/yasb/clipboard.py | 50 +++++++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 01a8f03e0..97de9790a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ https://github.com/user-attachments/assets/aab8d8e8-248f-46a1-919c-9b0601236ac1 - **[Cava](https://github.com/amnweb/yasb/wiki/(Widget)-Cava)**: Displays audio visualizer using Cava. - **[Copilot](https://github.com/amnweb/yasb/wiki/(Widget)-Copilot)**: GitHub Copilot usage with a detailed menu showing statistics - **[CPU](https://github.com/amnweb/yasb/wiki/(Widget)-CPU)**: Shows the current CPU usage. -- **[Clipboard](https://github.com/amnweb/yasb/wiki/(Widget)-Clipboard)**: A powerful, persistent clipboard manager for YASB. +- **[Clipboard](https://github.com/amnweb/yasb/wiki/(Widget)-Clipboard)**: A native lightweight clipboard manager for YASB. - **[Clock](https://github.com/amnweb/yasb/wiki/(Widget)-Clock)**: Displays the current time and date. - **[Custom](https://github.com/amnweb/yasb/wiki/(Widget)-Custom)**: Create a custom widget. - **[Github](https://github.com/amnweb/yasb/wiki/(Widget)-Github)**: Shows notifications from GitHub. diff --git a/docs/widgets/(Widget)-Clipboard.md b/docs/widgets/(Widget)-Clipboard.md index 1f880ca52..7b6c8a4dc 100644 --- a/docs/widgets/(Widget)-Clipboard.md +++ b/docs/widgets/(Widget)-Clipboard.md @@ -1,6 +1,6 @@ # Clipboard Widget for YASB -A lightweight, modern clipboard manager for YASB that integrates directly with the native Windows Clipboard History (Win + V). This widget provides real-time access to your system's clip buffer without the need for heavy local storage or complex background monitoring. +A lightweight clipboard manager for YASB that integrates directly with the native Windows Clipboard History (Win + V). This widget provides real-time access to your system's clip buffer without the need for heavy local storage or complex background monitoring. **Note for Users:** This widget requires the following Python packages to interact with Windows APIs: `pip install winrt-Windows.ApplicationModel.DataTransfer winrt-Windows.Foundation` @@ -8,9 +8,9 @@ A lightweight, modern clipboard manager for YASB that integrates directly with t ## Features - **Native Windows Sync**: Syncs in real-time with your official Windows Clipboard History. - **Search**: Built-in real-time search bar to filter through your text-based history. +- **History**: View your Clipboard history and delete a single history item or your entire history. - **Image Support**: Full support for previewing and re-copying images directly from the history list. - **Long text preview**: Hover over a copied long piece of text to display complete text preview. -- **Zero-SDK Footprint**: Uses modular WinRT bindings to keep the installation under 10MB. ## Options diff --git a/src/core/widgets/yasb/clipboard.py b/src/core/widgets/yasb/clipboard.py index 92515f78a..9dd1e4d9b 100644 --- a/src/core/widgets/yasb/clipboard.py +++ b/src/core/widgets/yasb/clipboard.py @@ -3,7 +3,7 @@ from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QIcon, QPixmap -from PyQt6.QtWidgets import QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget from winrt.windows.applicationmodel.datatransfer import Clipboard, DataPackage, StandardDataFormats from core.utils.utilities import PopupWidget, build_widget_label @@ -165,12 +165,30 @@ def _init_ui(self): self._render_items(self._all_items) def _render_items(self, items): + # 1. Properly clear EVERYTHING (widgets and layouts) while self.container_layout.count(): - child = self.container_layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() + item = self.container_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + else: + # If it's a layout (the row), we need to clear its contents too + sub_layout = item.layout() + if sub_layout: + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + if sub_item.widget(): + sub_item.widget().deleteLater() + sub_layout.deleteLater() + + # 2. Re-render the items + if not items: + self.container_layout.addWidget(QLabel("No items match your search.")) + return for item in items: + row = QHBoxLayout() + btn = QPushButton() btn.setProperty("class", "clipboard-item") btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -186,7 +204,29 @@ def _render_items(self, items): btn.setText(" [Image]") btn.clicked.connect(lambda _, i=item: self._parent_widget.set_system_clipboard(i)) - self.container_layout.addWidget(btn) + + del_btn = QPushButton(self._icons["search_clear"]) + del_btn.setFixedWidth(35) + del_btn.setStyleSheet("color: #e74c3c; font-weight: bold;") + del_btn.clicked.connect(lambda _, i=item: self._delete_item(i)) + + row.addWidget(btn) + row.addWidget(del_btn) + self.container_layout.addLayout(row) + + def _delete_item(self, item_data): + """Removes a single item from Windows History""" + try: + # We pass the 'raw_item' stored during _fetch_and_show + success = Clipboard.delete_item_from_history(item_data["raw_item"]) + if not success: + logging.warning("Windows refused to delete the item.") + except AttributeError: + logging.error("Single item deletion is not supported by this WinRT package.") + + # Refresh the UI + self.close() + asyncio.create_task(self._parent_widget._fetch_and_show()) def _filter_items(self, query): filtered = [] From 01399d2afb723338951b7adbc30a787f9ec89c1a Mon Sep 17 00:00:00 2001 From: Washik Date: Wed, 4 Feb 2026 22:08:42 +0100 Subject: [PATCH 4/4] new-validation-schema --- docs/widgets/(Widget)-Clipboard.md | 8 +- src/core/validation/widgets/yasb/clipboard.py | 152 +++++++---------- src/core/widgets/yasb/clipboard.py | 157 ++++++++++++------ 3 files changed, 166 insertions(+), 151 deletions(-) diff --git a/docs/widgets/(Widget)-Clipboard.md b/docs/widgets/(Widget)-Clipboard.md index 7b6c8a4dc..aa4e3cef8 100644 --- a/docs/widgets/(Widget)-Clipboard.md +++ b/docs/widgets/(Widget)-Clipboard.md @@ -18,7 +18,7 @@ A lightweight clipboard manager for YASB that integrates directly with the nativ | :--- | :--- | :--- | :--- | | `type` | `string` | `yasb.clipboard.ClipboardWidget` | The widget class identifier. | | `label` | `string` | `\udb80\udd4d {clipboard}` | Primary label format. Supports `{clipboard}` token. | -| `label_alt` | `string` | `{clipboard}` | Alternative label format (swapped on right-click). | +| `label_alt` | `string` | `CLIPBOARD` | Alternative label format (swapped on right-click). | | `max_length` | `integer` | `30` | Max characters to display in the bar before truncation. | | `max_history` | `integer` | `50` | Maximum number of history items to fetch from Windows. | | `class_name` | `string` | `""` | Additional CSS class for the widget container. | @@ -39,7 +39,6 @@ A lightweight clipboard manager for YASB that integrates directly with the nativ | :--- | :--- | | `toggle_menu` | Opens/closes the clipboard history popup (Scheduled asynchronously). | | `toggle_label` | Switches display between `label` and `label_alt` on the bar. | -| `do_nothing` | No action. | --- @@ -50,8 +49,8 @@ A lightweight clipboard manager for YASB that integrates directly with the nativ clipboard: type: "yasb.clipboard.ClipboardWidget" options: - label: "\udb80\udd4d {clipboard}" - label_alt: "CLIPBOARD: {clipboard}" + label: "\udb80\udd4d" + label_alt: "CLIPBOARD" max_length: 25 menu: blur: false @@ -60,7 +59,6 @@ A lightweight clipboard manager for YASB that integrates directly with the nativ direction: "down" callbacks: on_left: "toggle_menu" - on_middle: "do_nothing" on_right: "toggle_label" ``` diff --git a/src/core/validation/widgets/yasb/clipboard.py b/src/core/validation/widgets/yasb/clipboard.py index 152973c7d..c21b127c3 100644 --- a/src/core/validation/widgets/yasb/clipboard.py +++ b/src/core/validation/widgets/yasb/clipboard.py @@ -1,95 +1,59 @@ -DEFAULTS = { - "label": "\udb80\udd4d {clipboard}", - "label_alt": "{clipboard}", - "class_name": "", - "max_length": 30, - "max_history": 50, - "container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0}, - "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, - "menu": { - "blur": True, - "round_corners": True, - "round_corners_type": "normal", - "border_color": "System", - "alignment": "right", - "direction": "down", - "offset_top": 6, - "offset_left": 0, - "max_item_length": 50, - }, - "icons": { - "clipboard": "\udb80\udd4d", - "clear": "\uf1f8", - "search_clear": "\uf00d", - }, - "callbacks": {"on_left": "toggle_menu", "on_middle": "do_nothing", "on_right": "toggle_label"}, -} +from typing import Literal -VALIDATION_SCHEMA = { - "label": {"type": "string", "default": DEFAULTS["label"]}, - "label_alt": {"type": "string", "default": DEFAULTS["label_alt"]}, - "class_name": {"type": "string", "required": False, "default": DEFAULTS["class_name"]}, - "max_length": {"type": "integer", "required": False, "default": DEFAULTS["max_length"], "min": 5, "max": 100}, - "max_history": {"type": "integer", "required": False, "default": DEFAULTS["max_history"], "min": 10, "max": 500}, - "container_padding": { - "type": "dict", - "required": False, - "schema": { - "top": {"type": "integer", "default": DEFAULTS["container_padding"]["top"]}, - "left": {"type": "integer", "default": DEFAULTS["container_padding"]["left"]}, - "bottom": {"type": "integer", "default": DEFAULTS["container_padding"]["bottom"]}, - "right": {"type": "integer", "default": DEFAULTS["container_padding"]["right"]}, - }, - "default": DEFAULTS["container_padding"], - }, - "animation": { - "type": "dict", - "required": False, - "schema": { - "enabled": {"type": "boolean", "default": DEFAULTS["animation"]["enabled"]}, - "type": {"type": "string", "default": DEFAULTS["animation"]["type"]}, - "duration": {"type": "integer", "default": DEFAULTS["animation"]["duration"]}, - }, - "default": DEFAULTS["animation"], - }, - "menu": { - "type": "dict", - "required": False, - "schema": { - "blur": {"type": "boolean", "default": DEFAULTS["menu"]["blur"]}, - "round_corners": {"type": "boolean", "default": DEFAULTS["menu"]["round_corners"]}, - "round_corners_type": { - "type": "string", - "default": DEFAULTS["menu"]["round_corners_type"], - "allowed": ["normal", "small"], - }, - "border_color": {"type": "string", "default": DEFAULTS["menu"]["border_color"]}, - "alignment": {"type": "string", "default": DEFAULTS["menu"]["alignment"]}, - "direction": {"type": "string", "default": DEFAULTS["menu"]["direction"]}, - "offset_top": {"type": "integer", "default": DEFAULTS["menu"]["offset_top"]}, - "offset_left": {"type": "integer", "default": DEFAULTS["menu"]["offset_left"]}, - "max_item_length": {"type": "integer", "default": DEFAULTS["menu"]["max_item_length"]}, - }, - "default": DEFAULTS["menu"], - }, - "icons": { - "type": "dict", - "required": False, - "schema": { - "clipboard": {"type": "string", "default": DEFAULTS["icons"]["clipboard"]}, - "clear": {"type": "string", "default": DEFAULTS["icons"]["clear"]}, - "search_clear": {"type": "string", "default": DEFAULTS["icons"]["search_clear"]}, - }, - "default": DEFAULTS["icons"], - }, - "callbacks": { - "type": "dict", - "required": False, - "schema": { - "on_left": {"type": "string", "default": DEFAULTS["callbacks"]["on_left"]}, - "on_middle": {"type": "string", "default": DEFAULTS["callbacks"]["on_middle"]}, - "on_right": {"type": "string", "default": DEFAULTS["callbacks"]["on_right"]}, - }, - "default": DEFAULTS["callbacks"], - }, -} +from pydantic import Field + +from core.validation.widgets.base_model import ( + AnimationConfig, + CallbacksConfig, + CustomBaseModel, + KeybindingConfig, + PaddingConfig, + ShadowConfig, +) + + +class ClipboardMenuConfig(CustomBaseModel): + """Configuration for the clipboard popup menu.""" + + blur: bool = True + round_corners: bool = True + round_corners_type: Literal["normal", "small"] = "normal" + border_color: str = "System" + alignment: Literal["left", "right", "center"] = "right" + direction: Literal["up", "down"] = "down" + offset_top: int = 6 + offset_left: int = 0 + max_item_length: int = Field(default=50, ge=10, le=200) + + +class ClipboardIconsConfig(CustomBaseModel): + """Configuration for clipboard widget icons.""" + + clipboard: str = "\udb80\udd4d" + clear: str = "\uf1f8" + search_clear: str = "\uf00d" + + +class ClipboardCallbacksConfig(CallbacksConfig): + """Callbacks configuration with clipboard-specific defaults.""" + + on_left: str = "toggle_menu" + on_right: str = "toggle_label" + + +class ClipboardConfig(CustomBaseModel): + """Main configuration model for the Clipboard widget.""" + + label: str = "\udb80\udd4d {clipboard}" + label_alt: str = "{clipboard}" + class_name: str = "" + max_length: int = Field(default=30, ge=5, le=100) + max_history: int = Field(default=50, ge=10, le=500) + menu: ClipboardMenuConfig = ClipboardMenuConfig() + icons: ClipboardIconsConfig = ClipboardIconsConfig() + animation: AnimationConfig = AnimationConfig() + container_padding: PaddingConfig = PaddingConfig() + label_shadow: ShadowConfig = ShadowConfig() + container_shadow: ShadowConfig = ShadowConfig() + keybindings: list[KeybindingConfig] = [] + callbacks: ClipboardCallbacksConfig = ClipboardCallbacksConfig() diff --git a/src/core/widgets/yasb/clipboard.py b/src/core/widgets/yasb/clipboard.py index 9dd1e4d9b..f5a5b70e4 100644 --- a/src/core/widgets/yasb/clipboard.py +++ b/src/core/widgets/yasb/clipboard.py @@ -3,36 +3,78 @@ from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QIcon, QPixmap -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget +from PyQt6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) from winrt.windows.applicationmodel.datatransfer import Clipboard, DataPackage, StandardDataFormats -from core.utils.utilities import PopupWidget, build_widget_label -from core.validation.widgets.yasb.clipboard import VALIDATION_SCHEMA +from core.utils.utilities import PopupWidget, add_shadow, build_widget_label +from core.utils.widgets.animation_manager import AnimationManager +from core.validation.widgets.yasb.clipboard import ClipboardConfig from core.widgets.base import BaseWidget class ClipboardWidget(BaseWidget): - validation_schema = VALIDATION_SCHEMA + """A clipboard manager widget that integrates with Windows Clipboard History.""" - def __init__(self, **kwargs): - super().__init__(class_name=f"clipboard-widget {kwargs.get('class_name', '')}") - self._config = kwargs + validation_schema = ClipboardConfig + + def __init__(self, config: ClipboardConfig): + super().__init__(class_name=f"clipboard-widget {config.class_name}") + self.config = config self._show_alt = False - self._widget_container_layout = self.layout() - self.widget_label = build_widget_label(self, self._config["label"].format(clipboard="")) self._menu = None + # Set up widget container layout + self._widget_container_layout = QHBoxLayout() + self._widget_container_layout.setSpacing(0) + self._widget_container_layout.setContentsMargins( + self.config.container_padding.left, + self.config.container_padding.top, + self.config.container_padding.right, + self.config.container_padding.bottom, + ) + + # Initialize container + self._widget_container = QFrame() + self._widget_container.setLayout(self._widget_container_layout) + self._widget_container.setProperty("class", "widget-container") + add_shadow(self._widget_container, self.config.container_shadow.model_dump()) + + # Add the container to the main widget layout + self.widget_layout.addWidget(self._widget_container) + + # Build widget labels + build_widget_label( + self, + self.config.label.format(clipboard=""), + self.config.label_alt.format(clipboard=""), + self.config.label_shadow.model_dump(), + ) + + # Register callbacks self.register_callback("toggle_menu", self.toggle_menu) self.register_callback("toggle_label", self.toggle_label) - self.callback_left = "toggle_menu" - self.callback_right = "toggle_label" + + self.callback_left = self.config.callbacks.on_left + self.callback_right = self.config.callbacks.on_right + self.callback_middle = self.config.callbacks.on_middle async def _fetch_and_show(self): + """Fetch clipboard history from Windows and show the popup menu.""" try: history = await Clipboard.get_history_items_async() items = [] if history.status.value == 0: - for item in list(history.items)[: self._config["max_history"]]: + for item in list(history.items)[: self.config.max_history]: content = item.content entry = {"id": item.id, "type": "text", "data": None, "raw_item": item} @@ -56,12 +98,13 @@ async def _fetch_and_show(self): except Exception: continue - self._menu = ClipboardPopup(self, items, self._config) + self._menu = ClipboardPopup(self, items, self.config) self._menu.show_menu() except Exception as e: logging.error(f"Async Clipboard Error: {e}") def toggle_menu(self): + """Toggle the clipboard history popup menu.""" try: if self._menu and self._menu.isVisible(): self._menu.hide() @@ -72,16 +115,18 @@ def toggle_menu(self): asyncio.create_task(self._fetch_and_show()) def toggle_label(self): + """Toggle between primary and alternate labels with animation.""" + if self.config.animation.enabled: + AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) + self._show_alt = not self._show_alt - lbl = self._config["label_alt"] if self._show_alt else self._config["label"] - while self.layout().count(): - child = self.layout().takeAt(0) - if child.widget(): - child.widget().deleteLater() - self.widget_label = build_widget_label(self, lbl.format(clipboard="")) + for widget in self._widgets: + widget.setVisible(not self._show_alt) + for widget in self._widgets_alt: + widget.setVisible(self._show_alt) def set_system_clipboard(self, item): - # 1. Update the actual Windows Clipboard + """Set an item to the system clipboard.""" dp = DataPackage() if item["type"] == "text": dp.set_text(item["data"]) @@ -91,67 +136,75 @@ def set_system_clipboard(self, item): Clipboard.set_content(dp) Clipboard.flush() - # 2. Provide Visual Feedback on the Bar + # Provide visual feedback self._flash_copied_message() if self._menu: self._menu.hide() def _find_label_widget(self): - """Helper to find the QLabel created by build_widget_label""" - for i in range(self.layout().count()): - widget = self.layout().itemAt(i).widget() + """Helper to find the QLabel created by build_widget_label.""" + active_widgets = self._widgets_alt if self._show_alt else self._widgets + for widget in active_widgets: if isinstance(widget, QLabel): return widget return None def _flash_copied_message(self): + """Briefly show a 'Copied!' message on the widget label.""" label = self._find_label_widget() if label: - # Use the hex code directly here. - # We add for bolding since the CSS selector is gone. - icon = self._config["icons"]["clipboard"] + icon = self.config.icons.clipboard flash_text = f"{icon} Copied!" - label.setText(flash_text) QTimer.singleShot(1500, self._revert_label) def _revert_label(self): - """Returns the bar label to its original state by updating text""" + """Revert the label to its original state.""" label = self._find_label_widget() if label: - lbl = self._config["label_alt"] if self._show_alt else self._config["label"] + lbl = self.config.label_alt if self._show_alt else self.config.label label.setText(lbl.format(clipboard="")) class ClipboardPopup(PopupWidget): - def __init__(self, parent_widget, items, config): - super().__init__(parent_widget, config["menu"]) + """Popup widget for displaying clipboard history.""" + + def __init__(self, parent_widget: ClipboardWidget, items: list, config: ClipboardConfig): + super().__init__( + parent_widget, + blur=config.menu.blur, + round_corners=config.menu.round_corners, + round_corners_type=config.menu.round_corners_type, + border_color=config.menu.border_color, + ) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._parent_widget = parent_widget self._all_items = items - self._icons = config["icons"] - self._max_item_len = config["menu"].get("max_item_length", 50) + self._icons = config.icons + self._menu_config = config.menu self._init_ui() def _init_ui(self): + """Initialize the popup UI.""" self.main_layout = QVBoxLayout(self) self.setMinimumWidth(320) - - # FIX: Set property on the instances, not the methods self.setProperty("class", "clipboard-menu") + # Search bar self.search_bar = QLineEdit() self.search_bar.setProperty("class", "search-input") self.search_bar.setPlaceholderText("Search history...") self.search_bar.textChanged.connect(self._filter_items) self.main_layout.addWidget(self.search_bar) - clear_btn = QPushButton(f"{self._icons['clear']} Clear All History") + # Clear all button + clear_btn = QPushButton(f"{self._icons.clear} Clear All History") clear_btn.setProperty("class", "clear-button") clear_btn.clicked.connect(lambda: [Clipboard.clear_history(), self.close()]) self.main_layout.addWidget(clear_btn) + # Scroll area for items self.scroll = QScrollArea() self.scroll.setProperty("class", "scroll-area") self.scroll.setWidgetResizable(True) @@ -165,14 +218,14 @@ def _init_ui(self): self._render_items(self._all_items) def _render_items(self, items): - # 1. Properly clear EVERYTHING (widgets and layouts) + """Render the clipboard items in the popup.""" + # Clear existing items while self.container_layout.count(): item = self.container_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() else: - # If it's a layout (the row), we need to clear its contents too sub_layout = item.layout() if sub_layout: while sub_layout.count(): @@ -181,11 +234,12 @@ def _render_items(self, items): sub_item.widget().deleteLater() sub_layout.deleteLater() - # 2. Re-render the items if not items: self.container_layout.addWidget(QLabel("No items match your search.")) return + max_item_len = self._menu_config.max_item_length + for item in items: row = QHBoxLayout() @@ -195,7 +249,7 @@ def _render_items(self, items): if item["type"] == "text": clean = item["data"].replace("\n", " ").strip() - display = (clean[: self._max_item_len] + "..") if len(clean) > self._max_item_len else clean + display = (clean[:max_item_len] + "..") if len(clean) > max_item_len else clean btn.setText(display) btn.setToolTip(item["data"]) else: @@ -205,7 +259,7 @@ def _render_items(self, items): btn.clicked.connect(lambda _, i=item: self._parent_widget.set_system_clipboard(i)) - del_btn = QPushButton(self._icons["search_clear"]) + del_btn = QPushButton(self._icons.search_clear) del_btn.setFixedWidth(35) del_btn.setStyleSheet("color: #e74c3c; font-weight: bold;") del_btn.clicked.connect(lambda _, i=item: self._delete_item(i)) @@ -215,9 +269,8 @@ def _render_items(self, items): self.container_layout.addLayout(row) def _delete_item(self, item_data): - """Removes a single item from Windows History""" + """Remove a single item from Windows Clipboard History.""" try: - # We pass the 'raw_item' stored during _fetch_and_show success = Clipboard.delete_item_from_history(item_data["raw_item"]) if not success: logging.warning("Windows refused to delete the item.") @@ -229,6 +282,7 @@ def _delete_item(self, item_data): asyncio.create_task(self._parent_widget._fetch_and_show()) def _filter_items(self, query): + """Filter clipboard items based on search query.""" filtered = [] for i in self._all_items: if i["type"] == "image": @@ -238,12 +292,11 @@ def _filter_items(self, query): self._render_items(filtered) def show_menu(self): + """Show the popup menu positioned relative to the parent widget.""" self.show() - parent_geo = self._parent_widget.mapToGlobal(self._parent_widget.rect().bottomLeft()) - screen_geo = self.screen().availableGeometry() - x, y = parent_geo.x(), parent_geo.y() - if x + self.width() > screen_geo.right(): - x = screen_geo.right() - self.width() - if y + self.height() > screen_geo.bottom(): - y = parent_geo.y() - self._parent_widget.height() - self.height() - self.move(x, y) + self.setPosition( + alignment=self._menu_config.alignment, + direction=self._menu_config.direction, + offset_left=self._menu_config.offset_left, + offset_top=self._menu_config.offset_top, + )