refactor! : drop subprocess usage and centralize brightness service initialization
This commit is contained in:
21 files changed
+834
-828
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import subprocess
|
||||
from enum import Enum, auto
|
||||
|
||||
from fabric.utils import Gdk, GLib, exec_shell_command, logger
|
||||
from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
@@ -12,6 +11,7 @@ from fabric.widgets.separator import Separator
|
||||
from services.bluetooth import BluetoothClient, BluetoothDevice
|
||||
from shared.widgets.smooth_switch import SmoothSwitch
|
||||
from shared.window.animated_scrollwindow import AnimatedScrollable
|
||||
from utils.functions import spawn_detached
|
||||
from utils.utils import svg_file
|
||||
|
||||
|
||||
@@ -290,8 +290,6 @@ class BluetoothDeviceSlot(Box):
|
||||
if event.button != 3: # only right-click
|
||||
return False
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
menu = Gtk.Menu()
|
||||
|
||||
if not self.device.paired:
|
||||
@@ -567,7 +565,7 @@ class BluetoothConnections(Box):
|
||||
def open_bluetooth_settings(self, *_):
|
||||
"""Open Blueman bluetooth manager"""
|
||||
try:
|
||||
subprocess.Popen(["blueman-manager"], start_new_session=True)
|
||||
spawn_detached(["blueman-manager"])
|
||||
if self.parent and hasattr(self.parent, "hide_controlcenter"):
|
||||
self.parent.hide_controlcenter()
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import subprocess
|
||||
|
||||
from fabric.utils import Gdk, GLib, get_relative_path, idle_add, logger
|
||||
from fabric.utils import Gdk, GLib, idle_add, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
@@ -9,6 +7,7 @@ from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
|
||||
from services.brightness import Brightness
|
||||
from services.modus import is_caffeine_active, toggle_caffeine
|
||||
from services.network import NetworkClient
|
||||
from shared.widgets.flat_scale import FlatScale
|
||||
from shared.window.applet_window import AppletWindow
|
||||
@@ -24,7 +23,14 @@ from window.controlcenter.per_app_volume import PerAppVolumeControl
|
||||
from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager
|
||||
from window.controlcenter.wifi import WifiConnections
|
||||
|
||||
brightness_service = Brightness()
|
||||
_brightness_instance: "Brightness | None" = None
|
||||
|
||||
|
||||
def get_brightness_service() -> "Brightness":
|
||||
global _brightness_instance
|
||||
if _brightness_instance is None:
|
||||
_brightness_instance = Brightness()
|
||||
return _brightness_instance
|
||||
|
||||
|
||||
class ModusControlCenter(AppletWindow):
|
||||
@@ -48,8 +54,6 @@ class ModusControlCenter(AppletWindow):
|
||||
# Flight mode and caffeine states
|
||||
self.flight_mode = False
|
||||
self.caffeine_mode = False
|
||||
self._caffeine_process = None
|
||||
|
||||
# Loading flags - music and expanded player are no longer lazy loaded
|
||||
self._per_app_volume_initialized = False
|
||||
self._signals_connected = False # Track if signals are connected
|
||||
@@ -111,10 +115,10 @@ class ModusControlCenter(AppletWindow):
|
||||
h_expand=True,
|
||||
)
|
||||
|
||||
current_brightness = brightness_service.screen_brightness
|
||||
current_brightness = get_brightness_service().screen_brightness
|
||||
brightness_percentage = (
|
||||
int((current_brightness / brightness_service.max_screen) * 100)
|
||||
if brightness_service.max_screen > 0
|
||||
int((current_brightness / get_brightness_service().max_screen) * 100)
|
||||
if get_brightness_service().max_screen > 0
|
||||
else 50
|
||||
)
|
||||
|
||||
@@ -129,7 +133,7 @@ class ModusControlCenter(AppletWindow):
|
||||
)
|
||||
|
||||
# Only connect brightness controls if brightness service is available
|
||||
if brightness_service.max_screen > 0:
|
||||
if get_brightness_service().max_screen > 0:
|
||||
pass
|
||||
else:
|
||||
# Disable brightness scale if no backlight device available
|
||||
@@ -577,13 +581,15 @@ class ModusControlCenter(AppletWindow):
|
||||
)
|
||||
|
||||
# Connect brightness controls if brightness service is available
|
||||
if brightness_service.max_screen > 0:
|
||||
if get_brightness_service().max_screen > 0:
|
||||
self.brightness_scale.connect("value-changed", self.set_brightness)
|
||||
self.brightness_scale.connect("scroll-event", self.on_brightness_scroll)
|
||||
self._signal_connections.append(
|
||||
(
|
||||
brightness_service,
|
||||
brightness_service.connect("screen", self.brightness_changed),
|
||||
get_brightness_service(),
|
||||
get_brightness_service().connect(
|
||||
"screen", self.brightness_changed
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -715,23 +721,8 @@ class ModusControlCenter(AppletWindow):
|
||||
self.per_app_volume_center_box.set_size_request(300, -1)
|
||||
|
||||
def _check_initial_states(self):
|
||||
# Check if caffeine is already running - use faster method
|
||||
try:
|
||||
# Use faster check with timeout
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "modus-inhibit"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=0.5, # Add timeout to prevent hanging
|
||||
)
|
||||
self.caffeine_mode = bool(result.stdout.strip())
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
self.caffeine_mode = False
|
||||
|
||||
# Flight mode starts as False (normal mode)
|
||||
self.caffeine_mode = is_caffeine_active()
|
||||
self.flight_mode = False
|
||||
|
||||
# Update initial labels (will be set after widgets are created)
|
||||
GLib.timeout_add(100, self._update_initial_labels)
|
||||
|
||||
def _update_initial_labels(self):
|
||||
@@ -778,30 +769,13 @@ class ModusControlCenter(AppletWindow):
|
||||
|
||||
def toggle_caffeine(self, *_):
|
||||
try:
|
||||
if self.caffeine_mode:
|
||||
inhibit_script = get_relative_path("../../utils/inhibit.py")
|
||||
subprocess.run(["python3", inhibit_script, "off"], check=False)
|
||||
self.caffeine_mode = False
|
||||
if self._caffeine_process:
|
||||
try:
|
||||
self._caffeine_process.terminate()
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._caffeine_process = None
|
||||
else:
|
||||
inhibit_script = get_relative_path("../../utils/inhibit.py")
|
||||
self._caffeine_process = subprocess.Popen(
|
||||
["python3", inhibit_script, "on"], start_new_session=True
|
||||
)
|
||||
self.caffeine_mode = True
|
||||
|
||||
self.caffeine_mode = toggle_caffeine()
|
||||
self.caffeine_icon.dynamic_file(
|
||||
"applets/caffeine-on.svg"
|
||||
if self.caffeine_mode
|
||||
else "applets/caffeine-off.svg"
|
||||
)
|
||||
self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to toggle caffeine: {e}")
|
||||
|
||||
@@ -816,17 +790,17 @@ class ModusControlCenter(AppletWindow):
|
||||
if not self._signals_connected:
|
||||
return
|
||||
self._updating_brightness = True
|
||||
brightness_value = int((brightness / 100) * brightness_service.max_screen)
|
||||
brightness_service.screen_brightness = brightness_value
|
||||
brightness_value = int((brightness / 100) * get_brightness_service().max_screen)
|
||||
get_brightness_service().screen_brightness = brightness_value
|
||||
self._updating_brightness = False
|
||||
|
||||
def brightness_changed(self, _, brightness_value):
|
||||
if not self._signals_connected or self._updating_brightness:
|
||||
return
|
||||
|
||||
if brightness_service.max_screen > 0:
|
||||
if get_brightness_service().max_screen > 0:
|
||||
brightness_percentage = int(
|
||||
(brightness_value / brightness_service.max_screen) * 100
|
||||
(brightness_value / get_brightness_service().max_screen) * 100
|
||||
)
|
||||
|
||||
GLib.idle_add(
|
||||
@@ -1214,22 +1188,7 @@ class ModusControlCenter(AppletWindow):
|
||||
logger.warning(f"Widget cleanup failed: {e}")
|
||||
|
||||
def _cleanup_processes(self):
|
||||
"""Clean up any running processes"""
|
||||
try:
|
||||
# Clean up caffeine process
|
||||
if self._caffeine_process:
|
||||
try:
|
||||
self._caffeine_process.terminate()
|
||||
self._caffeine_process.wait(timeout=1)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to terminate caffeine process: {e}")
|
||||
finally:
|
||||
self._caffeine_process = None
|
||||
|
||||
logger.debug("All processes cleaned up successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Process cleanup failed: {e}")
|
||||
logger.debug("All processes cleaned up successfully")
|
||||
|
||||
def _complete_cleanup(self):
|
||||
"""Perform complete cleanup of all resources"""
|
||||
|
||||
@@ -1,86 +1,30 @@
|
||||
import subprocess
|
||||
|
||||
from fabric.utils import logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
from services.modus import (
|
||||
is_night_light_active,
|
||||
toggle_night_light,
|
||||
)
|
||||
from utils.utils import svg_file
|
||||
|
||||
|
||||
class NightLightControl:
|
||||
"""Control night light using hyprsunset"""
|
||||
|
||||
def __init__(self):
|
||||
self.is_active = False
|
||||
self._check_initial_state()
|
||||
|
||||
def _check_initial_state(self):
|
||||
"""Check if hyprsunset is currently running"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "hyprsunset"], capture_output=True, text=True
|
||||
)
|
||||
self.is_active = bool(result.stdout.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check hyprsunset status: {e}")
|
||||
self.is_active = False
|
||||
|
||||
def toggle(self):
|
||||
"""Toggle night light on/off"""
|
||||
try:
|
||||
if self.is_active:
|
||||
# Turn off night light by killing hyprsunset
|
||||
subprocess.run(["pkill", "hyprsunset"], check=False)
|
||||
self.is_active = False
|
||||
logger.debug("Night light turned off")
|
||||
else:
|
||||
# Turn on night light with default temperature (3000K)
|
||||
subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True)
|
||||
self.is_active = True
|
||||
logger.debug("Night light turned on")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to toggle night light: {e}")
|
||||
return False
|
||||
|
||||
def set_temperature(self, temperature: int):
|
||||
"""Set night light temperature (1000-6500K)"""
|
||||
try:
|
||||
# Kill existing process
|
||||
subprocess.run(["pkill", "hyprsunset"], check=False)
|
||||
|
||||
# Start with new temperature
|
||||
subprocess.Popen(
|
||||
["hyprsunset", "-t", str(temperature)], start_new_session=True
|
||||
)
|
||||
self.is_active = True
|
||||
logger.debug(f"Night light temperature set to {temperature}K")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set night light temperature: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_night_light_widget(control_center):
|
||||
"""Create night light widget for control center"""
|
||||
|
||||
# Initialize night light control
|
||||
night_light = NightLightControl()
|
||||
is_active = is_night_light_active()
|
||||
|
||||
# Create icon
|
||||
night_light_icon = svg_file(
|
||||
(
|
||||
"applets/redshift-status-on.svg"
|
||||
if night_light.is_active
|
||||
if is_active
|
||||
else "applets/redshift-status-off.svg"
|
||||
),
|
||||
size=42,
|
||||
)
|
||||
|
||||
# Create status label
|
||||
night_light_status_label = Label(
|
||||
label="On" if night_light.is_active else "Off",
|
||||
label="On" if is_active else "Off",
|
||||
name="nightlight-widget-label",
|
||||
style_classes="status-label",
|
||||
max_chars_width=15,
|
||||
@@ -88,19 +32,16 @@ def create_night_light_widget(control_center):
|
||||
h_align="start",
|
||||
)
|
||||
|
||||
def toggle_night_light(*_):
|
||||
"""Toggle night light and update UI"""
|
||||
if night_light.toggle():
|
||||
# Update icon
|
||||
def toggle_ui(*_):
|
||||
if toggle_night_light():
|
||||
is_active = is_night_light_active()
|
||||
night_light_icon.dynamic_file(
|
||||
"applets/redshift-status-on.svg"
|
||||
if night_light.is_active
|
||||
if is_active
|
||||
else "applets/redshift-status-off.svg"
|
||||
)
|
||||
# Update status label
|
||||
night_light_status_label.set_label("On" if night_light.is_active else "Off")
|
||||
night_light_status_label.set_label("On" if is_active else "Off")
|
||||
|
||||
# Create widget box (similar to bluetooth_widget structure)
|
||||
night_light_widget = Box(
|
||||
name="nightlight-widget",
|
||||
orientation="h",
|
||||
@@ -109,7 +50,7 @@ def create_night_light_widget(control_center):
|
||||
Button(
|
||||
name="nightlight-icon-button",
|
||||
child=night_light_icon,
|
||||
on_clicked=toggle_night_light,
|
||||
on_clicked=toggle_ui,
|
||||
),
|
||||
Button(
|
||||
name="nightlight-info-button",
|
||||
@@ -130,7 +71,7 @@ def create_night_light_widget(control_center):
|
||||
night_light_status_label,
|
||||
],
|
||||
),
|
||||
on_clicked=toggle_night_light,
|
||||
on_clicked=toggle_ui,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -74,6 +74,9 @@ class PerAppVolumeControl(Box):
|
||||
audio_service.connect("stream-removed", self._on_stream_changed)
|
||||
)
|
||||
|
||||
# Track last known app names to avoid unnecessary rebuilds
|
||||
self._last_app_names = frozenset()
|
||||
|
||||
# Initial population
|
||||
self._populate_apps()
|
||||
|
||||
@@ -84,9 +87,15 @@ class PerAppVolumeControl(Box):
|
||||
"""Auto-refresh the application list every 2 seconds"""
|
||||
if self._destroyed:
|
||||
self._refresh_timer = None
|
||||
return False # Remove GLib source
|
||||
self._populate_apps()
|
||||
return True # Continue the timer
|
||||
return False
|
||||
if not audio_service:
|
||||
return True
|
||||
apps = audio_service.applications or []
|
||||
current_names = frozenset(a.name or "" for a in apps)
|
||||
if current_names != self._last_app_names:
|
||||
self._last_app_names = current_names
|
||||
self._populate_apps()
|
||||
return True
|
||||
|
||||
def _on_map(self, *_):
|
||||
if self._refresh_timer is None:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import subprocess
|
||||
|
||||
from fabric.utils import Gdk, GLib, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
@@ -12,7 +10,11 @@ from services.network import NetworkClient
|
||||
from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog
|
||||
from shared.widgets.smooth_switch import SmoothSwitch
|
||||
from shared.window.animated_scrollwindow import AnimatedScrollable
|
||||
from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength
|
||||
from utils.functions import (
|
||||
get_wifi_connecting_icon,
|
||||
get_wifi_icon_for_strength,
|
||||
spawn_detached,
|
||||
)
|
||||
from utils.utils import svg_file
|
||||
|
||||
|
||||
@@ -107,7 +109,7 @@ class WifiNetworkSlot(Box):
|
||||
self.dimage.add_style_class("disconnecting")
|
||||
|
||||
if self.wifi_service and self.wifi_service._device:
|
||||
subprocess.Popen(
|
||||
spawn_detached(
|
||||
[
|
||||
"nmcli",
|
||||
"device",
|
||||
@@ -472,7 +474,7 @@ class WifiConnections(Box):
|
||||
def open_network_settings(self, *_):
|
||||
"""Open NetworkManager connection editor"""
|
||||
try:
|
||||
subprocess.Popen(["nm-connection-editor"], start_new_session=True)
|
||||
spawn_detached(["nm-connection-editor"])
|
||||
if self.parent and hasattr(self.parent, "hide_controlcenter"):
|
||||
self.parent.hide_controlcenter()
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -35,6 +35,8 @@ executor = ThreadPoolExecutor(max_workers=4)
|
||||
# Global cache for weather data
|
||||
_weather_cache: Dict[str, Tuple[Any, float]] = {}
|
||||
_location_cache: Dict[str, Tuple[float, float, float]] = {}
|
||||
_WEATHER_CACHE_MAX = 10
|
||||
_LOCATION_CACHE_MAX = 10
|
||||
|
||||
|
||||
def http_get_json(
|
||||
@@ -98,6 +100,8 @@ def get_coordinates(city: str) -> Optional[Tuple[float, float]]:
|
||||
try:
|
||||
lat = float(data[0]["lat"])
|
||||
lon = float(data[0]["lon"])
|
||||
while len(_location_cache) >= _LOCATION_CACHE_MAX:
|
||||
_location_cache.pop(next(iter(_location_cache)), None)
|
||||
_location_cache[cache_key] = (lat, lon, current_time)
|
||||
return lat, lon
|
||||
except (ValueError, KeyError):
|
||||
@@ -191,6 +195,8 @@ def get_weather(callback):
|
||||
|
||||
formatted_data = format_weather_data(weather_data, location)
|
||||
if formatted_data:
|
||||
while len(_weather_cache) >= _WEATHER_CACHE_MAX:
|
||||
_weather_cache.pop(next(iter(_weather_cache)), None)
|
||||
_weather_cache[cache_key] = (formatted_data, current_time)
|
||||
GLib.idle_add(callback, formatted_data)
|
||||
else:
|
||||
@@ -452,6 +458,7 @@ class Calendar(Box):
|
||||
def update_calendar(self):
|
||||
for child in self.calendar_grid.get_children():
|
||||
self.calendar_grid.remove(child)
|
||||
child.destroy()
|
||||
self.month_label.set_label(calendar.month_name[self.current_month])
|
||||
cal = calendar.monthcalendar(self.current_year, self.current_month)
|
||||
for week in cal:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import Dock
|
||||
|
||||
__all__ = ["Dock"]
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from fabric.utils import GLib
|
||||
|
||||
from .constants import ANIM_INTERVAL_MS, IDLE_THRESHOLD, LERP_FACTOR
|
||||
from .layout import DockLayout
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .canvas import DockCanvas
|
||||
|
||||
|
||||
class DockAnimator:
|
||||
def __init__(self, canvas: DockCanvas):
|
||||
self._canvas = canvas
|
||||
self._timer_id: Optional[int] = None
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._timer_id = GLib.timeout_add(ANIM_INTERVAL_MS, self._tick)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._timer_id is not None:
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
|
||||
def _tick(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
|
||||
canvas = self._canvas
|
||||
items = canvas.model.items
|
||||
dirty = False
|
||||
|
||||
for item in items:
|
||||
delta = item.target_scale - item.current_scale
|
||||
if abs(delta) > IDLE_THRESHOLD:
|
||||
item.current_scale += delta * LERP_FACTOR
|
||||
dirty = True
|
||||
else:
|
||||
item.current_scale = item.target_scale
|
||||
|
||||
model_w = canvas._canvas_max_width()
|
||||
model_h = canvas._canvas_height()
|
||||
DockLayout.compute(
|
||||
items,
|
||||
canvas._mouse_x,
|
||||
canvas._mouse_y,
|
||||
canvas._base_icon_size(),
|
||||
model_w,
|
||||
model_h,
|
||||
canvas._mouse_inside,
|
||||
)
|
||||
|
||||
if (
|
||||
model_w != canvas._last_requested_width
|
||||
or model_h != canvas._last_requested_height
|
||||
):
|
||||
canvas._last_requested_width = model_w
|
||||
canvas._last_requested_height = model_h
|
||||
canvas._update_size_request()
|
||||
|
||||
if dirty or canvas._needs_redraw:
|
||||
canvas._needs_redraw = False
|
||||
canvas.queue_draw()
|
||||
|
||||
return self._running
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from utils.utils import toml_file
|
||||
|
||||
PINNED_APPS_FILE = toml_file("dock.toml")
|
||||
|
||||
LERP_FACTOR = 0.20
|
||||
IDLE_THRESHOLD = 0.0005
|
||||
ANIM_FPS = 60
|
||||
ANIM_INTERVAL_MS = 1000 // ANIM_FPS
|
||||
|
||||
MAX_SCALE = 2.0
|
||||
MIN_SCALE = 1.0
|
||||
SIGMA_FACTOR = 1.3
|
||||
|
||||
ICON_GAP = 8
|
||||
BG_PADDING_H = 14
|
||||
BG_PADDING_V = 12
|
||||
CANVAS_TOP_PAD = 28
|
||||
INDICATOR_H = 10
|
||||
SEPARATOR_WIDTH = 1
|
||||
SEPARATOR_GAP = 4
|
||||
|
||||
BG_RADIUS = 20.0
|
||||
BG_ALPHA = 0.82
|
||||
BORDER_ALPHA = 0.50
|
||||
BORDER_WIDTH = 1.0
|
||||
|
||||
ICON_CORNER_RADIUS_FACTOR = 0.22
|
||||
|
||||
INDICATOR_RADIUS = 2.5
|
||||
INDICATOR_COLOR = (1.0, 1.0, 1.0, 0.95)
|
||||
|
||||
BADGE_FONT_SIZE = 9.0
|
||||
BADGE_PADDING = 2
|
||||
BADGE_RADIUS = 4.0
|
||||
BADGE_BG = (0.0, 0.0, 0.0, 0.70)
|
||||
BADGE_FG = (1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
SEPARATOR_COLOR = (1.0, 1.0, 1.0, 0.25)
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from .constants import (
|
||||
BG_PADDING_H,
|
||||
BG_PADDING_V,
|
||||
ICON_GAP,
|
||||
INDICATOR_H,
|
||||
MAX_SCALE,
|
||||
MIN_SCALE,
|
||||
SIGMA_FACTOR,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .canvas import DockItem
|
||||
|
||||
|
||||
class DockLayout:
|
||||
@staticmethod
|
||||
def compute(
|
||||
items: List[DockItem],
|
||||
mouse_x: float,
|
||||
mouse_y: float,
|
||||
base_icon_size: int,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
mouse_inside: bool,
|
||||
) -> None:
|
||||
if not items:
|
||||
return
|
||||
|
||||
sigma = base_icon_size * SIGMA_FACTOR
|
||||
amplitude = MAX_SCALE - MIN_SCALE
|
||||
|
||||
n = len(items)
|
||||
base_w = base_icon_size + ICON_GAP
|
||||
total_base = n * base_icon_size + max(n - 1, 0) * ICON_GAP
|
||||
start_x = (canvas_w - total_base) / 2.0
|
||||
|
||||
for i, item in enumerate(items):
|
||||
est_cx = start_x + i * base_w + base_icon_size / 2.0
|
||||
|
||||
if mouse_inside:
|
||||
dist = abs(est_cx - mouse_x)
|
||||
target = MIN_SCALE + amplitude * math.exp(
|
||||
-(dist * dist) / (2.0 * sigma * sigma)
|
||||
)
|
||||
else:
|
||||
target = MIN_SCALE
|
||||
|
||||
item.target_scale = target
|
||||
|
||||
bg_h = base_icon_size + 2 * BG_PADDING_V
|
||||
bg_y = canvas_h - INDICATOR_H - bg_h
|
||||
baseline_y = bg_y + BG_PADDING_V + base_icon_size
|
||||
|
||||
rendered_widths = [base_icon_size * item.current_scale for item in items]
|
||||
total_w = sum(rendered_widths) + max(n - 1, 0) * ICON_GAP
|
||||
cursor_x = (canvas_w - total_w) / 2.0
|
||||
|
||||
for i, item in enumerate(items):
|
||||
iw = rendered_widths[i]
|
||||
ih = base_icon_size * item.current_scale
|
||||
ix = cursor_x
|
||||
iy = baseline_y - ih
|
||||
item.render_x = ix
|
||||
item.render_y = iy
|
||||
item.render_w = iw
|
||||
item.render_h = ih
|
||||
item.drag_index = i
|
||||
cursor_x += iw + ICON_GAP
|
||||
|
||||
@staticmethod
|
||||
def background_rect(
|
||||
items: List[DockItem],
|
||||
base_icon_size: int,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
) -> Tuple[float, float, float, float]:
|
||||
bg_h = base_icon_size + 2 * BG_PADDING_V
|
||||
bg_y = canvas_h - INDICATOR_H - bg_h
|
||||
|
||||
if items:
|
||||
min_x = min(item.render_x for item in items)
|
||||
max_x = max(item.render_x + item.render_w for item in items)
|
||||
bg_x = min_x - BG_PADDING_H
|
||||
bg_w = (max_x - min_x) + 2 * BG_PADDING_H
|
||||
else:
|
||||
bg_x = BG_PADDING_H
|
||||
bg_w = canvas_w - 2 * BG_PADDING_H
|
||||
|
||||
return bg_x, bg_y, bg_w, bg_h
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fabric.utils import GLib, logger, random
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from services.config import config, on_config_change
|
||||
from utils.occlusion import check_occlusion
|
||||
|
||||
from .canvas import DockCanvas
|
||||
|
||||
|
||||
class Dock(Window):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
layer="top",
|
||||
anchor="bottom center",
|
||||
exclusivity="none",
|
||||
title="modus-dock",
|
||||
name="dock",
|
||||
)
|
||||
|
||||
screen = self.get_screen()
|
||||
if screen:
|
||||
visual = screen.get_rgba_visual()
|
||||
if visual:
|
||||
self.set_visual(visual)
|
||||
self.set_app_paintable(True)
|
||||
|
||||
self.canvas = DockCanvas(self)
|
||||
|
||||
canvas_box = Box(
|
||||
name="dock-canvas-box",
|
||||
children=[self.canvas],
|
||||
)
|
||||
|
||||
self.revealer = Revealer(
|
||||
child=canvas_box,
|
||||
transition_duration=200,
|
||||
transition_type="slide-up",
|
||||
)
|
||||
|
||||
hover_strip = Box(
|
||||
name="dock-hover-strip",
|
||||
)
|
||||
|
||||
outer_box = Box(
|
||||
name="dock-outer-box",
|
||||
orientation="v",
|
||||
children=[self.revealer, hover_strip],
|
||||
)
|
||||
|
||||
self.children = EventBox(
|
||||
events=["enter-notify", "leave-notify"],
|
||||
child=outer_box,
|
||||
on_enter_notify_event=lambda *_: self.on_hover_enter(),
|
||||
on_leave_notify_event=lambda *_: self.on_hover_leave(),
|
||||
)
|
||||
|
||||
self.dock_height = 100
|
||||
self.is_hovered = False
|
||||
self.hide_ticket = 0
|
||||
self._startup_grace = True
|
||||
|
||||
self.revealer.set_reveal_child(True)
|
||||
self.show_all()
|
||||
|
||||
on_config_change(self._on_config_change)
|
||||
|
||||
if config().get("dock_auto_hide", True):
|
||||
GLib.timeout_add(2000, self._start_occlusion_monitoring)
|
||||
else:
|
||||
self._occlusion_timer_id = None
|
||||
|
||||
def _start_occlusion_monitoring(self) -> bool:
|
||||
self._startup_grace = False
|
||||
self.setup_occlusion_monitoring()
|
||||
return False
|
||||
|
||||
def _on_config_change(self, new_config, old_config) -> None:
|
||||
if config().has_changed("dock_enabled", old_config):
|
||||
self._update_visibility()
|
||||
|
||||
if config().has_changed("dock_icon_size", old_config):
|
||||
self.canvas.update_icon_size()
|
||||
|
||||
if config().has_changed("dock_auto_hide", old_config):
|
||||
if new_config.get("dock_auto_hide", True):
|
||||
self.setup_occlusion_monitoring()
|
||||
else:
|
||||
if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id:
|
||||
GLib.source_remove(self._occlusion_timer_id)
|
||||
self._occlusion_timer_id = None
|
||||
self.revealer.set_reveal_child(True)
|
||||
|
||||
if config().has_changed("dock_hide_special_workspace_apps", old_config):
|
||||
self.canvas._rebuild_model()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
if config().get("dock_enabled", True):
|
||||
self.show()
|
||||
self.revealer.set_reveal_child(True)
|
||||
else:
|
||||
self.hide()
|
||||
|
||||
def on_hover_enter(self) -> None:
|
||||
self.is_hovered = True
|
||||
self.hide_ticket = random.getrandbits(32)
|
||||
self.revealer.set_reveal_child(True)
|
||||
|
||||
def on_hover_leave(self) -> None:
|
||||
self.is_hovered = False
|
||||
ticket = random.getrandbits(32)
|
||||
self.hide_ticket = ticket
|
||||
|
||||
def delayed_hide(t):
|
||||
if t == self.hide_ticket and not self.is_hovered:
|
||||
if config().get("dock_auto_hide", True):
|
||||
is_occ = config().get(
|
||||
"dock_always_occluded", False
|
||||
) or check_occlusion(("bottom", self.dock_height))
|
||||
if is_occ:
|
||||
self.revealer.set_reveal_child(False)
|
||||
return False
|
||||
|
||||
GLib.timeout_add(500, delayed_hide, ticket)
|
||||
|
||||
def setup_occlusion_monitoring(self) -> None:
|
||||
if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id:
|
||||
GLib.source_remove(self._occlusion_timer_id)
|
||||
self._occlusion_timer_id = None
|
||||
|
||||
def check_dock_occlusion() -> bool:
|
||||
try:
|
||||
if config().get("dock_always_occluded", False):
|
||||
is_occ = True
|
||||
else:
|
||||
is_occ = check_occlusion(("bottom", self.dock_height))
|
||||
|
||||
if is_occ and not self.is_hovered and self.revealer.get_reveal_child():
|
||||
self.revealer.set_reveal_child(False)
|
||||
elif not is_occ and not self.revealer.get_reveal_child():
|
||||
self.revealer.set_reveal_child(True)
|
||||
elif is_occ and self.is_hovered:
|
||||
if not self.revealer.get_reveal_child():
|
||||
self.revealer.set_reveal_child(True)
|
||||
except Exception as e:
|
||||
logger.error(f"[Dock] Occlusion check error: {e}")
|
||||
return True
|
||||
|
||||
self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion)
|
||||
|
||||
def destroy(self) -> None:
|
||||
if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id:
|
||||
GLib.source_remove(self._occlusion_timer_id)
|
||||
self._occlusion_timer_id = None
|
||||
|
||||
if hasattr(self, "canvas") and self.canvas:
|
||||
self.canvas.destroy()
|
||||
|
||||
super().destroy()
|
||||
@@ -625,38 +625,63 @@ class NotificationWidget(Box):
|
||||
return get_fallback_notification_icon((width, height))
|
||||
|
||||
def _get_notification_pixbuf(self, notification):
|
||||
"""Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat"""
|
||||
notification_id = getattr(notification, "id", int(time.time()))
|
||||
"""Notification pixbuf — loads notification image or app icon, scaled consistently."""
|
||||
pixbuf = None
|
||||
|
||||
try:
|
||||
# Try to get cached notification image first
|
||||
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
|
||||
# 1. Try cache_metadata keys first (cheap — no disk/pixbuf load)
|
||||
cache_meta = getattr(notification, "cache_metadata", {}) or {}
|
||||
notif_key = cache_meta.get("notification_image_cache_key")
|
||||
if notif_key:
|
||||
try:
|
||||
pixbuf = get_cached_notification_image(notif_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Try notification image via image_pixbuf (only if no cached)
|
||||
if not pixbuf:
|
||||
try:
|
||||
img = getattr(notification, "image_pixbuf", None)
|
||||
if img is not None:
|
||||
nid = getattr(notification, "id", 0)
|
||||
ck = get_notification_image_cache_key(nid, img)
|
||||
cached = get_cached_notification_image(ck)
|
||||
if cached:
|
||||
pixbuf = cached
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. App icon via cache_metadata
|
||||
if not pixbuf:
|
||||
app_key = cache_meta.get("app_icon_cache_key")
|
||||
if app_key:
|
||||
try:
|
||||
cache_key = get_notification_image_cache_key(
|
||||
notification_id, notification.image_pixbuf
|
||||
)
|
||||
cached_image = get_cached_notification_image(cache_key)
|
||||
if cached_image:
|
||||
return cached_image
|
||||
except Exception as image_error:
|
||||
logger.debug(f"Notification image processing failed: {image_error}")
|
||||
# Continue to app icon fallback
|
||||
from window.notification.unified_cache import get_from_cache
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to process notification image: {e}")
|
||||
pixbuf = get_from_cache(app_key, (35, 35))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Direct app icon caching
|
||||
if not pixbuf:
|
||||
app_icon = getattr(notification, "app_icon", None)
|
||||
if app_icon:
|
||||
try:
|
||||
pixbuf = cache_notification_icon(app_icon, (35, 35))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. Fallback
|
||||
if not pixbuf:
|
||||
pixbuf = get_fallback_notification_icon((35, 35))
|
||||
|
||||
# Use cached app icon as fallback
|
||||
try:
|
||||
app_icon_source = getattr(notification, "app_icon", None)
|
||||
if app_icon_source:
|
||||
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
|
||||
if cached_app_icon:
|
||||
return cached_app_icon
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get cached app icon: {e}")
|
||||
|
||||
# Ultimate fallback
|
||||
return get_fallback_notification_icon((35, 35))
|
||||
return pixbuf.scale_simple(
|
||||
NOTIFICATION_IMAGE_SIZE,
|
||||
NOTIFICATION_IMAGE_SIZE,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
)
|
||||
except Exception:
|
||||
return pixbuf
|
||||
|
||||
def create_action_buttons(self, notification):
|
||||
return Box(
|
||||
@@ -1122,10 +1147,11 @@ class ModusNoti(Window):
|
||||
|
||||
self.current_notification = new_box
|
||||
|
||||
# Clear any existing children
|
||||
# Clear and destroy any existing children
|
||||
for child in list(self.notifications.children):
|
||||
try:
|
||||
self.notifications.remove(child)
|
||||
child.destroy()
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
|
||||
@@ -1154,10 +1180,11 @@ class ModusNoti(Window):
|
||||
GLib.source_remove(self._transition_timer_id)
|
||||
self._transition_timer_id = None
|
||||
|
||||
# Safely remove notification box
|
||||
# Safely remove and destroy notification box
|
||||
try:
|
||||
if notification_box in self.notifications.children:
|
||||
self.notifications.remove(notification_box)
|
||||
notification_box.destroy()
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
|
||||
@@ -1197,8 +1224,11 @@ class ModusNoti(Window):
|
||||
|
||||
# Also clean current notification if showing
|
||||
if self.current_notification:
|
||||
# Mark for cache cleanup when clearing queue
|
||||
self.current_notification.notif_box._should_cleanup_cache = True
|
||||
try:
|
||||
self.current_notification.notification.close("dismissed-by-user")
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
|
||||
# Clear animation timers
|
||||
if self._transition_timer_id:
|
||||
|
||||
@@ -698,6 +698,8 @@ class NotificationCenter(AppletWindow):
|
||||
|
||||
self.add_keybinding("Escape", self._on_escape_pressed)
|
||||
|
||||
MAX_GROUPS = 15
|
||||
|
||||
def _rebuild_notification_groups(self):
|
||||
"""Rebuild notification groups from scratch with enhanced asset preloading and debugging"""
|
||||
# Clear existing groups
|
||||
@@ -709,23 +711,27 @@ class NotificationCenter(AppletWindow):
|
||||
child.destroy()
|
||||
|
||||
# Group notifications by app name and preload assets with debugging
|
||||
rebuild_count = 0
|
||||
for cached_notification in notification_service.cached_notifications:
|
||||
app_name = cached_notification._notification.app_name
|
||||
getattr(cached_notification._notification, "id", None)
|
||||
|
||||
# Skip ignored apps during rebuild
|
||||
if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
|
||||
continue
|
||||
|
||||
# Preload assets for each cached notification to ensure display consistency
|
||||
preload_notification_assets(cached_notification._notification)
|
||||
|
||||
self.notification_groups[app_name].append(cached_notification)
|
||||
rebuild_count += 1
|
||||
|
||||
# Sort groups by most recent notification
|
||||
sorted_groups = sorted(
|
||||
self.notification_groups.items(),
|
||||
key=lambda item: max(
|
||||
(getattr(n._notification, "id", 0) for n in item[1]), default=0
|
||||
),
|
||||
reverse=True,
|
||||
)[: self.MAX_GROUPS]
|
||||
|
||||
self.notification_groups.clear()
|
||||
# Create group widgets and handle limited apps
|
||||
for app_name, notifications in self.notification_groups.items():
|
||||
for app_name, notifications in sorted_groups:
|
||||
# Sort notifications by ID (highest ID first - newest notifications)
|
||||
# This ensures the latest notifications appear at the top of each group
|
||||
notifications.sort(
|
||||
@@ -736,11 +742,10 @@ class NotificationCenter(AppletWindow):
|
||||
if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
|
||||
if len(notifications) > 5:
|
||||
# Keep only the 5 most recent notifications
|
||||
self.notification_groups[app_name] = notifications[:5]
|
||||
notifications = notifications[:5]
|
||||
|
||||
group_widget = ExpandableNotificationGroup(
|
||||
app_name, self.notification_groups[app_name]
|
||||
)
|
||||
group_widget = ExpandableNotificationGroup(app_name, notifications)
|
||||
self.notification_groups[app_name] = notifications
|
||||
self.group_widgets[app_name] = group_widget
|
||||
self.notifications_box.add(group_widget)
|
||||
|
||||
@@ -809,9 +814,10 @@ class NotificationCenter(AppletWindow):
|
||||
def _refresh_group_widget(self, group_widget):
|
||||
"""Refresh a group widget's content"""
|
||||
try:
|
||||
# Remove existing children
|
||||
# Remove and destroy existing children
|
||||
for child in group_widget.get_children():
|
||||
group_widget.remove(child)
|
||||
child.destroy()
|
||||
|
||||
# Recreate content
|
||||
group_widget.create_collapsed_state()
|
||||
@@ -886,6 +892,8 @@ class NotificationCenter(AppletWindow):
|
||||
|
||||
# Clear all remaining cached notification images AND icons when clear all is clicked
|
||||
cleanup_all_notification_caches() # Clear ALL caches (icons + images)
|
||||
for child in self.notifications_box.get_children():
|
||||
child.destroy()
|
||||
notification_service.clear_all_cached_notifications()
|
||||
self.hide()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
|
||||
from fabric.utils import GdkPixbuf, get_relative_path, logger, os
|
||||
|
||||
@@ -9,6 +10,9 @@ import shared.data as data
|
||||
# Unified notification cache directory (for both app icons and notification images)
|
||||
UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications")
|
||||
|
||||
_MEMORY_PIXBUF_CACHE: OrderedDict[str, GdkPixbuf.Pixbuf] = OrderedDict()
|
||||
_MEMORY_CACHE_MAX = 64
|
||||
|
||||
|
||||
def ensure_cache_dir():
|
||||
"""Ensure unified notification cache directory exists"""
|
||||
@@ -72,16 +76,23 @@ def save_to_cache(pixbuf, cache_key, size=None):
|
||||
|
||||
def get_from_cache(cache_key, size=None):
|
||||
"""Get a cached asset or return None if not found"""
|
||||
mem_key = f"{cache_key}:{size}"
|
||||
if mem_key in _MEMORY_PIXBUF_CACHE:
|
||||
_MEMORY_PIXBUF_CACHE.move_to_end(mem_key)
|
||||
return _MEMORY_PIXBUF_CACHE[mem_key]
|
||||
try:
|
||||
cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
|
||||
if os.path.exists(cache_path):
|
||||
# logger.debug(f"Using cached asset: {cache_key}")
|
||||
if size:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
cache_path, size[0], size[1], True
|
||||
)
|
||||
else:
|
||||
return GdkPixbuf.Pixbuf.new_from_file(cache_path)
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
|
||||
while len(_MEMORY_PIXBUF_CACHE) >= _MEMORY_CACHE_MAX:
|
||||
_MEMORY_PIXBUF_CACHE.popitem(last=False)
|
||||
_MEMORY_PIXBUF_CACHE[mem_key] = pixbuf
|
||||
return pixbuf
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cached asset: {e}")
|
||||
return None
|
||||
@@ -99,6 +110,9 @@ def cleanup_cache(cache_key=None):
|
||||
)
|
||||
if os.path.exists(cache_path):
|
||||
os.unlink(cache_path)
|
||||
mem_keys = [k for k in _MEMORY_PIXBUF_CACHE if k.startswith(cache_key)]
|
||||
for k in mem_keys:
|
||||
_MEMORY_PIXBUF_CACHE.pop(k, None)
|
||||
else:
|
||||
# Remove all cached assets
|
||||
for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
|
||||
@@ -108,6 +122,7 @@ def cleanup_cache(cache_key=None):
|
||||
os.unlink(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
|
||||
_MEMORY_PIXBUF_CACHE.clear()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup cache: {e}")
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class AudioOSDContainer(BaseOSDContainer):
|
||||
self.audio = Audio()
|
||||
self._last_volume = None
|
||||
self._last_muted = None
|
||||
self._speaker_handler_id = None
|
||||
self._setup_specific_components()
|
||||
self._connect_specific_signals()
|
||||
|
||||
@@ -44,7 +45,11 @@ class AudioOSDContainer(BaseOSDContainer):
|
||||
|
||||
def _connect_speaker_signals(self):
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self._on_speaker_stream_changed)
|
||||
if self._speaker_handler_id is not None:
|
||||
self.audio.speaker.disconnect(self._speaker_handler_id)
|
||||
self._speaker_handler_id = self.audio.speaker.connect(
|
||||
"changed", self._on_speaker_stream_changed
|
||||
)
|
||||
self._sync_with_audio()
|
||||
|
||||
def _on_speaker_changed(self, *_):
|
||||
@@ -104,8 +109,8 @@ class AudioOSDContainer(BaseOSDContainer):
|
||||
"""Disconnect signals from Audio service"""
|
||||
try:
|
||||
self.audio.disconnect_by_func(self._on_speaker_changed)
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.disconnect_by_func(self._on_speaker_stream_changed)
|
||||
if self.audio.speaker and self._speaker_handler_id is not None:
|
||||
self.audio.speaker.disconnect(self._speaker_handler_id)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
super().destroy()
|
||||
@@ -2,11 +2,10 @@ from pathlib import Path
|
||||
|
||||
import tomlkit
|
||||
|
||||
from fabric.utils import GLib, logger
|
||||
from fabric.utils import GLib, Gio, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gio
|
||||
|
||||
from shared.window.applet_window import AppletWindow
|
||||
from utils.functions import run_command, thread
|
||||
@@ -171,6 +170,7 @@ class CustomMods(Box):
|
||||
def _rebuild(self, mods):
|
||||
for child in list(self.get_children()):
|
||||
self.remove(child)
|
||||
child.destroy()
|
||||
|
||||
for popup in self._mod_popups.values():
|
||||
if popup:
|
||||
|
||||
@@ -357,9 +357,6 @@ class GlobalMenuDropdowns:
|
||||
|
||||
def _on_menu_changed(self, _, menu_items: list):
|
||||
"""Handle menu changes from the GlobalMenuService."""
|
||||
logger.info(
|
||||
f"[GlobalMenu] _on_menu_changed called with {len(menu_items)} items"
|
||||
)
|
||||
self._rebuild_dynamic_menus(menu_items)
|
||||
|
||||
def _rebuild_dynamic_menus(self, menu_items: list):
|
||||
|
||||
@@ -13,17 +13,16 @@ from utils.roam import modus_service
|
||||
from utils.utils import setup_cursor_hover, svg_file
|
||||
from window.controlcenter.main import ModusControlCenter
|
||||
from window.notification.notification_center import NotificationCenter
|
||||
from window.panel.components.custom_mods import CustomMods
|
||||
from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray
|
||||
from window.panel.components.globalmenu import GlobalMenu
|
||||
from window.panel.components.custom_mods import CustomMods
|
||||
from window.panel.notch import Notch
|
||||
from window.panel.components.indicators import (
|
||||
BatteryIndicator,
|
||||
BluetoothIndicator,
|
||||
NetworkIndicator,
|
||||
)
|
||||
|
||||
from window.panel.components.workspace import WorkspaceIndicator
|
||||
from window.panel.notch import Notch
|
||||
|
||||
apply_enhanced_system_tray()
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@ import math
|
||||
import os
|
||||
import time
|
||||
|
||||
from fabric.utils import GLib, Gtk
|
||||
from fabric.utils import GLib, Gtk, GdkPixbuf
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
from services.mpris import PlayerService
|
||||
from shared.widgets.custom_image import CustomImage
|
||||
|
||||
+20
-37
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from fabric.hyprland.widgets import get_hyprland_connection
|
||||
from fabric.utils import Gdk, GLib, logger
|
||||
from fabric.utils import Gdk, GdkPixbuf, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
@@ -9,6 +8,7 @@ from fabric.widgets.label import Label
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from services.config import config, on_config_change
|
||||
from services.modus import get_clients, get_active_window, focus_window
|
||||
from utils.functions import is_special_workspace
|
||||
from utils.icon_resolver import IconResolver
|
||||
|
||||
@@ -22,18 +22,17 @@ class ApplicationSwitcher(Window):
|
||||
anchor="center",
|
||||
exclusivity="auto",
|
||||
keyboard_mode="exclusive",
|
||||
visible=False, # Start hidden until explicitly shown
|
||||
visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.conn = get_hyprland_connection()
|
||||
self.icon_resolver = IconResolver()
|
||||
self.windows = []
|
||||
self.current_index = 0
|
||||
self.tab_pressed = False
|
||||
self.icon_size = 96
|
||||
self._pixbuf_cache: Dict[Tuple[str, int], GdkPixbuf.Pixbuf] = {}
|
||||
|
||||
# Subscribe to config changes
|
||||
on_config_change(self._on_config_changed)
|
||||
|
||||
container = Box(
|
||||
@@ -83,7 +82,6 @@ class ApplicationSwitcher(Window):
|
||||
return config().get("window_switcher_items_per_row", 10)
|
||||
|
||||
def _on_config_changed(self, new_config, old_config):
|
||||
# Refresh windows if settings that affect display change
|
||||
if config().has_changed(
|
||||
"hide_special_workspace", old_config
|
||||
) or config().has_changed("window_switcher_items_per_row", old_config):
|
||||
@@ -104,18 +102,20 @@ class ApplicationSwitcher(Window):
|
||||
self.ungrab_keyboard()
|
||||
|
||||
def create_icon_for_window(self, window):
|
||||
"""Create an icon image for a specific window"""
|
||||
class_name = window.get("class", "").lower()
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size)
|
||||
if not icon_img:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(
|
||||
"application-x-executable-symbolic", self.icon_size
|
||||
)
|
||||
|
||||
key = (class_name, self.icon_size)
|
||||
if key not in self._pixbuf_cache:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size)
|
||||
if not icon_img:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(
|
||||
"application-x-executable-symbolic", self.icon_size
|
||||
)
|
||||
if icon_img:
|
||||
self._pixbuf_cache[key] = icon_img
|
||||
icon_img = self._pixbuf_cache.get(key)
|
||||
icon_image = Image()
|
||||
if icon_img:
|
||||
icon_image.set_from_pixbuf(icon_img)
|
||||
|
||||
return icon_image
|
||||
|
||||
def _is_special_workspace(self, client):
|
||||
@@ -126,29 +126,23 @@ class ApplicationSwitcher(Window):
|
||||
child.destroy()
|
||||
|
||||
try:
|
||||
clients_data = self.conn.send_command("j/clients").reply
|
||||
if not clients_data:
|
||||
clients = get_clients()
|
||||
if not clients:
|
||||
return
|
||||
clients = json.loads(clients_data.decode("utf-8"))
|
||||
|
||||
# Filter out hidden windows and optionally special workspace windows
|
||||
filtered_windows = []
|
||||
hide_special = config().get("hide_special_workspace", True)
|
||||
|
||||
filtered_windows = []
|
||||
for c in clients:
|
||||
if c.get("hidden", False):
|
||||
continue
|
||||
# Skip clients in special workspaces if the setting is enabled
|
||||
if hide_special and self._is_special_workspace(c):
|
||||
continue
|
||||
filtered_windows.append(c)
|
||||
|
||||
self.windows = filtered_windows
|
||||
|
||||
active_data = self.conn.send_command("j/activewindow").reply
|
||||
active_window = (
|
||||
json.loads(active_data.decode("utf-8")) if active_data else None
|
||||
)
|
||||
active_window = get_active_window()
|
||||
|
||||
self.current_index = 0
|
||||
if active_window:
|
||||
@@ -157,7 +151,6 @@ class ApplicationSwitcher(Window):
|
||||
self.current_index = i
|
||||
break
|
||||
|
||||
# Create vertical container for rows
|
||||
rows_box = Box(orientation="v", spacing=16)
|
||||
self.view.add(rows_box)
|
||||
|
||||
@@ -175,7 +168,6 @@ class ApplicationSwitcher(Window):
|
||||
)
|
||||
rows_box.add(current_row)
|
||||
|
||||
# Create icon image for this window
|
||||
icon_image = self.create_icon_for_window(window)
|
||||
|
||||
button_content = Box(
|
||||
@@ -282,7 +274,6 @@ class ApplicationSwitcher(Window):
|
||||
return False
|
||||
|
||||
def update_selection(self):
|
||||
# Flatten all buttons from all rows to find them by index
|
||||
all_buttons = []
|
||||
rows_box = self.view.get_children()[0] if self.view.get_children() else None
|
||||
if rows_box:
|
||||
@@ -292,15 +283,12 @@ class ApplicationSwitcher(Window):
|
||||
for i, child in enumerate(all_buttons):
|
||||
if i == self.current_index:
|
||||
child.add_style_class("active")
|
||||
# Update selection label
|
||||
current_window = self.windows[self.current_index]
|
||||
app_class = current_window.get("class", "Unknown")
|
||||
# capitalize if it's all lowercase
|
||||
if app_class.islower():
|
||||
app_class = app_class.capitalize()
|
||||
self.selection_label.set_label(app_class)
|
||||
|
||||
# Update workspace label
|
||||
workspace = current_window.get("workspace", {})
|
||||
workspace_name = workspace.get("name", "Unknown")
|
||||
self.workspace_label.set_label(f"Workspace {workspace_name}")
|
||||
@@ -314,12 +302,7 @@ class ApplicationSwitcher(Window):
|
||||
window = self.windows[self.current_index]
|
||||
address = window.get("address")
|
||||
if address:
|
||||
try:
|
||||
GLib.spawn_command_line_async(
|
||||
f"hyprctl dispatch 'hl.dsp.focus({{ window = \"address:{address}\" }})'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to focus window: {e}")
|
||||
focus_window(address)
|
||||
|
||||
def _on_item_clicked(self, index):
|
||||
self.current_index = index
|
||||
|
||||
Reference in new issue
Block a user