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..03ec2c07b
--- /dev/null
+++ b/docs/widgets/(Widget)-Clipboard.md
@@ -0,0 +1,262 @@
+# 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.
+
+## 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 re-copying images directly from the history list.
+- **Long text preview**: Hover over a copied long piece of text to display text preview (configurable).
+- **Image list info**: Show image dimensions, size, and date in the history list (configurable).
+- **Feedback**: Visual feedback when content is copied.
+- **Customizable tooltips**: Enable/disable tooltips and adjust delay.
+
+## Options
+
+| Option | Type | Default | Description |
+| :--- | :--- | :--- | :--- |
+| `type` | `string` | `yasb.clipboard.ClipboardWidget` | The widget class identifier. |
+| `label` | `string` | `\udb80\udd4d` | Primary label format. |
+| `label_alt` | `string` | `CLIPBOARD` | Alternative label format (swapped on right-click). |
+| `max_history` | `integer` | `50` | Maximum number of history items to fetch from Windows. |
+| `class_name` | `string` | `""` | Additional CSS class for the widget container. |
+| `copied_feedback` | `string` | `\uf00c` | Feedback message when copying. |
+| `menu` | `dict` | (See Schema) | Configuration for the popup menu. |
+| `icons` | `dict` | (See Schema) | Custom icons for clipboard actions. |
+| `animation` | `dict` | (See Schema) | Animation configuration for label toggling. |
+| `label_shadow` | `dict` | (See Schema) | Shadow configuration for the label. |
+| `container_shadow` | `dict` | (See Schema) | Shadow configuration for the widget container. |
+
+## Icons Configuration Defaults
+
+| Key | Default | Description |
+| :--- | :--- | :--- |
+| `clear_icon` | `\uf1f8` | Clear all history icon. |
+| `delete_icon` | `\uf1f8` | Delete item icon. |
+
+## Menu Configuration Defaults
+
+| Key | Default | Description |
+| :--- | :--- | :--- |
+| `blur` | `true` | Enable blur effect on popup. |
+| `round_corners` | `true` | Enable rounded corners on popup. |
+| `round_corners_type` | `"normal"` | Corner radius style (`"normal"` or `"small"`). |
+| `border_color` | `"System"` | Border color for the popup. |
+| `alignment` | `"right"` | Horizontal alignment relative to widget (`"left"`, `"right"`, `"center"`). |
+| `direction` | `"down"` | Vertical direction for popup (`"up"` or `"down"`). |
+| `offset_top` | `6` | Top offset in pixels. |
+| `offset_left` | `0` | Left offset in pixels. |
+| `max_item_length` | `50` | Max characters to display for text items (10-200). |
+| `tooltip_enabled` | `true` | Enable custom tooltips for history items. |
+| `tooltip_delay` | `400` | Delay in milliseconds before showing tooltip (0-2000). |
+| `tooltip_position` | `"bottom"` | Tooltip position (`"top"` or `"bottom"`). Use `"top"` for bottom taskbar. |
+| `show_image_thumbnail` | `true` | Show image thumbnail in history list. If false, shows `image_replacement_text` instead. |
+| `image_replacement_text` | `"[Image]"` | Text to display for image items in history list when `show_image_thumbnail` is false. |
+| `show_image_list_info` | `true` | Show image dimensions, date, and size below the image in the history list. |
+| `image_info_position` | `"right"` | Position of image info (`"left"` or `"right"`). |
+
+## 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: "\uf0ea"
+ label_alt: "CLIPBOARD"
+ icons:
+ delete_icon: "\uf1f8"
+ clear_icon: "\uf1f8"
+ copied_feedback: "\uf00c"
+ menu:
+ blur: true
+ round_corners: true
+ alignment: "right"
+ direction: "down"
+ tooltip_enabled: true
+ tooltip_delay: 400
+ tooltip_position: "bottom"
+ show_image_thumbnail: true
+ image_replacement_text: "[IMAGE]"
+ show_image_list_info: true
+ image_info_position: "right"
+ callbacks:
+ on_left: "toggle_menu"
+ on_right: "toggle_label"
+```
+
+## Styling
+
+### Available CSS Classes
+
+| Class | Description |
+| :--- | :--- |
+| `.clipboard-widget` | Main widget container on the bar |
+| `.widget-container` | Inner container for widget content |
+| `.label` | Primary label/icon on the bar |
+| `.label.alt` | Alternate label on bar (has both `.label` and `.alt` classes) |
+| `.clipboard-menu` | Main popup container |
+| `.search-input` | Search bar input field |
+| `.clear-button` | Clear all history button |
+| `.scroll-area` | Scroll area container |
+| `.clipboard-scroll-content` | Content container inside scroll area |
+| `.clipboard-item` | Individual clipboard item container |
+| `.clipboard-item-content` | Content area (button + optional info) |
+| `.clipboard-item-btn` | Base class for all item buttons |
+| `.clipboard-item-btn.text-item` | Button for text items |
+| `.clipboard-item-btn.image-item` | Button for image items |
+| `.clipboard-item-info` | Image info container (dimensions, size, date) |
+| `.image-list-info` | Labels for image metadata |
+| `.delete-button` | Delete item button |
+| `.status-message` | Empty/error message label |
+| `.status-message.error` | Error message (e.g., clipboard disabled) |
+| `.status-message.empty` | Empty search result message |
+
+### Example CSS
+
+```css
+/* Widget on the bar */
+.clipboard-widget {
+ font-family: "JetBrainsMono Nerd Font", "Segoe UI Variable";
+}
+
+/* Widget Label (icon/text on bar) */
+.clipboard-widget .label {
+ color: #ffffff;
+}
+
+/* Main Popup Container */
+.clipboard-menu {
+ background-color: #1e1e2e;
+ border: 1px solid #11111b;
+ border-radius: 10px;
+ padding: 10px;
+ min-width: 320px;
+}
+
+/* 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: 10px;
+ color: #cdd6f4;
+ font-size: 13px;
+}
+
+.clipboard-menu .search-input:focus {
+ border: 1px solid #89b4fa;
+}
+
+/* Global Clear Button */
+.clipboard-menu .clear-button {
+ background-color: #cb3b42;
+ border-radius: 6px;
+ padding: 5px;
+ margin-bottom: 10px;
+ font-weight: bold;
+ font-size: 11px;
+}
+
+.clipboard-menu .clear-button:hover {
+ background-color: #cc6a6f;
+}
+
+/* Scroll Area */
+.clipboard-menu .scroll-area {
+ background: transparent;
+}
+
+/* Scroll Content Container */
+.clipboard-menu .clipboard-scroll-content {
+ background: transparent;
+}
+
+/* Clipboard Item Container */
+.clipboard-menu .clipboard-item {
+ background-color: rgba(255, 255, 255, 0.03);
+ border: 1px solid transparent;
+ border-radius: 6px;
+ margin-bottom: 6px;
+ padding: 4px 8px;
+ font-size: 12px;
+}
+
+.clipboard-menu .clipboard-item:hover {
+ background-color: rgba(255, 255, 255, 0.08);
+ border: 1px solid #11111b;
+}
+
+/* Content Area (button + optional info) */
+.clipboard-menu .clipboard-item-content {
+ background: transparent;
+}
+
+/* Item Button - Base styles */
+.clipboard-menu .clipboard-item-btn {
+ background: transparent;
+ border: none;
+ padding: 4px;
+}
+
+/* Text Item Button */
+.clipboard-menu .clipboard-item-btn.text-item {
+ text-align: left;
+}
+
+/* Image Item Button */
+.clipboard-menu .clipboard-item-btn.image-item {
+ text-align: left;
+}
+
+/* Image Info Container */
+.clipboard-menu .clipboard-item-info {
+ background: transparent;
+ margin-left: 4px;
+ min-width: 120px;
+}
+
+/* Image List Info (dimensions, size, date) */
+.clipboard-menu .image-list-info {
+ font-size: 10px;
+ color: #ffffff;
+ padding-left: 4px;
+}
+
+/* Delete Button */
+.clipboard-menu .delete-button {
+ background: #cb3b42;
+ border: none;
+ padding: 4px 8px;
+ margin-left: 8px;
+ min-width: 35px;
+ max-width: 35px;
+ /* You can also set min-height if desired, e.g., min-height: 30px; */
+}
+
+.clipboard-menu .delete-button:hover {
+ background-color: #cc6a6f;
+}
+
+/* Status Messages */
+.clipboard-menu .status-message {
+ text-align: center;
+ color: gray;
+}
+
+.clipboard-menu .status-message.error {
+ color: #e74c3c;
+}
+```
diff --git a/src/core/validation/widgets/yasb/clipboard.py b/src/core/validation/widgets/yasb/clipboard.py
new file mode 100644
index 000000000..c9c218eac
--- /dev/null
+++ b/src/core/validation/widgets/yasb/clipboard.py
@@ -0,0 +1,63 @@
+from typing import Literal
+
+from pydantic import Field
+
+from core.validation.widgets.base_model import (
+ AnimationConfig,
+ CallbacksConfig,
+ CustomBaseModel,
+ KeybindingConfig,
+ 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)
+ tooltip_enabled: bool = True
+ tooltip_delay: int = Field(default=400, ge=0, le=2000)
+ tooltip_position: Literal["top", "bottom"] = "bottom"
+ show_image_thumbnail: bool = True
+ image_replacement_text: str = "[Image]"
+ show_image_list_info: bool = True
+ image_info_position: Literal["right", "left"] = "right"
+
+
+class ClipboardIconsConfig(CustomBaseModel):
+ """Configuration for clipboard widget icons."""
+
+ clear_icon: str = "\uf1f8"
+ delete_icon: str = "\uf1f8"
+
+
+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"
+ label_alt: str = "CLIPBOARD"
+ class_name: str = ""
+ copied_feedback: str = "\uf00c"
+ max_history: int = Field(default=50, ge=10, le=500)
+ menu: ClipboardMenuConfig = ClipboardMenuConfig()
+ icons: ClipboardIconsConfig = ClipboardIconsConfig()
+ animation: AnimationConfig = AnimationConfig()
+ 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..298a8c3fc
--- /dev/null
+++ b/src/core/widgets/yasb/clipboard.py
@@ -0,0 +1,530 @@
+import asyncio
+import logging
+import textwrap
+
+from PyQt6.QtCore import Qt, QTimer
+from PyQt6.QtGui import QIcon, QPixmap
+from PyQt6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QVBoxLayout,
+)
+
+try:
+ from winrt.windows.applicationmodel.datatransfer import Clipboard, DataPackage, StandardDataFormats
+
+ HAS_WINRT = True
+except ImportError:
+ HAS_WINRT = False
+ Clipboard = None
+ DataPackage = None
+ StandardDataFormats = None
+
+from core.utils.tooltip import set_tooltip
+from core.utils.utilities import PopupWidget, add_shadow, build_widget_label, is_valid_qobject, refresh_widget_style
+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
+ self._fetching = False
+ self._missing_dependencies = not HAS_WINRT
+
+ # Set up widget container layout
+ self._widget_container_layout = QHBoxLayout()
+ self._widget_container_layout.setSpacing(0)
+ self._widget_container_layout.setContentsMargins(0, 0, 0, 0)
+
+ # 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)
+
+ if self._missing_dependencies:
+ # Show error label if dependencies are missing
+ build_widget_label(
+ self,
+ "\uf071 Missing dependencies",
+ "Start with 'pip install winrt-Windows.ApplicationModel.DataTransfer'",
+ self.config.label_shadow.model_dump(),
+ )
+ else:
+ # Build widget labels
+ build_widget_label(
+ self,
+ self.config.label.format(clipboard=""),
+ self.config.label_alt.format(clipboard=""),
+ self.config.label_shadow.model_dump(),
+ )
+ # Ensure all sub-labels (including icons) are catchable by .label CSS
+ for label in getattr(self, "_widgets", []) + getattr(self, "_widgets_alt", []):
+ if isinstance(label, QLabel):
+ current_class = label.property("class") or ""
+ if "label" not in current_class:
+ label.setProperty("class", f"{current_class} label".strip())
+
+ # 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."""
+ if self._fetching:
+ return
+
+ self._fetching = True
+ try:
+ # Clean up existing menu immediately to prevent visual stacking
+ if self._menu and is_valid_qobject(self._menu):
+ try:
+ # Set closing flag to ensure event filters are removed in hideEvent
+ self._menu._is_closing = True
+ # Stop any animations and hide immediately
+ if hasattr(self._menu, "_fade_animation"):
+ self._menu._fade_animation.stop()
+ self._menu.hide()
+ self._menu.deleteLater()
+ except Exception:
+ pass
+ self._menu = None
+
+ if not Clipboard.is_history_enabled():
+ self._menu = ClipboardPopup(
+ self,
+ [],
+ self.config,
+ status_message="Clipboard History is disabled.
Enable it in Windows Settings (Win+V).",
+ )
+ self._menu.show_menu()
+ return
+
+ 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"
+ entry["size"] = len(buffer)
+ entry["timestamp"] = item.timestamp
+ items.append(entry)
+ except Exception:
+ continue
+
+ # Persistent Singleton Popup: Reuse the existing instance or create it once.
+ if not self._menu or not is_valid_qobject(self._menu):
+ self._menu = ClipboardPopup(self, items, self.config)
+ else:
+ self._menu._is_closing = False
+ self._menu.update_items(items)
+
+ self._menu.show_menu()
+ except Exception as e:
+ logging.error(f"Async Clipboard Error: {e}")
+ finally:
+ self._fetching = False
+
+ def toggle_menu(self):
+ """Toggle the clipboard history popup menu."""
+ try:
+ if (
+ self._menu
+ and is_valid_qobject(self._menu)
+ and self._menu.isVisible()
+ and not getattr(self._menu, "_is_closing", False)
+ ):
+ self._menu.hide_animated()
+ return
+ except RuntimeError:
+ self._menu = None
+
+ try:
+ asyncio.create_task(self._fetch_and_show())
+ except Exception as e:
+ logging.error(f"Error toggling clipboard menu: {e}")
+
+ 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:
+ # User can include icons in the string if they want
+ flash_text = self.config.copied_feedback
+ 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, status_message: str = None
+ ):
+ 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, False)
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
+
+ self._parent_widget = parent_widget
+ self._all_items = items
+ self._status_message = status_message
+ self._icons = config.icons
+ self._menu_config = config.menu
+ self._ui_initialized = False
+
+ self._add_scrollbar_style()
+ self._init_ui()
+
+ @staticmethod
+ def _hide_tooltips():
+ """Hide any active custom tooltips."""
+ from core.utils.tooltip import CustomToolTip
+
+ if CustomToolTip._active_tooltip:
+ CustomToolTip._active_tooltip.hide()
+ CustomToolTip._active_tooltip = None
+
+ def hide_animated(self):
+ """Hide the popup and also hide any active tooltips."""
+ self._hide_tooltips()
+ super().hide_animated()
+
+ def hide(self):
+ """Hide the popup and also hide any active tooltips."""
+ self._hide_tooltips()
+ super().hide()
+
+ def _add_scrollbar_style(self):
+ """Inject generic QScrollBar styles into the scroll area."""
+ self._scrollbar_style = """
+ 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; }
+ """
+
+ def _init_ui(self):
+ """Initialize the popup UI. Guarded to run only once."""
+ if self._ui_initialized:
+ return
+ self._ui_initialized = True
+
+ # 1. Root Layout on self (Window)
+ # We use this ONLY to hold the _popup_content frame.
+ self.root_layout = QVBoxLayout(self)
+ self.root_layout.setContentsMargins(0, 0, 0, 0)
+ self.root_layout.addWidget(self._popup_content)
+
+ # 2. Main Layout on _popup_content (The Styled Frame)
+ self.main_layout = QVBoxLayout(self._popup_content)
+
+ # Ensure the frame is opaque and has the styling class
+ self._popup_content.setAutoFillBackground(False)
+ self._popup_content.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
+ self._popup_content.setProperty("class", "clipboard-menu")
+
+ # Force styling refresh on the frame
+ refresh_widget_style(self._popup_content)
+
+ # Search bar
+ self._search_timer = QTimer(self)
+ self._search_timer.setSingleShot(True)
+ self._search_timer.timeout.connect(lambda: self._filter_items(self.search_bar.text()))
+
+ self.search_bar = QLineEdit()
+ self.search_bar.setProperty("class", "search-input")
+ self.search_bar.setPlaceholderText("Search history...")
+ self.search_bar.textChanged.connect(lambda _: (self._hide_tooltips(), self._search_timer.start(150)))
+ self.main_layout.addWidget(self.search_bar)
+
+ # Clear all
+ clear_btn = QPushButton(f"{self._icons.clear_icon} Clear 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
+ self.scroll = QScrollArea()
+ self.scroll.setProperty("class", "scroll-area")
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setFrameShape(QFrame.Shape.NoFrame)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll.setViewportMargins(0, 0, -4, 0)
+ self.scroll.verticalScrollBar().setStyleSheet(self._scrollbar_style)
+ self.scroll.viewport().setAutoFillBackground(False)
+ self.main_layout.addWidget(self.scroll)
+
+ # Initial render
+ self._render_items(self._all_items)
+
+ def update_items(self, items, status_message=None):
+ """Update items in the existing popup instance."""
+ self._all_items = items
+ if status_message:
+ self._status_message = status_message
+
+ self.search_bar.blockSignals(True)
+ self.search_bar.clear()
+ self.search_bar.blockSignals(False)
+
+ self._render_items(items)
+
+ def _render_items(self, items):
+ """Render the clipboard items in the popup using the 'Nuclear Swap' method."""
+ # Create a brand new container instead of clearing the old one.
+ # This is the most robust way to ensure no "stacking" occurs.
+ new_container = QFrame()
+ new_container.setProperty("class", "clipboard-scroll-content")
+ container_layout = QVBoxLayout(new_container)
+ container_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ container_layout.setContentsMargins(0, 0, 0, 0)
+ container_layout.setSpacing(0)
+ container_layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetMinimumSize)
+
+ if not items:
+ msg = self._status_message if self._status_message else "No items match your search."
+ lbl = QLabel(msg)
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ lbl.setWordWrap(True)
+ lbl.setProperty("class", "status-message error" if self._status_message else "status-message empty")
+ container_layout.addWidget(lbl)
+ else:
+ max_item_len = self._menu_config.max_item_length
+ for item in items:
+ # Create item container with horizontal layout
+ item_container = QFrame()
+ item_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ item_container.setProperty("class", "clipboard-item")
+ item_layout = QHBoxLayout(item_container)
+ item_layout.setContentsMargins(0, 0, 0, 0)
+ item_layout.setSpacing(0)
+
+ # Content area (button + optional info)
+ content_area = QFrame()
+ content_area.setProperty("class", "clipboard-item-content")
+ content_layout = QHBoxLayout(content_area)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(0)
+
+ btn = QPushButton()
+
+ 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.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ btn.setProperty("class", "clipboard-item-btn text-item")
+ if self._menu_config.tooltip_enabled:
+ # Wrap long text to prevent tooltip from spanning screens, limit to 750 chars
+ max_chars = 750
+ display_text = item["data"][:max_chars] + ("..." if len(item["data"]) > max_chars else "")
+ wrapped_text = "
".join(textwrap.wrap(display_text, width=50))
+ set_tooltip(
+ item_container,
+ wrapped_text,
+ delay=self._menu_config.tooltip_delay,
+ position=self._menu_config.tooltip_position,
+ )
+ else:
+ # Show thumbnail OR replacement text based on config
+ pixmap = item["data"]
+ btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ btn.setProperty("class", "clipboard-item-btn image-item")
+ if self._menu_config.show_image_thumbnail:
+ btn.setIcon(QIcon(pixmap))
+ btn.setIconSize(pixmap.size().scaled(200, 80, Qt.AspectRatioMode.KeepAspectRatio))
+ btn.setText("")
+ else:
+ btn.setText(f" {self._menu_config.image_replacement_text}")
+
+ btn.clicked.connect(lambda _, i=item: self._parent_widget.set_system_clipboard(i))
+
+ # Make entire content_area clickable to copy the item
+ def copy_on_click(event, itm=item):
+ self._parent_widget.set_system_clipboard(itm)
+
+ content_area.mousePressEvent = copy_on_click
+
+ # Add image info stacked vertically
+ if item["type"] == "image" and self._menu_config.show_image_list_info:
+ pixmap = item["data"]
+ dims = f"{pixmap.width()} x {pixmap.height()}"
+ size_kb = item.get("size", 0) / 1024
+ timestamp = item.get("timestamp")
+
+ # Create a vertical layout for the info (stacked)
+ info_container = QFrame()
+ info_container.setProperty("class", "clipboard-item-info")
+ info_layout = QVBoxLayout(info_container)
+
+ dims_label = QLabel(dims)
+ dims_label.setProperty("class", "image-list-info")
+ info_layout.addWidget(dims_label)
+
+ size_label = QLabel(f"{size_kb:.1f} KB")
+ size_label.setProperty("class", "image-list-info")
+ info_layout.addWidget(size_label)
+
+ if timestamp:
+ try:
+ date_str = timestamp.astimezone().strftime("%Y-%m-%d %H:%M")
+ date_label = QLabel(date_str)
+ date_label.setProperty("class", "image-list-info")
+ info_layout.addWidget(date_label)
+ except Exception:
+ pass
+
+ # Add widgets in order based on config
+ if self._menu_config.image_info_position == "left":
+ content_layout.addWidget(info_container)
+ content_layout.addWidget(btn)
+ else:
+ content_layout.addWidget(btn)
+ content_layout.addWidget(info_container)
+ else:
+ # Text items or image without info
+ content_layout.addWidget(btn)
+
+ del_btn = QPushButton(self._icons.delete_icon)
+ del_btn.setProperty("class", "delete-button")
+ del_btn.clicked.connect(lambda _, i=item: self._delete_item(i))
+
+ item_layout.addWidget(content_area)
+ item_layout.addWidget(del_btn)
+ item_layout.setAlignment(del_btn, Qt.AlignmentFlag.AlignVCenter)
+
+ container_layout.addWidget(item_container)
+
+ # Refresh styles on all newly created widgets
+ refresh_widget_style(new_container)
+
+ # NUCLEAR SWAP: Swap the old container for the new one
+ old_container = self.scroll.takeWidget()
+ if old_container:
+ old_container.deleteLater()
+
+ self.scroll.setWidget(new_container)
+ # Force a resize update to ensure scrollarea knows about the new content
+ self.scroll.updateGeometry()
+
+ 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
+ try:
+ asyncio.create_task(self._parent_widget._fetch_and_show())
+ except Exception as e:
+ logging.error(f"Error refreshing clipboard after delete: {e}")
+
+ def _filter_items(self, query):
+ """Filter clipboard items based on search query."""
+ filtered = []
+ for i in self._all_items:
+ if i["type"] == "text" and query.lower() in i["data"].lower():
+ filtered.append(i)
+ elif i["type"] == "image" and (not query or query.lower() in "image"):
+ 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,
+ )