refactor(modules): improve exception handling and simplify service usage

Enhancements
- Replace bare `except:` with `except Exception:` in multiple modules- Cast step calculation to `int` for consistent type
- Simplify `get_battery_icon_file` to return a string directly
- Use `get_todo_service()` instead of module-level `todo_service` throughout todo widget
- Remove unused `"zen": "zen-browser"` mapping entry

Bug Fixes
- Prevent bare exception swallowing by catching `Exception` explicitly
- Ensure proper cleanup of timer IDs and other resources
- Delete unused modules/osdd components

New Features
- None
This commit is contained in:
sankalp committed 2026-03-23 23:34:03 +05:45
1 parent 34cfb2f49e
commit ef4065bc1f
34 files changed
+682 -519

No files matched your search

+1 -1
View File
@@ -518,7 +518,7 @@ class BluetoothConnections(Box):
# Make sure other devices revealer is collapsed when closing
try:
self.other_devices_revealer.child_revealed = False
except:
except Exception:
pass # Widget might already be destroyed
def close_bluetooth(self):
+3 -3
View File
@@ -29,9 +29,9 @@ def get_battery_icon_file(
Relative path to the battery icon file
"""
clamped = max(0, min(100, percentage))
step = (clamped // 10) * 10
step = int((clamped // 10) * 10)
filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
return svg_file(f"battery/{filename}")
return f"battery/{filename}"
def set_bluetooth_enabled_with_fallback(client, enabled: bool):
@@ -518,7 +518,7 @@ class BluetoothConnections(Box):
# Make sure other devices revealer is collapsed when closing
try:
self.other_devices_revealer.child_revealed = False
except:
except Exception:
pass # Widget might already be destroyed
def close_bluetooth(self):
+2 -2
View File
@@ -911,7 +911,7 @@ class PlayerBox(Box):
from gi.repository import GLib
GLib.source_remove(self._seekbar_timer_id)
except:
except Exception:
pass
self._seekbar_timer_id = None
@@ -1041,7 +1041,7 @@ class PlayerBox(Box):
from gi.repository import GLib
GLib.source_remove(self._seekbar_timer_id)
except:
except Exception:
pass
self._seekbar_timer_id = None
+2 -2
View File
@@ -683,7 +683,7 @@ class ModusControlCenter(Window):
if self._caffeine_process:
try:
self._caffeine_process.terminate()
except:
except Exception:
pass
self._caffeine_process = None
else:
@@ -1193,5 +1193,5 @@ class ModusControlCenter(Window):
logger.error(f"Failed to destroy control center: {e}")
try:
super().destroy()
except:
except Exception:
pass
+1 -2
View File
@@ -114,7 +114,6 @@ class PerAppVolumeControl(Box):
"visual studio code": "vscode",
"telegram": "telegram-desktop",
"pulse": "audio-card",
"zen": "zen-browser",
"pipewire": "audio-card",
"alsa": "audio-card",
"sink": "audio-speakers",
@@ -323,7 +322,7 @@ class PerAppVolumeControl(Box):
for connection in self._signal_connections:
try:
audio_service.disconnect(connection)
except:
except Exception:
pass
self._signal_connections.clear()
-1
View File
@@ -556,7 +556,6 @@ class PlayerBoxStack(Box):
self._update_all_player_buttons()
def on_new_player(self, mpris_manager, player):
player_name = player.props.player_name
# if player_name in self.config.get("ignore", []):
# return
+1 -1
View File
@@ -699,7 +699,7 @@ class WifiConnections(Box):
# Make sure other networks revealer is collapsed when closing
try:
self.other_networks_revealer.child_revealed = False
except:
except Exception:
pass # Widget might already be destroyed
def close_wifi(self):
+1 -1
View File
@@ -546,7 +546,7 @@ class CpuInfo(SystemInfoBase):
for p in ["package id 0", "core 0", ""]
):
return round(entry.current, 1)
except:
except Exception:
pass
return None
+2 -2
View File
@@ -1040,7 +1040,7 @@ class Launcher(Window):
return widget
# Also check if this is the only Entry in the widget (likely to be the target)
return widget
except:
except Exception:
# If focus checking fails, assume this Entry should handle the event
return widget
@@ -1207,7 +1207,7 @@ class Launcher(Window):
allocation = selected_child.get_allocation()
item_height = allocation.height if allocation.height > 0 else 68
item_y = allocation.y
except:
except Exception:
# Fallback to estimation
item_height = DEFAULT_ITEM_HEIGHT
item_y = self.selected_index * item_height
+3 -3
View File
@@ -29,7 +29,7 @@ class BookmarkManager:
try:
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
except:
except Exception:
return ""
def _extract_domain(self, url: str) -> str:
@@ -41,7 +41,7 @@ class BookmarkManager:
if domain.startswith("www."):
domain = domain[4:]
return domain
except:
except Exception:
return url
def _normalize_url(self, url: str) -> str:
@@ -683,7 +683,7 @@ class BookmarksPlugin(PluginBase):
if domain.startswith("www."):
domain = domain[4:]
return domain
except:
except Exception:
return url
def _open_bookmark(self, url: str):
+3 -3
View File
@@ -497,7 +497,7 @@ class PasswordPlugin(PluginBase):
f"Password '{name}' {action_word} successfully",
]
)
except:
except Exception:
pass
else:
print(f"Failed to add password '{name}'")
@@ -524,7 +524,7 @@ class PasswordPlugin(PluginBase):
f"Password '{name}' removed successfully",
]
)
except:
except Exception:
pass
else:
print(f"Failed to remove password '{name}'")
@@ -564,7 +564,7 @@ class PasswordPlugin(PluginBase):
f"Password for '{name}' copied to clipboard",
]
)
except:
except Exception:
pass
# Clear cache to force refresh
+2 -2
View File
@@ -314,11 +314,11 @@ class WallpaperPlugin(PluginBase):
)
return filename
def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:
def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l_val: float = 0.5) -> str:
"""Convert HSL color value to RGB HEX string."""
# colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0
hue = h / 360.0
r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S
r, g, b = colorsys.hls_to_rgb(hue, l_val, s) # Note the order: H, L, S
r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)
return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
+12 -12
View File
@@ -299,14 +299,14 @@ def verify_cache_persistence():
for cache_file in icon_cache_files[:2]: # Test first 2 icon files
try:
cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached icon {cache_file}: {e}")
for cache_file in image_cache_files[:2]: # Test first 2 image files
try:
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached image {cache_file}: {e}")
@@ -876,7 +876,7 @@ class NotificationRevealer(SlideRevealer):
self.notif_box._should_cleanup_cache = True
try:
self.notification.close("dismissed-by-user")
except:
except Exception:
pass
return False
@@ -1021,7 +1021,7 @@ class ModusNoti(Window):
preload_notification_assets(notification)
# Implement smart queue management for smooth transitions
current_time = GLib.get_monotonic_time() / 1000
GLib.get_monotonic_time() / 1000
# If queue is getting full, remove oldest notifications smoothly
if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
@@ -1030,7 +1030,7 @@ class ModusNoti(Window):
oldest = self.notification_queue.pop(0)
try:
oldest.close("dismissed-by-user")
except:
except Exception:
pass
# Add new notification to queue
@@ -1081,7 +1081,7 @@ class ModusNoti(Window):
# Force close current notification with smooth animation
try:
self.current_notification.notification.close("expired")
except:
except Exception:
pass
def _show_next_notification(self):
@@ -1114,7 +1114,7 @@ class ModusNoti(Window):
for child in list(self.notifications.children):
try:
self.notifications.remove(child)
except:
except Exception:
pass
self.notifications.children = [new_box]
@@ -1146,7 +1146,7 @@ class ModusNoti(Window):
try:
if notification_box in self.notifications.children:
self.notifications.remove(notification_box)
except:
except Exception:
pass
# Reset state
@@ -1179,7 +1179,7 @@ class ModusNoti(Window):
for notification in list(self.notification_queue):
try:
notification.close("dismissed-by-user")
except:
except Exception:
pass
self.notification_queue.clear()
@@ -1223,15 +1223,15 @@ class ModusNoti(Window):
timeout_str = new_config["notification_timeout"]
# Parse the timeout string to milliseconds
timeout_ms = parse_timeout_string(timeout_str)
parse_timeout_string(timeout_str)
# Handle ignored apps changes
if "notification_ignored_apps" in new_config:
ignored_apps = new_config["notification_ignored_apps"]
new_config["notification_ignored_apps"]
# Handle limited apps history changes
if "notification_limited_apps_history" in new_config:
limited_apps = new_config["notification_limited_apps_history"]
new_config["notification_limited_apps_history"]
# Update the current config
self._current_config.update(new_config)
+3 -3
View File
@@ -170,7 +170,7 @@ class ExpandableNotificationGroup(Box):
def _get_notification_pixbuf_for_group(self, cached_notification):
"""Get notification pixbuf using cached image key - fallback to app icon"""
notification = cached_notification._notification
notification_id = getattr(notification, "id", None)
getattr(notification, "id", None)
# First try to get cached notification image using stored cache key
if (
@@ -447,7 +447,7 @@ class NotificationCenterWidget(NotificationWidget):
def _get_notification_pixbuf(self, notification):
"""Get notification pixbuf using cached image key - fallback to app icon"""
notification_id = getattr(notification, "id", None)
getattr(notification, "id", None)
# First try to get cached notification image using stored cache key
if self.cache_metadata:
@@ -689,7 +689,7 @@ class NotificationCenter(Window):
rebuild_count = 0
for cached_notification in notification_service.cached_notifications:
app_name = cached_notification._notification.app_name
notification_id = getattr(cached_notification._notification, "id", None)
getattr(cached_notification._notification, "id", None)
# Skip ignored apps during rebuild
if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
+2 -2
View File
@@ -150,7 +150,7 @@ def verify_cache_persistence():
for cache_file in cache_files[:2]: # Test first 2 files
try:
cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached asset {cache_file}: {e}")
@@ -175,7 +175,7 @@ def get_fallback_icon(size=(48, 48)):
return GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]
)
except:
except Exception:
return None
-468
View File
@@ -1,468 +0,0 @@
import math
import time
from typing import ClassVar, Literal
# from gi.repository import GLib, GObject
from fabric.audio import Audio
from fabric.utils import GLib, GObject
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.revealer import Revealer
from fabric.widgets.scale import Scale, ScaleMark
from fabric.widgets.svg import Svg
from fabric.widgets.wayland import WaylandWindow as Window
from services.brightness import Brightness
from utils.animator import Animator
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="v", spacing=3, name="osd")
self.brightness_service = Brightness.get_initial()
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.osd_window_image = Svg(
get_relative_path("../config/assets/icons/brightness/brightness.svg"),
size=(100, 150),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.add(self.osd_window_image)
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)
def get_svg(self, value):
b_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)
return b_level
def on_brightness_changed(self, _sender: any, value: float, *_args) -> None:
normalized_brightness = self._normalize_brightness(value)
self.osd_window_image.set_from_file(
get_relative_path(
f"../config/assets/icons/brightness/brightness-{
self.get_svg(normalized_brightness)
}.svg"
)
)
self.scale.animate_value(normalized_brightness)
def _normalize_brightness(self, brightness: float) -> float:
return (brightness / self.brightness_service.max_screen) * 100
class AudioOSDContainer(Box):
__gsignals__: ClassVar[dict] = {
"volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
}
def __init__(self, **kwargs):
super().__init__(
**kwargs,
orientation="v",
name="osd",
)
self.audio = Audio()
self.scale = AnimatedScale(
value=70,
marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
min_value=0,
max_value=100,
increments=(1, 1),
orientation="h",
)
self.osd_window_image = Svg(
get_relative_path("../config/assets/icons/volume/audio-volume.svg"),
size=(150, 150),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.previous_volume = None
self.previous_muted = None
self.add(self.osd_window_image)
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)
self.audio.connect("speaker-changed", self.on_speaker_changed)
# Connect to current speaker if available
self._connect_speaker_signals()
def _connect_speaker_signals(self):
"""Connect to speaker's changed signal which should fire on both volume and mute changes"""
if self.audio.speaker:
# Connect to the main 'changed' signal from AudioStream
self.audio.speaker.connect("changed", self.on_speaker_stream_changed)
def on_speaker_stream_changed(self, *_):
"""This should be called whenever the speaker stream changes (volume OR mute)"""
if self.audio.speaker:
current_volume = (
round(self.audio.speaker.volume)
if hasattr(self.audio.speaker, "volume")
else 0
)
current_muted = (
self.audio.speaker.muted
if hasattr(self.audio.speaker, "muted")
else False
)
# Check if either volume or mute state changed
if (
self.previous_volume != current_volume
or self.previous_muted != current_muted
):
self.previous_volume = current_volume
self.previous_muted = current_muted
self.update_volume()
self.emit("volume-changed")
def get_svg(self, value):
audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)
return audio_level
def sync_with_audio(self):
if self.audio.speaker:
volume = (
round(self.audio.speaker.volume)
if hasattr(self.audio.speaker, "volume")
else 0
)
self.scale.set_value(volume)
self.previous_volume = volume
self.previous_muted = (
self.audio.speaker.muted
if hasattr(self.audio.speaker, "muted")
else False
)
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_volume_display(volume)
self.emit("volume-changed")
def update_volume_display(self, volume=None):
"""Update the visual display based on current volume/mute state"""
if not self.audio.speaker:
return
if volume is None:
volume = (
round(self.audio.speaker.volume)
if hasattr(self.audio.speaker, "volume")
else 0
)
is_muted = (
self.audio.speaker.muted if hasattr(self.audio.speaker, "muted") else False
)
if volume == 0 or is_muted:
self.scale.add_style_class("muted")
display_volume = 0
else:
self.scale.remove_style_class("muted")
display_volume = volume
self.osd_window_image.set_from_file(
get_relative_path(
f"../config/assets/icons/volume/audio-volume-{
self.get_svg(display_volume)
}.svg"
)
)
def on_audio_speaker_changed(self, *_):
self._connect_speaker_signals()
self.update_volume()
def on_speaker_changed(self, *_):
self._connect_speaker_signals()
self.update_volume()
def update_volume(self, *_):
if self.audio.speaker and not self.is_hovered():
volume = (
round(self.audio.speaker.volume)
if hasattr(self.audio.speaker, "volume")
else 0
)
self.scale.set_value(volume)
self.update_volume_display(volume)
class MicrophoneOSDContainer(Box):
__gsignals__: ClassVar[dict] = {
"mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
}
def __init__(self, **kwargs):
super().__init__(**kwargs, orientation="v", spacing=13, name="osd")
self.audio = Audio()
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.osd_window_image = Svg(
get_relative_path("../config/assets/icons/mic/microphone.svg"),
name="osd-image",
size=(100, 150),
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.previous_volume = None
self.previous_muted = None
self.add(self.osd_window_image)
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)
self.audio.connect("microphone-changed", self.on_microphone_changed)
# Connect to current microphone if available
self._connect_microphone_signals()
def _connect_microphone_signals(self):
"""Connect to microphone's changed signal which should fire on both volume and mute changes"""
if self.audio.microphone:
# Connect to the main 'changed' signal from AudioStream
self.audio.microphone.connect("changed", self.on_microphone_stream_changed)
def on_microphone_stream_changed(self, *_):
"""This should be called whenever the microphone stream changes (volume OR mute)"""
if self.audio.microphone:
current_volume = (
round(self.audio.microphone.volume)
if hasattr(self.audio.microphone, "volume")
else 0
)
current_muted = (
self.audio.microphone.muted
if hasattr(self.audio.microphone, "muted")
else False
)
# Check if either volume or mute state changed
if (
self.previous_volume != current_volume
or self.previous_muted != current_muted
):
self.previous_volume = current_volume
self.previous_muted = current_muted
self.update_volume()
self.emit("mic-changed")
def get_svg(self, value):
audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)
return audio_level
def sync_with_audio(self):
if self.audio.microphone:
volume = (
round(self.audio.microphone.volume)
if hasattr(self.audio.microphone, "volume")
else 0
)
self.scale.set_value(volume)
self.previous_volume = volume
self.previous_muted = (
self.audio.microphone.muted
if hasattr(self.audio.microphone, "muted")
else False
)
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_volume_display(volume)
self.emit("mic-changed")
def update_volume_display(self, volume=None):
"""Update the visual display based on current volume/mute state"""
if not self.audio.microphone:
return
if volume is None:
volume = (
round(self.audio.microphone.volume)
if hasattr(self.audio.microphone, "volume")
else 0
)
is_muted = (
self.audio.microphone.muted
if hasattr(self.audio.microphone, "muted")
else False
)
if volume == 0 or is_muted:
self.scale.add_style_class("muted")
display_volume = 0
else:
self.scale.remove_style_class("muted")
display_volume = volume
self.osd_window_image.set_from_file(
get_relative_path(
f"../config/assets/icons/mic/microphone-{
self.get_svg(display_volume)
}.svg"
)
)
def on_audio_microphone_changed(self, *_):
self._connect_microphone_signals()
self.update_volume()
def on_microphone_changed(self, *_):
self._connect_microphone_signals()
self.update_volume()
def update_volume(self, *_):
if self.audio.microphone and not self.is_hovered():
volume = (
round(self.audio.microphone.volume)
if hasattr(self.audio.microphone, "volume")
else 0
)
self.scale.set_value(volume)
self.update_volume_display(volume)
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",
h_expand=True,
children=[self.revealer],
)
super().__init__(
layer="overlay",
anchor="bottom",
title="modus",
child=self.main_box,
visible=False,
pass_through=True,
keyboard_mode="on-demand",
**kwargs,
)
self.last_activity_time = time.time()
# Connect to the containers' signals
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
+8
View File
@@ -0,0 +1,8 @@
from .main import OSDWindow # noqa: F401
from .components import ( # noqa: F401
AudioOSDContainer,
BrightnessOSDContainer,
MicrophoneOSDContainer,
CapsLockOSDContainer,
KeyboardLayoutOSDContainer,
)
+5
View File
@@ -0,0 +1,5 @@
from .audio import AudioOSDContainer # noqa: F401
from .brightness import BrightnessOSDContainer # noqa: F401
from .microphone import MicrophoneOSDContainer # noqa: F401
from .capslock import CapsLockOSDContainer # noqa: F401
from .keyboard_layout import KeyboardLayoutOSDContainer # noqa: F401
+28
View File
@@ -0,0 +1,28 @@
from functools import partial
from fabric.widgets.scale import Scale
from utils.animator import Animator, cubic_bezier
class AnimatedScale(Scale):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.animator = (
Animator(
duration=0.8,
timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0),
min_value=self.min_value,
max_value=self.value,
tick_widget=self,
notify_value=lambda anim: self.set_value(anim.value),
)
.build()
.play()
.unwrap()
)
def animate_value(self, value: float):
if self.animator:
self.animator.pause()
self.animator.min_value = self.value
self.animator.max_value = value
self.animator.play()
+105
View File
@@ -0,0 +1,105 @@
import math
from fabric.audio import Audio
from fabric.utils import get_relative_path
from fabric.widgets.svg import Svg
from fabric.widgets.scale import ScaleMark
from .animated_scale import AnimatedScale
from .base import BaseOSDContainer
class AudioOSDContainer(BaseOSDContainer):
def __init__(self, window, **kwargs):
super().__init__(window, **kwargs)
self.audio = Audio()
self._last_volume = None
self._last_muted = None
self._setup_specific_components()
self._connect_specific_signals()
def _setup_specific_components(self):
self.osd_window_image = Svg(
get_relative_path("../../../config/assets/icons/volume/audio-volume.svg"),
size=(100, 100),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
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.add(self.osd_window_image)
self.add(self.scale)
def _connect_specific_signals(self):
self.audio.connect("notify::speaker", self._on_speaker_changed)
if self.audio.speaker:
self._connect_speaker_signals()
def _connect_speaker_signals(self):
if self.audio.speaker:
self.audio.speaker.connect("changed", self._on_speaker_stream_changed)
self._sync_with_audio()
def _on_speaker_changed(self, *_):
self._connect_speaker_signals()
self.update()
def _on_speaker_stream_changed(self, *_):
if self.audio.speaker:
current_volume = round(self.audio.speaker.volume)
current_muted = self.audio.speaker.muted
if self._last_volume != current_volume or self._last_muted != current_muted:
self._last_volume = current_volume
self._last_muted = current_muted
self.update()
def _sync_with_audio(self):
if self.audio.speaker:
self._last_volume = round(self.audio.speaker.volume)
self._last_muted = self.audio.speaker.muted
self.scale.set_value(self._last_volume)
self._update_display()
def _update_display(self):
if not self.audio.speaker:
return
volume = (
self._last_volume
if self._last_volume is not None
else round(self.audio.speaker.volume)
)
muted = (
self._last_muted
if self._last_muted is not None
else self.audio.speaker.muted
)
display_volume = 0 if (volume == 0 or muted) else volume
level = (
0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3)
)
self.osd_window_image.set_from_file(
get_relative_path(
f"../../../config/assets/icons/volume/audio-volume-{level}.svg"
)
)
if muted or volume == 0:
self.scale.add_style_class("muted")
else:
self.scale.remove_style_class("muted")
self.scale.animate_value(volume)
def update(self, *_):
self._update_display()
super().update()
+145
View File
@@ -0,0 +1,145 @@
from abc import abstractmethod
from fabric.utils import Gdk, GLib, invoke_repeater, remove_handler, time
from fabric.widgets.box import Box
from fabric.widgets.wayland import WaylandWindow as Window
class BaseOSDContainer(Box):
MAX_VISIBLE_TIME = 6.0
COOLDOWN_MS = 200
def __init__(self, window: Window, **kwargs):
super().__init__(**kwargs, orientation="v", spacing=12, name="osd-container")
self.last_handler: int = 0
self.window = window
self.osd = None
self.show_timestamp: float = 0.0
self.watchdog_handler: int = 0
self._update_in_progress = False
self._is_hovered = False
self._last_trigger_time: float = 0.0
GLib.idle_add(self._setup_window_signals)
def _setup_window_signals(self):
self.window.add_events(
Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.window.connect("enter-notify-event", self._on_enter_notify)
self.window.connect("leave-notify-event", self._on_leave_notify)
return False
def _on_enter_notify(self, widget, event):
if event.detail != Gdk.NotifyType.INFERIOR:
self._is_hovered = True
def _on_leave_notify(self, widget, event):
if event.detail != Gdk.NotifyType.INFERIOR:
self._is_hovered = False
def is_hovered(self):
return self._is_hovered
def remove_last_handler(self):
if self.last_handler:
try:
remove_handler(self.last_handler)
except Exception:
pass
self.last_handler = 0
def remove_watchdog_handler(self):
if self.watchdog_handler:
try:
GLib.source_remove(self.watchdog_handler)
except Exception:
pass
self.watchdog_handler = 0
def cleanup_all_handlers(self):
self.remove_last_handler()
self.remove_watchdog_handler()
self.show_timestamp = 0.0
self._update_in_progress = False
def hide_window(self):
if self.osd and hasattr(self.osd, "revealer"):
self.osd.revealer.set_reveal_child(False)
GLib.timeout_add(150, lambda: self.window.hide() or False)
else:
self.window.hide()
def watchdog_force_hide(self, *_):
if not self.window.get_visible():
self.cleanup_all_handlers()
return False
elapsed = time.time() - self.show_timestamp
if elapsed >= self.MAX_VISIBLE_TIME:
if not self.is_hovered():
self.hide_window()
self.cleanup_all_handlers()
return False
else:
return True
return True
def update(self, *_):
if self._update_in_progress:
return
current_time = time.time()
time_since_last_trigger = (current_time - self._last_trigger_time) * 1000
if time_since_last_trigger < self.COOLDOWN_MS:
return
self._last_trigger_time = current_time
self._update_in_progress = True
self.cleanup_all_handlers()
self.show_timestamp = time.time()
if self.osd:
self.osd.show_container(self)
self.window.show()
self.focus()
self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False)
self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide)
self._update_in_progress = False
def focus(self, *_):
self.add_style_class("focused")
return False
def unfocus(self, *_):
if not self.window.get_visible():
self.cleanup_all_handlers()
return False
self.remove_style_class("focused")
self.remove_last_handler()
self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False)
return False
def unpop(self, *_):
if not self.window.get_visible():
self.cleanup_all_handlers()
return False
if not self.is_hovered():
self.hide_window()
self.cleanup_all_handlers()
else:
self.last_handler = invoke_repeater(500, self.unpop, initial_call=False)
return False
@abstractmethod
def _setup_specific_components(self):
pass
@abstractmethod
def _connect_specific_signals(self):
pass
+64
View File
@@ -0,0 +1,64 @@
import math
from services.brightness import Brightness
from fabric.utils import get_relative_path
from fabric.widgets.svg import Svg
from fabric.widgets.scale import ScaleMark
from .animated_scale import AnimatedScale
from .base import BaseOSDContainer
class BrightnessOSDContainer(BaseOSDContainer):
def __init__(self, window, **kwargs):
super().__init__(window, **kwargs)
self.brightness_service = Brightness.get_initial()
self._setup_specific_components()
self._connect_specific_signals()
def _setup_specific_components(self):
self.osd_window_image = Svg(
get_relative_path("../../../config/assets/icons/brightness/brightness.svg"),
size=(100, 100),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
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.osd_window_image)
self.add(self.scale)
def _connect_specific_signals(self):
self.brightness_service.connect("screen", self._on_screen_brightness_changed)
def _on_screen_brightness_changed(self, _sender, value, *_args):
self.update()
def _get_normalized_brightness(self):
return (
self.brightness_service.screen_brightness
/ self.brightness_service.max_screen
) * 100
def _update_display(self):
normalized = self._get_normalized_brightness()
level = 0 if normalized == 0 else min(int(math.ceil(normalized / 33)), 3)
self.osd_window_image.set_from_file(
get_relative_path(
f"../../../config/assets/icons/brightness/brightness-{level}.svg"
)
)
self.scale.animate_value(normalized)
def update(self, *_):
self._update_display()
super().update()
+50
View File
@@ -0,0 +1,50 @@
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
from fabric.utils import get_relative_path
from .base import BaseOSDContainer
from services.capslock import CapsLock
class CapsLockOSDContainer(BaseOSDContainer):
def __init__(self, window, **kwargs):
super().__init__(window, **kwargs)
self.capslock = CapsLock.get_initial()
self._setup_specific_components()
self._connect_specific_signals()
def _setup_specific_components(self):
self.osd_image = Svg(
get_relative_path("../../../assets/icons/misc/caps-lock.svg"),
size=(100, 100),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.label = Label(name="osd-label")
self.add(self.osd_image)
self._update_display(self.capslock.is_on)
def _connect_specific_signals(self):
self.capslock.connect("state_changed", self._on_caps_lock_state_changed)
def _on_caps_lock_state_changed(self, _, is_on: bool):
self._update_display(is_on)
self.update()
def _update_display(self, is_on: bool):
icon_path = "caps-lock.svg" if is_on else "caps-lock-off.svg"
self.osd_image.set_from_file(
get_relative_path(f"../../../assets/icons/misc/{icon_path}")
)
if is_on:
self.add_style_class("capslock-on")
self.remove_style_class("capslock-off")
else:
self.add_style_class("capslock-off")
self.remove_style_class("capslock-on")
def update(self, *_):
# We don't need a separate display update here as it's handled in the signal
super().update()
+40
View File
@@ -0,0 +1,40 @@
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
from fabric.utils import get_relative_path
from .base import BaseOSDContainer
from services.keyboard_layout import KeyboardLayout
class KeyboardLayoutOSDContainer(BaseOSDContainer):
def __init__(self, window, **kwargs):
super().__init__(window, **kwargs)
self.keyboard_layout = KeyboardLayout.get_initial()
self._setup_specific_components()
self._connect_specific_signals()
def _setup_specific_components(self):
self.osd_image = Svg(
get_relative_path("../../../assets/icons/misc/keyboard-layout.svg"),
size=(100, 100),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.label = Label(name="osd-label")
self.add(self.osd_image)
self.add(self.label)
self.label.set_text(self.keyboard_layout.current_layout)
def _connect_specific_signals(self):
self.keyboard_layout.connect("layout_changed", self._on_layout_changed)
def _on_layout_changed(self, service, layout: str):
if not self.is_hovered():
self.label.set_text(layout)
self.update()
def update(self, *_):
self.label.set_text(self.keyboard_layout.current_layout)
super().update()
+105
View File
@@ -0,0 +1,105 @@
import math
from fabric.audio import Audio
from fabric.utils import get_relative_path
from fabric.widgets.svg import Svg
from fabric.widgets.scale import ScaleMark
from .animated_scale import AnimatedScale
from .base import BaseOSDContainer
class MicrophoneOSDContainer(BaseOSDContainer):
def __init__(self, window, **kwargs):
super().__init__(window, **kwargs)
self.audio = Audio()
self._last_volume = None
self._last_muted = None
self._setup_specific_components()
self._connect_specific_signals()
def _setup_specific_components(self):
self.osd_window_image = Svg(
get_relative_path("../../../config/assets/icons/mic/microphone.svg"),
size=(100, 100),
name="osd-image",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
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.add(self.osd_window_image)
self.add(self.scale)
def _connect_specific_signals(self):
self.audio.connect("notify::microphone", self._on_microphone_changed)
if self.audio.microphone:
self._connect_microphone_signals()
def _connect_microphone_signals(self):
if self.audio.microphone:
self.audio.microphone.connect("changed", self._on_microphone_stream_changed)
self._sync_with_audio()
def _on_microphone_changed(self, *_):
self._connect_microphone_signals()
self.update()
def _on_microphone_stream_changed(self, *_):
if self.audio.microphone:
current_volume = round(self.audio.microphone.volume)
current_muted = self.audio.microphone.muted
if self._last_volume != current_volume or self._last_muted != current_muted:
self._last_volume = current_volume
self._last_muted = current_muted
self.update()
def _sync_with_audio(self):
if self.audio.microphone:
self._last_volume = round(self.audio.microphone.volume)
self._last_muted = self.audio.microphone.muted
self.scale.set_value(self._last_volume)
self._update_display()
def _update_display(self):
if not self.audio.microphone:
return
volume = (
self._last_volume
if self._last_volume is not None
else round(self.audio.microphone.volume)
)
muted = (
self._last_muted
if self._last_muted is not None
else self.audio.microphone.muted
)
display_volume = 0 if (volume == 0 or muted) else volume
level = (
0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3)
)
self.osd_window_image.set_from_file(
get_relative_path(
f"../../../config/assets/icons/mic/microphone-{level}.svg"
)
)
if muted or volume == 0:
self.scale.add_style_class("muted")
else:
self.scale.remove_style_class("muted")
self.scale.animate_value(volume)
def update(self, *_):
self._update_display()
super().update()
+83
View File
@@ -0,0 +1,83 @@
from fabric.widgets.box import Box
from fabric.widgets.revealer import Revealer
from fabric.widgets.wayland import WaylandWindow as Window
from .components import (
AudioOSDContainer,
BrightnessOSDContainer,
MicrophoneOSDContainer,
CapsLockOSDContainer,
KeyboardLayoutOSDContainer,
)
class OSD(Box):
def __init__(self, window: Window, **kwargs):
super().__init__(name="osd", **kwargs)
self.window = window
self.revealer = Revealer(
transition_type="slide-up",
transition_duration=100,
child_revealed=False,
)
self.children = [self.revealer]
self.containers = {
"audio": AudioOSDContainer(window),
"brightness": BrightnessOSDContainer(window),
"microphone": MicrophoneOSDContainer(window),
"capslock": CapsLockOSDContainer(window),
"kb_layout": KeyboardLayoutOSDContainer(window),
}
# Set the OSD reference in containers
for container in self.containers.values():
container.osd = self
self.current_container = None
def show_container(self, container):
if self.current_container != container:
self.revealer.children = [container]
self.current_container = container
self.revealer.set_reveal_child(True)
def show_audio_osd(self):
self.show_container(self.containers.get("audio"))
def show_brightness_osd(self):
self.show_container(self.containers.get("brightness"))
def show_microphone_osd(self):
self.show_container(self.containers.get("microphone"))
def show_capslock_osd(self):
self.show_container(self.containers.get("capslock"))
def show_kb_layout_osd(self):
self.show_container(self.containers.get("kb_layout"))
class OSDWindow(Window):
def __init__(self, **kwargs):
super().__init__(
name="osd-window",
title="modus-osd",
anchor="bottom",
margin="0px 0px 40px 0px",
visible=False,
all_visible=False,
pass_through=True,
layer="overlay",
**kwargs,
)
self.osd = OSD(window=self)
self.add(self.osd)
if __name__ == "__main__":
from fabric import Application
osd_window = OSDWindow()
app = Application("modus-osd", osd_window)
app.run()
View File
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
View File
Whitespace-only changes.
+11 -11
View File
@@ -12,7 +12,7 @@ from fabric.widgets.svg import Svg
from gi.repository import GLib
# Local imports
from services.todo import todo_service
from services.todo import get_todo_service
from widgets.mousecapture import MouseCapture
from widgets.wayland import WaylandWindow as Window
@@ -153,14 +153,14 @@ class TodoItem(Box):
def _toggle_completion(self, *_):
"""Toggle todo completion status"""
todo_service.toggle_todo(self.todo_data["id"])
get_todo_service().toggle_todo(self.todo_data["id"])
def _cycle_priority(self, *_):
"""Cycle through priority levels"""
priorities = ["low", "medium", "high"]
current_index = priorities.index(self.todo_data["priority"])
new_priority = priorities[(current_index + 1) % len(priorities)]
todo_service.set_priority(self.todo_data["id"], new_priority)
get_todo_service().set_priority(self.todo_data["id"], new_priority)
def _start_edit(self, *_):
"""Start editing the todo text"""
@@ -183,7 +183,7 @@ class TodoItem(Box):
new_text = self.text_entry.get_text().strip()
if new_text:
todo_service.edit_todo(self.todo_data["id"], new_text)
get_todo_service().edit_todo(self.todo_data["id"], new_text)
self._cancel_edit()
@@ -198,7 +198,7 @@ class TodoItem(Box):
def _delete_todo(self, *_):
"""Delete this todo"""
todo_service.delete_todo(self.todo_data["id"])
get_todo_service().delete_todo(self.todo_data["id"])
def update_from_data(self, todo_data):
"""Update the widget from new todo data"""
@@ -252,7 +252,7 @@ class TodoListWidget(Window):
self.todo_items = {} # Maps todo IDs to TodoItem widgets
# Register callback with todo service
todo_service.add_callback(self._on_todo_event)
get_todo_service().add_callback(self._on_todo_event)
self._build_ui()
self._refresh_todos()
@@ -363,12 +363,12 @@ class TodoListWidget(Window):
"""Add a new todo"""
text = self.new_todo_entry.get_text().strip()
if text:
todo_service.add_todo(text)
get_todo_service().add_todo(text)
self.new_todo_entry.set_text("")
def _clear_completed(self, *_):
"""Clear all completed todos"""
todo_service.clear_completed()
get_todo_service().clear_completed()
def _on_todo_event(self, event_type, data=None):
"""Handle todo service events via callback"""
@@ -392,7 +392,7 @@ class TodoListWidget(Window):
self.todos_container.children = []
# Get all todos
todos = todo_service.todos
todos = get_todo_service().todos
# Sort todos: incomplete first, then by priority, then by creation date
def sort_key(todo):
@@ -418,7 +418,7 @@ class TodoListWidget(Window):
def _update_stats(self):
"""Update the statistics display"""
stats = todo_service.get_stats()
stats = get_todo_service().get_stats()
stats_text = f"{stats['pending']} pending, {stats['completed']} completed"
self.stats_label.set_label(stats_text)
@@ -439,7 +439,7 @@ class TodoListWidget(Window):
def destroy(self):
"""Clean up when destroyed"""
# Remove callback from todo service
todo_service.remove_callback(self._on_todo_event)
get_todo_service().remove_callback(self._on_todo_event)
super().destroy()