✨ feat: add on-screen display for volume and brightness

This commit is contained in:
sankalp committed 2025-07-30 10:02:11 +05:45
1 parent f74605997f
commit 19a2b925d5
6 files changed
+663 -44

No files matched your search

+1
View File
@@ -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;
+4 -3
View File
@@ -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(
+442
View File
@@ -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
@@ -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
+6 -41
View File
@@ -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")
)
)
+51
View File
@@ -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);
}