From e0e45cedfb1c84db9fb2e7a5b7dc460b622609a9 Mon Sep 17 00:00:00 2001 From: strillard Date: Mon, 25 Aug 2025 14:33:56 +0200 Subject: [PATCH 1/3] Add Image-Gif Widget --- docs/widgets/(Widget)-Image-Gif.md | 68 +++++++ src/config.yaml | 55 ++++-- src/core/validation/widgets/yasb/image_gif.py | 108 +++++++++++ src/core/widgets/yasb/image_gif.py | 183 ++++++++++++++++++ 4 files changed, 397 insertions(+), 17 deletions(-) create mode 100644 docs/widgets/(Widget)-Image-Gif.md create mode 100644 src/core/validation/widgets/yasb/image_gif.py create mode 100644 src/core/widgets/yasb/image_gif.py diff --git a/docs/widgets/(Widget)-Image-Gif.md b/docs/widgets/(Widget)-Image-Gif.md new file mode 100644 index 000000000..5b5956c13 --- /dev/null +++ b/docs/widgets/(Widget)-Image-Gif.md @@ -0,0 +1,68 @@ +# Image/Gif Widget Configuration + +| Option | Type | Default | Description | +|-------------------------|---------|----------------------------------------------|-----------------------------------------------------------------------------| +| `label` | string | `` | The primary label format. | +| `label_alt` | string | `{file_path}` | The alternative label format. +| `update_interval` | integer | `5000` | The interval in milliseconds to update the widget. | +| `file_path` | string | `` | Path to file. Can be : png, jpg, gif, webp | +| `speed`| integer | `100` | Playback speed (percentage) | +| `height`| integer | `24` | Height of the image/gif inside the bar. Can act differently if **KeepAspectRatio** is **True** or **False** | +| `width` | integer | `24` | Width of the image/gif inside the bar. Can act differently if **KeepAspectRatio** is **True** or **False** | +| `keep_aspect_ratio` | boolean | `True` | Keep aspect ratio of current image/gif +| `callbacks` | dict | `{on_left: 'toggle_label', on_middle: 'pause_gif', on_right: 'do_nothing'}` | Callback functions for different mouse button actions. | +| `animation` | dict | `{'enabled': True, 'type': 'fadeInOut', 'duration': 200}` | Animation settings for the widget. | +| `container_padding` | dict | `{'top': 0, 'left': 0, 'bottom': 0, 'right': 0}` | Explicitly set padding inside widget container. | +| `container_shadow` | dict | `None` | Container shadow options. | +| `label_shadow` | dict | `None` | Label shadow options. | + +## Example Configuration +```yaml +gif: + type: "yasb.image.ImageWidget" + options: + label: "" + label_alt: "{file_path}" + file_path: "C:\\Users\\stant\\Desktop\\your_file.gif" + update_interval: 5000 + callbacks: + on_left: "toggle_label" + on_middle: "pause_gif" + on_right: "do_nothing" + container_padding: + top: 0 + left: 6 + bottom: 0 + right: 6 + speed: 100 + height: 24 + width: 24 + keep_aspect_ratio: True +``` + +## Description of Options + +- **label**: The primary label format for the widget. You can use placeholders like `{file_path}`, `{speed}` or `{file_name}` here. +- **label_alt**: The alternative label format for the widget. +- **update_interval**: The interval in milliseconds to update the widget. +- **file_path**: A string that contain the path to the file. It can be : **png, jpg, gif, webp** +- **speed**: Playback speed. Only useful if current file is a gif or a webp. 100 = normal speed, 50 = half speed, 200 = x2 speed +- **height**: Height of the image/gif inside the bar. Can act differently if **KeepAspectRatio** is **True** or **False** +- **width**: Width of the image/gif inside the bar. Can act differently if **KeepAspectRatio** is **True** or **False** +- **keep_aspect_ratio**: A boolean indicating whether to keep current file aspect ratio when displayed. +- **callbacks**: A dictionary specifying the callbacks for mouse events. It contains: + - **on_left**: The name of the callback function for left mouse button click. + - **on_middle**: The name of the callback function for middle mouse button click. + - **on_right**: The name of the callback function for right mouse button click. +- **animation**: A dictionary specifying the animation settings for the widget. It contains three keys: `enabled`, `type`, and `duration`. The `type` can be `fadeInOut` and the `duration` is the animation duration in milliseconds. +- **container_padding**: Explicitly set padding inside widget container. Use this option to set padding inside the widget container. You can set padding for top, left, bottom and right sides of the widget container. +- **container_shadow**: Container shadow options. +- **label_shadow**: Label shadow options. + +## Example Style +```css +.image-gif-widget {} +.image-gif-widget .widget-container {} +.image-gif-widget .widget-container .label {} +.image-gif-widget .widget-container .label.alt {} +``` \ No newline at end of file diff --git a/src/config.yaml b/src/config.yaml index 0a563d5a8..0510d22ad 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -39,29 +39,30 @@ bars: right: 4 widgets: left: - - "home" - - "komorebi_workspaces" - - "komorebi_active_layout" - - "active_window" + - "home" + - "komorebi_workspaces" + - "komorebi_active_layout" + - "active_window" + - "gif" center: - - "clock" + - "clock" right: - - "media" - - "weather" - - "microphone" - - "volume" - - "notifications" - - "power_menu" + - "media" + - "weather" + - "microphone" + - "volume" + - "notifications" + - "power_menu" widgets: home: type: "yasb.home.HomeWidget" options: label: "\udb81\udf17" menu_list: - - { title: "User Home", path: "~" } - - { title: "Download", path: "~\\Downloads" } - - { title: "Documents", path: "~\\Documents" } - - { title: "Pictures", path: "~\\Pictures" } + - { title: "User Home", path: "~" } + - { title: "Download", path: "~\\Downloads" } + - { title: "Documents", path: "~\\Documents" } + - { title: "Pictures", path: "~\\Pictures" } system_menu: true power_menu: true blur: false @@ -131,7 +132,7 @@ widgets: label: "{%a, %d %b %H:%M}" label_alt: "{%A, %d %B %Y %H:%M}" timezones: [] - calendar: + calendar: blur: false round_corners: false alignment: "center" @@ -281,4 +282,24 @@ widgets: callbacks: on_left: "toggle_notification" on_right: "do_nothing" - on_middle: "toggle_label" \ No newline at end of file + on_middle: "toggle_label" + gif: + type: "yasb.image_gif.ImageGifWidget" + options: + label: "" + label_alt: "{file_name}" + file_path: "path/your_file.gif" + update_interval: 5000 + callbacks: + on_left: "toggle_label" + on_middle: "pause_gif" + on_right: "do_nothing" + container_padding: + top: 0 + left: 6 + bottom: 0 + right: 6 + speed: 100 + height: 24 + width: 24 + keep_aspect_ratio: False \ No newline at end of file diff --git a/src/core/validation/widgets/yasb/image_gif.py b/src/core/validation/widgets/yasb/image_gif.py new file mode 100644 index 000000000..61ad9b0cc --- /dev/null +++ b/src/core/validation/widgets/yasb/image_gif.py @@ -0,0 +1,108 @@ +DEFAULTS = { + "label": "", + "label_alt": "{gif_path}", + "file_path": "", + "width": 24, + "height": 24, + "speed": 100, + "keep_aspect_ratio": True, + "update_interval": 5000, + "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, + "container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0}, + "callbacks": {"on_left": "toggle_label", "on_middle": "pause_gif", "on_right": "do_nothing"}, +} + +VALIDATION_SCHEMA = { + "label": { + "type": "string", + "default": DEFAULTS["label"], + }, + "label_alt": { + "type": "string", + "default": DEFAULTS["label_alt"], + }, + "file_path": { + "type": "string", + "default": DEFAULTS["file_path"], + }, + "width": { + "type": "integer", + "default": DEFAULTS["width"], + }, + "height": { + "type": "integer", + "default": DEFAULTS["height"], + }, + "speed": { + "type": "integer", + "default": DEFAULTS["speed"], + }, + "keep_aspect_ratio": { + "type": "boolean", + "default": DEFAULTS["keep_aspect_ratio"], + }, + "update_interval": { + "type": "integer", + "default": DEFAULTS["update_interval"], + }, + "callbacks": { + "type": "dict", + "schema": { + "on_left": { + "type": "string", + "nullable": True, + "default": DEFAULTS["callbacks"]["on_left"], + }, + "on_middle": { + "type": "string", + "nullable": True, + "default": DEFAULTS["callbacks"]["on_middle"], + }, + "on_right": {"type": "string", "nullable": True, "default": DEFAULTS["callbacks"]["on_right"]}, + }, + "default": DEFAULTS["callbacks"], + }, + "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"], + }, + "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"], "min": 0}, + }, + "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}, + }, +} diff --git a/src/core/widgets/yasb/image_gif.py b/src/core/widgets/yasb/image_gif.py new file mode 100644 index 000000000..3e5e09ab5 --- /dev/null +++ b/src/core/widgets/yasb/image_gif.py @@ -0,0 +1,183 @@ +import os +import re + +from PyQt6.QtCore import QSize, Qt, QTimer +from PyQt6.QtGui import QImageReader, QMovie +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QWidget + +from core.utils.utilities import add_shadow, build_widget_label +from core.utils.widgets.animation_manager import AnimationManager +from core.validation.widgets.yasb.image_gif import VALIDATION_SCHEMA +from core.widgets.base import BaseWidget + + +class ImageGifWidget(BaseWidget): + validation_schema = VALIDATION_SCHEMA + + _instances: list["ImageGifWidget"] = [] + _shared_timer: QTimer | None = None + + def __init__( + self, + label: str, + label_alt: str, + file_path: str, + width: int, + height: int, + speed: int, + keep_aspect_ratio: bool, + animation: dict[str, str], + update_interval: int = 0, + callbacks: dict = None, + container_padding: dict = None, + label_shadow: dict = None, + container_shadow: dict = None, + **kwargs, + ): + super().__init__(class_name="image-gif-widget", **kwargs) + self._show_alt_label = False + self._label_content = label + self._label_alt_content = label_alt + self._update_interval = update_interval + self._padding = container_padding + self._label_shadow = label_shadow + self._container_shadow = container_shadow + self._speed = speed + self._keep_aspect_ratio = keep_aspect_ratio + self._animation = animation + self._file_path = file_path + self._width = width + self._height = height + + self._movie_label = QLabel() + self._movie_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._movie = QMovie() + + # Construct container + self._widget_container_layout: QHBoxLayout = 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: QWidget = QWidget() + 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_container_layout.addWidget(self._movie_label) + self.widget_layout.addWidget(self._widget_container) + + build_widget_label(self, self._label_content, self._label_alt_content, None) + + self._setup() + + # Callbacks + self.callback_left = callbacks.get("on_left", "do_nothing") + self.callback_right = callbacks.get("on_right", "do_nothing") + self.callback_middle = callbacks.get("on_middle", "do_nothing") + + self.register_callback("toggle_label", self._toggle_label) + self.register_callback("pause_gif", self._pause_gif) + + if self not in ImageGifWidget._instances: + ImageGifWidget._instances.append(self) + + if update_interval > 0 and ImageGifWidget._shared_timer is None: + ImageGifWidget._shared_timer = QTimer(self) + ImageGifWidget._shared_timer.setInterval(update_interval) + ImageGifWidget._shared_timer.timeout.connect(self._update_label) + ImageGifWidget._shared_timer.start() + + self._update_label() + + def _setup(self): + """Image/Gif setup""" + file_path = self._file_path + + if not file_path or not os.path.exists(file_path): + self._show_error_placeholder() + return + + try: + if self._movie: + self._movie.stop() + self._movie.deleteLater() + + self._movie = QMovie(file_path) + + if self._width or self._height: + self._movie.setScaledSize(self._get_scaled_size()) + + self._movie.setSpeed(self._speed) + + self._movie_label.setMovie(self._movie) + self._movie.start() + + except Exception as e: + print(f"Error when loading file : {file_path}: {e}") + self._show_error_placeholder() + + def _show_error_placeholder(self): + """Display error when file could not be loaded.""" + self._movie_label.setText("Error loading file") + + def _toggle_label(self): + AnimationManager.animate(self, self._animation["type"], self._animation["duration"]) + self._show_alt_label = not self._show_alt_label + for widget in self._widgets: + widget.setVisible(not self._show_alt_label) + for widget in self._widgets_alt: + widget.setVisible(self._show_alt_label) + self._update_label() + + def _pause_gif(self): + if self._movie: + if self._movie.state() == QMovie.MovieState.Paused: + self._movie.setPaused(False) + else: + self._movie.setPaused(True) + + def _get_scaled_size(self): + """Get correct size for displaying image/gif.""" + if not self._movie: + return QSize(self._width, self._height) + + reader = QImageReader(self._file_path) + size = reader.size() + + target_width = self._width + target_height = self._height + + if self._keep_aspect_ratio: + return size.scaled(target_width, target_height, Qt.AspectRatioMode.KeepAspectRatio) + else: + return size.scaled(target_width, target_height, Qt.AspectRatioMode.IgnoreAspectRatio) + + def _update_label(self): + """Update label using current playback speed and file path.""" + active_widgets = self._widgets_alt if self._show_alt_label else self._widgets + active_label_content = self._label_alt_content if self._show_alt_label else self._label_content + label_parts = re.split("(.*?)", active_label_content) + label_parts = [part for part in label_parts if part] + widget_index = 0 + + label_options = { + "{speed}": self._speed, + "{file_path}": self._file_path, + "{file_name}": os.path.basename(self._file_path), + } + + for part in label_parts: + part = part.strip() + for fmt_str, value in label_options.items(): + part = part.replace(fmt_str, str(value)) + + if part and widget_index < len(active_widgets) and isinstance(active_widgets[widget_index], QLabel): + if "" in part: + icon = re.sub(r"|", "", part).strip() + active_widgets[widget_index].setText(icon) + else: + active_widgets[widget_index].setText(part) + widget_index += 1 From 87100ef36e598247f845a0b00a05070ffe793f12 Mon Sep 17 00:00:00 2001 From: Stanislas Trillard <159046288+strillard@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:03:20 +0200 Subject: [PATCH 2/3] fix label_alt --- src/core/validation/widgets/yasb/image_gif.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/validation/widgets/yasb/image_gif.py b/src/core/validation/widgets/yasb/image_gif.py index 61ad9b0cc..2905ab84d 100644 --- a/src/core/validation/widgets/yasb/image_gif.py +++ b/src/core/validation/widgets/yasb/image_gif.py @@ -1,6 +1,6 @@ DEFAULTS = { "label": "", - "label_alt": "{gif_path}", + "label_alt": "{file_path}", "file_path": "", "width": 24, "height": 24, From 135829623244c920e9cc0844ecffee24c295c20c Mon Sep 17 00:00:00 2001 From: strillard Date: Mon, 25 Aug 2025 19:13:30 +0200 Subject: [PATCH 3/3] Update (Widget)-Image-Gif.md (container_padding deprecated). Switch back to original default config file --- docs/widgets/(Widget)-Image-Gif.md | 7 ------- src/config.yaml | 23 +---------------------- src/core/widgets/yasb/image_gif.py | 4 ++-- 3 files changed, 3 insertions(+), 31 deletions(-) diff --git a/docs/widgets/(Widget)-Image-Gif.md b/docs/widgets/(Widget)-Image-Gif.md index 5b5956c13..5baa475f6 100644 --- a/docs/widgets/(Widget)-Image-Gif.md +++ b/docs/widgets/(Widget)-Image-Gif.md @@ -12,7 +12,6 @@ | `keep_aspect_ratio` | boolean | `True` | Keep aspect ratio of current image/gif | `callbacks` | dict | `{on_left: 'toggle_label', on_middle: 'pause_gif', on_right: 'do_nothing'}` | Callback functions for different mouse button actions. | | `animation` | dict | `{'enabled': True, 'type': 'fadeInOut', 'duration': 200}` | Animation settings for the widget. | -| `container_padding` | dict | `{'top': 0, 'left': 0, 'bottom': 0, 'right': 0}` | Explicitly set padding inside widget container. | | `container_shadow` | dict | `None` | Container shadow options. | | `label_shadow` | dict | `None` | Label shadow options. | @@ -29,11 +28,6 @@ gif: on_left: "toggle_label" on_middle: "pause_gif" on_right: "do_nothing" - container_padding: - top: 0 - left: 6 - bottom: 0 - right: 6 speed: 100 height: 24 width: 24 @@ -55,7 +49,6 @@ gif: - **on_middle**: The name of the callback function for middle mouse button click. - **on_right**: The name of the callback function for right mouse button click. - **animation**: A dictionary specifying the animation settings for the widget. It contains three keys: `enabled`, `type`, and `duration`. The `type` can be `fadeInOut` and the `duration` is the animation duration in milliseconds. -- **container_padding**: Explicitly set padding inside widget container. Use this option to set padding inside the widget container. You can set padding for top, left, bottom and right sides of the widget container. - **container_shadow**: Container shadow options. - **label_shadow**: Label shadow options. diff --git a/src/config.yaml b/src/config.yaml index 0510d22ad..d75b30cd7 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -43,7 +43,6 @@ bars: - "komorebi_workspaces" - "komorebi_active_layout" - "active_window" - - "gif" center: - "clock" right: @@ -282,24 +281,4 @@ widgets: callbacks: on_left: "toggle_notification" on_right: "do_nothing" - on_middle: "toggle_label" - gif: - type: "yasb.image_gif.ImageGifWidget" - options: - label: "" - label_alt: "{file_name}" - file_path: "path/your_file.gif" - update_interval: 5000 - callbacks: - on_left: "toggle_label" - on_middle: "pause_gif" - on_right: "do_nothing" - container_padding: - top: 0 - left: 6 - bottom: 0 - right: 6 - speed: 100 - height: 24 - width: 24 - keep_aspect_ratio: False \ No newline at end of file + on_middle: "toggle_label" \ No newline at end of file diff --git a/src/core/widgets/yasb/image_gif.py b/src/core/widgets/yasb/image_gif.py index 3e5e09ab5..c27eda0f5 100644 --- a/src/core/widgets/yasb/image_gif.py +++ b/src/core/widgets/yasb/image_gif.py @@ -3,7 +3,7 @@ from PyQt6.QtCore import QSize, Qt, QTimer from PyQt6.QtGui import QImageReader, QMovie -from PyQt6.QtWidgets import QHBoxLayout, QLabel, QWidget +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel from core.utils.utilities import add_shadow, build_widget_label from core.utils.widgets.animation_manager import AnimationManager @@ -60,7 +60,7 @@ def __init__( self._padding["left"], self._padding["top"], self._padding["right"], self._padding["bottom"] ) # Initialize container - self._widget_container: QWidget = QWidget() + 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)