diff --git a/.gitignore b/.gitignore index 9830e361e..d75adced7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ dist/ build/ # Temporary files *.tmp -*.temp \ No newline at end of file +*.temp +*.patch +nul + diff --git a/docs/widgets/(Widget)-Overlay-Container.md b/docs/widgets/(Widget)-Overlay-Container.md new file mode 100644 index 000000000..1df60c3f2 --- /dev/null +++ b/docs/widgets/(Widget)-Overlay-Container.md @@ -0,0 +1,457 @@ +# Overlay Container Widget Options + +| Option | Type | Default | Description | +|-----------------|---------|-------------------------------------------------------------------------|-----------------------------------------------------------------------------| +| `target` | string | `"full"` | Target area for overlay: `"full"` (entire bar), `"left"`, `"center"`, `"right"` (bar sections), `"widget"` (specific widget), or `"custom"` (custom position) | +| `target_widget` | string | `""` | Name of specific widget to overlay (when `target: "widget"`) | +| `position` | string | `"behind"` | Z-order position: `"behind"` or `"above"` | +| `offset_x` | integer | `0` | Horizontal offset in pixels | +| `offset_y` | integer | `0` | Vertical offset in pixels | +| `width` | string/integer | `"auto"` | Width: `"auto"` (match target) or pixel value | +| `height` | string/integer | `"auto"` | Height: `"auto"` (match target) or pixel value | +| `opacity` | float | `0.5` | Overlay opacity (0.0-1.0) | +| `pass_through_clicks` | boolean | `true` | Allow mouse clicks to pass through overlay | +| `z_index` | integer | `-1` | Z-index: `-1` (behind), `0` (same level), `1` (front) | +| `child_widget_name` | string | `""` | Name of widget to display in overlay (optional if using background) | +| `show_toggle` | boolean | `false` | Show toggle button in bar | +| `toggle_label` | string | `"\uf06e"` | Toggle button icon/text | +| `auto_show` | boolean | `true` | Show overlay automatically on startup | +| `callbacks` | dict | `{'on_left': 'toggle_overlay', 'on_middle': 'do_nothing', 'on_right': 'do_nothing'}` | Callbacks for mouse events | +| `container_padding` | dict | `{'top': 0, 'left': 0, 'bottom': 0, 'right': 0}` | Padding for toggle container | +| `container_shadow` | dict | See below | Shadow effect for toggle container | +| `label_shadow` | dict | See below | Shadow effect for toggle label | +| `background_media` | dict | See below | Background media (image/GIF/video) options | +| `background_shader` | dict | See below | Background shader (GPU-accelerated) options | + +## Shadow Options +| Option | Type | Default | Description | +|----------------------|---------|------------|--------------------------------------------------------------| +| `enabled` | bool | `false` | Enable shadow effect | +| `color` | string | `"#000000"`| Shadow color (hex or named color) | +| `offset` | list | `[0, 0]` | Shadow offset `[x, y]` in pixels | +| `radius` | int | `0` | Shadow blur radius in pixels | + +## Background Media Options +| Option | Type | Default | Description | +|----------------------|---------|------------|--------------------------------------------------------------| +| `enabled` | bool | `false` | Enable background media | +| `file` | string | `""` | Full path to media file | +| `type` | string | `"auto"` | Media type: `"auto"`, `"image"`, `"animated"`, `"video"` | +| `fit` | string | `"cover"` | Fit mode: `"fill"`, `"contain"`, `"cover"`, `"stretch"`, `"center"`, `"tile"`, `"scale-down"` | +| `opacity` | float | `1.0` | Media opacity (0.0-1.0) | +| `loop` | bool | `true` | Loop animated media/video | +| `muted` | bool | `true` | Mute video audio | +| `playback_rate` | float | `1.0` | Playback speed (0.1-5.0) | +| `volume` | float | `1.0` | Video volume (0.0-1.0) | +| `offset_x` | int | `0` | Widget position offset - moves entire media widget horizontally | +| `offset_y` | int | `0` | Widget position offset - moves entire media widget vertically | +| `alignment` | string | `"center"` | Coarse positioning: `"top-left"`, `"top-center"`, `"top-right"`, `"center-left"`, `"center"`, `"center-right"`, `"bottom-left"`, `"bottom-center"`, `"bottom-right"` | +| `view_offset_x` | int | `0` | Fine-tuning - shifts visible area horizontally (in pixels) | +| `view_offset_y` | int | `0` | Fine-tuning - shifts visible area vertically (in pixels) | +| `css_class` | string | `""` | Custom CSS class for styling (filters, borders, etc.) | + +**Supported Formats:** +- **Images**: PNG, JPG, JPEG, BMP, WEBP, SVG +- **Animated**: GIF, APNG, animated WEBP +- **Video**: MP4, AVI, MOV, WEBM, MKV, M4V, FLV + +## Background Shader Options +| Option | Type | Default | Description | +|----------------------|---------|------------|--------------------------------------------------------------| +| `enabled` | bool | `false` | Enable GPU shader background (requires PyOpenGL) | +| `preset` | string | `"plasma"` | Shader preset: `"plasma"`, `"wave"`, `"ripple"`, `"tunnel"`, `"mandelbrot"`, `"noise"`, `"gradient"`, `"custom"` | +| `custom_vertex_file` | string | `""` | Path to custom vertex shader (GLSL) | +| `custom_fragment_file`| string | `""` | Path to custom fragment shader (GLSL) | +| `speed` | float | `1.0` | Animation speed (0.1-10.0) | +| `scale` | float | `1.0` | Effect scale (0.1-10.0) | +| `opacity` | float | `1.0` | Shader opacity (0.0-1.0) | +| `colors` | list | `[]` | Custom colors for shader (hex strings) | + +> [!NOTE] +> Shader backgrounds require PyOpenGL: `pip install PyOpenGL` + +> [!IMPORTANT] +> Shader has priority over media - only one can be active at a time. + +## Available Callbacks +- `toggle_overlay`: Toggle overlay visibility +- `do_nothing`: No action + +## Example Configuration + +### Basic: Cava Behind Media Widget +```yaml +bars: + primary-bar: + widgets: + left: ["media", "media_overlay"] + +widgets: + media_overlay: + type: "yasb.overlay_container.OverlayContainerWidget" + options: + target: "left" + opacity: 0.3 + pass_through_clicks: true + z_index: -1 + child_widget_name: "cava_background" + auto_show: true + + cava_background: + type: "yasb.cava.CavaWidget" + options: + bar_height: 32 + bar_type: "waves_mirrored" + gradient: 1 + bars_number: 54 + framerate: 60 +``` + +### Video Background with Custom Alignment +```yaml +widgets: + video_overlay: + type: "yasb.overlay_container.OverlayContainerWidget" + options: + target: "full" + child_widget_name: "" + opacity: 0.8 + pass_through_clicks: true + z_index: -1 + background_media: + enabled: true + file: "C:/Users/YourName/Videos/background.mp4" + type: "video" + fit: "cover" + opacity: 0.3 + loop: true + muted: true + alignment: "top-center" # Show top part of video + css_class: "my-video-bg" +``` + +### Image Background with Custom CSS +```yaml +widgets: + image_overlay: + type: "yasb.overlay_container.OverlayContainerWidget" + options: + target: "left" + child_widget_name: "media" + background_media: + enabled: true + file: "C:/Users/YourName/Pictures/background.png" + type: "image" + fit: "cover" + opacity: 0.5 + alignment: "bottom-center" # Show bottom part of tall image + css_class: "custom-media-bg" +``` + +### Animated Shader Background +```yaml +widgets: + shader_overlay: + type: "yasb.overlay_container.OverlayContainerWidget" + options: + target: "full" + child_widget_name: "cava_widget" + opacity: 0.9 + pass_through_clicks: true + z_index: -1 + background_shader: + enabled: true + preset: "plasma" + speed: 1.5 + scale: 2.0 + opacity: 0.4 + colors: ["#00ffd2", "#f8ef02", "#ff003c"] +``` + +### Toggle Button with Shadows +```yaml +widgets: + toggle_overlay: + type: "yasb.overlay_container.OverlayContainerWidget" + options: + target: "left" + child_widget_name: "cava_widget" + show_toggle: true + toggle_label: "\uf06e" + auto_show: false + container_shadow: + enabled: true + color: "#000000AA" + offset: [2, 2] + radius: 8 + label_shadow: + enabled: true + color: "#00ffd2" + offset: [0, 0] + radius: 10 +``` + +### Target Specific Widget +```yaml +widgets: + widget_overlay: + type: "yasb.overlay_container.OverlayContainerWidget" + options: + target: "widget" + target_widget: "media" + child_widget_name: "cava_widget" + opacity: 0.3 + pass_through_clicks: true + z_index: -1 +``` + +## Styling + +The widget can be styled using CSS: + +```css +/* Toggle button container */ +.overlay-container-widget .toggle-container { + background: transparent; + padding: 0; +} + +/* Toggle button */ +.overlay-container-widget .toggle-button { + color: #00ffd2; + padding: 0px 4px; + font-size: 14px; +} + +.overlay-container-widget .toggle-button:hover { + color: #f8ef02; +} + +.overlay-container-widget .toggle-button.active { + color: #ff003c; +} + +/* Overlay panel */ +.overlay-panel { + background: transparent; +} + +/* Child widget inside overlay */ +.overlay-panel .cava-widget { + background: transparent; +} + +/* Background media with custom class */ +.overlay-background-media.my-video-bg { + border-radius: 8px; + /* Add any custom styling */ +} + +.overlay-background-media.custom-media-bg { + filter: blur(2px); + /* Apply filters or transformations */ +} +``` + +## Description of Options +- **target:** Target area for overlay. Use `"full"` for entire bar, `"left"/"center"/"right"` for bar sections, `"widget"` for specific widget, or `"custom"` for custom position. +- **target_widget:** Name of specific widget to overlay (only used when `target: "widget"`). +- **position:** Z-order position relative to bar widgets. Use `"behind"` to place overlay behind widgets or `"above"` to place it in front. +- **offset_x/offset_y:** Fine-tune overlay position with pixel offsets. +- **width/height:** Overlay dimensions. Use `"auto"` to match target size or specify pixel values. +- **opacity:** Overall overlay transparency. 0.0 is invisible, 1.0 is fully opaque. Recommended: 0.3-0.5 for backgrounds. +- **pass_through_clicks:** Critical for interactive widgets! Set to `true` when overlay covers clickable widgets to allow clicks to pass through. +- **z_index:** Fine control over stacking order. `-1` places behind widgets, `0` at same level, `1` in front. +- **child_widget_name:** Name of widget to display in overlay. Can be empty if using only background media/shader. +- **show_toggle:** Show a toggle button in the bar to show/hide the overlay. +- **toggle_label:** Icon or text for the toggle button (supports Font Awesome icons). +- **auto_show:** Automatically show overlay on startup. Set to `false` if you want manual control via toggle button. +- **callbacks:** Mouse event handlers. Default left click toggles overlay visibility. +- **container_padding:** Padding around toggle button container. +- **container_shadow:** Shadow effect for toggle button container. Useful for making button stand out. +- **label_shadow:** Shadow effect for toggle button label. Can create glow effects. +- **background_media:** Display images, GIFs, or videos as overlay background. Supports various fit modes and playback controls. +- **background_media.alignment:** Controls which part of the media is visible when it exceeds the overlay size. For example, `"top-center"` shows the top part of a tall image, while `"bottom-center"` shows the bottom part. +- **background_media.css_class:** Custom CSS class applied to the media widget for advanced styling via CSS (filters, transforms, borders, etc.). +- **background_shader:** GPU-accelerated animated backgrounds using GLSL shaders. Includes 7 presets or load custom shaders. + +> [!NOTE] +> When using Cava widget with `target: "full"`, bars_number is automatically limited to prevent lag (100 for waves_mirrored, 150 for waves). + +## Available CSS Classes +```css +/* Toggle button container */ +.overlay-container-widget .toggle-container { } + +/* Toggle button */ +.overlay-container-widget .toggle-button { } +.overlay-container-widget .toggle-button:hover { } +.overlay-container-widget .toggle-button.active { } + +/* Overlay panel */ +.overlay-panel { } + +/* Background media (with optional custom class) */ +.overlay-background-media { } +.overlay-background-media.your-custom-class { } + +/* Child widget inside overlay (example with cava) */ +.overlay-panel .cava-widget { } +.overlay-panel .cava-widget .widget-container { } +``` + +## Advanced Media Positioning + +Beyond the `alignment` property for coarse positioning, you have **full control** over the visible area of media through the `view_offset_x` and `view_offset_y` properties. + +### Fine Control: view_offset_x and view_offset_y + +These properties allow pixel-perfect control over which part of the media is visible: + +**Config:** +```yaml +background_media: + file: "tall_image.png" + fit: "cover" + alignment: "top-center" # Coarse positioning + view_offset_x: -50 # Shift view 50px left + view_offset_y: -100 # Shift view 100px up +``` + +### Workflow + +1. **Use `alignment`** for initial positioning (e.g., `"top-center"`) +2. **Use `view_offset_x/y`** for precise pixel adjustments +3. **Use `css_class`** for visual styling (filters, borders, etc.) +4. **Iterate** by modifying values until desired result + +### Example: Tall Image + +**Scenario:** 1000px tall image, 32px bar, want to show the part at 300px from top. + +**Config:** +```yaml +background_media: + file: "tall_image.png" + fit: "cover" + alignment: "top-center" + view_offset_y: -300 # Shift view 300px up + css_class: "custom-media" +``` + +**CSS (optional for styling):** +```css +.overlay-background-media.custom-media { + /* Visual filters */ + filter: blur(2px) brightness(0.8); + + /* Borders and shadows */ + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} +``` + +### Difference between offset_x/y and view_offset_x/y + +- **`offset_x/y`**: Moves the entire media widget (changes widget position) +- **`view_offset_x/y`**: Shifts the visible area within the media (changes which part is visible) + +### Supported CSS Properties + +```css +.overlay-background-media.my-class { + /* Visual filters */ + filter: blur(3px) brightness(0.8) contrast(1.2) grayscale(30%); + + /* Borders and shadows */ + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + + /* Additional opacity */ + opacity: 0.8; +} +``` + +**Note:** Qt CSS (QSS) does not support `transform`, so use `view_offset_x/y` for precise positioning. + +## Example Styling +```css +/* Toggle button container */ +.overlay-container-widget .toggle-container { + background: transparent; + padding: 0; +} + +/* Toggle button - normal state */ +.overlay-container-widget .toggle-button { + color: #00ffd2; + padding: 0px 4px; + font-size: 14px; +} + +/* Toggle button - hover */ +.overlay-container-widget .toggle-button:hover { + color: #f8ef02; +} + +/* Toggle button - active (overlay visible) */ +.overlay-container-widget .toggle-button.active { + color: #ff003c; +} + +/* Overlay panel */ +.overlay-panel { + background: transparent; +} + +/* Child widget inside overlay */ +.overlay-panel .cava-widget { + background: transparent; + max-height: 32px; +} + +.overlay-panel .cava-widget .widget-container { + background: transparent; +} + +/* Custom media styling */ +.overlay-background-media.blurred { + filter: blur(3px); +} + +.overlay-background-media.rounded { + border-radius: 12px; +} + +.overlay-background-media.grayscale { + filter: grayscale(100%); +} +``` + +## Troubleshooting + +**Overlay doesn't appear:** +- Check `child_widget_name` is configured or background is enabled +- Verify `auto_show: true` or click toggle button +- Increase `opacity` to `1.0` for testing +- Check logs for errors + +**Can't click underlying widgets:** +- Set `pass_through_clicks: true` +- Ensure `z_index: -1` + +**Shader not working:** +- Install PyOpenGL: `pip install PyOpenGL` +- Check logs for OpenGL errors +- Verify GPU supports OpenGL 3.3+ + +**Video/GIF not playing:** +- Verify file path is correct and absolute +- Check file format is supported +- Ensure file is not corrupted +- Check logs for media loading errors + +**Media alignment not working as expected:** +- Try different alignment values (`"top-center"`, `"bottom-center"`, etc.) +- Ensure `fit` mode is set appropriately (`"cover"` works best with alignment) +- Use `offset_x`/`offset_y` for fine-tuning after setting alignment diff --git a/src/core/utils/tooltip.py b/src/core/utils/tooltip.py index 253ec6e9a..bee4ec4df 100644 --- a/src/core/utils/tooltip.py +++ b/src/core/utils/tooltip.py @@ -320,16 +320,38 @@ def __init__(self, widget, tooltip_text, delay: int, position=None, parent=None) def cleanup(self): """Clean up resources when the event filter is no longer needed.""" - self.hide_timer.stop() - self.poll_timer.stop() - self.hover_timer.stop() + try: + self.hide_timer.stop() + except (RuntimeError, AttributeError): + pass + + try: + self.poll_timer.stop() + except (RuntimeError, AttributeError): + pass + + try: + self.hover_timer.stop() + except (RuntimeError, AttributeError): + pass if self._app_event_filter_installed: - QGuiApplication.instance().removeEventFilter(self) - self._app_event_filter_installed = False - - if self.tooltip and self.tooltip.isVisible(): - self.tooltip.start_fade_out() + try: + app = QGuiApplication.instance() + if app is not None: + app.removeEventFilter(self) + except (RuntimeError, AttributeError): + # Application already destroyed or filter already removed + pass + finally: + self._app_event_filter_installed = False + + if self.tooltip: + try: + if self.tooltip.isVisible(): + self.tooltip.start_fade_out() + except (RuntimeError, AttributeError): + pass self.tooltip = None def _on_hover_timer(self): @@ -374,10 +396,20 @@ def _hide_tooltip(self): if self.tooltip and self.tooltip.isVisible(): self.tooltip.start_fade_out() if self._app_event_filter_installed: - QGuiApplication.instance().removeEventFilter(self) - self._app_event_filter_installed = False + try: + app = QGuiApplication.instance() + if app is not None: + app.removeEventFilter(self) + except (RuntimeError, AttributeError): + # Application already destroyed or filter already removed + pass + finally: + self._app_event_filter_installed = False self._mouse_inside = False - self.poll_timer.stop() + try: + self.poll_timer.stop() + except (RuntimeError, AttributeError): + pass # Clear reference to tooltip so it can be returned to pool self.tooltip = None diff --git a/src/core/utils/utilities.py b/src/core/utils/utilities.py index c56a567e0..d8ba571d3 100644 --- a/src/core/utils/utilities.py +++ b/src/core/utils/utilities.py @@ -11,18 +11,31 @@ import psutil from PyQt6 import sip -from PyQt6.QtCore import QEvent, QObject, QPoint, QPropertyAnimation, QRect, QSize, Qt, QTimer, pyqtSlot +from PyQt6.QtCore import QEasingCurve, QEvent, QObject, QPoint, QPropertyAnimation, QRect, QSize, Qt, QTimer, QVariantAnimation, pyqtProperty, pyqtSlot from PyQt6.QtGui import ( QColor, QFontMetrics, QPainter, QPaintEvent, + QPixmap, QResizeEvent, QScreen, QStaticText, QTransform, ) -from PyQt6.QtWidgets import QApplication, QDialog, QFrame, QGraphicsDropShadowEffect, QLabel, QMenu, QWidget +from PyQt6.QtOpenGLWidgets import QOpenGLWidget +from PyQt6.QtWidgets import ( + QApplication, + QDialog, + QFrame, + QGraphicsDropShadowEffect, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsView, + QLabel, + QMenu, + QWidget, +) from winrt.windows.data.xml.dom import XmlDocument from winrt.windows.ui.notifications import ToastNotification, ToastNotificationManager @@ -533,7 +546,13 @@ def set_auto_close_enabled(self, enabled: bool): def hideEvent(self, event): if self._is_closing: - QApplication.instance().removeEventFilter(self) + try: + app = QApplication.instance() + if app is not None: + app.removeEventFilter(self) + except (RuntimeError, AttributeError): + # Application already destroyed or filter already removed + pass try: # Restart autohide timer if applicable @@ -671,13 +690,37 @@ def __init__( self._raw_text = text self._text = "" # Will be built by _build_text_and_metrics - # Initialize metrics and text + # Initialize font metrics self._font_metrics = QFontMetrics(self.font()) + + # For throttling update calls (smooth animation optimization) + self._last_painted_offset = 0 + + # Use QVariantAnimation for smooth scrolling (left/right styles) + # Use QTimer for bounce styles (more complex logic) + if self._style in {self.Style.SCROLL_LEFT, self.Style.SCROLL_RIGHT}: + self._scroll_animation = QVariantAnimation(self) + self._scroll_animation.valueChanged.connect(self._on_animation_value_changed) + self._scroll_animation.setLoopCount(-1) # Infinite loop + self._scroll_animation.setEasingCurve(QEasingCurve.Type.Linear) # Constant speed + self._scroll_timer = None + else: + self._scroll_timer = QTimer(self) + self._scroll_timer.timeout.connect(self._scroll_text) + self._scroll_timer.start(self._update_interval) + self._scroll_animation = None + + # Build text and metrics AFTER creating animation/timer self._build_text_and_metrics() - self._scroll_timer = QTimer(self) - self._scroll_timer.timeout.connect(self._scroll_text) - self._scroll_timer.start(self._update_interval) + # Enable widget optimizations for smoother rendering + if self._scroll_animation: + # Opaque paint - no background clearing needed (faster) + self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True) + # No system background - we draw everything (faster) + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True) + # Static contents - hint for compositor optimization + self.setAttribute(Qt.WidgetAttribute.WA_StaticContents, False) def _ease(self, offset: int, max_offset: int, slope: int = 20, pos: float = 0.8, min_value: float = 0.5) -> float: """ @@ -689,16 +732,61 @@ def _ease(self, offset: int, max_offset: int, slope: int = 20, pos: float = 0.8, x = abs(2 * (offset / max_offset) - 1 if max_offset else 0) return (1 + math.tanh(-slope * (x - pos))) * (1 - min_value) / 2 + min_value + def _on_animation_value_changed(self, value): + """Callback for QVariantAnimation - updates offset and triggers repaint""" + # Keep float precision to avoid rounding stutters + self._offset = value + + # Throttle updates: only repaint when movement is visually significant (≥0.5px) + # This drastically reduces repaint calls while maintaining smooth appearance + if self.isVisible() and abs(self._offset - self._last_painted_offset) >= 0.5: + self._last_painted_offset = self._offset + self.update() + + def _render_text_to_pixmap(self): + """Pre-render text to pixmap for ultra-smooth scrolling (cache optimization)""" + # Calculate pixmap size - needs to fit repeated text for seamless loop + pixmap_width = self._text_width * 2 # Double width for seamless scrolling + pixmap_height = self.height() + + # Create high-DPI pixmap for crisp rendering + device_pixel_ratio = self.devicePixelRatio() + self._text_pixmap = QPixmap(int(pixmap_width * device_pixel_ratio), int(pixmap_height * device_pixel_ratio)) + self._text_pixmap.setDevicePixelRatio(device_pixel_ratio) + self._text_pixmap.fill(Qt.GlobalColor.transparent) + + # Render text to pixmap + painter = QPainter(self._text_pixmap) + painter.setFont(self.font()) + painter.setPen(self.palette().color(self.foregroundRole())) + + # Enable antialiasing for smooth text + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + + # Draw text twice for seamless loop + text_y = self._text_y - self._font_metrics.ascent() + painter.drawStaticText(0, text_y, self._static_text) + painter.drawStaticText(self._text_width, text_y, self._static_text) + painter.end() + @override def setText(self, a0: str | None): super().setText(a0) self._offset = 0 self._raw_text = a0 or "" + # Stop animation if running + if self._scroll_animation and self._scroll_animation.state() == QVariantAnimation.State.Running: + self._scroll_animation.stop() + # Re-build text, re-calculate metrics, and check for scrolling self._build_text_and_metrics() + # Update offset immediately based on new state - self._scroll_text() + if self._scroll_timer: + self._scroll_text() def _build_text_and_metrics(self): """ @@ -742,9 +830,34 @@ def _build_text_and_metrics(self): self._text_bb_width = self._font_metrics.boundingRect(self._text).width() self._text_y = (self.height() + self._font_metrics.ascent() - self._font_metrics.descent() + 1) // 2 + # Pre-render text to pixmap for smooth animation (cache optimization) + if self._scroll_animation and self._scrolling_needed: + self._render_text_to_pixmap() + if self._max_width: self.setMaximumWidth(self._font_metrics.averageCharWidth() * self._max_width) + # Configure and start animation for left/right styles + if self._scroll_animation: + if self._scrolling_needed: + # Calculate scroll speed: pixels per second + # We want consistent speed regardless of text length + pixels_per_second = 40 # Slower = smoother (less frame pressure) + duration_ms = int((self._text_width / pixels_per_second) * 1000) + + self._scroll_animation.setStartValue(0) + self._scroll_animation.setEndValue(self._text_width) + self._scroll_animation.setDuration(duration_ms) + + if self._scroll_animation.state() != QVariantAnimation.State.Running: + self._scroll_animation.start() + else: + # Stop animation if scrolling not needed + if self._scroll_animation.state() == QVariantAnimation.State.Running: + self._scroll_animation.stop() + self._offset = 0 + self.update() + @pyqtSlot() def _scroll_text(self): """Update the offset based on the state calculated in _build_text_and_metrics()""" @@ -790,6 +903,15 @@ def _scroll_text(self): def paintEvent(self, a0: QPaintEvent | None): painter = QPainter(self) + # Enable all rendering hints for maximum smoothness + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) + + # Clear background for opaque paint (required with WA_OpaquePaintEvent) + if self.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent): + painter.fillRect(self.rect(), self.palette().color(self.backgroundRole())) + content_rect = QRect( self._margin.left(), self._margin.top(), @@ -802,22 +924,21 @@ def paintEvent(self, a0: QPaintEvent | None): text_y = self._text_y - self._font_metrics.ascent() if self._style == ScrollingLabel.Style.SCROLL_LEFT: - if self._scrolling_needed: - extra_text = x - self._text_width - painter.drawStaticText(extra_text, text_y, self._static_text) - while x < self._margin.left() + content_rect.width(): - painter.drawStaticText(x, text_y, self._static_text) - x += self._text_width + if self._scrolling_needed and hasattr(self, '_text_pixmap'): + # Use cached pixmap for ultra-smooth scrolling (10-100x faster!) + # Calculate source rect from pixmap (wraps around seamlessly) + offset_in_pixmap = self._offset % self._text_width + source_rect = QRect(int(offset_in_pixmap), 0, content_rect.width(), content_rect.height()) + painter.drawPixmap(content_rect, self._text_pixmap, source_rect) else: painter.drawStaticText(self._margin.left(), text_y, self._static_text) elif self._style == ScrollingLabel.Style.SCROLL_RIGHT: - if self._scrolling_needed: - extra_text = x + self._text_width - painter.drawStaticText(extra_text, text_y, self._static_text) - while x > self._margin.left() - self._text_width: - painter.drawStaticText(x, text_y, self._static_text) - x -= self._text_width + if self._scrolling_needed and hasattr(self, '_text_pixmap'): + # Use cached pixmap for ultra-smooth scrolling + offset_in_pixmap = (-self._offset) % self._text_width + source_rect = QRect(int(offset_in_pixmap), 0, content_rect.width(), content_rect.height()) + painter.drawPixmap(content_rect, self._text_pixmap, source_rect) else: painter.drawStaticText(self._margin.left(), text_y, self._static_text) @@ -845,21 +966,18 @@ def resizeEvent(self, a0: QResizeEvent | None): super().resizeEvent(a0) # Re-build text, re-calculate metrics, and check for scrolling self._build_text_and_metrics() - # Update offset immediately based on new state - self._scroll_text() + # Update offset immediately based on new state (only for timer-based scrolling) + if self._scroll_timer: + self._scroll_text() class Singleton(type): - """Singleton metaclass for regular python classes""" + _instances = {} - _instances: dict[Any, Any] = {} - _lock = Lock() - - def __call__(cls, *args: Any, **kwargs: Any): - with cls._lock: - if cls not in cls._instances: - cls._instances[cls] = super().__call__(*args, **kwargs) - return cls._instances[cls] + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] class QSingleton(type(QObject)): diff --git a/src/core/utils/widgets/media/media.py b/src/core/utils/widgets/media/media.py index 69186f7b5..a89e898e3 100644 --- a/src/core/utils/widgets/media/media.py +++ b/src/core/utils/widgets/media/media.py @@ -1,12 +1,15 @@ import asyncio +import ctypes import io import logging +import re import time from functools import partial from typing import Any, Callable from PIL import Image -from PyQt6.QtCore import QObject, pyqtSignal +from pycaw.pycaw import AudioUtilities +from PyQt6.QtCore import QObject, QTimer, pyqtSignal from qasync import asyncSlot # type: ignore from winrt.windows.media.control import ( GlobalSystemMediaTransportControlsSession, @@ -26,6 +29,78 @@ logger = logging.getLogger("WindowsMedia") REFRESH_INTERVAL = 0.1 +FALLBACK_CHECK_INTERVAL = 0.25 # 250ms for responsive fallback updates + +# Virtual Key Codes for media controls +VK_MEDIA_PLAY_PAUSE = 0xB3 +VK_MEDIA_PREV_TRACK = 0xB1 +VK_MEDIA_NEXT_TRACK = 0xB0 + +# Windows constants for SendInput +KEYEVENTF_EXTENDEDKEY = 0x0001 +KEYEVENTF_KEYUP = 0x0002 +INPUT_KEYBOARD = 1 + +# Windows constants for WM_APPCOMMAND (more reliable than SendInput for media keys) +WM_APPCOMMAND = 0x319 +APPCOMMAND_MEDIA_PLAY_PAUSE = 14 +APPCOMMAND_MEDIA_NEXTTRACK = 11 +APPCOMMAND_MEDIA_PREVIOUSTRACK = 12 +HWND_BROADCAST = 0xFFFF + + +# Define INPUT structure for SendInput +class KEYBDINPUT(ctypes.Structure): + _fields_ = [ + ("wVk", ctypes.c_ushort), + ("wScan", ctypes.c_ushort), + ("dwFlags", ctypes.c_ulong), + ("time", ctypes.c_ulong), + ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), + ] + + +class INPUT(ctypes.Structure): + class _INPUT(ctypes.Union): + _fields_ = [("ki", KEYBDINPUT)] + + _anonymous_ = ("_input",) + _fields_ = [("type", ctypes.c_ulong), ("_input", _INPUT)] + + +# Mock classes for fallback mode +class MockMediaControls: + """Mock controls object for fallback mode - all controls enabled""" + + def __init__(self): + self.is_play_pause_toggle_enabled = True + self.is_previous_enabled = True + self.is_next_enabled = True + self.is_playback_position_enabled = False # No seeking in fallback mode + + +class MockPlaybackInfo: + """Mock playback info for fallback mode""" + + def __init__(self, initial_playing=False): + # 3 = Paused, 4 = Playing + self.playback_status = 4 if initial_playing else 3 + self.playback_rate = 1.0 + self.controls = MockMediaControls() + + def toggle_playback_status(self): + """Toggle between playing and paused""" + if self.playback_status == 3: # Was paused + self.playback_status = 4 # Now playing + else: # Was playing + self.playback_status = 3 # Now paused + + +class MockSession: + """Mock session object for fallback mode""" + + def __init__(self, app_id: str): + self.source_app_user_model_id = app_id class SessionState: @@ -66,6 +141,14 @@ def __init__(self): self._trackers: dict[str, SessionState] = {} self._current_session_id: str = "" + # Fallback mode state + self._fallback_mode = False + self._fallback_timer: QTimer | None = None + self._fallback_app_id = "FallbackMedia" + self._fallback_is_playing = False + self._fallback_title = "" + self._fallback_artist = "" + self._loop.create_task(self.run()) @property @@ -90,12 +173,332 @@ async def run(self): self._interpolate_and_emit(self._trackers) await asyncio.sleep(REFRESH_INTERVAL) except Exception as e: - logger.error(f"Failed to start WindowsMedia worker: {e}", exc_info=True) - self._running = False + logger.warning(f"Windows Media Session Manager unavailable: {e}") + logger.info("Activating fallback mode with direct media controls") + self._activate_fallback_mode() async def stop(self): """Stop the WindowsMedia worker refresh loop""" self._running = False + if self._fallback_timer: + self._fallback_timer.stop() + + def _activate_fallback_mode(self): + """Activate fallback mode using direct audio detection""" + self._fallback_mode = True + logger.info("Fallback mode activated - using direct media key controls") + + # Create fallback session + state = SessionState(self._fallback_app_id) + state.session = MockSession(self._fallback_app_id) + state.playback_info = MockPlaybackInfo(initial_playing=False) + state.is_current = True + self._trackers[self._fallback_app_id] = state + self._current_session_id = self._fallback_app_id + + # Start fallback update timer + self._fallback_timer = QTimer(self) # Set parent to self + self._fallback_timer.timeout.connect(self._check_fallback_media_state) + interval_ms = int(FALLBACK_CHECK_INTERVAL * 1000) + self._fallback_timer.start(interval_ms) + logger.info(f"Fallback timer started with interval {interval_ms}ms") + + # Emit initial signals + self.media_data_changed.emit(self._trackers) + self.current_session_changed.emit() + + # Do an immediate check + logger.info("Running initial fallback media state check") + self._check_fallback_media_state() + + def _check_fallback_media_state(self): + """Check for media playback in fallback mode using audio detection""" + logger.debug("_check_fallback_media_state called") + if not self._fallback_mode: + logger.debug("Not in fallback mode, skipping check") + return + + try: + # Whitelist of known desktop media applications + DESKTOP_MEDIA_APPS = [ + "spotify.exe", "vlc.exe", "wmplayer.exe", "groove.exe", + "itunes.exe", "musicbee.exe", "foobar2000.exe", "aimp.exe", + "winamp.exe", "mediaplayer.exe", "potplayer.exe", + "mpc-hc64.exe", "mpc-hc.exe", "clementine.exe", "audacious.exe" + ] + + # Get all audio sessions + sessions = AudioUtilities.GetAllSessions() + logger.debug(f"Found {len(sessions)} audio sessions") + has_media = False + current_app_name = None + is_playing = False + + # First pass: check for active audio sessions from whitelisted apps (playing) + for session in sessions: + if session.Process and session.Process.name(): + app_name = session.Process.name() + + # Only check whitelisted media apps + if app_name.lower() not in [app.lower() for app in DESKTOP_MEDIA_APPS]: + continue + + peak = self._get_audio_peak(session) + + if peak and peak > 0.01: # Audio is playing + # Try to parse window title to see if this is actually a media player + title, artist = self._parse_window_title_for_app(app_name) + logger.debug(f"Session with audio: {app_name}, peak: {peak}, title: '{title}', artist: '{artist}'") + + if title or artist: # Valid media information found + has_media = True + current_app_name = app_name + is_playing = True + logger.info(f"Active media detected: {app_name} - {artist} - {title}") + break + else: + logger.debug(f"Skipping {app_name} - has audio but no media info in window title") + + # Second pass: if no active audio, check whitelisted media players (paused state) + if not has_media: + logger.debug("First pass found no media, checking for paused media players from whitelist") + import psutil + + # Only check whitelisted media apps + for proc in psutil.process_iter(['name']): + try: + app_name = proc.info['name'] + if not app_name: + continue + + # Only check whitelisted media apps + if app_name.lower() not in [app.lower() for app in DESKTOP_MEDIA_APPS]: + continue + + logger.debug(f"Checking whitelisted process: {app_name}") + title, artist = self._parse_window_title_for_app(app_name) + logger.debug(f" -> title: '{title}', artist: '{artist}'") + + if title or artist: # Valid media information found + has_media = True + current_app_name = app_name + is_playing = False + logger.info(f"Paused media detected: {app_name} - {artist} - {title}") + break + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if has_media: + self._update_fallback_state(current_app_name, is_playing=is_playing) + else: + # No media detected at all - clear state if needed + if self._fallback_is_playing or self._fallback_title or self._fallback_artist: + self._update_fallback_state(None, is_playing=False) + + except Exception as e: + logger.debug(f"Error checking fallback media state: {e}") + + def _get_audio_peak(self, session) -> float | None: + """Get audio peak level for a session""" + try: + if hasattr(session, "_ctl") and hasattr(session._ctl, "QueryInterface"): + from ctypes import c_float + from comtypes import COMMETHOD, GUID, HRESULT, IUnknown + from comtypes import POINTER as COM_POINTER + + # Define IAudioMeterInformation interface + class IAudioMeterInformation(IUnknown): + _iid_ = GUID("{C02216F6-8C67-4B5B-9D00-D008E73E0064}") + _methods_ = [ + COMMETHOD([], HRESULT, "GetPeakValue", (["out"], COM_POINTER(c_float), "pfPeak")), + ] + + meter = session._ctl.QueryInterface(IAudioMeterInformation) + peak = meter.GetPeakValue() + return peak + else: + logger.debug(f"Session missing _ctl or QueryInterface") + return None + except Exception as e: + logger.debug(f"Error getting audio peak: {e}") + return None + + def _update_fallback_state(self, app_name: str | None, is_playing: bool): + """Update fallback session state""" + state = self._trackers.get(self._fallback_app_id) + if not state: + return + + # Update playing state + prev_playing = self._fallback_is_playing + self._fallback_is_playing = is_playing + + if prev_playing != is_playing: + logger.debug(f"Fallback playback state changed: {prev_playing} -> {is_playing}") + + if state.playback_info: + state.is_playing = is_playing + state.playback_info.playback_status = 4 if is_playing else 3 + + # Update track info and session state + state_changed = False + if app_name: + # Media player is open (playing or paused) - update track info + title, artist = self._parse_window_title_for_app(app_name) + if title or artist: + # Check if track info changed + if state.title != title or state.artist != artist: + logger.debug(f"Track info changed: '{state.artist} - {state.title}' -> '{artist} - {title}'") + state_changed = True + state.title = title + state.artist = artist + self._fallback_title = title + self._fallback_artist = artist + state.is_current = True + else: + # No media player - clear track info + state.title = "" + state.artist = "" + self._fallback_title = "" + self._fallback_artist = "" + if state.is_current: # Only if it was current before + state_changed = True + state.is_current = False + + # Emit signals ONLY if state actually changed to avoid unnecessary updates + if prev_playing != is_playing or state_changed: + logger.debug(f"Emitting signals: prev_playing={prev_playing}, is_playing={is_playing}, state_changed={state_changed}") + # In fallback mode, skip playback_info_changed to prevent UI conflicts + self.media_data_changed.emit(self._trackers) + self.media_properties_changed.emit() + + def _parse_window_title_for_app(self, app_name: str) -> tuple[str, str]: + """Parse window title to extract artist and title - prioritizes windows with track info""" + try: + import win32gui + import win32process + + def window_callback(hwnd, windows): + if win32gui.IsWindowVisible(hwnd): + title = win32gui.GetWindowText(hwnd) + if title: + try: + _, pid = win32process.GetWindowThreadProcessId(hwnd) + windows.append((hwnd, title, pid)) + except: + pass + + windows = [] + win32gui.EnumWindows(window_callback, windows) + + # Get process name without .exe + target_process = app_name.lower().replace('.exe', '') + + # Collect all windows belonging to this process + process_windows = [] + for hwnd, title, pid in windows: + try: + import psutil + process = psutil.Process(pid) + window_process = process.name().lower().replace('.exe', '') + + # Check if this window belongs to our target process + if window_process == target_process: + process_windows.append(title) + except: + continue + + if not process_windows: + return "", "" + + # Spotify format: "Artist - Title" + if "spotify" in target_process: + # Prioritize windows with track info (contain " - " and are not generic Spotify titles) + track_windows = [ + t for t in process_windows + if " - " in t and not t.lower() in ["spotify", "spotify premium", "spotify free"] + ] + + if track_windows: + # Return the longest track window (most complete info) + best_title = max(track_windows, key=len) + logger.debug(f"Found Spotify track window: '{best_title}'") + parts = best_title.split(" - ", 1) + if len(parts) == 2: + return parts[1].strip(), parts[0].strip() # title, artist + + # No track windows found, check for generic Spotify window + spotify_windows = [t for t in process_windows if "spotify" in t.lower()] + if spotify_windows: + # Spotify is open but no track - return generic title + best_title = max(spotify_windows, key=len) + process_name = target_process.capitalize() + logger.debug(f"Spotify open but no track: '{best_title}'") + return best_title, process_name + + # Browser format varies - only detect media-playing tabs + if any(browser in target_process for browser in ["chrome", "firefox", "edge", "brave"]): + # Media site patterns (whitelist) + media_patterns = [ + "youtube", "spotify", "soundcloud", "twitch", "netflix", + "amazon prime", "disney+", "apple music", "tidal", + "deezer", "pandora", "bandcamp", "mixcloud" + ] + + # Filter windows that match media patterns and have " - " + media_windows = [ + t for t in process_windows + if " - " in t and any(pattern in t.lower() for pattern in media_patterns) + ] + + if media_windows: + best_title = max(media_windows, key=len) + parts = best_title.split(" - ") + if len(parts) >= 2: + return parts[0].strip(), parts[1].strip() # title, artist + + # Fallback: return longest window title with process name as artist (but not for browsers) + # For browsers, we already checked for media patterns above - don't return non-media tabs + is_browser = any(browser in target_process for browser in ["chrome", "firefox", "edge", "brave"]) + if process_windows and not is_browser: + best_title = max(process_windows, key=len) + process_name = target_process.capitalize() + return best_title, process_name + + except Exception as e: + logger.debug(f"Error parsing window title: {e}") + + return "", "" + + def _send_media_key(self, vk_code: int): + """Send a media command using WM_APPCOMMAND (more reliable than SendInput for UIPI)""" + try: + # Map VK codes to APPCOMMAND constants + appcommand_map = { + VK_MEDIA_PLAY_PAUSE: APPCOMMAND_MEDIA_PLAY_PAUSE, + VK_MEDIA_NEXT_TRACK: APPCOMMAND_MEDIA_NEXTTRACK, + VK_MEDIA_PREV_TRACK: APPCOMMAND_MEDIA_PREVIOUSTRACK, + } + + appcommand = appcommand_map.get(vk_code) + if appcommand is None: + logger.error(f"Unknown media key: {vk_code}") + return + + logger.info(f"Sending WM_APPCOMMAND: {appcommand} (VK: {vk_code:#x})") + + # Use PostMessage instead of SendMessage to avoid blocking/deadlock + # lParam = appcommand << 16 | device << 12 | keys + lParam = appcommand << 16 + result = ctypes.windll.user32.PostMessageW(HWND_BROADCAST, WM_APPCOMMAND, 0, lParam) + + if result: + logger.info("WM_APPCOMMAND posted successfully") + else: + logger.error(f"PostMessage failed, error code: {ctypes.get_last_error()}") + + except Exception as e: + logger.error(f"Failed to send media command {vk_code}: {e}", exc_info=True) async def _refresh_sessions(self, manager: SessionManager): """Refresh session states from the manager""" @@ -306,28 +709,44 @@ def switch_current_session(self, direction: int): async def play_pause(self): """Play/pause the current session""" try: - if self.current_session and self.current_session.session is not None: + if self._fallback_mode: + self._send_media_key(VK_MEDIA_PLAY_PAUSE) + # Toggle manual state tracking in fallback + # The fallback detector will automatically detect the state change + # and emit the necessary signals + if self.current_session and self.current_session.playback_info: + self.current_session.playback_info.toggle_playback_status() + self._fallback_is_playing = not self._fallback_is_playing + elif self.current_session and self.current_session.session is not None: await self.current_session.session.try_toggle_play_pause_async() except Exception as e: - logger.error(f"Error playing/pausing: {e}") + logger.error(f"Error playing/pausing: {e}", exc_info=True) @asyncSlot() async def prev(self): """Skip to previous track""" + logger.info(f"=== prev called! fallback_mode={self._fallback_mode} ===") try: - if self.current_session and self.current_session.session is not None: + if self._fallback_mode: + logger.info("Sending VK_MEDIA_PREV_TRACK key") + self._send_media_key(VK_MEDIA_PREV_TRACK) + elif self.current_session and self.current_session.session is not None: await self.current_session.session.try_skip_previous_async() except Exception as e: - logger.error(f"Error skipping previous: {e}") + logger.error(f"Error skipping previous: {e}", exc_info=True) @asyncSlot() async def next(self): """Skip to next track""" + logger.info(f"=== next called! fallback_mode={self._fallback_mode} ===") try: - if self.current_session and self.current_session.session is not None: + if self._fallback_mode: + logger.info("Sending VK_MEDIA_NEXT_TRACK key") + self._send_media_key(VK_MEDIA_NEXT_TRACK) + elif self.current_session and self.current_session.session is not None: await self.current_session.session.try_skip_next_async() except Exception as e: - logger.error(f"Error skipping next: {e}") + logger.error(f"Error skipping next: {e}", exc_info=True) async def seek_to_position(self, position: float): """Seek to specific position in seconds.""" diff --git a/src/core/utils/widgets/overlay_container/__init__.py b/src/core/utils/widgets/overlay_container/__init__.py new file mode 100644 index 000000000..7f9cc9014 --- /dev/null +++ b/src/core/utils/widgets/overlay_container/__init__.py @@ -0,0 +1,20 @@ +from .overlay_background_media import OverlayBackgroundMedia + +# Import OverlayBackgroundShader with error handling +# This allows the module to load even if OpenGL dependencies are missing +try: + from .overlay_background_shader import OverlayBackgroundShader +except Exception as e: + import logging + logging.warning(f"Failed to import OverlayBackgroundShader: {e}") + # Create a dummy class that does nothing + class OverlayBackgroundShader: + def __init__(self, *args, **kwargs): + logging.warning("OverlayBackgroundShader unavailable - shader backgrounds will not work") + self.widget = None + def get_widget(self): + return None + def cleanup(self): + pass + +__all__ = ["OverlayBackgroundMedia", "OverlayBackgroundShader"] \ No newline at end of file diff --git a/src/core/utils/widgets/overlay_container/overlay_background_media.py b/src/core/utils/widgets/overlay_container/overlay_background_media.py new file mode 100644 index 000000000..d08e5bf63 --- /dev/null +++ b/src/core/utils/widgets/overlay_container/overlay_background_media.py @@ -0,0 +1,450 @@ +""" +Media Background Handler for Overlay Container Widget +Supports images, animated images (GIF, APNG), and videos as backgrounds. +""" + +import logging +import os +from pathlib import Path +from PyQt6.QtCore import Qt, QUrl, QSize, QRect, QPoint +from PyQt6.QtGui import QPixmap, QMovie, QPainter +from PyQt6.QtWidgets import QLabel, QSizePolicy, QGraphicsOpacityEffect, QWidget +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget + +# Hardcoded limits based on typical YASB bar dimensions +MAX_MEDIA_HEIGHT = 200 # Pixels - double typical bar height +MAX_MEDIA_WIDTH = 3840 # Pixels - 4K monitor width +MIN_MEDIA_HEIGHT = 10 # Pixels - minimum visible +MIN_MEDIA_WIDTH = 10 # Pixels - minimum visible + +# Supported formats +IMAGE_FORMATS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.svg'} +ANIMATED_FORMATS = {'.gif', '.apng', '.webp'} # webp can be both static and animated +VIDEO_FORMATS = {'.mp4', '.avi', '.mov', '.webm', '.mkv', '.m4v', '.flv'} + + +class OffsetMediaLabel(QLabel): + """Custom QLabel that supports view offset for precise media positioning.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._view_offset_x = 0 + self._view_offset_y = 0 + self._original_pixmap = None + self._original_movie = None + self._cached_scaled_pixmap = None # Cache scaled pixmap to avoid recreating every frame + self._last_widget_size = None # Track size changes + + def set_view_offset(self, x: int, y: int): + """Set the view offset for the media.""" + self._view_offset_x = x + self._view_offset_y = y + self._cached_scaled_pixmap = None # Invalidate cache + self.update() # Trigger repaint + + def setPixmap(self, pixmap: QPixmap): + """Override setPixmap to store original pixmap.""" + # Clear old pixmap to free memory + if self._original_pixmap: + self._original_pixmap = None + self._original_pixmap = pixmap + self._cached_scaled_pixmap = None # Invalidate cache + super().setPixmap(pixmap) + + def setMovie(self, movie: QMovie): + """Override setMovie to store original movie.""" + # Clear old movie reference + if self._original_movie: + self._original_movie = None + self._original_movie = movie + self._cached_scaled_pixmap = None # Invalidate cache + super().setMovie(movie) + + def resizeEvent(self, event): + """Handle resize to invalidate cache.""" + super().resizeEvent(event) + self._cached_scaled_pixmap = None + self._last_widget_size = None + + def paintEvent(self, event): + """Custom paint event to apply view offset.""" + if self._view_offset_x == 0 and self._view_offset_y == 0: + # No offset, use default painting + super().paintEvent(event) + return + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + + # Get the pixmap to draw (from movie or static pixmap) + pixmap_to_draw = None + if self._original_movie and self._original_movie.isValid(): + pixmap_to_draw = self._original_movie.currentPixmap() + elif self._original_pixmap and not self._original_pixmap.isNull(): + pixmap_to_draw = self._original_pixmap + + if not pixmap_to_draw or pixmap_to_draw.isNull(): + super().paintEvent(event) + return + + # Calculate scaled size based on widget size and scaling mode + widget_rect = self.rect() + current_size = widget_rect.size() + + # Use cached scaled pixmap if size hasn't changed (CRITICAL for performance) + if self.hasScaledContents(): + # Check if we need to rescale + if self._cached_scaled_pixmap is None or self._last_widget_size != current_size: + # Scaled contents - pixmap fills widget + self._cached_scaled_pixmap = pixmap_to_draw.scaled( + current_size, + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self._last_widget_size = current_size + scaled_pixmap = self._cached_scaled_pixmap + else: + # Not scaled - use original size or fit to widget + scaled_pixmap = pixmap_to_draw + + # Apply view offset + draw_x = self._view_offset_x + draw_y = self._view_offset_y + + # Draw the pixmap with offset + painter.drawPixmap(draw_x, draw_y, scaled_pixmap) + painter.end() + + def cleanup(self): + """Clean up resources to prevent memory leaks.""" + # Clear cached pixmaps + self._cached_scaled_pixmap = None + self._original_pixmap = None + self._original_movie = None + self._last_widget_size = None + + +class OverlayBackgroundMedia: + """Handles media (image/video) backgrounds for overlay panel.""" + + def __init__(self, media_config: dict, parent_widget): + self.config = media_config + self.parent = parent_widget + self.widget = None + self.media_player = None + self.media_type = None + + if not self.config.get("enabled", False): + return + + file_path = self.config.get("file", "") + if not file_path or not os.path.exists(file_path): + logging.error(f"OverlayBackgroundMedia: File not found: {file_path}") + return + + self._load_media(file_path) + + def _detect_media_type(self, file_path: str) -> str: + """Detect media type from file extension and content.""" + ext = Path(file_path).suffix.lower() + + # User specified type + specified_type = self.config.get("type", "auto") + if specified_type != "auto": + return specified_type + + # Auto-detect from extension + if ext in VIDEO_FORMATS: + return "video" + elif ext in ANIMATED_FORMATS: + # For webp, we'd need to check if it's actually animated + # For now, treat as animated + return "animated" + elif ext in IMAGE_FORMATS: + return "image" + else: + logging.warning(f"OverlayBackgroundMedia: Unknown media type for {ext}, defaulting to image") + return "image" + + def _validate_media_dimensions(self, width: int, height: int) -> bool: + """Validate media dimensions against hardcoded limits.""" + if width < MIN_MEDIA_WIDTH or height < MIN_MEDIA_HEIGHT: + logging.error( + f"OverlayBackgroundMedia: Media too small ({width}x{height}). " + f"Minimum: {MIN_MEDIA_WIDTH}x{MIN_MEDIA_HEIGHT}px" + ) + return False + + if width > MAX_MEDIA_WIDTH or height > MAX_MEDIA_HEIGHT: + logging.warning( + f"OverlayBackgroundMedia: Media very large ({width}x{height}). " + f"Recommended max: {MAX_MEDIA_WIDTH}x{MAX_MEDIA_HEIGHT}px. " + f"Performance may be affected." + ) + # Allow but warn - Qt will handle scaling + + return True + + def _load_media(self, file_path: str): + """Load media file and create appropriate widget.""" + self.media_type = self._detect_media_type(file_path) + logging.info(f"OverlayBackgroundMedia: Loading {self.media_type} from {file_path}") + + try: + if self.media_type == "image": + self._load_static_image(file_path) + elif self.media_type == "animated": + self._load_animated_image(file_path) + elif self.media_type == "video": + self._load_video(file_path) + except Exception as e: + logging.error(f"OverlayBackgroundMedia: Error loading media: {e}", exc_info=True) + + def _load_static_image(self, file_path: str): + """Load static image as background.""" + pixmap = QPixmap(file_path) + + if pixmap.isNull(): + logging.error(f"OverlayBackgroundMedia: Failed to load image: {file_path}") + return + + # Validate dimensions + if not self._validate_media_dimensions(pixmap.width(), pixmap.height()): + return + + # Create label widget with offset support + self.widget = OffsetMediaLabel(self.parent) + self.widget.setPixmap(pixmap) + self._apply_widget_settings() + + # Apply view offset if specified + view_offset_x = self.config.get("view_offset_x", 0) + view_offset_y = self.config.get("view_offset_y", 0) + if view_offset_x != 0 or view_offset_y != 0: + self.widget.set_view_offset(view_offset_x, view_offset_y) + logging.info(f"OverlayBackgroundMedia: Applied view offset ({view_offset_x}, {view_offset_y})") + + logging.info(f"OverlayBackgroundMedia: Loaded static image ({pixmap.width()}x{pixmap.height()})") + + def _load_animated_image(self, file_path: str): + """Load animated image (GIF, APNG) as background.""" + movie = QMovie(file_path) + + if not movie.isValid(): + logging.error(f"OverlayBackgroundMedia: Failed to load animated image: {file_path}") + return + + # Get first frame to validate dimensions + movie.jumpToFrame(0) + pixmap = movie.currentPixmap() + if not self._validate_media_dimensions(pixmap.width(), pixmap.height()): + return + + # Create label widget with offset support + self.widget = OffsetMediaLabel(self.parent) + self.widget.setMovie(movie) + + # Configure caching and playback speed + movie.setCacheMode(QMovie.CacheMode.CacheAll) + + # Set playback speed (100 = normal speed) + movie.setSpeed(int(self.config.get("playback_rate", 1.0) * 100)) + + # Configure looping via signal handler + # QMovie doesn't have setLoopCount in PyQt6, so we use finished signal + if self.config.get("loop", True): + # Reconnect to start on finish for infinite loop + movie.finished.connect(movie.start) + # If loop is False, movie will stop after playing once (default behavior) + + self._apply_widget_settings() + + # Apply view offset if specified + view_offset_x = self.config.get("view_offset_x", 0) + view_offset_y = self.config.get("view_offset_y", 0) + if view_offset_x != 0 or view_offset_y != 0: + self.widget.set_view_offset(view_offset_x, view_offset_y) + logging.info(f"OverlayBackgroundMedia: Applied view offset ({view_offset_x}, {view_offset_y})") + + movie.start() + + logging.info(f"OverlayBackgroundMedia: Loaded animated image ({pixmap.width()}x{pixmap.height()})") + + def _load_video(self, file_path: str): + """Load video as background.""" + # Create video widget + self.widget = QVideoWidget(self.parent) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + # Create media player + self.media_player = QMediaPlayer(self.parent) + + # Create audio output with volume control + audio_output = QAudioOutput(self.parent) + + # Set muted state + audio_output.setMuted(self.config.get("muted", True)) + + # Set volume (0.0 to 1.0) + volume = self.config.get("volume", 1.0) + audio_output.setVolume(volume) + + # Set up player + self.media_player.setAudioOutput(audio_output) + self.media_player.setVideoOutput(self.widget) + self.media_player.setSource(QUrl.fromLocalFile(file_path)) + + # Configure playback rate + playback_rate = self.config.get("playback_rate", 1.0) + self.media_player.setPlaybackRate(playback_rate) + + # Configure looping + if self.config.get("loop", True): + self.media_player.setLoops(QMediaPlayer.Loops.Infinite) + + self._apply_widget_settings() + + # Start playback + self.media_player.play() + + logging.info(f"OverlayBackgroundMedia: Loaded video from {file_path}") + + def _apply_widget_settings(self): + """Apply common widget settings (fit, opacity, etc.).""" + if not self.widget: + return + + # Set as background (don't accept focus/input) + self.widget.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + # Apply CSS class for custom styling + css_class = self.config.get("css_class", "") + if css_class: + self.widget.setProperty("class", f"overlay-background-media {css_class}") + logging.info(f"OverlayBackgroundMedia: Applied CSS class: {css_class}") + else: + self.widget.setProperty("class", "overlay-background-media") + + # Store offset values as widget attributes for positioning + self.widget.media_offset_x = self.config.get("offset_x", 0) + self.widget.media_offset_y = self.config.get("offset_y", 0) + + # Apply opacity using QGraphicsOpacityEffect (works for child widgets) + opacity = self.config.get("opacity", 1.0) + if opacity < 1.0: + opacity_effect = QGraphicsOpacityEffect() + opacity_effect.setOpacity(opacity) + self.widget.setGraphicsEffect(opacity_effect) + + # Apply fit mode and alignment + fit_mode = self.config.get("fit", "cover") + alignment = self.config.get("alignment", "center") + self._apply_fit_mode(fit_mode, alignment) + + def _apply_fit_mode(self, fit_mode: str, alignment: str = "center"): + """Apply the fit mode and alignment to the widget.""" + if not self.widget: + return + + # Convert alignment string to Qt alignment flags + qt_alignment = self._get_qt_alignment(alignment) + + # For QLabel with pixmap/movie + if isinstance(self.widget, QLabel): + if fit_mode == "fill": + self.widget.setScaledContents(True) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.widget.setAlignment(qt_alignment) + elif fit_mode == "contain": + self.widget.setScaledContents(False) + self.widget.setAlignment(qt_alignment) + elif fit_mode == "cover": + self.widget.setScaledContents(True) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.widget.setAlignment(qt_alignment) + elif fit_mode == "stretch": + self.widget.setScaledContents(True) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.widget.setAlignment(qt_alignment) + elif fit_mode == "center": + self.widget.setScaledContents(False) + self.widget.setAlignment(qt_alignment) + elif fit_mode == "scale-down": + self.widget.setScaledContents(False) + self.widget.setAlignment(qt_alignment) + + # For QVideoWidget + elif isinstance(self.widget, QVideoWidget): + if fit_mode == "fill" or fit_mode == "cover": + self.widget.setAspectRatioMode(Qt.AspectRatioMode.IgnoreAspectRatio) + elif fit_mode == "contain": + self.widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + elif fit_mode == "stretch": + self.widget.setAspectRatioMode(Qt.AspectRatioMode.IgnoreAspectRatio) + else: + self.widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + + def _get_qt_alignment(self, alignment: str) -> Qt.AlignmentFlag: + """Convert alignment string to Qt alignment flags.""" + alignment_map = { + "top-left": Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, + "top-center": Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter, + "top-right": Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight, + "center-left": Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + "center": Qt.AlignmentFlag.AlignCenter, + "center-right": Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, + "bottom-left": Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft, + "bottom-center": Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, + "bottom-right": Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, + } + + return alignment_map.get(alignment, Qt.AlignmentFlag.AlignCenter) + + def get_widget(self): + """Get the media widget.""" + return self.widget + + def cleanup(self): + """Clean up resources.""" + logging.debug("OverlayBackgroundMedia: Starting cleanup") + + # Stop and clean up media player + if self.media_player: + try: + self.media_player.stop() + self.media_player.setSource(QUrl()) + # Disconnect all signals to prevent memory leaks + self.media_player.disconnect() + except Exception as e: + logging.debug(f"OverlayBackgroundMedia: Error cleaning up media player: {e}") + finally: + self.media_player = None + + # Clean up widget + if self.widget: + try: + if isinstance(self.widget, QLabel): + movie = self.widget.movie() + if movie: + # Stop movie and disconnect signals + movie.stop() + try: + movie.disconnect() + except Exception: + pass # Ignore if no connections + + # Call cleanup on OffsetMediaLabel if it has the method + if hasattr(self.widget, 'cleanup'): + self.widget.cleanup() + + self.widget.setParent(None) + self.widget.deleteLater() + except Exception as e: + logging.debug(f"OverlayBackgroundMedia: Error cleaning up widget: {e}") + finally: + self.widget = None + + logging.debug("OverlayBackgroundMedia: Cleanup completed") diff --git a/src/core/utils/widgets/overlay_container/overlay_background_shader.py b/src/core/utils/widgets/overlay_container/overlay_background_shader.py new file mode 100644 index 000000000..0cfa848d1 --- /dev/null +++ b/src/core/utils/widgets/overlay_container/overlay_background_shader.py @@ -0,0 +1,544 @@ +""" +Shader Background Handler for Overlay Container Widget +Supports preset and custom GLSL shaders as animated backgrounds. +""" + +import logging +import os +import time +import struct +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import QWidget, QGraphicsOpacityEffect + +# OpenGL is optional - if not installed, shader features won't be available +try: + from PyQt6.QtOpenGLWidgets import QOpenGLWidget + from PyQt6.QtOpenGL import QOpenGLShaderProgram, QOpenGLShader, QOpenGLBuffer, QOpenGLVertexArrayObject + from OpenGL import GL + OPENGL_AVAILABLE = True +except ImportError as e: + OPENGL_AVAILABLE = False + QOpenGLWidget = QWidget # Fallback to regular QWidget + logging.warning(f"OpenGL not available ({e}). Shader backgrounds will not be available. Install with: pip install PyOpenGL PyOpenGL_accelerate") + + +# Preset shader sources +PRESET_SHADERS = {} + +# Default vertex shader (used for all presets) +DEFAULT_VERTEX_SHADER = """ +#version 330 core +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +out vec2 TexCoord; + +void main() +{ + gl_Position = vec4(aPos, 1.0); + TexCoord = aTexCoord; +} +""" + +# Plasma shader +PRESET_SHADERS["plasma"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +void main() +{ + vec2 uv = TexCoord * scale; + float t = time * speed; + + float v1 = sin(uv.x * 10.0 + t); + float v2 = sin(10.0 * (uv.x * sin(t / 2.0) + uv.y * cos(t / 3.0)) + t); + float v3 = sin(sqrt(100.0 * (uv.x * uv.x + uv.y * uv.y) + 1.0) + t); + float v = v1 + v2 + v3; + + vec3 color; + if (numColors >= 3) { + float t = (sin(v * 0.5) + 1.0) / 2.0; + color = mix(colors[0], mix(colors[1], colors[2], t), t); + } else { + color = vec3(sin(v), sin(v + 2.0), sin(v + 4.0)) * 0.5 + 0.5; + } + + FragColor = vec4(color, 1.0); +} +""" + +# Wave shader +PRESET_SHADERS["wave"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +void main() +{ + vec2 uv = TexCoord * scale; + float t = time * speed; + + float wave = sin(uv.x * 5.0 + t) * cos(uv.y * 5.0 + t * 0.5) * 0.5 + 0.5; + + vec3 color; + if (numColors >= 2) { + color = mix(colors[0], colors[1], wave); + } else { + color = vec3(wave, wave * 0.8, wave * 1.2); + } + + FragColor = vec4(color, 1.0); +} +""" + +# Ripple shader +PRESET_SHADERS["ripple"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +void main() +{ + vec2 uv = (TexCoord - 0.5) * 2.0 * scale; + float t = time * speed; + + float dist = length(uv); + float ripple = sin(dist * 10.0 - t * 5.0) * 0.5 + 0.5; + ripple *= 1.0 - smoothstep(0.0, 2.0, dist); + + vec3 color; + if (numColors >= 2) { + color = mix(colors[0], colors[1], ripple); + } else { + color = vec3(0.2, 0.5, 1.0) * ripple; + } + + FragColor = vec4(color, 1.0); +} +""" + +# Tunnel shader +PRESET_SHADERS["tunnel"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +void main() +{ + vec2 uv = (TexCoord - 0.5) * 2.0; + float t = time * speed; + + float angle = atan(uv.y, uv.x); + float radius = length(uv); + + float tunnel = mod(1.0 / radius + t, 1.0); + float spiral = mod(angle * 2.0 + t, 6.28) / 6.28; + + vec3 color; + if (numColors >= 2) { + color = mix(colors[0], colors[1], tunnel * spiral); + } else { + color = vec3(tunnel, spiral, tunnel * spiral); + } + + FragColor = vec4(color, 1.0); +} +""" + +# Mandelbrot shader +PRESET_SHADERS["mandelbrot"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +void main() +{ + vec2 c = (TexCoord - 0.5) * 3.0 * scale; + c.x += sin(time * speed * 0.2) * 0.5; + c.y += cos(time * speed * 0.15) * 0.5; + + vec2 z = vec2(0.0); + float iterations = 0.0; + const float maxIterations = 50.0; + + for (float i = 0.0; i < maxIterations; i++) { + z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; + if (length(z) > 2.0) break; + iterations++; + } + + float t = iterations / maxIterations; + + vec3 color; + if (numColors >= 3) { + color = mix(colors[0], mix(colors[1], colors[2], t), t); + } else { + color = vec3(t, t * t, sqrt(t)); + } + + FragColor = vec4(color, 1.0); +} +""" + +# Noise shader +PRESET_SHADERS["noise"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +float random(vec2 st) { + return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); +} + +float noise(vec2 st) { + vec2 i = floor(st); + vec2 f = fract(st); + float a = random(i); + float b = random(i + vec2(1.0, 0.0)); + float c = random(i + vec2(0.0, 1.0)); + float d = random(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; +} + +void main() +{ + vec2 uv = TexCoord * scale; + float t = time * speed; + + float n = noise(uv * 5.0 + t); + n += 0.5 * noise(uv * 10.0 + t * 1.5); + n += 0.25 * noise(uv * 20.0 + t * 2.0); + n /= 1.75; + + vec3 color; + if (numColors >= 2) { + color = mix(colors[0], colors[1], n); + } else { + color = vec3(n); + } + + FragColor = vec4(color, 1.0); +} +""" + +# Gradient shader +PRESET_SHADERS["gradient"] = """ +#version 330 core +in vec2 TexCoord; +out vec4 FragColor; + +uniform float time; +uniform float speed; +uniform float scale; +uniform vec2 resolution; +uniform vec3 colors[3]; +uniform int numColors; + +void main() +{ + float t = mod(time * speed * 0.2, 1.0); + vec2 uv = TexCoord; + + vec3 color; + if (numColors >= 3) { + float pos = (uv.x + uv.y) * 0.5 + t; + pos = fract(pos); + if (pos < 0.5) { + color = mix(colors[0], colors[1], pos * 2.0); + } else { + color = mix(colors[1], colors[2], (pos - 0.5) * 2.0); + } + } else if (numColors >= 2) { + color = mix(colors[0], colors[1], uv.x); + } else { + color = vec3(uv.x, uv.y, 1.0 - uv.x); + } + + FragColor = vec4(color, 1.0); +} +""" + + +class ShaderWidget(QOpenGLWidget): + """OpenGL widget for rendering shaders.""" + + def __init__(self, parent, shader_config): + super().__init__(parent) + self.config = shader_config + self.shader_program = None + self.vao = None + self.vbo = None + self.start_time = time.time() + self.timer = QTimer(self) + self.timer.timeout.connect(self.update) + self.timer.start(16) # ~60 FPS + + # Parse colors + self.colors = self._parse_colors(shader_config.get("colors", [])) + + # Set background to transparent + self.setAutoFillBackground(False) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def _parse_colors(self, color_strings): + """Parse color strings to RGB tuples.""" + colors = [] + for color_str in color_strings: + color = QColor(color_str) + if color.isValid(): + colors.append((color.redF(), color.greenF(), color.blueF())) + + # Default colors if none specified + if not colors: + colors = [ + (0.0, 1.0, 1.0), # Cyan + (1.0, 0.0, 1.0), # Magenta + (1.0, 1.0, 0.0), # Yellow + ] + + return colors + + def initializeGL(self): + """Initialize OpenGL resources.""" + try: + # Create shader program + self.shader_program = QOpenGLShaderProgram(self) + + # Load shaders + preset = self.config.get("preset", "plasma") + if preset == "custom": + vertex_file = self.config.get("custom_vertex_file", "") + fragment_file = self.config.get("custom_fragment_file", "") + + if vertex_file and os.path.exists(vertex_file): + with open(vertex_file, 'r') as f: + vertex_source = f.read() + else: + vertex_source = DEFAULT_VERTEX_SHADER + + if fragment_file and os.path.exists(fragment_file): + with open(fragment_file, 'r') as f: + fragment_source = f.read() + else: + logging.error("ShaderWidget: Custom fragment shader file not found") + fragment_source = PRESET_SHADERS["plasma"] + else: + vertex_source = DEFAULT_VERTEX_SHADER + fragment_source = PRESET_SHADERS.get(preset, PRESET_SHADERS["plasma"]) + + # Compile shaders + if not self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Vertex, vertex_source): + logging.error(f"ShaderWidget: Failed to compile vertex shader: {self.shader_program.log()}") + return + + if not self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Fragment, fragment_source): + logging.error(f"ShaderWidget: Failed to compile fragment shader: {self.shader_program.log()}") + return + + if not self.shader_program.link(): + logging.error(f"ShaderWidget: Failed to link shader program: {self.shader_program.log()}") + return + + # Create fullscreen quad vertices (2 triangles) + # Format: x, y, z, u, v (position + texture coordinates) + vertices = [ + # First triangle (bottom-left, bottom-right, top-left) + -1.0, -1.0, 0.0, 0.0, 0.0, # Bottom-left + 1.0, -1.0, 0.0, 1.0, 0.0, # Bottom-right + -1.0, 1.0, 0.0, 0.0, 1.0, # Top-left + # Second triangle (bottom-right, top-right, top-left) + 1.0, -1.0, 0.0, 1.0, 0.0, # Bottom-right + 1.0, 1.0, 0.0, 1.0, 1.0, # Top-right + -1.0, 1.0, 0.0, 0.0, 1.0, # Top-left + ] + + # Convert to bytes + vertex_data = struct.pack(f'{len(vertices)}f', *vertices) + + # Create VAO + self.vao = QOpenGLVertexArrayObject() + if not self.vao.create(): + logging.error("ShaderWidget: Failed to create VAO") + return + self.vao.bind() + + # Create VBO + self.vbo = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer) + if not self.vbo.create(): + logging.error("ShaderWidget: Failed to create VBO") + return + self.vbo.bind() + self.vbo.allocate(vertex_data, len(vertex_data)) + + # Set vertex attribute pointers + self.shader_program.bind() + + # Position attribute (location 0) + self.shader_program.enableAttributeArray(0) + self.shader_program.setAttributeBuffer(0, GL.GL_FLOAT, 0, 3, 5 * 4) # 3 floats, stride 5*4 bytes + + # Texture coordinate attribute (location 1) + self.shader_program.enableAttributeArray(1) + self.shader_program.setAttributeBuffer(1, GL.GL_FLOAT, 3 * 4, 2, 5 * 4) # 2 floats, offset 3*4 bytes + + # Unbind + self.vao.release() + self.vbo.release() + self.shader_program.release() + + logging.info(f"ShaderWidget: Initialized {preset} shader with vertex buffers") + + except Exception as e: + logging.error(f"ShaderWidget: Error initializing OpenGL: {e}", exc_info=True) + + def paintGL(self): + """Render the shader.""" + if not OPENGL_AVAILABLE or not self.shader_program or not self.vao: + return + + # Clear background + GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) + + # Bind shader program + self.shader_program.bind() + + # Set uniforms + elapsed_time = time.time() - self.start_time + speed = self.config.get("speed", 1.0) + scale = self.config.get("scale", 1.0) + + self.shader_program.setUniformValue("time", float(elapsed_time)) + self.shader_program.setUniformValue("speed", float(speed)) + self.shader_program.setUniformValue("scale", float(scale)) + self.shader_program.setUniformValue("resolution", float(self.width()), float(self.height())) + + # Set colors + num_colors = min(len(self.colors), 3) + self.shader_program.setUniformValue("numColors", num_colors) + for i in range(num_colors): + self.shader_program.setUniformValue(f"colors[{i}]", *self.colors[i]) + + # Draw fullscreen quad + self.vao.bind() + GL.glDrawArrays(GL.GL_TRIANGLES, 0, 6) # 6 vertices (2 triangles) + self.vao.release() + + self.shader_program.release() + + def resizeGL(self, w, h): + """Handle resize events.""" + if OPENGL_AVAILABLE: + GL.glViewport(0, 0, w, h) + + def cleanup(self): + """Clean up OpenGL resources.""" + if self.vao: + self.vao.destroy() + self.vao = None + + if self.vbo: + self.vbo.destroy() + self.vbo = None + + if self.shader_program: + self.shader_program = None + + +class OverlayBackgroundShader: + """Handles shader backgrounds for overlay panel.""" + + def __init__(self, shader_config: dict, parent_widget): + self.config = shader_config + self.parent = parent_widget + self.widget = None + + if not self.config.get("enabled", False): + return + + if not OPENGL_AVAILABLE: + logging.error("OverlayBackgroundShader: PyOpenGL not installed. Cannot create shader background.") + logging.info("Install PyOpenGL with: pip install PyOpenGL") + return + + self._create_shader_widget() + + def _create_shader_widget(self): + """Create the shader widget.""" + try: + self.widget = ShaderWidget(self.parent, self.config) + + # Apply opacity using QGraphicsOpacityEffect (works for child widgets) + opacity = self.config.get("opacity", 1.0) + if opacity < 1.0: + opacity_effect = QGraphicsOpacityEffect() + opacity_effect.setOpacity(opacity) + self.widget.setGraphicsEffect(opacity_effect) + + # Set as background + self.widget.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + logging.info(f"OverlayBackgroundShader: Created shader widget with preset '{self.config.get('preset', 'plasma')}'") + + except Exception as e: + logging.error(f"OverlayBackgroundShader: Error creating shader widget: {e}", exc_info=True) + + def get_widget(self): + """Get the shader widget.""" + return self.widget + + def cleanup(self): + """Clean up resources.""" + if self.widget: + if hasattr(self.widget, 'timer'): + self.widget.timer.stop() + if hasattr(self.widget, 'cleanup'): + self.widget.cleanup() + self.widget.setParent(None) + self.widget.deleteLater() + self.widget = None diff --git a/src/core/validation/widgets/yasb/overlay_container.py b/src/core/validation/widgets/yasb/overlay_container.py new file mode 100644 index 000000000..4f1a3b181 --- /dev/null +++ b/src/core/validation/widgets/yasb/overlay_container.py @@ -0,0 +1,370 @@ +VALIDATION_SCHEMA = { + "target": { + "type": "string", + "default": "full", + "required": False, + "allowed": ["full", "left", "center", "right", "custom", "widget"] + }, + "target_widget": { + "type": "string", + "default": "", + "required": False + }, + "position": { + "type": "string", + "default": "behind", + "required": False, + "allowed": ["behind", "above"] + }, + "offset_x": { + "type": "integer", + "default": 0, + "required": False + }, + "offset_y": { + "type": "integer", + "default": 0, + "required": False + }, + "width": { + "type": ["string", "integer"], + "default": "auto", + "required": False + }, + "height": { + "type": ["string", "integer"], + "default": "auto", + "required": False + }, + "opacity": { + "type": "float", + "default": 0.5, + "required": False, + "min": 0.0, + "max": 1.0 + }, + "pass_through_clicks": { + "type": "boolean", + "default": True, + "required": False + }, + "z_index": { + "type": "integer", + "default": -1, + "required": False, + "min": -1, + "max": 1 + }, + "child_widget_name": { + "type": "string", + "default": "", + "required": True + }, + "show_toggle": { + "type": "boolean", + "default": False, + "required": False + }, + "toggle_label": { + "type": "string", + "default": "\uf06e", + "required": False + }, + "auto_show": { + "type": "boolean", + "default": True, + "required": False + }, + "callbacks": { + "type": "dict", + "default": { + "on_left": "toggle_overlay", + "on_middle": "do_nothing", + "on_right": "do_nothing" + }, + "required": False, + "schema": { + "on_left": { + "type": "string", + "default": "toggle_overlay", + "required": False, + }, + "on_middle": { + "type": "string", + "default": "do_nothing", + "required": False, + }, + "on_right": { + "type": "string", + "default": "do_nothing", + "required": False, + } + } + }, + "container_padding": { + "type": "dict", + "default": {"top": 0, "left": 0, "bottom": 0, "right": 0}, + "required": False, + "schema": { + "top": { + "type": "integer", + "default": 0, + "required": False + }, + "left": { + "type": "integer", + "default": 0, + "required": False + }, + "bottom": { + "type": "integer", + "default": 0, + "required": False + }, + "right": { + "type": "integer", + "default": 0, + "required": False + } + } + }, + "container_shadow": { + "type": "dict", + "default": { + "enabled": False, + "color": "#000000", + "offset": [0, 0], + "radius": 0 + }, + "required": False, + "schema": { + "enabled": { + "type": "boolean", + "default": False, + "required": False + }, + "color": { + "type": "string", + "default": "#000000", + "required": False + }, + "offset": { + "type": "list", + "default": [0, 0], + "required": False, + "schema": { + "type": "integer" + } + }, + "radius": { + "type": "integer", + "default": 0, + "required": False + } + } + }, + "label_shadow": { + "type": "dict", + "default": { + "enabled": False, + "color": "#000000", + "offset": [0, 0], + "radius": 0 + }, + "required": False, + "schema": { + "enabled": { + "type": "boolean", + "default": False, + "required": False + }, + "color": { + "type": "string", + "default": "#000000", + "required": False + }, + "offset": { + "type": "list", + "default": [0, 0], + "required": False, + "schema": { + "type": "integer" + } + }, + "radius": { + "type": "integer", + "default": 0, + "required": False + } + } + }, + "background_media": { + "type": "dict", + "default": { + "enabled": False, + "file": "", + "type": "auto", + "fit": "cover", + "opacity": 1.0, + "loop": True, + "muted": True, + "playback_rate": 1.0, + "volume": 1.0, + "offset_x": 0, + "offset_y": 0 + }, + "required": False, + "schema": { + "enabled": { + "type": "boolean", + "default": False, + "required": False + }, + "file": { + "type": "string", + "default": "", + "required": False + }, + "type": { + "type": "string", + "default": "auto", + "required": False, + "allowed": ["auto", "image", "animated", "video"] + }, + "fit": { + "type": "string", + "default": "cover", + "required": False, + "allowed": ["fill", "contain", "cover", "stretch", "center", "tile", "scale-down"] + }, + "opacity": { + "type": "float", + "default": 1.0, + "required": False, + "min": 0.0, + "max": 1.0 + }, + "loop": { + "type": "boolean", + "default": True, + "required": False + }, + "muted": { + "type": "boolean", + "default": True, + "required": False + }, + "playback_rate": { + "type": "float", + "default": 1.0, + "required": False, + "min": 0.1, + "max": 5.0 + }, + "volume": { + "type": "float", + "default": 1.0, + "required": False, + "min": 0.0, + "max": 1.0 + }, + "offset_x": { + "type": "integer", + "default": 0, + "required": False + }, + "offset_y": { + "type": "integer", + "default": 0, + "required": False + }, + "alignment": { + "type": "string", + "default": "center", + "required": False, + "allowed": ["top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right"] + }, + "css_class": { + "type": "string", + "default": "", + "required": False + }, + "view_offset_x": { + "type": "integer", + "default": 0, + "required": False + }, + "view_offset_y": { + "type": "integer", + "default": 0, + "required": False + } + } + }, + "background_shader": { + "type": "dict", + "default": { + "enabled": False, + "preset": "plasma", + "custom_vertex_file": "", + "custom_fragment_file": "", + "speed": 1.0, + "scale": 1.0, + "opacity": 1.0, + "colors": [] + }, + "required": False, + "schema": { + "enabled": { + "type": "boolean", + "default": False, + "required": False + }, + "preset": { + "type": "string", + "default": "plasma", + "required": False, + "allowed": ["plasma", "wave", "ripple", "tunnel", "mandelbrot", "noise", "gradient", "custom"] + }, + "custom_vertex_file": { + "type": "string", + "default": "", + "required": False + }, + "custom_fragment_file": { + "type": "string", + "default": "", + "required": False + }, + "speed": { + "type": "float", + "default": 1.0, + "required": False, + "min": 0.1, + "max": 10.0 + }, + "scale": { + "type": "float", + "default": 1.0, + "required": False, + "min": 0.1, + "max": 10.0 + }, + "opacity": { + "type": "float", + "default": 1.0, + "required": False, + "min": 0.0, + "max": 1.0 + }, + "colors": { + "type": "list", + "default": [], + "required": False, + "schema": { + "type": "string" + } + } + } + } +} diff --git a/src/core/validation/widgets/yasb/terminal_menu.py b/src/core/validation/widgets/yasb/terminal_menu.py new file mode 100644 index 000000000..ac19db1fa --- /dev/null +++ b/src/core/validation/widgets/yasb/terminal_menu.py @@ -0,0 +1,97 @@ +DEFAULTS = { + "label": "\uf489", + "container_padding": {"top": 0, "left": 4, "bottom": 0, "right": 0}, + "blur": True, + "round_corners": True, + "round_corners_type": "small", + "border_color": "None", + "alignment": "left", + "direction": "down", + "offset_top": 6, + "offset_left": 0, + "shield_icon": "\ud83d\udee1", # 🛡 Unicode shield icon + "terminal_list": [ + {"name": "Git Bash", "path": "C:\\Program Files\\Git\\git-bash.exe"}, + {"name": "Git GUI", "path": "C:\\Program Files\\Git\\cmd\\git-gui.exe"}, + {"name": "Git CMD", "path": "C:\\Program Files\\Git\\git-cmd.exe"}, + {"name": "CMD", "path": "cmd.exe"}, + {"name": "PowerShell", "path": "powershell.exe"}, + ], + "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, + "callbacks": {"on_left": "toggle_menu"}, +} + +VALIDATION_SCHEMA = { + "label": {"type": "string", "default": DEFAULTS["label"]}, + "terminal_list": { + "required": False, + "type": "list", + "schema": { + "type": "dict", + "schema": { + "name": {"type": "string", "required": True}, + "path": {"type": "string", "required": True}, + "icon": {"type": "string", "required": False}, # Optional custom icon per terminal + }, + }, + "default": DEFAULTS["terminal_list"], + }, + "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"], + }, + "blur": {"type": "boolean", "default": DEFAULTS["blur"], "required": False}, + "round_corners": {"type": "boolean", "default": DEFAULTS["round_corners"], "required": False}, + "round_corners_type": {"type": "string", "default": DEFAULTS["round_corners_type"], "required": False}, + "border_color": {"type": "string", "default": DEFAULTS["border_color"], "required": False}, + "alignment": {"type": "string", "default": DEFAULTS["alignment"], "required": False}, + "direction": {"type": "string", "default": DEFAULTS["direction"], "required": False}, + "offset_top": {"type": "integer", "default": DEFAULTS["offset_top"], "required": False}, + "offset_left": {"type": "integer", "default": DEFAULTS["offset_left"], "required": False}, + "shield_icon": {"type": "string", "default": DEFAULTS["shield_icon"], "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"], + }, + "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": { + "required": False, + "type": "dict", + "schema": {"on_left": {"type": "string", "default": DEFAULTS["callbacks"]["on_left"]}}, + "default": DEFAULTS["callbacks"], + }, +} diff --git a/src/core/widgets/yasb/cava.py b/src/core/widgets/yasb/cava.py index 40cbbb5e9..79cfcc7af 100644 --- a/src/core/widgets/yasb/cava.py +++ b/src/core/widgets/yasb/cava.py @@ -15,6 +15,103 @@ from core.widgets.base import BaseWidget +# Global registry to track all active cava processes +_active_cava_processes = [] +_cava_process_lock = threading.Lock() +_cleanup_lock = threading.Lock() + + +def kill_all_cava_processes_sync(): + """ + Kill ALL cava.exe processes unconditionally. + Uses filesystem-based sentinel to ensure execution only once per YASB instance. + Cleans up stale sentinel files from dead YASB processes. + """ + import time + import tempfile + import psutil + + temp_dir = tempfile.gettempdir() + current_pid = os.getpid() + sentinel_file = os.path.join(temp_dir, f"yasb_cava_cleanup_{current_pid}.lock") + + with _cleanup_lock: + # CRITICAL: Clean up stale sentinel files from dead YASB processes + try: + for filename in os.listdir(temp_dir): + if filename.startswith("yasb_cava_cleanup_") and filename.endswith(".lock"): + # Extract PID from filename + try: + pid_str = filename.replace("yasb_cava_cleanup_", "").replace(".lock", "") + old_pid = int(pid_str) + + # Check if process is still alive + if old_pid != current_pid and not psutil.pid_exists(old_pid): + # Process is dead, remove stale sentinel + stale_file = os.path.join(temp_dir, filename) + os.remove(stale_file) + logging.debug(f"Removed stale sentinel file for dead PID {old_pid}") + except (ValueError, OSError): + pass + except Exception as e: + logging.debug(f"Error cleaning stale sentinel files: {e}") + + # Check if cleanup already done for this YASB process + if os.path.exists(sentinel_file): + return + + try: + # Kill ALL cava.exe processes unconditionally + result = subprocess.run( + ["taskkill", "/F", "/IM", "cava.exe"], + capture_output=True, + text=True, + creationflags=subprocess.CREATE_NO_WINDOW, + timeout=5 + ) + + # Check if any processes were killed + if result.returncode == 0: + logging.info("Killed all existing cava.exe processes on startup") + # Wait for processes to fully terminate + time.sleep(0.5) + elif "not found" in result.stderr.lower() or "no tasks" in result.stderr.lower(): + logging.debug("No existing cava.exe processes found on startup") + else: + logging.debug(f"taskkill result: {result.stderr}") + except subprocess.TimeoutExpired: + logging.warning("Timeout while trying to kill cava processes") + except Exception as e: + logging.debug(f"Error killing cava processes: {e}") + finally: + # Create sentinel file to mark cleanup as done for this YASB instance + try: + with open(sentinel_file, 'w') as f: + f.write(str(time.time())) + except Exception: + pass + + +def cleanup_all_cava_processes(): + """Clean up all tracked cava processes - called on module unload.""" + with _cava_process_lock: + for proc in _active_cava_processes[:]: + try: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + except Exception: + pass + _active_cava_processes.clear() + + +# Register cleanup on exit +atexit.register(cleanup_all_cava_processes) + + class CavaBar(QFrame): def __init__(self, cava_widget): super().__init__() @@ -460,6 +557,11 @@ def __init__( self._widget_container_layout.addWidget(error_label) return + # CRITICAL: Kill ALL cava.exe processes ONCE before ANY widget starts + # Thread-safe with lock to ensure only first widget executes this + # Includes 500ms delay to ensure processes fully terminate + kill_all_cava_processes_sync() + # Add the custom bar frame self._bar_frame = CavaBar(self) self._widget_container_layout.addWidget(self._bar_frame) @@ -504,17 +606,38 @@ def _reload_cava(self): logging.error(f"Error reloading cava: {e}") def stop_cava(self) -> None: + """Stop cava process and thread with robust cleanup.""" self._stop_cava = True self.colors.clear() - if hasattr(self, "_cava_process") and self._cava_process.poll() is None: + + # Stop and cleanup cava process + if hasattr(self, "_cava_process"): try: - self._cava_process.terminate() - self._cava_process.wait(timeout=2) - except subprocess.TimeoutExpired: - self._cava_process.kill() + # Remove from global registry + with _cava_process_lock: + if self._cava_process in _active_cava_processes: + _active_cava_processes.remove(self._cava_process) + + # Terminate process if still running + if self._cava_process.poll() is None: + try: + self._cava_process.terminate() + self._cava_process.wait(timeout=2) + logging.debug(f"Terminated cava process (instance {self._instance_id})") + except subprocess.TimeoutExpired: + self._cava_process.kill() + self._cava_process.wait(timeout=1) + logging.warning(f"Killed cava process (instance {self._instance_id})") + except Exception as e: + logging.debug(f"Error stopping cava process: {e}") + + # Wait for thread to finish if hasattr(self, "thread_cava") and self.thread_cava.is_alive(): if threading.current_thread() != self.thread_cava: - self.thread_cava.join(timeout=2) + try: + self.thread_cava.join(timeout=2) + except Exception as e: + logging.debug(f"Error joining cava thread: {e}") def initialize_colors(self) -> None: self.foreground_color = QColor(self._foreground) @@ -615,6 +738,11 @@ def process_audio(): stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW, ) + + # Register process in global registry for cleanup + with _cava_process_lock: + _active_cava_processes.append(self._cava_process) + logging.debug(f"Started cava process (instance {self._instance_id}, PID {self._cava_process.pid})") chunk = bytesize * self._bars_number fmt = bytetype * self._bars_number diff --git a/src/core/widgets/yasb/media.py b/src/core/widgets/yasb/media.py index 62ae8ae26..ea3ab598d 100644 --- a/src/core/widgets/yasb/media.py +++ b/src/core/widgets/yasb/media.py @@ -311,7 +311,8 @@ def show_menu(self): # Create layout for text information (title, artist, slider, controls) text_layout = QVBoxLayout() - text_layout.setContentsMargins(0, 0, 0, 0) + # Add left padding to separate text from thumbnail + text_layout.setContentsMargins(12, 0, 0, 0) text_layout.setSpacing(0) text_layout.setProperty("class", "text-layout") @@ -604,7 +605,28 @@ def _toggle_label(self): def _toggle_play_pause(self): if self.animation["enabled"]: AnimationManager.animate(self, self.animation["type"], self.animation["duration"]) - _ = self.media.play_pause() + + # Call the media control + WindowsMedia().play_pause() + + # In fallback mode, manually toggle the play/pause icon + logger.info( + f"Toggle play/pause - fallback_mode: {getattr(self, '_fallback_mode', None)}, play_label: {self._play_label is not None}" + ) + if hasattr(self, "_fallback_mode") and self._fallback_mode and self._play_label is not None: + current_text = self._play_label.text() + logger.info( + f"Current icon: {current_text}, play icon: {self._media_button_icons['play']}, pause icon: {self._media_button_icons['pause']}" + ) + # Toggle between play and pause icons + if current_text == self._media_button_icons["play"]: + self._play_label.setText(self._media_button_icons["pause"]) + self._is_playing = True + logger.info("Changed to PAUSE icon") + else: + self._play_label.setText(self._media_button_icons["play"]) + self._is_playing = False + logger.info("Changed to PLAY icon") def _on_timeline_properties_changed(self): """Handle timeline property updates.""" @@ -688,10 +710,11 @@ def _on_session_status_changed(self): active_label.show() else: - # Hide thumbnail and label fields + # Hide thumbnail and show "No media playing" message self._thumbnail_label.hide() - active_label.hide() - active_label.setText("") + if not self._controls_only: + active_label.show() + active_label.setText("No media playing") if not self._controls_hide: if self._play_label is not None: self._play_label.setText(self._media_button_icons["play"]) @@ -713,6 +736,11 @@ def _on_session_status_changed(self): def _on_playback_info_changed(self): if self.current_session is None or self.current_session.playback_info is None: return + + # In fallback mode, button states are managed by _on_media_properties_changed + if hasattr(self.media, '_fallback_mode') and self.media._fallback_mode: + return + # Set play-pause state icon playback_info = self.current_session.playback_info is_playing = playback_info.playback_status == 4 @@ -723,16 +751,16 @@ def _on_playback_info_changed(self): if not self._controls_hide: play_icon = self._media_button_icons["pause" if is_playing else "play"] - # We need to clear any inline styles: setStyleSheet("") + # Update main widget button + self._play_label.setText(play_icon) + self._play_label.setProperty("class", f"btn play {'disabled' if not is_play_enabled else ''}") + self._play_label.setCursor( + Qt.CursorShape.PointingHandCursor if is_play_enabled else Qt.CursorShape.ArrowCursor + ) + refresh_widget_style(self._play_label) + # Clear any inline styles # Related to https://github.com/amnweb/yasb/issues/481 - if self._play_label is not None: - self._play_label.setText(play_icon) - self._play_label.setProperty("class", f"btn play {'disabled' if not is_play_enabled else ''}") - self._play_label.setCursor( - Qt.CursorShape.PointingHandCursor if is_play_enabled else Qt.CursorShape.ArrowCursor - ) - refresh_widget_style(self._play_label) - self._play_label.setStyleSheet("") + self._play_label.setStyleSheet("") if self._prev_label is not None: self._prev_label.setProperty("class", f"btn prev {'disabled' if not is_prev_enabled else ''}") @@ -825,6 +853,72 @@ def _on_media_properties_changed(self): except Exception as e: logger.error(f"Error updating popup content: {e}") + # Update button states in fallback mode + if hasattr(self.media, '_fallback_mode') and self.media._fallback_mode and not self._controls_hide: + if self.current_session is not None: + has_media_app = bool(self.current_session.title or self.current_session.artist) + initial_playing = getattr(self.media, '_fallback_is_playing', False) + + if has_media_app: + # Enable controls when media app is active + if self._play_label is not None: + # Set correct icon: pause icon if playing, play icon if paused + initial_icon = self._media_button_icons["pause"] if initial_playing else self._media_button_icons["play"] + current_icon = self._play_label.text() + + # Only update if icon changed to avoid unnecessary updates + if current_icon != initial_icon: + self._play_label.setText(initial_icon) + + self._play_label.setProperty("class", "btn play") + self._play_label.setCursor(Qt.CursorShape.PointingHandCursor) + refresh_widget_style(self._play_label) + self._play_label.update() # Force visual update + + if self._prev_label is not None: + self._prev_label.setProperty("class", "btn prev") + self._prev_label.setCursor(Qt.CursorShape.PointingHandCursor) + refresh_widget_style(self._prev_label) + + if self._next_label is not None: + self._next_label.setProperty("class", "btn next") + self._next_label.setCursor(Qt.CursorShape.PointingHandCursor) + refresh_widget_style(self._next_label) + else: + # Disable controls when no media app is active + if self._play_label is not None: + self._play_label.setText(self._media_button_icons["play"]) + self._play_label.setProperty("class", "btn play disabled") + self._play_label.setCursor(Qt.CursorShape.ArrowCursor) + refresh_widget_style(self._play_label) + + if self._prev_label is not None: + self._prev_label.setProperty("class", "btn prev disabled") + self._prev_label.setCursor(Qt.CursorShape.ArrowCursor) + refresh_widget_style(self._prev_label) + + if self._next_label is not None: + self._next_label.setProperty("class", "btn next disabled") + self._next_label.setCursor(Qt.CursorShape.ArrowCursor) + refresh_widget_style(self._next_label) + else: + # Disable controls when no session is active + if self._play_label is not None: + self._play_label.setText(self._media_button_icons["play"]) + self._play_label.setProperty("class", "btn play disabled") + self._play_label.setCursor(Qt.CursorShape.ArrowCursor) + refresh_widget_style(self._play_label) + + if self._prev_label is not None: + self._prev_label.setProperty("class", "btn prev disabled") + self._prev_label.setCursor(Qt.CursorShape.ArrowCursor) + refresh_widget_style(self._prev_label) + + if self._next_label is not None: + self._next_label.setProperty("class", "btn next disabled") + self._next_label.setCursor(Qt.CursorShape.ArrowCursor) + refresh_widget_style(self._next_label) + active_label = self._label_alt if self._show_alt_label else self._label active_label_content = self._label_alt_content if self._show_alt_label else self._label_content @@ -834,31 +928,50 @@ def _on_media_properties_changed(self): # Process label content if self.current_session is not None: - try: - items = ( - ("title", self.current_session.title), - ("artist", self.current_session.artist), - ) - formatted_info: dict[str, str] = {"s": self._separator} - for k, v in items: - formatted_info[k] = self._format_max_field_size(v) + # Check if we have actual media info (title or artist not empty) + has_media_info = bool(self.current_session.title or self.current_session.artist) - # Clean the label content from any empty placeholders or dangling separators - cleaned_content = clean_string(active_label_content, formatted_info) + if has_media_info: + try: + items = ( + ("title", self.current_session.title), + ("artist", self.current_session.artist), + ) + formatted_info: dict[str, str] = {"s": self._separator} + for k, v in items: + formatted_info[k] = self._format_max_field_size(v) - # Replace the remaining placeholders and separators - formatted_label = cleaned_content.format_map(formatted_info) + # Clean the label content from any empty placeholders or dangling separators + cleaned_content = clean_string(active_label_content, formatted_info) - # Finally, truncate the label if necessary - if self._max_field_size.get("truncate_whole_label"): - formatted_label = self._format_max_field_size(formatted_label) - except Exception as e: - logger.error(f"Error formatting label: {e}", exc_info=True) - if self.current_session and self.current_session.title: - formatted_label = self._format_max_field_size(self.current_session.title) + # Replace the remaining placeholders and separators + formatted_label = cleaned_content.format_map(formatted_info) + + # Finally, truncate the label if necessary + if self._max_field_size.get("truncate_whole_label"): + formatted_label = self._format_max_field_size(formatted_label) + except Exception as e: + logger.error(f"Error formatting label: {e}", exc_info=True) + if self.current_session and self.current_session.title: + formatted_label = self._format_max_field_size(self.current_session.title) + else: + formatted_label = "No media" + + # Only update text if it has changed to avoid resetting scroll position + current_text = active_label.text() + if current_text != formatted_label: + active_label.setText(formatted_label) + # Force update for scrolling labels to ensure animation starts + if isinstance(active_label, ScrollingLabel): + active_label.update() else: - formatted_label = "No media" - active_label.setText(formatted_label) + logger.debug(f"Text unchanged, skipping setText to preserve scroll position") + else: + # Session exists but no media info (player closed) + active_label.setText("No media playing") + else: + # No session - show a message + active_label.setText("No media playing") # If we don't want the thumbnail, stop here if not self._show_thumbnail: @@ -1128,9 +1241,11 @@ def _format_max_field_size(self, text: str, field_type: FieldTypes = "default"): def _create_media_button(self, icon: str, action: Callable[..., Any]): if not self._controls_hide: label = ClickableLabel(self) + # Start disabled by default - will be enabled when media is detected label.setProperty("class", "btn disabled") label.setAlignment(Qt.AlignmentFlag.AlignCenter) label.setText(icon) + label.setCursor(Qt.CursorShape.ArrowCursor) label.data = action self._widget_container_layout.addWidget(label) return label @@ -1144,9 +1259,25 @@ def _create_media_buttons(self): def execute_code(self, func: Callable[..., Any]): try: - func() + # In fallback mode without media app, don't execute - controls are disabled + if hasattr(self.media, '_fallback_mode') and self.media._fallback_mode: + if self.current_session is None or not bool(self.current_session.title or self.current_session.artist): + logger.info("Controls disabled - no media app detected") + return + + import asyncio + import inspect + + # Check if the function is a coroutine (async) + if inspect.iscoroutinefunction(func): + # Get the current event loop and schedule the coroutine + loop = asyncio.get_event_loop() + loop.create_task(func()) + else: + # Regular synchronous function + func() except Exception as e: - logger.error(f"Error executing code: {e}") + logger.error(f"Error executing code: {e}", exc_info=True) def wheelEvent(self, a0: QWheelEvent | None): if a0 is None: @@ -1406,16 +1537,20 @@ def __init__(self, parent: MediaWidget | None = None): self.data: Callable[..., Any] | None = None def mousePressEvent(self, ev: QMouseEvent | None): - if ev is None: - return - if ev.button() == Qt.MouseButton.LeftButton and self.data: - if self.parent_widget is None: + try: + if not ev: return - if self.parent_widget.animation["enabled"]: - AnimationManager.animate( - self, self.parent_widget.animation["type"], self.parent_widget.animation["duration"] - ) - self.parent_widget.execute_code(self.data) + + if ev.button() == Qt.MouseButton.LeftButton and self.data: + if self.parent_widget is None: + return + if self.parent_widget.animation["enabled"]: + AnimationManager.animate( + self, self.parent_widget.animation["type"], self.parent_widget.animation["duration"] + ) + self.parent_widget.execute_code(self.data) + except Exception as e: + logger.error(f"Exception in mousePressEvent: {e}", exc_info=True) class WheelEventFilter(QObject): diff --git a/src/core/widgets/yasb/overlay_container.py b/src/core/widgets/yasb/overlay_container.py new file mode 100644 index 000000000..7c48041e2 --- /dev/null +++ b/src/core/widgets/yasb/overlay_container.py @@ -0,0 +1,999 @@ +import logging + +from PyQt6.QtCore import QEvent, QPoint, QRect, Qt, QTimer +from PyQt6.QtWidgets import QFrame, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QWidget + +from core.config import get_config +from core.utils.utilities import add_shadow, refresh_widget_style +from core.utils.widget_builder import WidgetBuilder +from core.utils.widgets.overlay_container import OverlayBackgroundMedia, OverlayBackgroundShader +from core.validation.widgets.yasb.overlay_container import VALIDATION_SCHEMA +from core.widgets.base import BaseWidget + + +class OverlayPanel(QWidget): + """ + Overlay panel that integrates directly into the bar. + Positions itself relative to bar sections or specific widgets. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + # No window flags - this is a direct child of the bar + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + + # Container for the child widget + self._container = QFrame(self) + self._container.setProperty("class", "overlay-panel") + + # Layout for the child widget + self._layout = QHBoxLayout(self._container) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(0) + + # Main layout + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(self._container) + + self._child_widget = None + self._background_widget = None + self._pass_through = False + + def set_background_widget(self, widget): + """Set a background widget that will be positioned behind the child widget.""" + if self._background_widget: + self._background_widget.setParent(None) + self._background_widget.deleteLater() + + self._background_widget = widget + if widget: + # Set as child of container but don't add to layout + widget.setParent(self._container) + # Show the widget first (required for OpenGL initialization) + widget.show() + # Position it to cover the entire container + # Use QTimer to ensure container has valid geometry + QTimer.singleShot(0, lambda: self._update_background_geometry()) + # Lower it so it appears behind the child widget + widget.lower() + + def _update_background_geometry(self): + """Update background widget geometry to match container.""" + if self._background_widget and self._container: + rect = self._container.rect() + if rect.isValid() and rect.width() > 0 and rect.height() > 0: + # Apply offset if widget has offset attributes + offset_x = getattr(self._background_widget, "media_offset_x", 0) + offset_y = getattr(self._background_widget, "media_offset_y", 0) + + # Create new rect with offset applied + adjusted_rect = QRect(rect.x() + offset_x, rect.y() + offset_y, rect.width(), rect.height()) + self._background_widget.setGeometry(adjusted_rect) + else: + # Retry after a short delay if container doesn't have valid geometry yet + QTimer.singleShot(50, self._update_background_geometry) + + def set_child_widget(self, widget): + """Set the child widget to be displayed in the overlay.""" + if self._child_widget: + self._layout.removeWidget(self._child_widget) + self._child_widget.setParent(None) + + self._child_widget = widget + if widget: + self._layout.addWidget(widget) + + def set_pass_through(self, enabled: bool): + """Enable or disable mouse event pass-through.""" + self._pass_through = enabled + if enabled: + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + else: + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + + def resizeEvent(self, event): + """Update background widget geometry when container is resized.""" + super().resizeEvent(event) + if self._background_widget: + # Apply offset if widget has offset attributes + offset_x = getattr(self._background_widget, "media_offset_x", 0) + offset_y = getattr(self._background_widget, "media_offset_y", 0) + + rect = self._container.rect() + adjusted_rect = QRect(rect.x() + offset_x, rect.y() + offset_y, rect.width(), rect.height()) + self._background_widget.setGeometry(adjusted_rect) + + def cleanup(self): + """Clean up the overlay panel.""" + if self._background_widget: + self._background_widget.setParent(None) + self._background_widget.deleteLater() + self._background_widget = None + + if self._child_widget: + self._layout.removeWidget(self._child_widget) + self._child_widget.setParent(None) + self._child_widget = None + + self.setParent(None) + self.deleteLater() + + +class OverlayContainerWidget(BaseWidget): + """ + Container widget that creates an overlay integrated directly into the bar. + Can contain any child widget configured through YAML. + + New features: + - Direct bar integration (no separate window) + - Position relative to specific widgets + - Proper z-ordering (behind/above) + - Automatic resize tracking + + Use cases: + - Background visualization (e.g., cava behind media widget) + - Decorative overlays + - Additional information layers + """ + + validation_schema = VALIDATION_SCHEMA + + def __init__( + self, + target: str, + target_widget: str, + position: str, + offset_x: int, + offset_y: int, + width: str | int, + height: str | int, + opacity: float, + pass_through_clicks: bool, + z_index: int, + child_widget_name: str, + show_toggle: bool, + toggle_label: str, + auto_show: bool, + callbacks: dict[str, str], + container_padding: dict[str, int], + container_shadow: dict[str, any], + label_shadow: dict[str, any], + background_media: dict[str, any], + background_shader: dict[str, any], + ): + super().__init__(class_name="overlay-container-widget") + + # Configuration + self._target = target + self._target_widget = target_widget + self._position = position # "behind" or "above" + self._offset_x = offset_x + self._offset_y = offset_y + self._width = width + self._height = height + self._opacity = opacity + self._pass_through_clicks = pass_through_clicks + self._z_index = z_index + self._child_widget_name = child_widget_name + self._show_toggle = show_toggle + self._toggle_label = toggle_label + self._auto_show = auto_show + self._padding = container_padding + self._container_shadow = container_shadow + self._label_shadow = label_shadow + self._background_media = background_media + self._background_shader = background_shader + + # State + self._overlay_panel = None + self._child_widget = None + self._bar_widget = None + self._target_widget_ref = None + self._is_visible = auto_show + self._update_timer = None # Debounce timer for geometry updates + self._is_updating = False # Prevent recursive updates + self._is_cleaning_up = False # Prevent operations during cleanup + self._media_background = None # Media background handler + self._shader_background = None # Shader background handler + self._init_retry_count = 0 # Track initialization retries + self._max_init_retries = 50 # Max 5 seconds (50 * 100ms) + + # Setup UI + self._setup_ui() + + # Register callbacks + self.register_callback("toggle_overlay", self._toggle_overlay) + self.callback_left = callbacks.get("on_left", "toggle_overlay") + self.callback_middle = callbacks.get("on_middle", "do_nothing") + self.callback_right = callbacks.get("on_right", "do_nothing") + + # Defer initialization + QTimer.singleShot(100, self._initialize_overlay) + + def _setup_ui(self): + """Setup the UI for the widget.""" + logging.info( + f"OverlayContainerWidget._setup_ui: show_toggle={self._show_toggle}, child={self._child_widget_name}" + ) + + 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"] + ) + logging.debug(f"OverlayContainerWidget._setup_ui: margins set to {self._padding}") + + self._widget_container = QFrame() + self._widget_container.setLayout(self._widget_container_layout) + self._widget_container.setProperty("class", "toggle-container") + add_shadow(self._widget_container, self._container_shadow) + self.widget_layout.addWidget(self._widget_container) + logging.debug("OverlayContainerWidget._setup_ui: widget_container created and added to layout") + + # Toggle button (optional) + if self._show_toggle: + from PyQt6.QtWidgets import QSizePolicy + + self._toggle_button = QLabel(self._toggle_label) + self._toggle_button.setProperty("class", "toggle-button") + self._toggle_button.setCursor(Qt.CursorShape.PointingHandCursor) + + # Set size policy to prevent toggle button from expanding + self._toggle_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + + # CRITICAL: Set minimum width via Qt property (cannot be overridden by CSS) + # This ensures toggle button is ALWAYS visible regardless of user CSS + self._toggle_button.setMinimumWidth(20) + + # Add inline stylesheet for padding (improves visibility) + # We use object name selector for specificity + self._toggle_button.setObjectName("yasb-overlay-toggle") + self._toggle_button.setStyleSheet(""" + QLabel#yasb-overlay-toggle { + min-width: 20px; + padding: 0px 4px; + } + """) + + add_shadow(self._toggle_button, self._label_shadow) + self._widget_container_layout.addWidget(self._toggle_button) + + logging.info( + f"OverlayContainerWidget._setup_ui: Toggle button created with label '{self._toggle_label}', min_width=20" + ) + logging.debug(f"OverlayContainerWidget._setup_ui: Toggle button visible={self._toggle_button.isVisible()}") + else: + # If no toggle, hide the container itself + logging.warning("OverlayContainerWidget._setup_ui: show_toggle=False, hiding entire widget") + self.hide() + + def _initialize_overlay(self): + """Initialize the overlay panel after bar context is available.""" + try: + logging.debug(f"OverlayContainerWidget._initialize_overlay: Starting initialization (retry {self._init_retry_count}/{self._max_init_retries})") + + # Wait for bar context + if not hasattr(self, "bar_id") or self.bar_id is None: + self._init_retry_count += 1 + + if self._init_retry_count >= self._max_init_retries: + logging.error(f"OverlayContainerWidget: Failed to initialize after {self._max_init_retries} retries (5 seconds). bar_id not available.") + logging.error(f"OverlayContainerWidget: Widget hierarchy: {self._get_widget_hierarchy()}") + return + + logging.debug(f"OverlayContainerWidget._initialize_overlay: Waiting for bar_id, retrying in 100ms ({self._init_retry_count}/{self._max_init_retries})") + QTimer.singleShot(100, self._initialize_overlay) + return + + logging.info(f"OverlayContainerWidget._initialize_overlay: bar_id={self.bar_id}") + + # Find the bar widget + self._bar_widget = self._find_bar_widget() + if not self._bar_widget: + logging.error("OverlayContainerWidget: Could not find bar widget") + logging.error(f"OverlayContainerWidget: Widget hierarchy: {self._get_widget_hierarchy()}") + return + + logging.info( + f"OverlayContainerWidget._initialize_overlay: Found bar widget: {self._bar_widget.__class__.__name__}" + ) + + # Create child widget + self._create_child_widget() + + # Create overlay panel + self._create_overlay_panel() + + # Show if auto_show is enabled + if self._auto_show: + logging.info("OverlayContainerWidget._initialize_overlay: auto_show=True, calling _show_overlay()") + self._show_overlay() + else: + logging.info("OverlayContainerWidget._initialize_overlay: auto_show=False, overlay will be hidden") + + # Set initial toggle button state + if self._show_toggle and hasattr(self, "_toggle_button"): + if self._is_visible: + self._toggle_button.setProperty("class", "toggle-button active") + else: + self._toggle_button.setProperty("class", "toggle-button") + refresh_widget_style(self._toggle_button) + logging.debug( + f"OverlayContainerWidget._initialize_overlay: Toggle button state set (visible={self._is_visible})" + ) + + # Install event filter for resize tracking + self._install_event_filters() + + logging.info(f"OverlayContainerWidget initialized with child: {self._child_widget_name}") + logging.info( + f"OverlayContainerWidget state: self.isVisible()={self.isVisible()}, _toggle_button.isVisible()={self._toggle_button.isVisible() if hasattr(self, '_toggle_button') else 'N/A'}" + ) + + except Exception as e: + logging.error(f"OverlayContainerWidget: Error initializing overlay: {e}", exc_info=True) + + def _get_widget_hierarchy(self): + """Get widget hierarchy for debugging.""" + hierarchy = [] + widget = self + while widget: + hierarchy.append(f"{widget.__class__.__name__}") + widget = widget.parent() + return " -> ".join(hierarchy) + + def _find_bar_widget(self): + """Find the bar widget that contains this widget.""" + parent = self.parent() + while parent: + if parent.__class__.__name__ == "Bar": + return parent + parent = parent.parent() + return None + + def _create_child_widget(self): + """Create the child widget dynamically using WidgetBuilder.""" + try: + # If no child widget name specified, that's OK - user might only want background + if not self._child_widget_name: + logging.info( + "OverlayContainerWidget: No child_widget_name specified, overlay will only show background" + ) + return + + config = get_config() + widgets_config = config.get("widgets", {}) + + if self._child_widget_name not in widgets_config: + logging.error(f"OverlayContainerWidget: Child widget '{self._child_widget_name}' not found in config") + return + + # Get child widget config + child_config = widgets_config[self._child_widget_name] + + # If child is a cava widget, limit bars_number based on bar_type to prevent lag + if child_config.get("type", "").endswith("CavaWidget"): + options = child_config.get("options", {}) + bar_type = options.get("bar_type", "bars") + bars_number = options.get("bars_number", 200) + + # Apply performance limits + if bar_type == "waves_mirrored" and bars_number > 100: + logging.warning( + f"OverlayContainerWidget: Clamping bars_number from {bars_number} to 100 for waves_mirrored to prevent lag" + ) + options["bars_number"] = 100 + elif bar_type == "waves" and bars_number > 150: + logging.warning( + f"OverlayContainerWidget: Clamping bars_number from {bars_number} to 150 for waves to prevent lag" + ) + options["bars_number"] = 150 + + # Use WidgetBuilder to create the child widget + widget_builder = WidgetBuilder(widgets_config) + self._child_widget = widget_builder._build_widget(self._child_widget_name) + + if not self._child_widget: + logging.error(f"OverlayContainerWidget: Failed to build child widget '{self._child_widget_name}'") + return + + # Propagate bar context + try: + self._child_widget.bar_id = self.bar_id + self._child_widget.monitor_hwnd = self.monitor_hwnd + self._child_widget.parent_layout_type = getattr(self, "parent_layout_type", None) + except Exception as e: + logging.debug(f"OverlayContainerWidget: Could not propagate bar context: {e}") + + logging.info(f"OverlayContainerWidget: Created child widget '{self._child_widget_name}'") + + except Exception as e: + logging.error(f"OverlayContainerWidget: Error creating child widget: {e}") + + def _create_overlay_panel(self): + """Create the overlay panel as a direct child of the bar.""" + logging.debug( + f"OverlayContainerWidget._create_overlay_panel: child_widget={self._child_widget is not None}, bar_widget={self._bar_widget is not None}" + ) + + # Bar widget is required, but child widget is optional (user might only want background) + if not self._bar_widget: + logging.error("OverlayContainerWidget: Cannot create overlay panel without bar widget") + return + + # Check if we have anything to display (child widget OR background) + has_background = self._background_shader.get("enabled", False) or self._background_media.get("enabled", False) + + if not self._child_widget and not has_background: + logging.error("OverlayContainerWidget: Cannot create overlay panel without child widget or background") + return + + # Create overlay panel as direct child of bar + logging.info("OverlayContainerWidget._create_overlay_panel: Creating OverlayPanel with bar parent") + self._overlay_panel = OverlayPanel(self._bar_widget) + logging.debug( + f"OverlayContainerWidget._create_overlay_panel: OverlayPanel created, visible={self._overlay_panel.isVisible()}" + ) + + # Priority: shader > media (only one can be active at a time) + # Shader has priority because it's more advanced and can be customized more + + # Add background shader if enabled + if self._background_shader.get("enabled", False): + self._shader_background = OverlayBackgroundShader(self._background_shader, self._overlay_panel._container) + shader_widget = self._shader_background.get_widget() + if shader_widget: + # Set shader widget as background (will be positioned behind child widget) + self._overlay_panel.set_background_widget(shader_widget) + logging.info("OverlayContainerWidget: Added background shader to overlay panel") + # Add background media if enabled and shader is not + elif self._background_media.get("enabled", False): + self._media_background = OverlayBackgroundMedia(self._background_media, self._overlay_panel._container) + media_widget = self._media_background.get_widget() + if media_widget: + # Set media widget as background (will be positioned behind child widget) + self._overlay_panel.set_background_widget(media_widget) + logging.info("OverlayContainerWidget: Added background media to overlay panel") + + self._overlay_panel.set_child_widget(self._child_widget) + + # Apply opacity using QGraphicsOpacityEffect (works for child widgets) + if self._opacity < 1.0: + opacity_effect = QGraphicsOpacityEffect() + opacity_effect.setOpacity(self._opacity) + self._overlay_panel.setGraphicsEffect(opacity_effect) + + self._overlay_panel.set_pass_through(self._pass_through_clicks) + + logging.info("OverlayContainerWidget: Created overlay panel") + + def _install_event_filters(self): + """Install event filters for resize tracking.""" + if self._bar_widget: + self._bar_widget.installEventFilter(self) + logging.debug("OverlayContainerWidget: Installed event filter on bar widget") + + # If targeting a specific widget, install filter on it too + if self._target == "widget" and self._target_widget: + target_widget = self._find_target_widget() + if target_widget: + self._target_widget_ref = target_widget + target_widget.installEventFilter(self) + logging.debug( + f"OverlayContainerWidget: Installed event filter on target widget '{self._target_widget}'" + ) + + # If targeting a section, install filters on all widgets in that section + elif self._target in ["left", "center", "right"]: + section_container = self._find_section_container(self._target) + if section_container: + section_container.installEventFilter(self) + logging.debug(f"OverlayContainerWidget: Installed event filter on section container '{self._target}'") + + # Also install on widgets inside the section + for widget in section_container.findChildren(BaseWidget): + widget.installEventFilter(self) + logging.debug( + f"OverlayContainerWidget: Installed event filter on widget {widget.__class__.__name__} in section '{self._target}'" + ) + + def eventFilter(self, obj, event): + """Handle events for resize tracking.""" + event_type = event.type() + + if event_type in (QEvent.Type.Resize, QEvent.Type.Move, QEvent.Type.Show, QEvent.Type.Hide): + obj_name = obj.__class__.__name__ + + # Check if event is from tracked objects + should_update = False + + if obj == self._bar_widget: + logging.debug(f"OverlayContainerWidget: Bar widget event: {event_type.name}") + should_update = True + elif obj == self._target_widget_ref: + logging.debug(f"OverlayContainerWidget: Target widget ({obj_name}) event: {event_type.name}") + should_update = True + elif isinstance(obj, (BaseWidget, QFrame)): + # Event from widget in section - update if we're tracking sections + if self._target in ["left", "center", "right"]: + logging.debug(f"OverlayContainerWidget: Section widget ({obj_name}) event: {event_type.name}") + should_update = True + + if should_update: + self._schedule_geometry_update() + + return super().eventFilter(obj, event) + + def _schedule_geometry_update(self): + """Schedule a geometry update with debouncing to prevent flickering.""" + # Don't schedule updates if we're cleaning up + if self._is_cleaning_up: + logging.debug("OverlayContainerWidget: Skipping geometry update during cleanup") + return + + # Don't schedule if timer is None (already cleaned up) + if self._update_timer is None and hasattr(self, '_is_cleaning_up') and self._is_cleaning_up: + return + + # Cancel any pending update + if self._update_timer: + try: + self._update_timer.stop() + except (RuntimeError, AttributeError): + # Timer already destroyed + return + + # Schedule new update after short delay + if not self._update_timer: + try: + self._update_timer = QTimer(self) + self._update_timer.setSingleShot(True) + self._update_timer.timeout.connect(self._update_overlay_geometry) + except (RuntimeError, AttributeError): + # Widget already destroyed + return + + # 50ms debounce - accumulates rapid changes + try: + self._update_timer.start(50) + except (RuntimeError, AttributeError): + # Timer already destroyed + pass + + def _find_target_widget(self): + """Find the target widget by name.""" + if not self._target_widget or not self._bar_widget: + logging.debug("OverlayContainerWidget: _find_target_widget called without target_widget or bar_widget") + return None + + logging.debug(f"OverlayContainerWidget: Searching for target widget '{self._target_widget}'") + + try: + config = get_config() + widgets_config = config.get("widgets", {}) + + # Get the target widget's type + if self._target_widget not in widgets_config: + logging.warning(f"OverlayContainerWidget: Widget '{self._target_widget}' not found in config") + return None + + target_config = widgets_config[self._target_widget] + target_type = target_config.get("type", "") + + logging.debug(f"OverlayContainerWidget: Target widget type: {target_type}") + + # Extract class name from type (e.g., "yasb.media.MediaWidget" -> "MediaWidget") + target_class_name = target_type.split(".")[-1] if target_type else "" + + if not target_class_name: + logging.warning(f"OverlayContainerWidget: Could not determine class for '{self._target_widget}'") + return None + + # Find all widgets of this type in the bar + matching_widgets = [] + for widget in self._bar_widget.findChildren(BaseWidget): + logging.debug(f"OverlayContainerWidget: Checking widget: {widget.__class__.__name__}") + if widget.__class__.__name__ == target_class_name: + matching_widgets.append(widget) + logging.debug(f"OverlayContainerWidget: Found matching widget: {widget.__class__.__name__}") + + # If only one match, return it + if len(matching_widgets) == 1: + logging.info( + f"OverlayContainerWidget: Found target widget '{self._target_widget}' ({target_class_name}) at {matching_widgets[0].geometry()}" + ) + return matching_widgets[0] + elif len(matching_widgets) > 1: + logging.warning( + f"OverlayContainerWidget: Multiple widgets of type '{target_class_name}' found ({len(matching_widgets)}). " + f"Using the first one." + ) + return matching_widgets[0] + else: + logging.warning(f"OverlayContainerWidget: No widget of type '{target_class_name}' found") + # List all available widgets for debugging + all_widgets = [w.__class__.__name__ for w in self._bar_widget.findChildren(BaseWidget)] + logging.debug(f"OverlayContainerWidget: Available widgets: {all_widgets}") + return None + + except Exception as e: + logging.error(f"OverlayContainerWidget: Error finding target widget: {e}", exc_info=True) + return None + + def _update_overlay_geometry(self): + """Update overlay geometry based on target configuration.""" + if not self._overlay_panel or not self._bar_widget: + logging.debug("OverlayContainerWidget: _update_overlay_geometry called without overlay_panel or bar_widget") + return + + # Prevent recursive updates + if self._is_updating: + logging.debug("OverlayContainerWidget: Skipping recursive geometry update") + return + + self._is_updating = True + try: + logging.debug(f"OverlayContainerWidget: Updating geometry with target='{self._target}'") + + # Calculate target geometry + if self._target == "widget" and self._target_widget: + logging.debug( + f"OverlayContainerWidget: Using widget geometry for target_widget='{self._target_widget}'" + ) + target_rect = self._calculate_widget_geometry() + elif self._target == "full": + logging.debug("OverlayContainerWidget: Using full bar geometry") + target_rect = self._calculate_full_geometry() + elif self._target in ["left", "center", "right"]: + logging.debug(f"OverlayContainerWidget: Using section geometry for section='{self._target}'") + target_rect = self._calculate_section_geometry(self._target) + else: # custom + logging.debug("OverlayContainerWidget: Using custom geometry") + target_rect = self._calculate_custom_geometry() + + if target_rect is None: + logging.warning("OverlayContainerWidget: target_rect is None, cannot set geometry") + return + + logging.debug(f"OverlayContainerWidget: Calculated rect before offset: {target_rect}") + + # Apply offsets + if self._offset_x != 0 or self._offset_y != 0: + target_rect.translate(self._offset_x, self._offset_y) + logging.debug( + f"OverlayContainerWidget: Applied offset ({self._offset_x}, {self._offset_y}), new rect: {target_rect}" + ) + + # Set geometry + self._overlay_panel.setGeometry(target_rect) + logging.info(f"OverlayContainerWidget: Set overlay geometry to {target_rect}") + + # Set z-order + self._update_z_order() + + except Exception as e: + logging.error(f"OverlayContainerWidget: Error updating geometry: {e}", exc_info=True) + finally: + self._is_updating = False + + def _calculate_widget_geometry(self) -> QRect: + """Calculate geometry relative to target widget.""" + if not self._target_widget_ref: + self._target_widget_ref = self._find_target_widget() + if not self._target_widget_ref: + logging.warning("OverlayContainerWidget: Target widget not found, falling back to full geometry") + return self._calculate_full_geometry() + + # Get global geometry of target widget + target_global_pos = self._target_widget_ref.mapToGlobal(QPoint(0, 0)) + bar_global_pos = self._bar_widget.mapToGlobal(QPoint(0, 0)) + + # Convert to bar-local coordinates + local_x = target_global_pos.x() - bar_global_pos.x() + local_y = target_global_pos.y() - bar_global_pos.y() + + width = self._calculate_dimension(self._width, self._target_widget_ref.width()) + height = self._calculate_dimension(self._height, self._target_widget_ref.height()) + + return QRect(local_x, local_y, width, height) + + def _calculate_full_geometry(self) -> QRect: + """Calculate geometry for full bar coverage.""" + bar_rect = self._bar_widget.rect() + + width = self._calculate_dimension(self._width, bar_rect.width()) + height = self._calculate_dimension(self._height, bar_rect.height()) + + return QRect(0, 0, width, height) + + def _calculate_section_geometry(self, section: str) -> QRect: + """Calculate geometry for a specific section.""" + logging.debug(f"OverlayContainerWidget: Calculating geometry for section '{section}'") + container = self._find_section_container(section) + + if container: + # Get position relative to bar + container_pos = container.pos() + container_size = container.size() + + logging.debug( + f"OverlayContainerWidget: Section container found at pos={container_pos}, size={container_size}" + ) + + width = self._calculate_dimension(self._width, container.width()) + height = self._calculate_dimension(self._height, container.height()) + + rect = QRect(container_pos.x(), container_pos.y(), width, height) + logging.debug(f"OverlayContainerWidget: Section geometry calculated: {rect}") + return rect + + # Fallback + logging.warning( + f"OverlayContainerWidget: Section container '{section}' not found, falling back to full geometry" + ) + return self._calculate_full_geometry() + + def _find_section_container(self, section: str): + """Find the container widget for a specific section.""" + if not self._bar_widget: + logging.debug("OverlayContainerWidget: _find_section_container called without bar_widget") + return None + + class_name = f"container-{section}" + logging.debug(f"OverlayContainerWidget: Searching for section container with class '{class_name}'") + + containers_found = [] + for child in self._bar_widget.findChildren(QFrame): + child_class = child.property("class") + if child_class: + logging.debug(f"OverlayContainerWidget: Found QFrame with class: {child_class}") + if class_name in child_class: + containers_found.append(child) + logging.info( + f"OverlayContainerWidget: Found section container '{section}' with class '{child_class}'" + ) + return child + + if not containers_found: + logging.warning(f"OverlayContainerWidget: No section container found for '{section}'") + # List all containers for debugging + all_containers = [ + child.property("class") for child in self._bar_widget.findChildren(QFrame) if child.property("class") + ] + logging.debug(f"OverlayContainerWidget: Available containers: {all_containers}") + + return None + + def _calculate_custom_geometry(self) -> QRect: + """Calculate custom geometry.""" + bar_rect = self._bar_widget.rect() + + width = self._width if isinstance(self._width, int) else bar_rect.width() + height = self._height if isinstance(self._height, int) else bar_rect.height() + + return QRect(0, 0, width, height) + + def _calculate_dimension(self, dimension: str | int, reference: int) -> int: + """Calculate actual dimension from configuration value.""" + if isinstance(dimension, int): + return dimension + elif dimension == "auto": + return reference + else: + return reference + + def _update_z_order(self): + """Update z-order based on configuration.""" + if not self._overlay_panel: + return + + # Use raise_() and lower_() to control stacking + if self._position == "behind" or self._z_index == -1: + self._overlay_panel.lower() + elif self._position == "above" or self._z_index == 1: + self._overlay_panel.raise_() + # else z_index == 0, leave at default + + def _toggle_overlay(self): + """Toggle overlay visibility.""" + if self._is_visible: + self._hide_overlay() + else: + self._show_overlay() + + # Update toggle button visual state + if self._show_toggle and hasattr(self, "_toggle_button"): + if self._is_visible: + self._toggle_button.setProperty("class", "toggle-button active") + else: + self._toggle_button.setProperty("class", "toggle-button") + refresh_widget_style(self._toggle_button) + + def _show_overlay(self): + """Show the overlay panel.""" + logging.debug(f"OverlayContainerWidget._show_overlay: Called, overlay_panel={self._overlay_panel is not None}") + + if not self._overlay_panel: + logging.warning("OverlayContainerWidget._show_overlay: No overlay panel, returning early") + return + + self._is_visible = True + logging.debug("OverlayContainerWidget._show_overlay: Set _is_visible=True") + + self._update_overlay_geometry() + logging.debug("OverlayContainerWidget._show_overlay: Updated geometry") + + self._overlay_panel.show() + logging.info( + f"OverlayContainerWidget._show_overlay: Called show() on overlay_panel, visible={self._overlay_panel.isVisible()}" + ) + + self._update_z_order() + logging.debug( + f"OverlayContainerWidget._show_overlay: Updated z-order (position={self._position}, z_index={self._z_index})" + ) + + logging.info("OverlayContainerWidget: Overlay shown successfully") + + def _hide_overlay(self): + """Hide the overlay panel.""" + if not self._overlay_panel: + return + + self._is_visible = False + self._overlay_panel.hide() + + logging.debug("OverlayContainerWidget: Overlay hidden") + + def showEvent(self, event): + """Handle show event.""" + super().showEvent(event) + + # When widget becomes visible again (e.g., Media Widget re-activated), + # restore overlay visibility if it was visible before + if self._overlay_panel and self._is_visible: + # Schedule overlay show and geometry update + QTimer.singleShot(0, self._show_overlay_after_widget_show) + + def _show_overlay_after_widget_show(self): + """Helper to show overlay after widget is shown.""" + if self._overlay_panel and self._is_visible: + self._overlay_panel.show() + self._update_overlay_geometry() + self._update_z_order() + + def hideEvent(self, event): + """Handle hide event.""" + super().hideEvent(event) + + if self._overlay_panel: + self._overlay_panel.hide() + + def cleanup(self): + """Clean up the widget with defensive error handling to prevent crashes.""" + logging.debug("OverlayContainerWidget: Starting cleanup") + + # Mark as cleaning up to prevent new operations + self._is_cleaning_up = True + + # Stop update timer first - CRITICAL to prevent callbacks on destroyed objects + if self._update_timer: + try: + self._update_timer.stop() + self._update_timer.timeout.disconnect() # Disconnect all slots + self._update_timer.deleteLater() + except (RuntimeError, AttributeError) as e: + # Timer already deleted or disconnected + logging.debug(f"OverlayContainerWidget: Timer already cleaned: {e}") + except Exception as e: + logging.warning(f"OverlayContainerWidget: Error stopping update timer: {e}") + finally: + self._update_timer = None + + # Remove ALL event filters to prevent ghost events on destroyed objects + # This is CRITICAL - event filters on destroyed objects cause segfaults + try: + if self._bar_widget: + try: + self._bar_widget.removeEventFilter(self) + logging.debug("OverlayContainerWidget: Removed bar event filter") + except (RuntimeError, AttributeError): + # Widget already destroyed + pass + except Exception as e: + logging.debug(f"OverlayContainerWidget: Error removing bar event filter: {e}") + + if self._target_widget_ref: + try: + self._target_widget_ref.removeEventFilter(self) + logging.debug("OverlayContainerWidget: Removed target widget event filter") + except (RuntimeError, AttributeError): + # Widget already destroyed + pass + except Exception as e: + logging.debug(f"OverlayContainerWidget: Error removing target widget event filter: {e}") + + # Remove event filters from section widgets + if self._target in ["left", "center", "right"] and self._bar_widget: + try: + section_container = self._find_section_container(self._target) + if section_container: + try: + section_container.removeEventFilter(self) + except (RuntimeError, AttributeError): + pass + + # Remove from all child widgets + try: + for widget in section_container.findChildren(BaseWidget): + try: + widget.removeEventFilter(self) + except (RuntimeError, AttributeError): + # Widget already destroyed + pass + except (RuntimeError, AttributeError): + # Section container already destroyed + pass + except Exception as e: + logging.debug(f"OverlayContainerWidget: Error removing section event filters: {e}") + except Exception as e: + logging.warning(f"OverlayContainerWidget: Critical error removing event filters: {e}") + + # Clean up backgrounds BEFORE overlay panel to prevent OpenGL context errors + # Order matters: shader/media widgets must be destroyed before their parent + if self._shader_background: + try: + # Hide first to stop rendering + shader_widget = self._shader_background.get_widget() + if shader_widget: + try: + shader_widget.hide() + shader_widget.setParent(None) + except (RuntimeError, AttributeError): + pass + + self._shader_background.cleanup() + logging.debug("OverlayContainerWidget: Cleaned up shader background") + except Exception as e: + logging.warning(f"OverlayContainerWidget: Error cleaning up shader background: {e}", exc_info=True) + finally: + self._shader_background = None + + if self._media_background: + try: + # Hide first to stop rendering + media_widget = self._media_background.get_widget() + if media_widget: + try: + media_widget.hide() + media_widget.setParent(None) + except (RuntimeError, AttributeError): + pass + + self._media_background.cleanup() + logging.debug("OverlayContainerWidget: Cleaned up media background") + except Exception as e: + logging.warning(f"OverlayContainerWidget: Error cleaning up media background: {e}", exc_info=True) + finally: + self._media_background = None + + # Clean up overlay panel last + if self._overlay_panel: + try: + self._overlay_panel.cleanup() + logging.debug("OverlayContainerWidget: Cleaned up overlay panel") + except Exception as e: + logging.warning(f"OverlayContainerWidget: Error cleaning up overlay panel: {e}", exc_info=True) + finally: + self._overlay_panel = None + + # Clear all references to prevent dangling pointers + self._child_widget = None + self._bar_widget = None + self._target_widget_ref = None + + logging.info("OverlayContainerWidget: Cleanup completed successfully") diff --git a/src/core/widgets/yasb/terminal_menu.py b/src/core/widgets/yasb/terminal_menu.py new file mode 100644 index 000000000..2bc3cbb26 --- /dev/null +++ b/src/core/widgets/yasb/terminal_menu.py @@ -0,0 +1,287 @@ +""" +Terminal Menu Widget +Provides a dropdown menu to launch configured terminal applications with admin support. +""" + +import logging +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget +from core.utils.utilities import PopupWidget, add_shadow +from core.utils.widgets.animation_manager import AnimationManager +from core.validation.widgets.yasb.terminal_menu import VALIDATION_SCHEMA +from core.widgets.base import BaseWidget + +# Windows-specific imports for admin launch +try: + import ctypes + WINDOWS_AVAILABLE = True +except ImportError: + WINDOWS_AVAILABLE = False + logging.warning("Windows-specific modules not available. Admin launch will not work.") + + +class ClickableTerminalRow(QWidget): + """Clickable row widget for terminal menu items.""" + + def __init__(self, terminal_info, shield_icon, parent_widget, parent=None): + super().__init__(parent) + self.terminal_info = terminal_info + self.shield_icon = shield_icon + self.parent_widget = parent_widget + self.setCursor(Qt.CursorShape.PointingHandCursor) + + # Create inner widget with menu-item class (like disk widget structure) + inner_widget = QWidget(self) + inner_widget.setProperty("class", "menu-item") + inner_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + # Create layout for inner widget + h_layout = QHBoxLayout(inner_widget) + h_layout.setContentsMargins(0, 0, 0, 0) + h_layout.setSpacing(8) + + # Terminal name + name_label = QLabel(terminal_info.get("name", "Terminal")) + name_label.setProperty("class", "terminal-name") + name_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + name_label.setIndent(8) # Add left indent to the label text + h_layout.addWidget(name_label, 1) + + # Admin shield icon (button) + self.shield_label = QLabel(shield_icon) + self.shield_label.setProperty("class", "admin-button") + self.shield_label.setToolTip("Launch as Administrator") + self.shield_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + h_layout.addWidget(self.shield_label, 0) + + # Main layout for outer widget + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(inner_widget, 1) # Stretch factor 1 + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + # Check if click is on admin shield area (right side) + shield_rect = self.shield_label.geometry() + if shield_rect.contains(event.pos()): + self.parent_widget._launch_terminal_admin(self.terminal_info) + else: + self.parent_widget._launch_terminal(self.terminal_info) + super().mousePressEvent(event) + + +class TerminalMenuWidget(BaseWidget): + """ + Terminal Menu Widget - Dropdown launcher for terminal applications. + + Features: + - Configurable list of terminal applications + - Normal and administrator launch support + - Customizable icons and labels + - Dropdown menu on click + """ + + validation_schema = VALIDATION_SCHEMA + + def __init__( + self, + label: str, + terminal_list: list[dict], + container_padding: dict[str, int], + blur: bool, + round_corners: bool, + round_corners_type: str, + border_color: str, + alignment: str, + direction: str, + offset_top: int, + offset_left: int, + shield_icon: str, + animation: dict, + callbacks: dict[str, str], + label_shadow: dict = None, + container_shadow: dict = None, + ): + super().__init__(0, class_name="terminal-menu-widget") + + self._label_content = label + self._terminal_list = terminal_list + self._padding = container_padding + self._blur = blur + self._round_corners = round_corners + self._round_corners_type = round_corners_type + self._border_color = border_color + self._alignment = alignment + self._direction = direction + self._offset_top = offset_top + self._offset_left = offset_left + self._shield_icon = shield_icon + self._animation = animation + self._label_shadow = label_shadow + self._container_shadow = container_shadow + + # 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") + add_shadow(self._widget_container, self._container_shadow) + + # Add the container to the main widget layout + self.widget_layout.addWidget(self._widget_container) + + # Create label (icon) + self._label = QLabel(self._label_content) + self._label.setProperty("class", "icon") + self._label.setCursor(Qt.CursorShape.PointingHandCursor) + self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) + add_shadow(self._label, self._label_shadow) + self._widget_container_layout.addWidget(self._label) + + # Register callbacks + self.register_callback("toggle_menu", self._toggle_menu) + + self.callback_left = callbacks.get("on_left", "toggle_menu") + + self.menu_dialog = None + + def _toggle_menu(self): + """Toggle the terminal menu dropdown.""" + if self._animation["enabled"]: + AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) + + if hasattr(self, "menu_dialog") and self.menu_dialog: + self.menu_dialog.hide() + + self._show_menu() + + def _show_menu(self): + """Display the terminal menu dropdown.""" + self.menu_dialog = PopupWidget( + self, + self._blur, + self._round_corners, + self._round_corners_type, + self._border_color, + ) + self.menu_dialog.setProperty("class", "terminal-menu") + + layout = QVBoxLayout() + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + for terminal in self._terminal_list: + row = ClickableTerminalRow(terminal, self._shield_icon, self) + layout.addWidget(row) + + self.menu_dialog.setLayout(layout) + self.menu_dialog.adjustSize() + + # Position the dialog + self.menu_dialog.setPosition( + alignment=self._alignment, + direction=self._direction, + offset_left=self._offset_left, + offset_top=self._offset_top, + ) + + self.menu_dialog.show() + + def _launch_terminal(self, terminal): + """Launch terminal normally using ShellExecuteW for consistency.""" + if not WINDOWS_AVAILABLE: + logging.error("Windows-specific modules not available. Cannot launch terminal.") + return + + path = terminal.get("path", "") + if not path: + logging.error(f"Terminal path not specified for {terminal.get('name', 'Unknown')}") + return + + try: + # Use ShellExecuteW with "open" verb for normal launch + # This provides consistent behavior with admin launch and better path handling + shell32 = ctypes.windll.shell32 + + # ShellExecuteW parameters + hwnd = None + operation = "open" # Normal launch (not elevated) + file = path + parameters = None + directory = None + show_cmd = 1 # SW_SHOWNORMAL + + result = shell32.ShellExecuteW( + hwnd, + operation, + file, + parameters, + directory, + show_cmd + ) + + # ShellExecuteW returns a value > 32 on success + if result > 32: + logging.info(f"Launched terminal: {terminal.get('name', path)}") + if self.menu_dialog: + self.menu_dialog.hide() + self.menu_dialog = None + else: + logging.error(f"Failed to launch terminal. Error code: {result}") + + except Exception as e: + logging.error(f"Failed to launch terminal {terminal.get('name', path)}: {e}") + + def _launch_terminal_admin(self, terminal): + """Launch terminal as administrator using Windows ShellExecute.""" + if not WINDOWS_AVAILABLE: + logging.error("Windows-specific modules not available. Cannot launch as admin.") + return + + path = terminal.get("path", "") + if not path: + logging.error(f"Terminal path not specified for {terminal.get('name', 'Unknown')}") + return + + try: + # Use ShellExecuteW with "runas" verb for admin elevation + shell32 = ctypes.windll.shell32 + + # ShellExecuteW parameters + hwnd = None + operation = "runas" + file = path + parameters = None + directory = None + show_cmd = 1 # SW_SHOWNORMAL + + result = shell32.ShellExecuteW( + hwnd, + operation, + file, + parameters, + directory, + show_cmd + ) + + # ShellExecuteW returns a value > 32 on success + if result > 32: + logging.info(f"Launched terminal as admin: {terminal.get('name', path)}") + if self.menu_dialog: + self.menu_dialog.hide() + self.menu_dialog = None + else: + logging.error(f"Failed to launch terminal as admin. Error code: {result}") + + except Exception as e: + logging.error(f"Failed to launch terminal as admin {terminal.get('name', path)}: {e}")