From beab79a405ba99de0b6be475d63bf9bf2277882f Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 31 Jul 2026 01:35:25 +0545 Subject: [PATCH] refactor: extract shared mixins, fix cpu waste, resolve browser icons - Extract CloseButtonRevealerMixin, PullToRefreshMixin, BasePlayerBoxStack - Replace mpris.py 2s+5s D-Bus polling with Seeked signal subscription - Remove redundant desktop/main.py os.listdir() poll (FileMonitor covers) - Remove switcher/main.py 200ms capture timer, chain from frame-ready - Resolve browser MPRIS icons via desktop file lookup, fallback to app name - Fix CodeQL py/clear-text-storage by rounding lat/lon and stripping from cache --- src/services/mpris.py | 121 ++++----- src/services/weather.py | 13 +- src/shared/widgets/close_button_revealer.py | 51 ++++ src/shared/widgets/player_box_stack.py | 188 ++++++++++++++ src/shared/widgets/pull_to_refresh.py | 100 ++++++++ src/window/controlcenter/bluetooth.py | 120 ++------- src/window/controlcenter/expanded_player.py | 169 +------------ src/window/controlcenter/player.py | 231 ++++-------------- src/window/controlcenter/wifi.py | 112 +-------- src/window/desktop/main.py | 27 -- src/window/notification/notification.py | 51 +--- .../notification/notification_center.py | 57 +---- src/window/switcher/main.py | 11 +- uv.lock | 4 +- 14 files changed, 515 insertions(+), 740 deletions(-) create mode 100644 src/shared/widgets/close_button_revealer.py create mode 100644 src/shared/widgets/player_box_stack.py create mode 100644 src/shared/widgets/pull_to_refresh.py diff --git a/src/services/mpris.py b/src/services/mpris.py index 693de32..8acf341 100644 --- a/src/services/mpris.py +++ b/src/services/mpris.py @@ -212,6 +212,7 @@ class PlayerService(Service): self._is_cleaning_up = False self._artwork_generation = 0 self._signal_ids = [] + self._subscription_ids: list[int] = [] self._last_emitted_status = "" self._failed_covers: set[str] = set() self._download_threads: dict[str, threading.Thread] = {} @@ -220,11 +221,10 @@ class PlayerService(Service): self._proxy.connect("g-properties-changed", self._on_properties_changed) ) - self._pos_source_id = 0 - self._status_source_id = 0 - self._last_polled_status = "" - self._start_status_polling() - self.poll_progress() + self._subscribe_seeked() + + if self.playback_status.lower() == "playing": + self.fabricating() metadata = self._get_metadata() if metadata: @@ -251,6 +251,49 @@ class PlayerService(Service): GLib.timeout_add(800, self._refresh_initial_state, attempt + 1) return False + def _subscribe_seeked(self): + conn = self._proxy.get_connection() + if conn: + self._subscription_ids.append( + conn.signal_subscribe( + self._proxy.get_name(), + MPRIS_PLAYER_IFACE, + "Seeked", + MPRIS_PLAYER_PATH, + None, + Gio.DBusSignalFlags.NONE, + self._on_seeked, + ) + ) + + def _on_seeked( + self, + _connection, + _sender_name, + _object_path, + _interface_name, + _signal_name, + params: GLib.Variant, + ): + if self._is_cleaning_up: + return + try: + pos = params.unpack()[0] / 1_000_000 + except Exception as e: + logger.warning(f"[mpris] Seeked unpack failed: {e}") + return + m = self._get_metadata() + dur = 0 + if m is not None: + v = m.get("mpris:length") + if v is not None: + dur = ( + v.get_uint64() / 1_000_000 + if isinstance(v, GLib.Variant) and v.get_type_string() == "t" + else int(v) / 1_000_000 + ) + self.track_position(pos, dur) + def get_artwork(self) -> str: return self._current_artwork_path @@ -270,49 +313,9 @@ class PlayerService(Service): logger.warning(f"Could not get position: {e}") return 0 - def _start_pos_fabricator(self): - if self._pos_source_id: - return - self._pos_source_id = GLib.timeout_add(2000, self._on_pos_poll) - - def _stop_pos_fabricator(self): - if not self._pos_source_id: - return - GLib.source_remove(self._pos_source_id) - self._pos_source_id = 0 - - def _on_pos_poll(self): - if self._is_cleaning_up: - return False - self.fabricating() - return True - - def _start_status_polling(self): - if self._status_source_id: - return - self._last_polled_status = self.playback_status - self._status_source_id = GLib.timeout_add(5000, self._on_status_poll) - - def _stop_status_polling(self): - if not self._status_source_id: - return - GLib.source_remove(self._status_source_id) - self._status_source_id = 0 - - def _on_status_poll(self): - if self._is_cleaning_up: - return False - status = self.playback_status - if status != self._last_polled_status: - self._last_polled_status = status - self.poll_progress() - self._notify_playback(status) - return True - def seek_position(self, pos: float): if self._is_cleaning_up: return - self._stop_pos_fabricator() try: self._proxy.call_sync( "SetPosition", @@ -335,13 +338,11 @@ class PlayerService(Service): except GLib.Error as e: logger.error(f"seek_position fallback failed: {e}") finally: - if self.playback_status.lower() == "playing": - self._start_pos_fabricator() + self.poll_progress() def seek(self, offset: float): if self._is_cleaning_up: return - self._stop_pos_fabricator() try: self._proxy.call_sync( "Seek", @@ -353,16 +354,13 @@ class PlayerService(Service): except GLib.Error as e: logger.error(f"Failed to seek: {e}") finally: - if self.playback_status.lower() == "playing": - self._start_pos_fabricator() + self.poll_progress() def poll_progress(self): if self._is_cleaning_up: return if self.playback_status.lower() == "playing": - self._start_pos_fabricator() - else: - self._stop_pos_fabricator() + self.fabricating() def fabricating(self, metadata=None): if self._is_cleaning_up: @@ -556,15 +554,22 @@ class PlayerService(Service): self._is_cleaning_up = True self._artwork_generation += 1 - self._stop_pos_fabricator() - self._stop_status_polling() - - for signal_id in self._signal_ids: + for sid in self._signal_ids: try: - self._proxy.disconnect(signal_id) + self._proxy.disconnect(sid) except Exception as e: logger.warning(f"Error disconnecting signal: {e}") self._signal_ids.clear() + + conn = self._proxy.get_connection() + for sid in self._subscription_ids: + try: + if conn: + conn.signal_unsubscribe(sid) + except Exception as e: + logger.warning(f"Error unsubscribing signal: {e}") + self._subscription_ids.clear() + self._current_artwork_path = "" self._failed_covers.clear() self._download_threads.clear() diff --git a/src/services/weather.py b/src/services/weather.py index e877056..d511a58 100644 --- a/src/services/weather.py +++ b/src/services/weather.py @@ -409,8 +409,8 @@ class Weather(Service): return result = raw["results"][0] location_data = { - "lat": result["latitude"], - "lon": result["longitude"], + "lat": round(result["latitude"], 1), + "lon": round(result["longitude"], 1), "city": result.get("name", "Unknown"), "country": result.get("country", ""), "config_location": target_loc, @@ -421,8 +421,8 @@ class Weather(Service): GLib.idle_add(self._set_error, "Location unavailable") return location_data = { - "lat": raw["lat"], - "lon": raw["lon"], + "lat": round(raw["lat"], 1), + "lon": round(raw["lon"], 1), "city": raw.get("city", "Unknown"), "country": raw.get("country", ""), "config_location": "", @@ -450,6 +450,11 @@ class Weather(Service): GLib.idle_add(self._set_error, "Weather unavailable") return else: + weather_data = { + k: v + for k, v in weather_data.items() + if k not in ("latitude", "longitude") + } self._weather_cache.set(weather_data) GLib.idle_add(self._update_properties, weather_data, location_data) diff --git a/src/shared/widgets/close_button_revealer.py b/src/shared/widgets/close_button_revealer.py new file mode 100644 index 0000000..6472a38 --- /dev/null +++ b/src/shared/widgets/close_button_revealer.py @@ -0,0 +1,51 @@ +from fabric.utils import Gdk, GLib +from gi.repository import Gtk + + +class CloseButtonRevealerMixin: + _close_button_hide_timeout_id: int | None + _hovered: bool + _is_close_button_hovered: bool + close_button_revealer: Gtk.Revealer + + def _schedule_close_button_hide(self): + self._cancel_close_button_hide() + self._close_button_hide_timeout_id = GLib.timeout_add( + 80, self._do_hide_close_button + ) + + def _cancel_close_button_hide(self): + if self._close_button_hide_timeout_id is not None: + GLib.source_remove(self._close_button_hide_timeout_id) + self._close_button_hide_timeout_id = None + + def _do_hide_close_button(self): + self._close_button_hide_timeout_id = None + if not self._hovered and not self._is_close_button_hovered: + self.close_button_revealer.set_reveal_child(False) + return False + + def _on_hover_enter(self, widget, event): + if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: + return False + self._hovered = True + self._cancel_close_button_hide() + self.close_button_revealer.set_reveal_child(True) + return False + + def _on_hover_leave(self, widget, event): + if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: + return False + self._hovered = False + self._schedule_close_button_hide() + return False + + def _on_close_button_enter(self, widget, event): + self._is_close_button_hovered = True + self._cancel_close_button_hide() + return False + + def _on_close_button_leave(self, widget, event): + self._is_close_button_hovered = False + self._schedule_close_button_hide() + return False diff --git a/src/shared/widgets/player_box_stack.py b/src/shared/widgets/player_box_stack.py new file mode 100644 index 0000000..50e305d --- /dev/null +++ b/src/shared/widgets/player_box_stack.py @@ -0,0 +1,188 @@ +from fabric.utils import bulk_connect, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button + + +class BasePlayerBoxStack(Box): + def __init__(self, mpris_manager, **kwargs): + super().__init__( + orientation="v", name="media", children=[self.player_stack], **kwargs + ) + self.player_stack.children = [self.no_media_box] + self.set_visible(True) + self.mpris_manager = mpris_manager + self.connect("map", self._on_map) + + if self.mpris_manager is not None: + self._init_signals() + + def _init_signals(self): + try: + connections = bulk_connect( + self.mpris_manager, + { + "new-player": self.on_new_player, + "player-vanish": self.on_lost_player, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + for name, player in self.mpris_manager.get_all_services().items(): + self.on_new_player(self.mpris_manager, name, player) + except Exception as e: + logger.error(f"Failed to initialize PlayerBoxStack signals: {e}") + + def _on_map(self, *_): + self._check_and_update_playing_state() + + def destroy(self): + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.error(f"An error occurred: {e}") + self._signal_connections.clear() + super().destroy() + + def _find_playing_player_index(self): + players = self.player_stack.get_children() + for i, player_box in enumerate(players): + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + return i + return None + + def _check_and_update_playing_state(self): + children = self.player_stack.get_children() + players = [p for p in children if p != self.no_media_box] + if len(players) > 0: + if self.no_media_box in children: + self.player_stack.remove(self.no_media_box) + playing_index = self._find_playing_player_index() + if playing_index is not None: + self.on_player_clicked_by_index(playing_index) + return + + if len(children) == 1 and children[0] == self.no_media_box: + return + + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_stack.set_visible_child(self.no_media_box) + + def on_player_playback_changed(self, player_box, status): + status = str(status).lower() + if status == "playing": + players = self.player_stack.get_children() + for i, pb in enumerate(players): + if pb == player_box and i != self.current_stack_pos: + self.on_player_clicked_by_index(i) + break + elif status in ["paused", "stopped"]: + self._check_and_update_playing_state() + + def _update_all_player_buttons(self): + show_buttons = len(self.player_buttons) > 1 + for child in self.player_stack.get_children(): + if hasattr(child, "update_buttons"): + child.update_buttons(self.player_buttons, show_buttons) + + def on_player_clicked_by_index(self, index): + if 0 <= index < len(self.player_buttons): + if self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + self.current_stack_pos = index + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos] + ) + self._update_all_player_buttons() + + def on_new_player(self, mpris_manager, name, player): + children = self.player_stack.get_children() + if len(children) == 1 and children[0] == self.no_media_box: + self.player_stack.children = [] + self.current_stack_pos = 0 + + self.set_visible(True) + new_player_box = self._create_player_box_widget(player) + self.player_stack.children = [*self.player_stack.children, new_player_box] + self.make_new_player_button(new_player_box) + if self.player_buttons: + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + self._check_and_update_playing_state() + self._update_all_player_buttons() + + def _create_player_box_widget(self, player): + from window.controlcenter.player import PlayerBox + + return PlayerBox(player=player, player_stack=self) + + def on_lost_player(self, mpris_manager, bus_name): + children = self.player_stack.get_children() + player_box_to_remove = None + for player_box in children: + if ( + hasattr(player_box, "player") + and getattr(player_box.player, "player_name", None) == bus_name + ): + player_box_to_remove = player_box + break + + if player_box_to_remove: + player_box_to_remove.destroy() + + remaining = [ + c for c in self.player_stack.get_children() if c != player_box_to_remove + ] + if len(remaining) == 0: + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_buttons = [] + return + + if self.current_stack_pos >= len(remaining): + self.current_stack_pos = max(0, len(remaining) - 1) + + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + self.player_stack.set_visible_child(remaining[self.current_stack_pos]) + self._update_all_player_buttons() + + def make_new_player_button(self, player_box): + new_button = Button(name="player-stack-button") + + def on_player_button_click(button: Button): + if self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + if button in self.player_buttons: + self.current_stack_pos = self.player_buttons.index(button) + button.add_style_class("active") + self.player_stack.set_visible_child(player_box) + + new_button.connect("clicked", on_player_button_click) + self.player_buttons.append(new_button) + player_box.connect( + "destroy", + lambda *_: ( + self.player_buttons.remove(new_button) + if new_button in self.player_buttons + else None + ), + ) + + def suspend(self): + for child in self.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + + def resume(self): + for child in self.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + + def _create_no_media_box(self): + raise NotImplementedError diff --git a/src/shared/widgets/pull_to_refresh.py b/src/shared/widgets/pull_to_refresh.py new file mode 100644 index 0000000..2291fbd --- /dev/null +++ b/src/shared/widgets/pull_to_refresh.py @@ -0,0 +1,100 @@ +from fabric.utils import Gdk, logger + + +class PullToRefreshMixin: + def setup_pull_to_refresh(self, scrolled_widget): + self._scrolled_widget = scrolled_widget + self.vadjustment = scrolled_widget.get_vadjustment() + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 + self._anim_finished_handler = None + + scrolled_widget.connect("scroll-event", self.on_scroll_event) + scrolled_widget.connect("button-press-event", self.on_button_press) + scrolled_widget.connect("button-release-event", self.on_button_release) + scrolled_widget.connect("motion-notify-event", self.on_motion_notify) + + scrolled_widget.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + self._trigger_scan() + self._trigger_refresh() + return True + return False + + def on_button_press(self, widget, event): + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + self._trigger_scan() + self._trigger_refresh() + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + self.refresh_indicator.set_label(self._get_pull_ready_label()) + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_label(self._get_pull_normal_label()) + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def _cancel_pending_refresh(self): + if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler: + try: + self._scrolled_widget.height_animator.disconnect( + self._anim_finished_handler + ) + except Exception as e: + logger.warning( + f"[pull-to-refresh] height_animator.disconnect(...) failed: {e}" + ) + self._anim_finished_handler = None + + def _refresh_after_animation(self): + if self._destroyed: + return False + anim = self._scrolled_widget.height_animator + if anim.playing: + self._anim_finished_handler = anim.connect( + "finished", + lambda *_: self._trigger_refresh() if not self._destroyed else None, + ) + else: + self._trigger_refresh() + return False + + def _get_pull_normal_label(self): + return "↓ Pull to refresh" + + def _get_pull_ready_label(self): + return "↑ Release to refresh" + + def _trigger_scan(self): + raise NotImplementedError + + def _trigger_refresh(self): + raise NotImplementedError diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py index 396918d..bfa984b 100644 --- a/src/window/controlcenter/bluetooth.py +++ b/src/window/controlcenter/bluetooth.py @@ -1,6 +1,6 @@ from enum import Enum, auto -from fabric.utils import Gdk, GLib, Gtk, idle_add, logger +from fabric.utils import GLib, Gtk, idle_add, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -9,6 +9,7 @@ from fabric.widgets.label import Label from fabric.widgets.separator import Separator from services.bluetooth import BluetoothClient, BluetoothDevice +from shared.widgets.pull_to_refresh import PullToRefreshMixin from shared.widgets.smooth_switch import SmoothSwitch from shared.window.animated_scrollwindow import AnimatedScrollable from utils.functions import spawn_detached @@ -304,7 +305,7 @@ class BluetoothDeviceSlot(Box): logger.warning(f"[Bluetooth] Failed to remove device: {e}") -class BluetoothConnections(Box): +class BluetoothConnections(PullToRefreshMixin, Box): def __init__( self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs ): @@ -422,7 +423,7 @@ class BluetoothConnections(Box): self.other_devices.set_visible(False) # Add pull-to-refresh functionality to scrolled window - self.setup_pull_to_refresh() + self.setup_pull_to_refresh(self.other_devices_scrolled) self.more_settings_button = Button( child=Label("More Settings", h_align="start"), @@ -507,31 +508,6 @@ class BluetoothConnections(Box): # Defer refresh until after the height animation completes idle_add(self._refresh_after_animation) - def _cancel_pending_refresh(self): - if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler: - try: - self.other_devices_scrolled.height_animator.disconnect( - self._anim_finished_handler - ) - except Exception as e: - logger.warning( - f"[bluetooth] self.other_devices_scrolled.height_animator.disconnect( s... failed: {e}" - ) - self._anim_finished_handler = None - - def _refresh_after_animation(self): - if self._destroyed: - return False - anim = self.other_devices_scrolled.height_animator - if anim.playing: - self._anim_finished_handler = anim.connect( - "finished", - lambda *_: self.force_device_refresh() if not self._destroyed else None, - ) - else: - self.force_device_refresh() - return False - def open_bluetooth_settings(self, *_): """Open Blueman bluetooth manager""" try: @@ -692,84 +668,22 @@ class BluetoothConnections(Box): self.other_devices.set_visible(False) self.other_devices_scrolled.snap_to_size(0) - def setup_pull_to_refresh(self): - """Setup pull-to-refresh gesture for the scrolled window""" - self.vadjustment = self.other_devices_scrolled.get_vadjustment() + def _trigger_scan(self): + if not self.client.scanning and hasattr(self.client, "scan"): + self.client.scan() - # Track gesture state - self.pull_start_y = 0 - self.is_pulling = False - self.pull_threshold = 50 # pixels to trigger refresh + def _trigger_refresh(self): + self.force_device_refresh() - # Connect to scroll events - self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) - self.other_devices_scrolled.connect("button-press-event", self.on_button_press) - self.other_devices_scrolled.connect( - "button-release-event", self.on_button_release - ) - self.other_devices_scrolled.connect( - "motion-notify-event", self.on_motion_notify - ) + def _get_pull_normal_label(self): + if self.client.scanning: + return "↓ Pull to stop scanning" + return "↓ Pull to scan for devices" - # Enable events - self.other_devices_scrolled.set_events( - Gdk.EventMask.SCROLL_MASK - | Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.POINTER_MOTION_MASK - ) - - def on_scroll_event(self, widget, event): - """Handle scroll events for pull-to-refresh""" - if self.vadjustment.get_value() <= 0: - if event.direction == Gdk.ScrollDirection.UP: - if not self.client.scanning and hasattr(self.client, "scan"): - self.client.scan() - self.force_device_refresh() - return True - return False - - def on_button_press(self, widget, event): - """Handle button press for touch/drag gestures""" - if self.vadjustment.get_value() <= 0: - self.pull_start_y = event.y - self.is_pulling = True - return False - - def on_button_release(self, widget, event): - """Handle button release for touch/drag gestures""" - if self.is_pulling: - pull_distance = event.y - self.pull_start_y - if pull_distance > self.pull_threshold: - if not self.client.scanning and hasattr(self.client, "scan"): - self.client.scan() - self.force_device_refresh() - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("ready-to-refresh") - self.is_pulling = False - return False - - def on_motion_notify(self, widget, event): - """Handle motion events for visual feedback during pull""" - if self.is_pulling and self.vadjustment.get_value() <= 0: - pull_distance = event.y - self.pull_start_y - if pull_distance > 0: - self.refresh_indicator.set_visible(True) - if pull_distance >= self.pull_threshold: - if self.client.scanning: - self.refresh_indicator.set_label("↑ Release to stop scanning") - else: - self.refresh_indicator.set_label("↑ Release to scan") - self.refresh_indicator.add_style_class("ready-to-refresh") - else: - if self.client.scanning: - self.refresh_indicator.set_label("↓ Pull to stop scanning") - else: - self.refresh_indicator.set_label("↓ Pull to scan for devices") - self.refresh_indicator.remove_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_visible(False) - return False + def _get_pull_ready_label(self): + if self.client.scanning: + return "↑ Release to stop scanning" + return "↑ Release to scan" def on_device_added(self, client: BluetoothClient, address: str): """Handle when a new device is added""" diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py index aa2f113..7120dc0 100644 --- a/src/window/controlcenter/expanded_player.py +++ b/src/window/controlcenter/expanded_player.py @@ -1,5 +1,4 @@ import os -import weakref from fabric.utils import GLib, bulk_connect, logger from fabric.widgets.box import Box @@ -12,11 +11,13 @@ from fabric.widgets.stack import Stack from fabric.widgets.wayland import WaylandWindow as Window from services.mpris import PlayerManager, PlayerService +from shared.widgets.player_box_stack import BasePlayerBoxStack from utils.gtk_utils import svg_file from window.controlcenter.player import ( PLAYER_FALLBACK_ART, apply_player_art, get_shared_mpris_manager, + resolve_mpris_player_icon, ) @@ -55,42 +56,18 @@ class EmbeddedExpandedPlayer(Box): super().destroy() -class PlayerBoxStack(Box): +class PlayerBoxStack(BasePlayerBoxStack): def __init__(self, mpris_manager: PlayerManager, **kwargs): self.player_stack = Stack(name="player-stack", transition_type="none") self.current_stack_pos = 0 self.player_buttons: list[Button] = [] - self._player_widgets = weakref.WeakValueDictionary() self._signal_connections = [] self.no_media_box = self._create_no_media_box() - super().__init__(orientation="v", name="media", children=[self.player_stack]) - self.player_stack.children = [self.no_media_box] - self.set_visible(True) - self.mpris_manager = mpris_manager - self.connect("map", self._on_map) + super().__init__(mpris_manager, **kwargs) - connections = bulk_connect( - self.mpris_manager, - {"new-player": self.on_new_player, "player-vanish": self.on_lost_player}, - ) - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) - - for name, player in self.mpris_manager.get_all_services().items(): - self.on_new_player(self.mpris_manager, name, player) - - def _on_map(self, *_): - self._check_and_update_playing_state() - - def destroy(self): - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.error(f"An error occurred: {e}") - self._signal_connections.clear() - super().destroy() + def _create_player_box_widget(self, player): + return PlayerBox(player=player, player_stack=self) def _create_no_media_box(self): fallback_cover_path = PLAYER_FALLBACK_ART @@ -139,138 +116,6 @@ class PlayerBoxStack(Box): ], ) - def _find_playing_player_index(self): - players = self.player_stack.get_children() - for i, player_box in enumerate(players): - if ( - hasattr(player_box, "player") - and str(player_box.player.playback_status).lower() == "playing" - ): - return i - return None - - def _check_and_update_playing_state(self): - players = [ - p for p in self.player_stack.get_children() if p != self.no_media_box - ] - if len(players) > 0: - if self.no_media_box in self.player_stack.get_children(): - self.player_stack.remove(self.no_media_box) - playing_index = self._find_playing_player_index() - if playing_index is not None: - self.on_player_clicked_by_index(playing_index) - return - - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - return - - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_stack.set_visible_child(self.no_media_box) - - def on_player_playback_changed(self, player_box, status): - status = str(status).lower() - if status == "playing": - players = self.player_stack.get_children() - for i, pb in enumerate(players): - if pb == player_box and i != self.current_stack_pos: - self.on_player_clicked_by_index(i) - break - elif status in ["paused", "stopped"]: - self._check_and_update_playing_state() - - def on_player_clicked_by_index(self, index): - if 0 <= index < len(self.player_buttons): - if self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - self.current_stack_pos = index - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos] - ) - self._update_all_player_buttons() - - def on_new_player(self, mpris_manager, name, player): - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - self.player_stack.children = [] - self.current_stack_pos = 0 - - self.set_visible(True) - new_player_box = PlayerBox(player=player, player_stack=self) - self.player_stack.children = [*self.player_stack.children, new_player_box] - self.make_new_player_button(new_player_box) - if self.player_buttons: - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - self._check_and_update_playing_state() - self._update_all_player_buttons() - - def on_lost_player(self, mpris_manager, player_name): - player_box_to_remove = None - for player_box in self.player_stack.get_children(): - if ( - hasattr(player_box, "player") - and player_box.player.player_name == player_name - ): - player_box_to_remove = player_box - break - - if player_box_to_remove: - player_box_to_remove.destroy() - - remaining_players = [ - p for p in self.player_stack.get_children() if p != player_box_to_remove - ] - if len(remaining_players) == 0: - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_buttons = [] - return - - if self.current_stack_pos >= len(self.player_stack.get_children()): - self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) - - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos] - ) - - self._update_all_player_buttons() - - def make_new_player_button(self, player_box): - new_button = Button(name="player-stack-button") - - def on_player_button_click(button: Button): - if self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - if button in self.player_buttons: - self.current_stack_pos = self.player_buttons.index(button) - button.add_style_class("active") - self.player_stack.set_visible_child(player_box) - - new_button.connect("clicked", on_player_button_click) - self.player_buttons.append(new_button) - player_box.connect( - "destroy", - lambda *_: ( - self.player_buttons.remove(new_button) - if new_button in self.player_buttons - else None - ), - ) - - def _update_all_player_buttons(self): - players = self.player_stack.get_children() - for player_box in players: - if hasattr(player_box, "update_buttons"): - player_box.update_buttons(self.player_buttons, len(players) > 1) - class PlayerBox(Box): def __init__(self, player: PlayerService, player_stack=None, **kwargs): @@ -303,7 +148,7 @@ class PlayerBox(Box): overlays=[ Box( children=Image( - icon_name=self.player.player_name, + icon_name=resolve_mpris_player_icon(self.player.player_name), name="player-app-icon", icon_size=20, ), diff --git a/src/window/controlcenter/player.py b/src/window/controlcenter/player.py index 7e6cf35..6bdbb42 100644 --- a/src/window/controlcenter/player.py +++ b/src/window/controlcenter/player.py @@ -9,16 +9,60 @@ from fabric.widgets.stack import Stack import shared.data as data from services.mpris import PlayerManager, PlayerService +from shared.widgets.player_box_stack import BasePlayerBoxStack from utils.gtk_utils import svg_file CACHE_DIR = f"{data.CACHE_DIR}/media" PLAYER_FALLBACK_ART = get_relative_path("../../assets/icons/music.svg") +MPRIS_ICON_FALLBACK = "application-x-executable-symbolic" if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) _shared_mpris_manager = None +def resolve_mpris_player_icon(player_name: str) -> str: + """Resolve MPRIS player icon from .desktop file.""" + raw = player_name or "" + app_name = raw.removeprefix("org.mpris.MediaPlayer2.") + + search_dirs = [ + os.path.join(os.path.expanduser("~/.local/share"), "applications"), + "/usr/share/applications", + "/usr/local/share/applications", + ] + for d in os.environ.get("XDG_DATA_DIRS", "/usr/share:/usr/local/share").split(":"): + p = os.path.join(d, "applications") + if p not in search_dirs: + search_dirs.append(p) + + candidates = [app_name] + parts = app_name.split("-") + for i in range(len(parts) - 1, 0, -1): + shorter = "-".join(parts[:i]) + if shorter not in candidates: + candidates.append(shorter) + + for base in search_dirs: + for name in candidates: + dp = os.path.join(base, name + ".desktop") + if os.path.exists(dp): + try: + with open(dp) as f: + for line in f: + if line.startswith("Icon="): + return line[5:].strip() + except OSError: + pass + + # No desktop file found — try app_name as direct icon name + for name in candidates: + if name: + return name + + return MPRIS_ICON_FALLBACK + + def get_shared_mpris_manager(): global _shared_mpris_manager if _shared_mpris_manager is None: @@ -61,7 +105,7 @@ def apply_player_art(album_cover_widget, player, service=None, path=None): return None -class PlayerBoxStack(Box): +class PlayerBoxStack(BasePlayerBoxStack): def __init__( self, mpris_manager: PlayerManager = None, control_center=None, **kwargs ): @@ -72,53 +116,12 @@ class PlayerBoxStack(Box): self._signal_connections = [] self.no_media_box = self._create_no_media_box() - super().__init__(orientation="v", name="media", children=[self.player_stack]) - self.player_stack.children = [self.no_media_box] - self.set_visible(True) - self.mpris_manager = mpris_manager or get_shared_mpris_manager() - self.connect("map", self._on_map) + super().__init__(mpris_manager or get_shared_mpris_manager(), **kwargs) - if self.mpris_manager is not None: - self._init_mpris_manager() - - def _init_mpris_manager(self): - try: - connections = bulk_connect( - self.mpris_manager, - { - "new-player": self.on_new_player, - "player-vanish": self.on_lost_player, - }, - ) - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) - - for name, player in self.mpris_manager.get_all_services().items(): - self.on_new_player(self.mpris_manager, name, player) - except Exception as e: - logger.error(f"Failed to initialize PlayerBoxStack signals: {e}") - - def _on_map(self, *_): - self._check_and_update_playing_state() - - def destroy(self): - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.error(f"An error occurred: {e}") - self._signal_connections.clear() - super().destroy() - - def suspend(self): - for child in self.player_stack.get_children(): - if hasattr(child, "suspend"): - child.suspend() - - def resume(self): - for child in self.player_stack.get_children(): - if hasattr(child, "resume"): - child.resume() + def _create_player_box_widget(self, player): + return PlayerBox( + player=player, player_stack=self, control_center=self.control_center + ) def _create_no_media_box(self): fallback_cover_path = get_relative_path("../../assets/icons/music.svg") @@ -172,138 +175,6 @@ class PlayerBoxStack(Box): ], ) - def _find_playing_player_index(self): - players = self.player_stack.get_children() - for i, player_box in enumerate(players): - if ( - hasattr(player_box, "player") - and str(player_box.player.playback_status).lower() == "playing" - ): - return i - return None - - def _check_and_update_playing_state(self): - players = [ - p for p in self.player_stack.get_children() if p != self.no_media_box - ] - if len(players) > 0: - if self.no_media_box in self.player_stack.get_children(): - self.player_stack.remove(self.no_media_box) - playing_index = self._find_playing_player_index() - if playing_index is not None: - self.on_player_clicked_by_index(playing_index) - return - - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - return - - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_stack.set_visible_child(self.no_media_box) - - def on_player_playback_changed(self, player_box, status): - status = str(status).lower() - if status == "playing": - players = self.player_stack.get_children() - for i, pb in enumerate(players): - if pb == player_box and i != self.current_stack_pos: - self.on_player_clicked_by_index(i) - break - elif status in ["paused", "stopped"]: - self._check_and_update_playing_state() - - def _update_all_player_buttons(self): - show_buttons = len(self.player_buttons) > 1 - for child in self.player_stack.get_children(): - if hasattr(child, "update_buttons"): - child.update_buttons(self.player_buttons, show_buttons) - - def on_player_clicked_by_index(self, index): - if 0 <= index < len(self.player_buttons): - if self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - self.current_stack_pos = index - self.player_buttons[self.current_stack_pos].add_style_class("active") - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos] - ) - self._update_all_player_buttons() - - def on_new_player(self, mpris_manager, name, player): - if ( - len(self.player_stack.get_children()) == 1 - and self.player_stack.get_children()[0] == self.no_media_box - ): - self.player_stack.children = [] - self.current_stack_pos = 0 - - new_player_box = PlayerBox( - player=player, player_stack=self, control_center=self.control_center - ) - self.player_stack.children = [*self.player_stack.children, new_player_box] - self.make_new_player_button(new_player_box) - if self.player_buttons: - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - self._check_and_update_playing_state() - self._update_all_player_buttons() - - def on_lost_player(self, mpris_manager, bus_name): - player_box_to_remove = None - for player_box in self.player_stack.get_children(): - if ( - hasattr(player_box, "player") - and getattr(player_box.player, "player_name", None) == bus_name - ): - player_box_to_remove = player_box - break - - if player_box_to_remove: - player_box_to_remove.destroy() - - remaining_players = [ - p for p in self.player_stack.get_children() if p != player_box_to_remove - ] - if len(remaining_players) == 0: - self.player_stack.children = [self.no_media_box] - self.current_stack_pos = 0 - self.player_buttons = [] - return - - if self.current_stack_pos >= len(self.player_stack.get_children()): - self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) - - if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) - self.player_stack.set_visible_child( - self.player_stack.get_children()[self.current_stack_pos] - ) - self._update_all_player_buttons() - - def make_new_player_button(self, player_box): - new_button = Button(name="player-stack-button") - - def on_player_button_click(button: Button): - if self.current_stack_pos < len(self.player_buttons): - self.player_buttons[self.current_stack_pos].remove_style_class("active") - if button in self.player_buttons: - self.current_stack_pos = self.player_buttons.index(button) - button.add_style_class("active") - self.player_stack.set_visible_child(player_box) - - new_button.connect("clicked", on_player_button_click) - self.player_buttons.append(new_button) - player_box.connect( - "destroy", - lambda *_: ( - self.player_buttons.remove(new_button) - if new_button in self.player_buttons - else None - ), - ) - class PlayerBox(Box): def __init__( @@ -327,7 +198,7 @@ class PlayerBox(Box): overlays=[ Box( children=Image( - icon_name=self.player.player_name, + icon_name=resolve_mpris_player_icon(self.player.player_name), name="player-app-icon", icon_size=20, ), diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py index 2435f8b..dfbd0e8 100644 --- a/src/window/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -1,4 +1,4 @@ -from fabric.utils import Gdk, GLib, idle_add, logger +from fabric.utils import GLib, idle_add, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -9,6 +9,7 @@ from fabric.widgets.separator import Separator from services import dns as dns_service from services.network import NetworkClient from shared.dialogs.sysauth_dialog import run_auth_dialog +from shared.widgets.pull_to_refresh import PullToRefreshMixin from shared.widgets.smooth_switch import SmoothSwitch from shared.window.animated_scrollwindow import AnimatedScrollable from utils.functions import ( @@ -402,7 +403,7 @@ class DnsSwitcher: self._load_current_dns() -class WifiConnections(Box): +class WifiConnections(PullToRefreshMixin, Box): def __init__(self, parent, show_back_button=True, network_service=None, **kwargs): super().__init__( spacing=8, @@ -506,7 +507,7 @@ class WifiConnections(Box): self.other_networks.set_visible(False) # Add pull-to-refresh functionality to scrolled window - self.setup_pull_to_refresh() + self.setup_pull_to_refresh(self.other_networks_scrolled) # Create More Settings button (same style as Other Networks button) self.more_settings_button = Button( @@ -574,33 +575,6 @@ class WifiConnections(Box): # Defer refresh until after the height animation completes idle_add(self._refresh_after_animation) - def _cancel_pending_refresh(self): - if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler: - try: - self.other_networks_scrolled.height_animator.disconnect( - self._anim_finished_handler - ) - except Exception as e: - logger.warning( - f"[wifi] self.other_networks_scrolled.height_animator.disconnect( ... failed: {e}" - ) - self._anim_finished_handler = None - - def _refresh_after_animation(self): - if self._destroyed: - return False - anim = self.other_networks_scrolled.height_animator - if anim.playing: - self._anim_finished_handler = anim.connect( - "finished", - lambda *_: ( - self.force_network_refresh() if not self._destroyed else None - ), - ) - else: - self.force_network_refresh() - return False - def on_network_ready(self, *_): """Called when network service is ready""" if self._network_ready_fired and self.wifi_service: @@ -812,78 +786,18 @@ class WifiConnections(Box): except Exception as e: logger.error(f"[WiFi] Error during forced network refresh: {e}") - def setup_pull_to_refresh(self): - """Setup pull-to-refresh gesture for the scrolled window""" - self.vadjustment = self.other_networks_scrolled.get_vadjustment() + def _trigger_scan(self): + if self.wifi_service: + self.wifi_service.scan() - # Track gesture state - self.pull_start_y = 0 - self.is_pulling = False - self.pull_threshold = 50 # pixels to trigger refresh + def _trigger_refresh(self): + self.force_network_refresh() - # Connect to scroll events - self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event) - self.other_networks_scrolled.connect("button-press-event", self.on_button_press) - self.other_networks_scrolled.connect( - "button-release-event", self.on_button_release - ) - self.other_networks_scrolled.connect( - "motion-notify-event", self.on_motion_notify - ) + def _get_pull_normal_label(self): + return "↓ Pull to scan for networks" - # Enable events - self.other_networks_scrolled.set_events( - Gdk.EventMask.SCROLL_MASK - | Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.POINTER_MOTION_MASK - ) - - def on_scroll_event(self, widget, event): - """Handle scroll events for pull-to-refresh""" - if self.vadjustment.get_value() <= 0: - if event.direction == Gdk.ScrollDirection.UP: - if self.wifi_service: - self.wifi_service.scan() - self.force_network_refresh() - return True - return False - - def on_button_press(self, widget, event): - """Handle button press for touch/drag gestures""" - if self.vadjustment.get_value() <= 0: - self.pull_start_y = event.y - self.is_pulling = True - return False - - def on_button_release(self, widget, event): - """Handle button release for touch/drag gestures""" - if self.is_pulling: - pull_distance = event.y - self.pull_start_y - if pull_distance > self.pull_threshold: - if self.wifi_service: - self.wifi_service.scan() - self.force_network_refresh() - self.refresh_indicator.set_visible(False) - self.refresh_indicator.remove_style_class("ready-to-refresh") - self.is_pulling = False - return False - - def on_motion_notify(self, widget, event): - """Handle motion events for visual feedback during pull""" - if self.is_pulling and self.vadjustment.get_value() <= 0: - pull_distance = event.y - self.pull_start_y - if pull_distance > 0: - self.refresh_indicator.set_visible(True) - if pull_distance >= self.pull_threshold: - self.refresh_indicator.set_label("↑ Release to scan") - self.refresh_indicator.add_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_label("↓ Pull to scan for networks") - self.refresh_indicator.remove_style_class("ready-to-refresh") - else: - self.refresh_indicator.set_visible(False) - return False + def _get_pull_ready_label(self): + return "↑ Release to scan" def on_destroy(self, widget): """Cleanup when widget is destroyed""" diff --git a/src/window/desktop/main.py b/src/window/desktop/main.py index 95ad36e..24455a2 100644 --- a/src/window/desktop/main.py +++ b/src/window/desktop/main.py @@ -194,8 +194,6 @@ class DesktopWidgetWindow(WaylandWindow): self.connect("button-press-event", self._on_window_button_press) self._setup_drag_dest() self._setup_file_monitor() - self._last_user_files: set[str] = set() - GLib.timeout_add(2000, self._poll_user_widgets) GLib.timeout_add(50, self._initial_build) @@ -251,31 +249,6 @@ class DesktopWidgetWindow(WaylandWindow): # fallback poll — catches deletions that Gio file monitor misses - def _poll_user_widgets(self) -> bool: - if not os.path.isdir(_CONFIG_DESKTOP_DIR): - return True - current = { - f[:-3] - for f in os.listdir(_CONFIG_DESKTOP_DIR) - if f.endswith(".py") and not f.startswith("_") - } - if current != self._last_user_files: - self._last_user_files = current - stale = {k for k, f in _user_key_to_file.items() if f not in current} - if stale: - logger.info(f"[DesktopWidgets] poll found stale: {stale}") - for key in stale: - mod_name = _user_key_to_file.pop(key, None) - if mod_name: - sys.modules.pop(f"user_widget_{mod_name}", None) - _user_widget_modules.pop(mod_name, None) - DesktopWidgetRegistry.unregister_user_keys(stale) - _load_user_widgets() - self.rebuild() - return True - - # file monitor — live-rebuild when config/desktop/ changes - def _setup_file_monitor(self) -> None: if not os.path.isdir(_CONFIG_DESKTOP_DIR): os.makedirs(_CONFIG_DESKTOP_DIR, exist_ok=True) diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index f72186c..acad904 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -19,6 +19,7 @@ import shared.data as data from services.config import get_config, on_config_change from services.modus import notification_service from shared.widgets.clipping_box import ClippingBox +from shared.widgets.close_button_revealer import CloseButtonRevealerMixin from shared.widgets.custom_image import CustomImage from shared.widgets.customrevealer import SlideRevealer from utils.gtk_utils import setup_cursor_hover, svg_file @@ -847,7 +848,7 @@ class NotificationWidget(Box): return data.NOTIFICATION_TIMEOUT -class NotificationRevealer(SlideRevealer): +class NotificationRevealer(CloseButtonRevealerMixin, SlideRevealer): def __init__( self, notification: Notification, @@ -872,7 +873,7 @@ class NotificationRevealer(SlideRevealer): setup_cursor_hover(self.close_button) self._is_close_button_hovered = False - self._is_popup_hovered = False + self._hovered = False self._close_button_hide_timeout_id = None self.close_button_event_box = EventBox( @@ -927,8 +928,8 @@ class NotificationRevealer(SlideRevealer): ], child=self.notif_box, ) - self.event_box.connect("enter-notify-event", self._on_popup_enter) - self.event_box.connect("leave-notify-event", self._on_popup_leave) + self.event_box.connect("enter-notify-event", self._on_hover_enter) + self.event_box.connect("leave-notify-event", self._on_hover_leave) self.event_box.connect("button-press-event", self._on_button_press) self.event_box.connect("button-release-event", self._on_button_release) self.event_box.connect("motion-notify-event", self._on_motion_notify) @@ -956,48 +957,6 @@ class NotificationRevealer(SlideRevealer): """Smoother easing function for better animation quality""" return 1 - pow(1 - t, 3) - def _on_popup_enter(self, widget, event): - if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: - return False - self._is_popup_hovered = True - self._cancel_close_button_hide() - self.close_button_revealer.set_reveal_child(True) - return False - - def _on_popup_leave(self, widget, event): - if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: - return False - self._is_popup_hovered = False - self._schedule_close_button_hide() - return False - - def _on_close_button_enter(self, widget, event): - self._is_close_button_hovered = True - self._cancel_close_button_hide() - return False - - def _on_close_button_leave(self, widget, event): - self._is_close_button_hovered = False - self._schedule_close_button_hide() - return False - - def _schedule_close_button_hide(self): - self._cancel_close_button_hide() - self._close_button_hide_timeout_id = GLib.timeout_add( - 80, self._do_hide_close_button - ) - - def _cancel_close_button_hide(self): - if self._close_button_hide_timeout_id is not None: - GLib.source_remove(self._close_button_hide_timeout_id) - self._close_button_hide_timeout_id = None - - def _do_hide_close_button(self): - self._close_button_hide_timeout_id = None - if not self._is_popup_hovered and not self._is_close_button_hovered: - self.close_button_revealer.set_reveal_child(False) - return False - def _on_button_press(self, widget, event): if event.button != 1: return False diff --git a/src/window/notification/notification_center.py b/src/window/notification/notification_center.py index 956f12f..0b44752 100644 --- a/src/window/notification/notification_center.py +++ b/src/window/notification/notification_center.py @@ -13,6 +13,7 @@ from fabric.widgets.scrolledwindow import ScrolledWindow import shared.data as data from services.modus import notification_service from shared.widgets.clipping_box import ClippingBox +from shared.widgets.close_button_revealer import CloseButtonRevealerMixin from shared.widgets.custom_image import CustomImage from shared.window.applet_window import AppletWindow from utils.functions import clear_children, escape_markup_text @@ -33,7 +34,7 @@ from window.notification.unified_cache import ( ) -class ExpandableNotificationGroup(Box): +class ExpandableNotificationGroup(CloseButtonRevealerMixin, Box): def __init__(self, app_name, notifications, **kwargs): super().__init__( name="notification-group", orientation="v", spacing=0, **kwargs @@ -457,7 +458,7 @@ class ExpandableNotificationGroup(Box): return True -class NotificationCenterWidget(NotificationWidget): +class NotificationCenterWidget(CloseButtonRevealerMixin, NotificationWidget): def __init__(self, notification, **kwargs): self.notification_id = notification.cache_id self.cache_metadata = getattr(notification, "cache_metadata", {}) @@ -539,7 +540,7 @@ class NotificationCenterWidget(NotificationWidget): setup_cursor_hover(self.close_button) self._is_close_button_hovered = False - self._is_content_hovered = False + self._hovered = False self._close_button_hide_timeout_id = None close_event_box = EventBox( @@ -549,7 +550,7 @@ class NotificationCenterWidget(NotificationWidget): close_event_box.connect("enter-notify-event", self._on_close_button_enter) close_event_box.connect("leave-notify-event", self._on_close_button_leave) - self.close_revealer = Gtk.Revealer( + self.close_button_revealer = Gtk.Revealer( transition_type=Gtk.RevealerTransitionType.CROSSFADE, transition_duration=200, child=close_event_box, @@ -618,12 +619,12 @@ class NotificationCenterWidget(NotificationWidget): ], child=content, ) - content_event_box.connect("enter-notify-event", self._on_content_enter) - content_event_box.connect("leave-notify-event", self._on_content_leave) + content_event_box.connect("enter-notify-event", self._on_hover_enter) + content_event_box.connect("leave-notify-event", self._on_hover_leave) overlay = Gtk.Overlay() overlay.add(content_event_box) - overlay.add_overlay(self.close_revealer) + overlay.add_overlay(self.close_button_revealer) overlay.show_all() return overlay @@ -697,48 +698,6 @@ class NotificationCenterWidget(NotificationWidget): except Exception as e: logger.error(f"Error removing notification {self.notification_id}: {e}") - def _on_content_enter(self, widget, event): - if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: - return False - self._is_content_hovered = True - self._cancel_close_button_hide() - self.close_revealer.set_reveal_child(True) - return False - - def _on_content_leave(self, widget, event): - if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR: - return False - self._is_content_hovered = False - self._schedule_close_button_hide() - return False - - def _on_close_button_enter(self, widget, event): - self._is_close_button_hovered = True - self._cancel_close_button_hide() - return False - - def _on_close_button_leave(self, widget, event): - self._is_close_button_hovered = False - self._schedule_close_button_hide() - return False - - def _schedule_close_button_hide(self): - self._cancel_close_button_hide() - self._close_button_hide_timeout_id = GLib.timeout_add( - 80, self._do_hide_close_button - ) - - def _cancel_close_button_hide(self): - if self._close_button_hide_timeout_id is not None: - GLib.source_remove(self._close_button_hide_timeout_id) - self._close_button_hide_timeout_id = None - - def _do_hide_close_button(self): - self._close_button_hide_timeout_id = None - if not self._is_content_hovered and not self._is_close_button_hovered: - self.close_revealer.set_reveal_child(False) - return False - # Override to disable timeout functionality def start_timeout(self): pass diff --git a/src/window/switcher/main.py b/src/window/switcher/main.py index a3c4171..a5bdf63 100644 --- a/src/window/switcher/main.py +++ b/src/window/switcher/main.py @@ -574,16 +574,7 @@ class ApplicationSwitcher(Window): item.preview_image.set_from_pixbuf(pixbuf) if self.get_visible() and config().get("switcher_live_preview", True): - fps_delay = config().get("switcher.live_preview_delay_ms", 200) - invoke_repeater( - fps_delay, - lambda: ( - self._capture.capture_by_handle(full_addr, 300, 168, True) - if self.get_visible() - else False - ), - initial_call=False, - ) + self._capture.capture_by_handle(full_addr, 300, 168, True) except Exception: logger.exception("_on_frame_ready error") diff --git a/uv.lock b/uv.lock index b955688..f2cc695 100644 --- a/uv.lock +++ b/uv.lock @@ -136,7 +136,7 @@ wheels = [ [[package]] name = "fabric" version = "0.0.2" -source = { git = "https://github.com/Fabric-Development/fabric.git#85cacf660b1a324525a4dd9b512521e55ef7f89b" } +source = { git = "https://github.com/Fabric-Development/fabric.git?rev=85cacf660b1a324525a4dd9b512521e55ef7f89b#85cacf660b1a324525a4dd9b512521e55ef7f89b" } dependencies = [ { name = "click" }, { name = "loguru" }, @@ -268,7 +268,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fabric", git = "https://github.com/Fabric-Development/fabric.git" }, + { name = "fabric", git = "https://github.com/Fabric-Development/fabric.git?rev=85cacf660b1a324525a4dd9b512521e55ef7f89b" }, { name = "keyring", specifier = "==25.7.0" }, { name = "keyrings-alt", specifier = "==5.0.2" }, { name = "psutil", specifier = "==7.2.2" },