From 19a2b925d578c7eca827e5be50c76bc293216dd5 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Wed, 30 Jul 2025 10:02:11 +0545 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20on-screen=20display?= =?UTF-8?q?=20for=20volume=20and=20brightness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.css | 1 + main.py | 7 +- modules/osd.py | 442 ++++++++++++++++++ .../panel/components/enhanced_system_tray.py | 159 +++++++ modules/panel/main.py | 47 +- styles/osd.css | 51 ++ 6 files changed, 663 insertions(+), 44 deletions(-) create mode 100644 modules/osd.py create mode 100644 modules/panel/components/enhanced_system_tray.py create mode 100644 styles/osd.css diff --git a/main.css b/main.css index f70444d..159db87 100644 --- a/main.css +++ b/main.css @@ -4,6 +4,7 @@ @import url("./styles/dock.css"); @import url("./styles/switcher.css"); @import url("./styles/launcher.css"); +@import url("./styles/osd.css"); * { all: unset; diff --git a/main.py b/main.py index d85f3d1..43265c6 100644 --- a/main.py +++ b/main.py @@ -10,14 +10,14 @@ from modules.panel.main import Panel from modules.dock import Dock from modules.switcher import ApplicationSwitcher from modules.launcher.main import Launcher - +from modules.osd import OSD for log in [ "fabric.hyprland.widgets", "fabric.audio.service", "fabric.bluetooth.service", "services.network", - "utils.wayland" + "utils.wayland", ]: logger.disable(log) @@ -36,6 +36,7 @@ if __name__ == "__main__": switcher = ApplicationSwitcher() launcher = Launcher() panel.launcher = launcher + osd = OSD() # Set corners visibility based on config # corners_visible = config.get("corners_visible", True) @@ -52,7 +53,7 @@ if __name__ == "__main__": _ = color_css_file.connect("changed", lambda *_: set_css()) # Make sure corners is added to the app - app = Application(f"{APP_NAME}", panel, dock, switcher, launcher) + app = Application(f"{APP_NAME}", panel, dock, switcher, launcher, osd) def set_css(): app.set_stylesheet_from_file( diff --git a/modules/osd.py b/modules/osd.py new file mode 100644 index 0000000..3667080 --- /dev/null +++ b/modules/osd.py @@ -0,0 +1,442 @@ +import time +from typing import ClassVar, Literal + +from fabric.audio import Audio +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scale import Scale, ScaleMark +from gi.repository import GLib, GObject + +import utils.icons as icons +from services.brightness import Brightness +from utils.animator import Animator +from utils.wayland import WaylandWindow as Window + + +class AnimatedScale(Scale): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.animator = None + + def animate_value(self, value: float): + if not self.animator: + self.animator = Animator( + bezier_curve=(0.34, 1.56, 0.64, 1.0), + duration=0.8, + min_value=self.min_value, + max_value=self.value, + tick_widget=self, + notify_value=lambda p, *_: self.set_value(p.value), + ) + self.animator.pause() + self.animator.min_value = self.value + self.animator.max_value = value + self.animator.play() + + +class BrightnessOSDContainer(Box): + def __init__(self, **kwargs): + super().__init__(**kwargs, orientation="h", spacing=12, name="osd") + self.brightness_service = Brightness.get_initial() + self.icon = Label(markup=icons.brightness, name="brighntess-icons") + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(0, 101, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + + self.add(self.icon) + self.add(self.scale) + self.update_brightness() + + self.scale.connect("value-changed", lambda *_: self.update_brightness()) + self.brightness_service.connect("screen", self.on_brightness_changed) + + def update_brightness(self) -> None: + current_brightness = self.brightness_service.screen_brightness + normalized_brightness = self._normalize_brightness(current_brightness) + if current_brightness != 0: + self.scale.animate_value(normalized_brightness) + self.update_icon(normalized_brightness) + + def update_icon(self, brightness_percentage: float) -> None: + icon_name = self.get_brightness_icon(brightness_percentage) + self.icon.set_label(icon_name) + + def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: + normalized_brightness = self._normalize_brightness(value) + self.scale.animate_value(normalized_brightness) + self.update_icon(normalized_brightness) + + def _normalize_brightness(self, brightness: float) -> float: + return (brightness / self.brightness_service.max_screen) * 100 + + def get_brightness_icon(self, brightness_percentage: float) -> str: + if brightness_percentage >= 67: + return icons.brightness_high + elif brightness_percentage >= 34: + return icons.brightness_medium + else: + return icons.brightness_low + + +class AudioOSDContainer(Box): + __gsignals__: ClassVar[dict] = { + "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), + } + + def __init__(self, **kwargs): + super().__init__(**kwargs, orientation="h", spacing=13, name="osd") + self.audio = Audio() + self.icon = Label(markup=icons.vol_high, name="vol-icon") + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + + self.previous_volume = None + self.previous_muted = None + + self.add(self.icon) + self.add(self.scale) + self.sync_with_audio() + + self.scale.connect("value-changed", self.on_volume_changed) + self.audio.connect("notify::speaker", self.on_audio_speaker_changed) + + # Connect to speaker-changed signal directly + self.audio.connect("speaker-changed", self.on_speaker_changed) + + # Connect to current speaker if available + if self.audio.speaker: + self.audio.speaker.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.speaker.connect("notify::muted", self.on_mute_changed) + + def sync_with_audio(self): + if self.audio.speaker: + volume = round(self.audio.speaker.volume) + self.scale.set_value(volume) + self.update_icon(volume) + self.previous_volume = volume + self.previous_muted = self.audio.speaker.muted + + def on_volume_changed(self, *_): + if self.audio.speaker: + volume = self.scale.value + if 0 <= volume <= 100: + self.audio.speaker.set_volume(volume) + self.update_icon(volume) + + if volume == 0 or (self.audio.speaker and self.audio.speaker.muted): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + self.emit("volume-changed") + + def on_audio_speaker_changed(self, *_): + if self.audio.speaker: + self.audio.speaker.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.speaker.connect("notify::muted", self.on_mute_changed) + self.update_volume() + + def on_speaker_changed(self, *_): + self.update_volume() + self.update_icon(round(self.audio.speaker.volume)) + + def on_volume_property_changed(self, *_): + if self.audio.speaker: + current_volume = round(self.audio.speaker.volume) + if self.previous_volume is None or current_volume != self.previous_volume: + self.previous_volume = current_volume + self.update_volume() + self.update_icon(current_volume) + self.emit("volume-changed") + + def update_volume(self, *_): + if self.audio.speaker and not self.is_hovered(): + volume = round(self.audio.speaker.volume) + self.scale.set_value(volume) + self.update_icon(volume) + + if volume == 0 or (self.audio.speaker and self.audio.speaker.muted): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + def update_icon(self, volume): + if not self.audio.speaker: + return + + vol_high_icon = icons.vol_high + vol_medium_icon = icons.vol_medium + vol_mute_icon = icons.vol_off + vol_off_icon = icons.vol_mute + + if self.audio.speaker and "bluetooth" in self.audio.speaker.icon_name: + vol_high_icon = icons.bluetooth_connected + vol_medium_icon = icons.bluetooth + vol_mute_icon = icons.bluetooth_off + vol_off_icon = icons.bluetooth_disconnected + + if self.audio.speaker.muted: + self.icon.set_label(vol_mute_icon) + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + if volume == 0: + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + if volume >= 67: + self.icon.set_label(vol_high_icon) + elif volume >= 34: + self.icon.set_label(vol_medium_icon) + elif volume >= 1: + # Use medium for low volumes too + self.icon.set_label(vol_medium_icon) + else: + self.icon.set_label(vol_off_icon) + + def _get_audio_icon_name(self, volume: int) -> str: + if volume >= 67: + return icons.vol_high + elif volume >= 34: + return icons.vol_medium + elif volume >= 1: + return icons.vol_mute + else: + return icons.vol_off + + def on_mute_changed(self, *_): + if self.audio.speaker: + current_muted = self.audio.speaker.muted + if self.previous_muted is None or current_muted != self.previous_muted: + self.previous_muted = current_muted + self.update_icon(round(self.audio.speaker.volume)) + GLib.idle_add(lambda: self.emit("volume-changed")) + + +class MicrophoneOSDContainer(Box): + __gsignals__: ClassVar[dict] = { + "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), + } + + def __init__(self, **kwargs): + super().__init__(**kwargs, orientation="h", spacing=13, name="osd") + self.audio = Audio() + self.icon = Label(markup=icons.mic, name="mic-icon") + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + + self.previous_volume = None + self.previous_muted = None + + self.add(self.icon) + self.add(self.scale) + self.sync_with_audio() + + self.scale.connect("value-changed", self.on_volume_changed) + self.audio.connect("notify::microphone", self.on_audio_microphone_changed) + + # Connect to microphone-changed signal directly + self.audio.connect("microphone-changed", self.on_microphone_changed) + + if self.audio.microphone: + self.audio.microphone.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.microphone.connect("notify::muted", self.on_mute_changed) + + def sync_with_audio(self): + if self.audio.microphone: + volume = round(self.audio.microphone.volume) + self.scale.set_value(volume) + self.update_icon(volume) + self.previous_volume = volume + self.previous_muted = self.audio.microphone.muted + + def on_volume_changed(self, *_): + if self.audio.microphone: + volume = self.scale.value + if 0 <= volume <= 100: + self.audio.microphone.set_volume(volume) + self.update_icon(volume) + + if volume == 0 or ( + self.audio.microphone and self.audio.microphone.muted + ): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + self.emit("mic-changed") + + def on_audio_microphone_changed(self, *_): + if self.audio.microphone: + self.audio.microphone.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.microphone.connect("notify::muted", self.on_mute_changed) + self.update_volume() + + def on_microphone_changed(self, *_): + self.update_volume() + self.update_icon(round(self.audio.microphone.volume)) + + def on_volume_property_changed(self, *_): + if self.audio.microphone: + current_volume = round(self.audio.microphone.volume) + if self.previous_volume is None or current_volume != self.previous_volume: + self.previous_volume = current_volume + self.update_volume() + self.update_icon(current_volume) + self.emit("mic-changed") + + def update_volume(self, *_): + if self.audio.microphone and not self.is_hovered(): + volume = round(self.audio.microphone.volume) + self.scale.set_value(volume) + self.update_icon(volume) + + if volume == 0 or (self.audio.microphone and self.audio.microphone.muted): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + def update_icon(self, volume): + if not self.audio.microphone: + return + + if self.audio.microphone.muted: + self.icon.set_label(icons.mic_mute) + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + if volume == 0: + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + if volume >= 67: + self.icon.set_label(icons.mic) + elif volume >= 34: + self.icon.set_label(icons.mic) + elif volume >= 1: + self.icon.set_label(icons.mic) + else: + self.icon.set_label(icons.mic_mute) + + def on_mute_changed(self, *_): + if self.audio.microphone: + current_muted = self.audio.microphone.muted + if self.previous_muted is None or current_muted != self.previous_muted: + self.previous_muted = current_muted + self.update_icon(round(self.audio.microphone.volume)) + GLib.idle_add(lambda: self.emit("mic-changed")) + + +class OSD(Window): + def __init__(self, **kwargs): + self.audio_container = AudioOSDContainer() + self.brightness_container = BrightnessOSDContainer() + self.microphone_container = MicrophoneOSDContainer() + + self.timeout = 1000 + + self.revealer = Revealer( + transition_type="slide-up", + transition_duration=100, + child_revealed=False, + ) + + self.main_box = Box( + orientation="v", + spacing=13, + children=[self.revealer], + ) + + super().__init__( + layer="overlay", + anchor="bottom", + child=self.main_box, + visible=False, + pass_through=True, + keyboard_mode="on-demand", + **kwargs, + ) + + self.last_activity_time = time.time() + + self.audio_container.connect("volume-changed", self.show_audio) + self.brightness_container.brightness_service.connect( + "screen", self.show_brightness + ) + self.microphone_container.connect("mic-changed", self.show_microphone) + + GLib.timeout_add(100, self.check_inactivity) + + def show_audio(self, *_): + self.show_box(box_to_show="audio") + self.reset_inactivity_timer() + + def show_brightness(self, *_): + self.show_box(box_to_show="brightness") + self.reset_inactivity_timer() + + def show_microphone(self, *_): + self.show_box(box_to_show="microphone") + self.reset_inactivity_timer() + + def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]): + self.set_visible(True) + if box_to_show == "audio": + self.revealer.children = self.audio_container + elif box_to_show == "brightness": + self.revealer.children = self.brightness_container + elif box_to_show == "microphone": + self.revealer.children = self.microphone_container + self.revealer.set_reveal_child(True) + self.reset_inactivity_timer() + + def start_hide_timer(self): + self.set_visible(False) + + def reset_inactivity_timer(self): + self.last_activity_time = time.time() + + def check_inactivity(self): + if time.time() - self.last_activity_time >= (self.timeout / 1000): + self.start_hide_timer() + return True diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py new file mode 100644 index 0000000..cee539f --- /dev/null +++ b/modules/panel/components/enhanced_system_tray.py @@ -0,0 +1,159 @@ +""" +Enhanced System Tray Icon Handling + +This module provides enhanced icon loading capabilities for system tray items, +including fallback mechanisms for file paths and common icon locations. +""" + +import os +from gi.repository import Gtk, GdkPixbuf +from fabric.system_tray.widgets import SystemTrayItem + + +def patched_do_update_properties(self, *_): + # Try default GTK theme first + icon_name = self._item.icon_name + attention_icon_name = self._item.attention_icon_name + + if self._item.status == "NeedsAttention" and attention_icon_name: + preferred_icon_name = attention_icon_name + else: + preferred_icon_name = icon_name + + # Try to load from default GTK theme + if preferred_icon_name: + try: + default_theme = Gtk.IconTheme.get_default() + if default_theme.has_icon(preferred_icon_name): + pixbuf = default_theme.load_icon( + preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE + ) + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + # Set tooltip + tooltip = self._item.tooltip + self.set_tooltip_markup( + tooltip.description or tooltip.title or self._item.title.title() + if self._item.title + else "Unknown" + ) + return + except: + pass + + # Enhanced fallback handling for file paths + if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name): + return + + # Fallback to original implementation + original_do_update_properties(self, *_) + + +def _try_load_icon_from_path(self, icon_path): + try: + # Check if it's a file path and handle it directly + if os.path.isabs(icon_path) or '/' in icon_path: + # Try to load as SVG from the original path if it exists + if os.path.exists(icon_path): + if icon_path.lower().endswith('.svg'): + # Load SVG directly + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + icon_path, self._icon_size, self._icon_size + ) + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + self._set_tooltip() + return True + else: + # Load other image formats + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + icon_path, self._icon_size, self._icon_size + ) + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + self._set_tooltip() + return True + + # If it's a file path, try to extract just the filename for theme lookup + filename = os.path.basename(icon_path) + if filename: + # Remove extension for theme lookup + name_without_ext = os.path.splitext(filename)[0] + default_theme = Gtk.IconTheme.get_default() + + # Try filename without extension + if default_theme.has_icon(name_without_ext): + pixbuf = default_theme.load_icon( + name_without_ext, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE + ) + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + self._set_tooltip() + return True + + # Try full filename + if default_theme.has_icon(filename): + pixbuf = default_theme.load_icon( + filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE + ) + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + self._set_tooltip() + return True + + # If it looks like a file path but doesn't exist, try common icon locations + if os.path.isabs(icon_path): + common_icon_dirs = [ + "/usr/share/icons", + "/usr/share/pixmaps", + "/usr/local/share/icons", + "/usr/local/share/pixmaps", + os.path.expanduser("~/.local/share/icons"), + os.path.expanduser("~/.icons") + ] + + filename = os.path.basename(icon_path) + for icon_dir in common_icon_dirs: + potential_path = os.path.join(icon_dir, filename) + if os.path.exists(potential_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + potential_path, self._icon_size, self._icon_size + ) + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + self._set_tooltip() + return True + except: + continue + + except Exception: + pass + + return False + + +def _set_tooltip(self): + tooltip = self._item.tooltip + self.set_tooltip_markup( + tooltip.description or tooltip.title or self._item.title.title() + if self._item.title + else "Unknown" + ) + + +def apply_enhanced_system_tray(): + # Store original method + global original_do_update_properties + original_do_update_properties = SystemTrayItem.do_update_properties + + # Attach helper methods to SystemTrayItem class + SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path + SystemTrayItem._set_tooltip = _set_tooltip + + # Replace the do_update_properties method + SystemTrayItem.do_update_properties = patched_do_update_properties + + +# Store reference to original method +original_do_update_properties = None diff --git a/modules/panel/main.py b/modules/panel/main.py index e564a91..acac968 100644 --- a/modules/panel/main.py +++ b/modules/panel/main.py @@ -1,54 +1,19 @@ -from fabric.system_tray.widgets import SystemTray, SystemTrayItem -from fabric.utils import exec_shell_command, get_relative_path +from fabric.system_tray.widgets import SystemTray +from fabric.utils import get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.datetime import DateTime from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer -from gi.repository import Gtk from fabric.widgets.svg import Svg from modules.panel.components.indicators import Indicators from modules.panel.components.menubar import MenuBar +from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray from utils.wayland import WaylandWindow as Window -original_do_update_properties = SystemTrayItem.do_update_properties - - -def patched_do_update_properties(self, *_): - # Try default GTK theme first - icon_name = self._item.icon_name - attention_icon_name = self._item.attention_icon_name - - if self._item.status == "NeedsAttention" and attention_icon_name: - preferred_icon_name = attention_icon_name - else: - preferred_icon_name = icon_name - - # Try to load from default GTK theme - if preferred_icon_name: - try: - default_theme = Gtk.IconTheme.get_default() - if default_theme.has_icon(preferred_icon_name): - pixbuf = default_theme.load_icon( - preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - # Set tooltip - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - return - except: - pass - original_do_update_properties(self, *_) - - -SystemTrayItem.do_update_properties = patched_do_update_properties +# Apply enhanced system tray icon handling +apply_enhanced_system_tray() class Panel(Window): @@ -166,4 +131,4 @@ class Panel(Window): else: self.chevron_button.get_child().set_from_file( get_relative_path("../../config/assets/icons/chevron-right.svg") - ) + ) \ No newline at end of file diff --git a/styles/osd.css b/styles/osd.css new file mode 100644 index 0000000..7f0d642 --- /dev/null +++ b/styles/osd.css @@ -0,0 +1,51 @@ +#osd { + background: var(--shadow); + padding: 12px 20px; + margin: 70px; + border-radius: 16px; +} + +#osd scale { + min-width: 150px; +} + +#osd trough { + background: var(--surface); + min-height: 10px; + margin-right: 4px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); + border-radius: 16px; +} + +#osd trough highlight { + border-radius: 100px 0px 0px 100px; + background: var(--primary); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#osd.muted trough highlight, +#osd.muted slider, +#osd scale.muted trough highlight, +#osd scale.muted slider { + background-color: var(--surface-bright); +} + +#osd slider { + background-color: var(--primary); + background-size: 20px 20px; + min-width: 4px; + min-height: 8px; + border-radius: 2px; + padding: 0px; + margin: -14px 0; + box-shadow: + -4px 0 0px 2px var(--shadow), + 4px 0 0px 2px var(--shadow); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#brighntess-icons.muted, +#vol-icon.muted, +#mic-icon.muted { + color: var(--outline); +}