diff --git a/docs/widgets/(Widget)-Image-Gif.md b/docs/widgets/(Widget)-Image-Gif.md new file mode 100644 index 00000000..5baa475f --- /dev/null +++ b/docs/widgets/(Widget)-Image-Gif.md @@ -0,0 +1,61 @@ +# 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_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" + 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_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 0a563d5a..d75b30cd 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -39,29 +39,29 @@ bars: right: 4 widgets: left: - - "home" - - "komorebi_workspaces" - - "komorebi_active_layout" - - "active_window" + - "home" + - "komorebi_workspaces" + - "komorebi_active_layout" + - "active_window" 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 +131,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" 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 00000000..2905ab84 --- /dev/null +++ b/src/core/validation/widgets/yasb/image_gif.py @@ -0,0 +1,108 @@ +DEFAULTS = { + "label": "", + "label_alt": "{file_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 00000000..c27eda0f --- /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 QFrame, QHBoxLayout, QLabel + +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 = 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_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