diff --git a/docs/widgets/(Widget)-Update-Check.md b/docs/widgets/(Widget)-Update-Check.md index 38f711831..055359fac 100644 --- a/docs/widgets/(Widget)-Update-Check.md +++ b/docs/widgets/(Widget)-Update-Check.md @@ -18,6 +18,8 @@ This widget checks for available updates using Windows Update, Winget, and Scoop | `tooltip` | boolean | `true` | Whether to show the tooltip on hover. | | `interval` | integer | `1440` | Check interval in minutes (30 to 10080). | | `exclude` | list | `[]` | List of updates to exclude (matched against name). | +| `show_popup_menu` | boolean | `false` | Whether to show a popup menu on left click. This allows the user to choose between opening Windows Update in Settings or executing a command (with a UAC elevation prompt) to directly update Windows Defender Virus' security intelligence updates. | +| `popup_menu_padding` | integer | `8` | Padding (in pixels) around the menu items if `show_popup_menu: true`. (Valid range: 0 - 80) | ### Winget Update Options @@ -92,6 +94,14 @@ update_check: .update-check-widget .widget-container.paired-right {} .update-check-widget .label {} .update-check-widget .icon {} +/* Styles for the new optional Windows Update popup menu */ +.widget-container.windows.menu-popup {} +.widget-container.windows.menu-popup .menu-item {} +.widget-container.windows.menu-popup .menu-item:hover {} +.widget-container.windows.menu-popup .menu-item .icon {} +.widget-container.windows.menu-popup .menu-item .label {} +.widget-container.windows.menu-popup .menu-item:hover .icon {} +.widget-container.windows.menu-popup .menu-item:hover .label {} ``` ### State Classes @@ -168,4 +178,36 @@ Example: font-weight: 600; font-size: 14px; } +/* Styles for the new optional Windows Update popup menu */ +.widget-container.windows.menu-popup { + background-color: var(--surface0); + border-radius: 4px; +} + +.widget-container.windows.menu-popup .menu-item { + background-color: var(--surface1); + border-radius: 4px; + padding: 4px; +} + +.widget-container.windows.menu-popup .menu-item:hover { + background-color: var(--surface2); +} + +.widget-container.windows.menu-popup .menu-item .icon { + color: var(--text); + font-size: 16px; + min-width: 20px; +} + +.widget-container.windows.menu-popup .menu-item .label { + color: var(--text); + font-size: 13px; + font-weight: 400; +} + +.widget-container.windows.menu-popup .menu-item:hover .icon, +.widget-container.windows.menu-popup .menu-item:hover .label { + color: var(--text); +} ``` diff --git a/src/core/validation/widgets/yasb/update_check.py b/src/core/validation/widgets/yasb/update_check.py index 45f38c768..d7d847ed7 100644 --- a/src/core/validation/widgets/yasb/update_check.py +++ b/src/core/validation/widgets/yasb/update_check.py @@ -12,6 +12,8 @@ class UpdateConfig(CustomBaseModel): class WindowsUpdateConfig(UpdateConfig): interval: int = Field(default=1440, ge=30, le=10080) + show_popup_menu: bool = False + popup_menu_padding: int = Field(default=8, ge=0, le=80) class WingetUpdateConfig(UpdateConfig): diff --git a/src/core/widgets/yasb/update_check.py b/src/core/widgets/yasb/update_check.py index 556d413ad..f54c375f6 100644 --- a/src/core/widgets/yasb/update_check.py +++ b/src/core/widgets/yasb/update_check.py @@ -1,14 +1,21 @@ +import logging +import os import re +from ctypes import windll from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel +from PyQt6.QtGui import QCursor, QPixmap +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout from core.utils.tooltip import set_tooltip -from core.utils.utilities import add_shadow, refresh_widget_style +from core.utils.utilities import PopupWidget, add_shadow, is_valid_qobject, refresh_widget_style +from core.utils.widgets.animation_manager import AnimationManager from core.utils.widgets.update_check.service import UpdateCheckService from core.validation.widgets.yasb.update_check import UpdateCheckWidgetConfig from core.widgets.base import BaseWidget +logger = logging.getLogger("UpdateCheckWidget") + # Sources and their config attribute names _SOURCES = ("winget", "scoop", "windows") @@ -24,12 +31,37 @@ def __init__(self, config: UpdateCheckWidgetConfig): self._label_widgets: dict[str, list[QLabel]] = {} self._counts: dict[str, int] = {} + # Create a variable for the popup + self._popup = None + + # Variables in the WindowsUpdateConfig that will be populated via "for source in _SOURCES" below + self._show_popup_menu = False + self._popup_menu_padding = 0 # Default value will be set via the WindowsUpdateConfig + + # These are the two hard-coded menu items to show in the popup - just the metadata - the execution commands happen below + self._popup_menu_items = [ + {"icon": "\ue62a", "launch": "windows_update", "name": "Open Windows Update"}, + {"icon": "\udb84\udfb6", "launch": "update_virus_defs", "name": "Update Virus Definitions"}, + ] + + # Set up defaults for all variables that are used for the popup but for which we do not yet have configuration / validation schema + self._container_shadow = None + self._blur = False + self._popup_image_icon_size = 16 + self._animation = {"enabled": True, "type": "fadeInOut", "duration": 200} + self._alignment = "center" + self._direction = "down" + self._popup_offset = {"top": 8, "left": 0} + for source in _SOURCES: cfg = getattr(self.config, f"{source}_update", None) if cfg and cfg.enabled: container, widgets = self._create_container(source, cfg.label) self._containers[source] = container self._label_widgets[source] = widgets + if source == "windows": + self._show_popup_menu = cfg.show_popup_menu + self._popup_menu_padding = cfg.popup_menu_padding else: self._containers[source] = None self._label_widgets[source] = [] @@ -171,8 +203,198 @@ def _make_mouse_handler(self, source: str): def handler(event): if event.button() == Qt.MouseButton.LeftButton: - self._service.handle_left_click(source) + if source == "windows": + if self._show_popup_menu == True: + self._toggle_popup() + else: + self._service.handle_left_click("windows") + else: + self._service.handle_left_click(source) elif event.button() == Qt.MouseButton.RightButton: self._service.handle_right_click(source) return handler + + 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, blur=self._blur, round_corners=True, round_corners_type="normal", border_color="None" + ) + self._popup.setProperty("class", "widget-container windows 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_menu_padding, + self._popup_menu_padding, + self._popup_menu_padding, + self._popup_menu_padding, + ) + + # Add menu items directly to the popup layout + if isinstance(self._popup_menu_items, list): + item = self.create_menu_item( + item_data=self._popup_menu_items[0], + parent=self, + icon_size=self._popup_image_icon_size, + animation=self._animation, + bottom_padding=self._popup_menu_padding, + ) + popup_layout.addWidget(item) + + item = self.create_menu_item( + item_data=self._popup_menu_items[1], + parent=self, + icon_size=self._popup_image_icon_size, + animation=self._animation, + bottom_padding=0, + ) + 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 create_menu_item(self, item_data, parent, icon_size, animation, bottom_padding): + if "icon" in item_data and "launch" in item_data: + # Create menu item + item = MenuItemWidget( + parent=parent, + icon=item_data["icon"], + label=item_data.get("name", ""), + launch=item_data["launch"], + icon_size=icon_size, + animation=animation, + bottom_padding=bottom_padding, + ) + return item + + def execute_code(self, data): + """Execute the command associated with a menu item.""" + try: + try: + if data == "windows_update": + self._service.handle_left_click("windows") + elif data == "update_virus_defs": + # Execute the Windows Defender command to update signatures + exePath = r"C:\Program Files\Windows Defender\MpCmdRun.exe" + parameters = r"-SignatureUpdate" + windll.shell32.ShellExecuteW( + None, + "runas", + exePath, + parameters, + None, + 1, + ) + # Run the right-click command too, to tell the service to clear and refresh + self._service.handle_right_click("windows") + + except Exception as e: + logger.error(f"Error executing popup menu choice: {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: + logger.error(f'Exception occurred: {str(e)} "{data}"') + + +class MenuItemWidget(QFrame): + """A single menu item in the popup.""" + + def __init__(self, parent, icon, label, launch, icon_size, animation, bottom_padding): + super().__init__() + self.parent_widget = parent + self._launch = launch + self._animation = animation + self._bottom_padding = bottom_padding + + self.setProperty("class", "menu-item") + self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + + self.setStyleSheet(f"QFrame .menu-item {{ margin-bottom: {self._bottom_padding}px; }}") + + # Create layout + layout = QHBoxLayout(self) + layout.setSpacing(8) + layout.setContentsMargins(3, 3, 3, 3) + + # 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) + + 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 a4ec39bb7..c0076f62b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -506,4 +506,38 @@ For more information about configuration options, please visit the Wiki https:// font-weight: 900; color: rgb(255, 255, 255); margin-top: -20px; -} \ No newline at end of file +} + +/* Styles for the new optional Windows Update popup menu */ + +.widget-container.windows.menu-popup { + background-color: var(--surface0); + border-radius: 4px; +} + +.widget-container.windows.menu-popup .menu-item { + background-color: var(--surface1); + border-radius: 4px; + padding: 4px; +} + +.widget-container.windows.menu-popup .menu-item:hover { + background-color: var(--surface2); +} + +.widget-container.windows.menu-popup .menu-item .icon { + color: var(--text); + font-size: 16px; + min-width: 20px; +} + +.widget-container.windows.menu-popup .menu-item .label { + color: var(--text); + font-size: 13px; + font-weight: 400; +} + +.widget-container.windows.menu-popup .menu-item:hover .icon, +.widget-container.windows.menu-popup .menu-item:hover .label { + color: var(--text); +}