diff --git a/README.md b/README.md index d5f93b553..97de9790a 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 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 new file mode 100644 index 000000000..aa4e3cef8 --- /dev/null +++ b/docs/widgets/(Widget)-Clipboard.md @@ -0,0 +1,161 @@ +# Clipboard Widget for YASB + +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` + +## 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. + +## 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 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, clear, and search. | + +## Icons Configuration Defaults + +| Key | Default | Description | +| :--- | :--- | :--- | +| `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/closes the clipboard history popup (Scheduled asynchronously). | +| `toggle_label` | Switches display between `label` and `label_alt` on the bar. | + +--- + + +## Configuration Example + +```yaml + clipboard: + type: "yasb.clipboard.ClipboardWidget" + options: + label: "\udb80\udd4d" + label_alt: "CLIPBOARD" + max_length: 25 + menu: + blur: false + round_corners: false + alignment: "right" + direction: "down" + callbacks: + on_left: "toggle_menu" + on_right: "toggle_label" +``` + +## Styling + +### Example CSS + +```css +.clipboard-widget { + font-family: "JetBrainsMono Nerd Font", "Segoe UI Variable"; +} +/* Widget on the bar */ +.clipboard-widget .label { + padding: 0 6px; + color: var(--mauve); +} + +/* Main Popup Container */ +.clipboard-menu { + background-color: var(--bg-color1); + border: 1px solid var(--bg-color2); + 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: 6px 10px; + margin-bottom: 6px; + color: var(--text1); + font-size: 13px; +} + +.clipboard-menu .search-input:focus { + border: 1px solid var(--blue); +} + +/* Global Clear Button */ +.clipboard-menu .clear-button { + background-color: rgba(243, 139, 168, 0.1); + color: var(--red); + 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.2); +} + +/* Scroll Area Styling */ +.clipboard-menu .scroll-area { + background: transparent; + border: none; +} + +/* Individual Clipboard Items (Buttons) */ +.clipboard-menu .clipboard-item { + 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-size: 12px; +} + +.clipboard-menu .clipboard-item:hover { + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid var(--bg-color2); +} + +/* Image Item Specifics (if needed) */ +.clipboard-menu .clipboard-item [icon] { + margin-right: 8px; +} + +/* Scrollbar Styling (Optional but looks better) */ +QScrollBar:vertical { + border: none; + background: transparent; + width: 4px; +} + +QScrollBar::handle:vertical { + background: var(--bg-color2); + border-radius: 2px; +} + +/* 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 new file mode 100644 index 000000000..c21b127c3 --- /dev/null +++ b/src/core/validation/widgets/yasb/clipboard.py @@ -0,0 +1,59 @@ +from typing import Literal + +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 new file mode 100644 index 000000000..f5a5b70e4 --- /dev/null +++ b/src/core/widgets/yasb/clipboard.py @@ -0,0 +1,302 @@ +import asyncio +import logging + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QIcon, QPixmap +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, 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): + """A clipboard manager widget that integrates with Windows Clipboard History.""" + + 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._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 = 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]: + 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"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() + return + except RuntimeError: + self._menu = None + + 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 + 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): + """Set an item to the system 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() + + # 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.""" + 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: + icon = self.config.icons.clipboard + flash_text = f"{icon} Copied!" + label.setText(flash_text) + QTimer.singleShot(1500, self._revert_label) + + def _revert_label(self): + """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 + label.setText(lbl.format(clipboard="")) + + +class ClipboardPopup(PopupWidget): + """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._menu_config = config.menu + self._init_ui() + + def _init_ui(self): + """Initialize the popup UI.""" + self.main_layout = QVBoxLayout(self) + self.setMinimumWidth(320) + 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 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) + 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): + """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: + 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() + + 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() + + 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[:max_item_len] + "..") if len(clean) > 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)) + + 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): + """Remove a single item from Windows Clipboard History.""" + try: + 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): + """Filter clipboard items based on search 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): + """Show the popup menu positioned relative to the parent widget.""" + self.show() + 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, + )