diff --git a/README.md b/README.md index be2a4ba8..9d1f3c75 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ https://github.com/user-attachments/assets/aab8d8e8-248f-46a1-919c-9b0601236ac1 - **[Libre Hardware Monitor](https://github.com/amnweb/yasb/wiki/(Widget)-Libre-HW-Monitor)**: Connects to Libre Hardware Monitor to get sensor data. - **[Media](https://github.com/amnweb/yasb/wiki/(Widget)-Media)**: Displays media controls and information. - **[Memory](https://github.com/amnweb/yasb/wiki/(Widget)-Memory)**: Shows current memory usage. +- **[Menu](https://github.com/amnweb/yasb/wiki/(Widget)-Menu)**: A customizable menu widget that displays items in a popup. - **[Microphone](https://github.com/amnweb/yasb/wiki/(Widget)-Microphone)**: Displays the current microphone status. - **[Notifications](https://github.com/amnweb/yasb/wiki/(Widget)-Notifications)**: Shows the number of notifications from Windows. - **[Notes](https://github.com/amnweb/yasb/wiki/(Widget)-Notes)**: A simple notes widget that allows you to add, delete, and view notes. diff --git a/docs/widgets/(Widget)-Menu.md b/docs/widgets/(Widget)-Menu.md new file mode 100644 index 00000000..4f5d2895 --- /dev/null +++ b/docs/widgets/(Widget)-Menu.md @@ -0,0 +1,160 @@ +# Menu Widget Options + +| Option | Type | Default | Description | +|------------|--------|---------|-----------------------------------------------------------------------------| +| `label` | string | `""` | The text label for the menu button. Can be empty if only using an icon. | +| `icon` | string | `""` | The icon for the menu button. Can be a Unicode character, emoji, or path to an image file. Can be empty if only using a label. | +| `class_name` | string | `""` | The CSS class name for styling the widget. Optional. | +| `image_icon_size` | int | `14` | The size of the icon in pixels if the icon is an image (for the button in the bar). | +| `popup_image_icon_size` | int | `16` | The size of the icon in pixels for menu items in the popup. | +| `menu_items` | list | `[]`| Menu items list with icon, launch command, and optional name. | +| `tooltip` | bool | `True`| Enable or disable tooltips. | +| `blur` | bool | `False`| Enable or disable blur effect on the popup window. | +| `alignment` | string | `"left"`| Popup alignment relative to the button: `"left"`, `"right"`, or `"center"`. | +| `direction` | string | `"down"`| Popup direction: `"down"` (below button) or `"up"` (above button). | +| `popup_offset` | dict | `{"top": 0, "left": 0}`| Offset for the popup position in pixels. | +| `animation` | dict | `{'enabled': True, 'type': 'fadeInOut', 'duration': 200}` | Animation settings for menu items. | +| `container_padding` | dict | `{"top": 0, "left": 0, "bottom": 0, "right": 0}`| Padding for the widget container in the bar. | +| `popup_padding` | dict | `{"top": 8, "left": 8, "bottom": 8, "right": 8}`| Padding for the popup window content. | +| `container_shadow` | dict | `None` | Container shadow options. | +| `label_shadow` | dict | `None` | Label shadow options. | + +## Example Configuration + +```yaml +menu: + type: "yasb.menu.MenuWidget" + options: + label: "Menu" + icon: "\uf0c9" # hamburger menu icon + menu_items: + - {icon: "\uf0a2", launch: "notification_center", name: "Notification Center"} + - {icon: "\ueb51", launch: "quick_settings", name: "Quick Settings"} + - {icon: "\uf422", launch: "search", name: "Search"} + - {icon: "\uf489", launch: "wt", name: "Windows Terminal"} + - {icon: "C:\\Users\\marko\\icons\\vscode.png", launch: "C:\\Users\\Username\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe", name: "VS Code"} + - {icon: "\udb81\udc4d", launch: "\"C:\\Program Files\\Mozilla Firefox\\firefox.exe\" -new-tab www.reddit.com", name: "Reddit"} + blur: true + alignment: "left" + direction: "down" + popup_offset: + top: 4 + left: 0 + label_shadow: + enabled: true + color: "black" + radius: 3 + offset: [1, 1] +``` + +## Example with Icon Only + +```yaml +menu_icon_only: + type: "yasb.menu.MenuWidget" + options: + icon: "\uf013" # settings icon + menu_items: + - {icon: "\uf013", launch: "ms-settings:", name: "Settings"} + - {icon: "\uf0c7", launch: "control", name: "Control Panel"} + - {icon: "\uf108", launch: "taskmgr", name: "Task Manager"} + popup_image_icon_size: 20 + blur: true +``` + +## Example with Label Only + +```yaml +menu_label_only: + type: "yasb.menu.MenuWidget" + options: + label: "Apps" + menu_items: + - {icon: "\uf489", launch: "wt", name: "Terminal"} + - {icon: "\uf07c", launch: "explorer", name: "File Explorer"} + - {icon: "\uf108", launch: "notepad", name: "Notepad"} +``` + +## Description of Options + +- **label:** The text label displayed on the menu button in the bar. Can be empty if you only want to show an icon. +- **icon:** The icon displayed on the menu button in the bar. Can be a Unicode character (e.g., `\uf0c9`), emoji, or an image file path (e.g., `C:\\path\\to\\icon.png`). Can be empty if you only want to show a label. +- **class_name:** The CSS class name for styling the widget. Optional. +- **image_icon_size:** The size in pixels of the icon on the menu button (if using an image file). +- **popup_image_icon_size:** The size in pixels of the icons for menu items in the popup window. +- **menu_items:** A list of menu items to display in the popup. Each item is a dictionary with the following keys: + - **icon:** The icon for the menu item. This can be a Unicode character (e.g., `\uf0a2`), an image path (e.g., `C:\\path\\to\\icon.png`), or emoji. + - **launch:** The command to execute when the menu item is clicked. This can include arguments and should be properly quoted if necessary. + - **name:** (Optional) The name of the menu item to display next to the icon and as a tooltip. +- **tooltip:** Enable or disable tooltips when hovering over the menu button and items. +- **blur:** Enable blur effect on the popup window background (Windows 10: Acrylic, Windows 11: Mica). +- **alignment:** How the popup is aligned relative to the menu button. Options: `"left"`, `"right"`, or `"center"`. +- **direction:** Whether the popup appears below (`"down"`) or above (`"up"`) the menu button. +- **popup_offset:** Fine-tune the popup position with pixel offsets: + - **top:** Vertical offset in pixels. + - **left:** Horizontal offset in pixels. +- **animation:** Animation settings when clicking menu items. Contains: + - **enabled:** Enable or disable animation. + - **type:** Animation type (e.g., `fadeInOut`). + - **duration:** Animation duration in milliseconds. +- **container_padding:** Padding around the widget container in the bar. +- **popup_padding:** Padding around the content inside the popup window. +- **container_shadow:** Shadow options for the widget container in the bar. +- **label_shadow:** Shadow options for the menu button label/icon. + +## CSS Styling + +The menu widget can be styled using CSS classes: + +```css +/* Menu button in the bar */ +.menu-widget .widget-container { + background-color: #1e1e1e; + border-radius: 4px; +} + +.menu-widget .icon { + color: #ffffff; + font-size: 14px; +} + +.menu-widget .label { + color: #ffffff; + padding: 0 8px; +} + +/* Popup window */ +.menu-popup { + background-color: #2d2d2d; + border-radius: 8px; + border: 1px solid #404040; +} + +/* Individual menu items */ +.menu-item { + background-color: transparent; + border-radius: 4px; + margin: 2px 0; +} + +.menu-item:hover { + background-color: #3d3d3d; +} + +.menu-item .icon { + color: #ffffff; + font-size: 16px; + min-width: 24px; +} + +.menu-item .label { + color: #ffffff; + font-size: 13px; +} +``` + +> [!NOTE] +> - You must specify at least one of `label` or `icon` for the menu button. You can use both together if desired. +> - Menu items automatically close the popup after being clicked. +> - The popup automatically closes when clicking outside of it. +> - Commands in `launch` support the same function map as the applications widget (e.g., `notification_center`, `quick_settings`, etc.). diff --git a/src/core/validation/widgets/yasb/menu.py b/src/core/validation/widgets/yasb/menu.py new file mode 100644 index 00000000..578b5f6b --- /dev/null +++ b/src/core/validation/widgets/yasb/menu.py @@ -0,0 +1,78 @@ +DEFAULTS = { + "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, + "tooltip": True, + "container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0}, + "popup_padding": {"top": 8, "left": 8, "bottom": 8, "right": 8}, + "popup_offset": {"top": 0, "left": 0}, + "alignment": "left", + "direction": "down", +} + +VALIDATION_SCHEMA = { + "label": {"type": "string", "required": False, "default": ""}, + "icon": {"type": "string", "required": False, "default": ""}, + "class_name": {"type": "string", "required": False, "default": ""}, + "image_icon_size": {"type": "integer", "required": False, "default": 14}, + "popup_image_icon_size": {"type": "integer", "required": False, "default": 16}, + "menu_items": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": { + "icon": {"type": "string"}, + "launch": {"type": "string"}, + "name": {"type": "string", "required": False}, + }, + }, + }, + "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"], + }, + "tooltip": {"type": "boolean", "default": True, "required": False}, + "blur": {"type": "boolean", "default": False, "required": False}, + "alignment": { + "type": "string", + "default": DEFAULTS["alignment"], + "required": False, + "allowed": ["left", "right", "center"], + }, + "direction": { + "type": "string", + "default": DEFAULTS["direction"], + "required": False, + "allowed": ["up", "down"], + }, + "popup_offset": {"type": "dict", "default": DEFAULTS["popup_offset"], "required": False}, + "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}, + }, + "container_padding": {"type": "dict", "default": DEFAULTS["container_padding"], "required": False}, + "popup_padding": {"type": "dict", "default": DEFAULTS["popup_padding"], "required": False}, +} diff --git a/src/core/widgets/yasb/menu.py b/src/core/widgets/yasb/menu.py new file mode 100644 index 00000000..59d3f5e9 --- /dev/null +++ b/src/core/widgets/yasb/menu.py @@ -0,0 +1,292 @@ +import logging +import os +import subprocess + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QCursor, QPixmap +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from core.utils.tooltip import set_tooltip +from core.utils.utilities import PopupWidget, add_shadow, is_valid_qobject +from core.utils.widgets.animation_manager import AnimationManager +from core.utils.win32.system_function import function_map +from core.validation.widgets.yasb.menu import VALIDATION_SCHEMA +from core.widgets.base import BaseWidget + + +class MenuWidget(BaseWidget): + validation_schema = VALIDATION_SCHEMA + + def __init__( + self, + label: str, + class_name: str, + menu_items: list[dict[str, str]], + icon: str, + image_icon_size: int, + popup_image_icon_size: int, + animation: dict[str, str], + tooltip: bool, + container_padding: dict[str, int], + popup_padding: dict[str, int], + blur: bool, + popup_offset: dict[str, int], + alignment: str, + direction: str, + label_shadow: dict | None = None, + container_shadow: dict | None = None, + ): + super().__init__(class_name=f"menu-widget {class_name}") + self._label = label + self._icon = icon + self._menu_items = menu_items + self._padding = container_padding + self._popup_padding = popup_padding + self._image_icon_size = image_icon_size + self._popup_image_icon_size = popup_image_icon_size + self._animation = animation + self._tooltip = tooltip + self._blur = blur + self._popup_offset = popup_offset + self._alignment = alignment + self._direction = direction + self._label_shadow = label_shadow + self._container_shadow = container_shadow + self._popup = None + + # Construct container + 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 + self._widget_container = QFrame() + self._widget_container.setLayout(self._widget_container_layout) + self._widget_container.setProperty("class", "widget-container") + if self._container_shadow is not None: + add_shadow(self._widget_container, self._container_shadow) + + # Create the menu button container + self._button_container = QWidget() + self._button_layout = QHBoxLayout(self._button_container) + self._button_layout.setSpacing(4) + self._button_layout.setContentsMargins(0, 0, 0, 0) + + # Create icon label if icon is provided + if self._icon: + self._icon_label = ClickableLabel(self) + self._icon_label.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + self._icon_label.setProperty("class", "icon") + self._icon_label.clicked.connect(self._toggle_popup) + + if os.path.isfile(self._icon): + pixmap = QPixmap(self._icon).scaled( + self._image_icon_size, + self._image_icon_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self._icon_label.setPixmap(pixmap) + else: + self._icon_label.setText(self._icon) + + if self._label_shadow is not None: + add_shadow(self._icon_label, self._label_shadow) + self._button_layout.addWidget(self._icon_label) + + # Create text label if label is provided + if self._label: + self._text_label = ClickableLabel(self) + self._text_label.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + self._text_label.setProperty("class", "label") + self._text_label.setText(self._label) + self._text_label.clicked.connect(self._toggle_popup) + + if self._label_shadow is not None: + add_shadow(self._text_label, self._label_shadow) + self._button_layout.addWidget(self._text_label) + + # Set tooltip + if self._tooltip: + tooltip_text = self._label if self._label else "Menu" + set_tooltip(self._button_container, tooltip_text, 0) + + # Add button container to widget container + self._widget_container_layout.addWidget(self._button_container) + + # Add the container to the main widget layout + self.widget_layout.addWidget(self._widget_container) + + def _toggle_popup(self): + """Toggle the menu popup visibility.""" + try: + if self._popup and is_valid_qobject(self._popup) and self._popup.isVisible(): + self._popup.hide_animated() + else: + self._show_popup() + except RuntimeError: + # Popup was deleted, create a new one + self._show_popup() + + def _show_popup(self): + """Create and show the popup menu.""" + # Close existing popup if any + try: + if self._popup and is_valid_qobject(self._popup): + self._popup.hide() + except RuntimeError: + pass + + # Create new popup + self._popup = PopupWidget( + parent=self._widget_container, + blur=self._blur, + round_corners=True, + round_corners_type="normal", + border_color="None", + ) + self._popup.setProperty("class", "menu-popup") + # Prevent click-through to windows behind the popup + self._popup.setAttribute(Qt.WidgetAttribute.WA_NoMouseReplay) + + # Create popup layout directly on the PopupWidget (like media.py does) + popup_layout = QVBoxLayout(self._popup) + popup_layout.setSpacing(0) + popup_layout.setContentsMargins( + self._popup_padding["left"], + self._popup_padding["top"], + self._popup_padding["right"], + self._popup_padding["bottom"], + ) + + # Add menu items directly to the popup layout + if isinstance(self._menu_items, list): + for item_data in self._menu_items: + if "icon" in item_data and "launch" in item_data: + # Create menu item + item = MenuItemWidget( + parent=self, + icon=item_data["icon"], + label=item_data.get("name", ""), + launch=item_data["launch"], + icon_size=self._popup_image_icon_size, + animation=self._animation, + tooltip=self._tooltip, + ) + popup_layout.addWidget(item) + + # Adjust popup size to content + self._popup.adjustSize() + + # Force Qt to apply the stylesheet to the popup and its children (guard against None) + popup_style = self._popup.style() + if popup_style is not None: + popup_style.unpolish(self._popup) + popup_style.polish(self._popup) + + popup_content = getattr(self._popup, "_popup_content", None) + if popup_content is not None: + content_style = popup_content.style() + if content_style is not None: + content_style.unpolish(popup_content) + content_style.polish(popup_content) + + # Position and show popup + self._popup.setPosition( + alignment=self._alignment, + direction=self._direction, + offset_left=self._popup_offset["left"], + offset_top=self._popup_offset["top"], + ) + self._popup.show() + + def execute_code(self, data): + """Execute the command associated with a menu item.""" + try: + if data in function_map: + function_map[data]() + else: + try: + if not any(param in data for param in ["-new-tab", "-new-window", "-private-window"]): + data = data.split() + subprocess.Popen(data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True) + except Exception as e: + logging.error(f"Error starting app: {str(e)}") + + # Close popup after executing command + try: + if self._popup and is_valid_qobject(self._popup) and self._popup.isVisible(): + self._popup.hide_animated() + except RuntimeError: + pass + except Exception as e: + logging.error(f'Exception occurred: {str(e)} "{data}"') + + +class ClickableLabel(QLabel): + """A label that emits a signal when clicked.""" + + clicked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.parent_widget = parent + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + + +class MenuItemWidget(QFrame): + """A single menu item in the popup.""" + + def __init__(self, parent, icon, label, launch, icon_size, animation, tooltip): + super().__init__() + self.parent_widget = parent + self._launch = launch + self._animation = animation + + self.setProperty("class", "menu-item") + self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + + # Create layout + layout = QHBoxLayout(self) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Create icon label + self._icon_label = QLabel() + self._icon_label.setProperty("class", "icon") + self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + if os.path.isfile(icon): + pixmap = QPixmap(icon).scaled( + icon_size, + icon_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self._icon_label.setPixmap(pixmap) + else: + self._icon_label.setText(icon) + + layout.addWidget(self._icon_label) + + # Create text label + self._text_label = QLabel(label) + self._text_label.setProperty("class", "label") + layout.addWidget(self._text_label, stretch=1) + + if tooltip and label: + set_tooltip(self, label, 0) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + event.accept() # Accept the event to prevent propagation + if self._animation["enabled"]: + AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) + self.parent_widget.execute_code(self._launch) + else: + super().mousePressEvent(event) diff --git a/src/styles.css b/src/styles.css index 4d277d24..d9d855a0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -197,6 +197,42 @@ For more information about configuration options, please visit the Wiki https:// .power-menu-popup .button.cancel.hover .label { color: rgb(255, 255, 255) } + +/* Menu Widget Popup */ +.menu-popup { + background-color: var(--bg-color1); + border-radius: 8px; + padding: 4px; +} + +.menu-popup .menu-item { + background-color: transparent; + border-radius: 4px; + padding: 8px; + margin: 2px 0; +} + +.menu-popup .menu-item:hover { + background-color: var(--bg-color2); +} + +.menu-popup .menu-item .icon { + color: var(--text2); + font-size: 16px; + min-width: 20px; +} + +.menu-popup .menu-item .label { + color: var(--text1); + font-size: 13px; + font-weight: 400; +} + +.menu-popup .menu-item:hover .icon, +.menu-popup .menu-item:hover .label { + color: var(--text0); +} + .uptime { font-size: 14px; margin-bottom: 10px;