From 7fdeb7beba2c80f06df61d76cc1fd309a9a120de Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 08:24:53 +0545 Subject: [PATCH 01/28] feat(globalmenu): update --- config/mods.toml | 24 +- src/shared/window/applet_window.py | 18 +- src/shared/window/dropdown.py | 5 +- src/styles/widgets/dropdown.css | 43 ++ src/styles/window/panel.css | 27 +- src/window/globalmenu/dbusmenu.py | 85 ++++ src/window/globalmenu/registrar.py | 188 +++++++ src/window/globalmenu/service.py | 156 ++---- src/window/panel/components/globalmenu.py | 567 +++++++++------------- src/window/panel/main.py | 8 +- 10 files changed, 652 insertions(+), 469 deletions(-) create mode 100644 src/window/globalmenu/registrar.py diff --git a/config/mods.toml b/config/mods.toml index 8b96f9d..ac80627 100644 --- a/config/mods.toml +++ b/config/mods.toml @@ -13,15 +13,15 @@ order = 1 on-clicked = "python3 -m utils.google_lens_cli" -# [Mods.terminal] -# icon = "terminal.svg" -# icon-size = 18 -# -# [[Mods.terminal.options]] -# label = "Terminal" -# on-clicked = "kitty" -# -# [[Mods.terminal.options]] -# label = "Terminal but scary" -# on-clicked = "zen-browser" -# divider = true +[Mods.terminal] +icon = "terminal.svg" +icon-size = 18 + +[[Mods.terminal.options]] +label = "Terminal" +on-clicked = "kitty" + +[[Mods.terminal.options]] +label = "Terminal but scary" +on-clicked = "zen-browser" +divider = true diff --git a/src/shared/window/applet_window.py b/src/shared/window/applet_window.py index 1583e8e..711bb0c 100644 --- a/src/shared/window/applet_window.py +++ b/src/shared/window/applet_window.py @@ -26,6 +26,8 @@ class AppletWindow(PopupWindow): def __init__(self, edge_margin: int = 0, **kwargs): self._is_open = False self._hide_timeout_id = None + self._parent_dropdown = kwargs.pop("parent_dropdown", None) + self._child_dropdown = None self.dismiss_layer = DismissLayer(on_dismiss=self.toggle) kwargs["keyboard_mode"] = "on-demand" super().__init__(edge_margin=edge_margin, **kwargs) @@ -44,7 +46,11 @@ class AppletWindow(PopupWindow): self._is_open = True active = AppletWindow._active_popup - if active and active != self: + if ( + active + and active != self + and active is not getattr(self, "_parent_dropdown", None) + ): try: if hasattr(active, "close_applet"): active.close_applet() @@ -57,6 +63,9 @@ class AppletWindow(PopupWindow): AppletWindow._active_popup = self + if self._parent_dropdown: + self._parent_dropdown._child_dropdown = self + if self._hide_timeout_id is not None: GLib.source_remove(self._hide_timeout_id) self._hide_timeout_id = None @@ -76,6 +85,13 @@ class AppletWindow(PopupWindow): self._is_open = False + if hasattr(self, "_child_dropdown") and self._child_dropdown: + try: + self._child_dropdown.close_applet() + except Exception: + pass + self._child_dropdown = None + if self._hide_timeout_id is not None: GLib.source_remove(self._hide_timeout_id) self._hide_timeout_id = None diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py index 2b3f404..0ae9f5f 100644 --- a/src/shared/window/dropdown.py +++ b/src/shared/window/dropdown.py @@ -20,13 +20,16 @@ def dropdown_divider(comment): class ModusDropdown(AppletWindow): - def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs): + def __init__( + self, dropdown_children=None, dropdown_id=None, parent_dropdown=None, **kwargs + ): super().__init__( layer="top", exclusivity="auto", name="dropdown-menu", title="modus-dropdown", visible=False, + parent_dropdown=parent_dropdown, **kwargs, ) diff --git a/src/styles/widgets/dropdown.css b/src/styles/widgets/dropdown.css index 813fd10..c82746d 100644 --- a/src/styles/widgets/dropdown.css +++ b/src/styles/widgets/dropdown.css @@ -62,3 +62,46 @@ border-bottom: 1px solid @glass-divider; margin: 2px 0; } + +/* ── GtkMenu submenus — same IDs as ModusDropdown ── */ + +/* Popup window transparent background */ +.background.popup { + background: transparent; +} + +/* Hide GtkMenuButton default arrow indicator */ +#global-menu indicator, +#panel-button indicator { + min-width: 0; + min-height: 0; + margin: 0; + padding: 0; + -gtk-icon-source: none; +} + +/* Menu item label text */ +#dropdown-option label { + font-size: apply(font-md); + margin: 0px; + color: @glass-text; + font-weight: 400; +} + +/* Menu item submenu arrow */ +#dropdown-option arrow { + color: @glass-text-secondary; +} + +/* Disabled — GTK3 uses :insensitive */ +#dropdown-option:insensitive { + color: @glass-text-disabled; +} + +/* Separator line inside GtkSeparatorMenuItem */ +#dropdown-divider menuitem *, +#dropdown-divider * { + background: @glass-divider; + min-height: 1px; + margin: 4px 0; +} diff --git a/src/styles/window/panel.css b/src/styles/window/panel.css index 4913bd3..7badc4c 100644 --- a/src/styles/window/panel.css +++ b/src/styles/window/panel.css @@ -5,15 +5,10 @@ } #panel-button:hover, -#panel-button.active, #battery-button:hover, -#battery-button.active, #network-button:hover, -#network-button.active, #bt-button:hover, -#bt-button.active, #global-menu:hover, -#global-menu.active, #workspaces>button:hover { background-color: @glass-hover; color: @glass-text; @@ -28,9 +23,11 @@ border-radius: apply(radius-sm); } -#battery-label, -#date-time { - font-weight: 500; +#panel-button:active, +#battery-button:active, +#network-button:active, +#bt-button:active { + background-color: transparent; } #global-menu { @@ -39,6 +36,15 @@ border-radius: apply(radius-xs); } +#global-menu:active { + background-color: transparent; +} + +#battery-label, +#date-time { + font-weight: 500; +} + #date-time { margin: 0 4px; } @@ -73,15 +79,10 @@ #panel-button:hover *, -#panel-button.active *, #battery-button:hover *, -#battery-button.active *, #network-button:hover *, -#network-button.active *, #bt-button:hover *, -#bt-button.active *, #global-menu:hover *, -#global-menu.active *, #workspaces>button:hover * { color: @glass-text; diff --git a/src/window/globalmenu/dbusmenu.py b/src/window/globalmenu/dbusmenu.py index 65f4c60..5194f96 100644 --- a/src/window/globalmenu/dbusmenu.py +++ b/src/window/globalmenu/dbusmenu.py @@ -275,6 +275,91 @@ class DBusMenuClient: f"[dbusmenu] click_item: Event call failed for item_id={item_id}: {e}" ) + self._try_gtk_actions_fallback(item_id, pid) + + def _try_gtk_actions_fallback(self, item_id, pid=0): + item = None + if self._cache: + item = self._find_in_cache(item_id) + if not item or not item.action_name: + return + bus = _get_bus() + action_name = item.action_name + for path in (self.object_path,): + try: + desc = bus.call_sync( + self.service_name, + path, + "org.gtk.Actions", + "Describe", + GLib.Variant("(s)", (action_name,)), + GLib.VariantType("(bgav)"), + Gio.DBusCallFlags.NONE, + 1000, + None, + ) + if not desc: + continue + enabled, param_type_v, state_v = desc.unpack() + if not enabled: + continue + has_state = state_v and state_v.n_children() > 0 + if has_state: + st = state_v.get_child_value(0) + state_type = st.get_type_string() if st else "" + new_val = ( + not st.get_boolean() if state_type == "b" else st.get_value() + ) + bus.call_sync( + self.service_name, + path, + "org.gtk.Actions", + "ChangeState", + GLib.Variant( + "(sv)", + (action_name, GLib.Variant(state_type, new_val)), + ), + None, + Gio.DBusCallFlags.NONE, + 1000, + None, + ) + else: + param_type = str(param_type_v) if param_type_v else "" + params = GLib.Variant("av", []) + if param_type and param_type != "()": + pt = GLib.VariantType(param_type) + params = GLib.Variant( + "av", [GLib.Variant.new_tuple(GLib.Variant(pt, None))] + ) + bus.call_sync( + self.service_name, + path, + "org.gtk.Actions", + "Activate", + GLib.Variant("(sava{sv})", (action_name, params, {})), + None, + Gio.DBusCallFlags.NONE, + 1000, + None, + ) + self.invalidate() + return + except Exception: + continue + + def _find_in_cache(self, item_id): + def walk(items): + for i in items: + if i.id == item_id: + return i + found = walk(i.children) + if found: + return found + return None + + return walk(self._cache) if self._cache else None + def invalidate(self): self._cache_valid = False diff --git a/src/window/globalmenu/registrar.py b/src/window/globalmenu/registrar.py new file mode 100644 index 0000000..46ea566 --- /dev/null +++ b/src/window/globalmenu/registrar.py @@ -0,0 +1,188 @@ +"""AppMenu Registrar — D-Bus service for com.canonical.AppMenu.Registrar. + +GTK apps with appmenu-gtk-module loaded call RegisterWindow(windowId, +objectPath) on this service to announce that they export a dbusmenu at the +given object path on their own bus name. We store those registrations and +expose GetMenuForWindow so the global menu bar can look them up. + +Follows NovaBar's clean Registrar pattern — standalone singleton with +hash-table storage and proper D-Bus skeleton. +""" + +from dataclasses import dataclass +from typing import Optional + +from fabric.utils import Gio, GLib, logger + + +@dataclass +class MenuRegistration: + """Registration entry for a window's menu.""" + + window_id: int + bus_name: str + object_path: str + + +REGISTRAR_XML = """ + + + + + + + + + + + + + + + + + + + +""" + + +class Registrar: + """Singleton D-Bus service implementing com.canonical.AppMenu.Registrar. + + Mirrors NovaBar's ``GlobalMenu.Registrar`` — owns the well-known bus + name, stores window→menu registrations, and exposes ``GetMenuForWindow``. + """ + + _instance: Optional["Registrar"] = None + + @classmethod + def get_instance(cls) -> "Registrar": + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self._registrations: dict[int, MenuRegistration] = {} + self._bus_owner_id: Optional[int] = None + self._registration_id: Optional[int] = None + self._connection: Optional[Gio.DBusConnection] = None + + # ---- Public API ---- + + def start(self): + """Acquire the bus name and export the D-Bus object.""" + try: + bus = _get_bus() + node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML) + self._registration_id = bus.register_object( + "/com/canonical/AppMenu/Registrar", + node.interfaces[0], + self._handle_method, + None, + None, + ) + Gio.bus_own_name_on_connection( + bus, + "com.canonical.AppMenu.Registrar", + Gio.BusNameOwnerFlags.NONE, + None, + None, + ) + self._connection = bus + logger.info("[Registrar] AppMenu Registrar D-Bus service started") + except Exception as e: + logger.error(f"[Registrar] Failed to start: {e}") + + def stop(self): + """Release the bus name and unregister the object.""" + if self._connection and self._registration_id is not None: + try: + self._connection.unregister_object(self._registration_id) + except Exception as e: + logger.warning(f"[Registrar] Failed to unregister object: {e}") + self._registration_id = None + + def get_menu_for_window(self, window_id: int) -> Optional[MenuRegistration]: + """Look up the menu registration for a window.""" + return self._registrations.get(window_id) + + def get_menu_for_bus_name(self, bus_name: str) -> Optional[MenuRegistration]: + """Look up the menu registration by D-Bus bus name.""" + for reg in self._registrations.values(): + if reg.bus_name == bus_name: + return reg + return None + + def get_all_registrations(self) -> dict[int, MenuRegistration]: + """Return a copy of all current registrations.""" + return dict(self._registrations) + + # ---- Internal ---- + + def register_window(self, window_id: int, sender: str, menu_path: str): + """Store a window→menu registration.""" + reg = MenuRegistration( + window_id=window_id, + bus_name=sender, + object_path=menu_path, + ) + self._registrations[window_id] = reg + logger.debug( + f"[Registrar] Registered window {window_id} -> {sender} {menu_path}" + ) + + def unregister_window(self, window_id: int): + """Remove a window registration.""" + self._registrations.pop(window_id, None) + logger.debug(f"[Registrar] Unregistered window {window_id}") + + def _handle_method( + self, + connection, + sender, + object_path, + _interface_name, + method_name, + parameters, + invocation, + ): + """D-Bus method dispatcher.""" + try: + if method_name == "RegisterWindow": + window_id, menu_path = parameters.unpack() + self.register_window(window_id, sender, str(menu_path)) + invocation.return_value(None) + + elif method_name == "UnregisterWindow": + window_id = parameters.unpack()[0] + self.unregister_window(window_id) + invocation.return_value(None) + + elif method_name == "GetMenuForWindow": + window_id = parameters.unpack()[0] + reg = self.get_menu_for_window(window_id) + if reg: + invocation.return_value( + GLib.Variant("(so)", (reg.bus_name, reg.object_path)) + ) + else: + invocation.return_value( + GLib.Variant("(so)", (sender, "/com/canonical/menu/0")) + ) + + elif method_name == "GetMenus": + menus = [ + (0, reg.bus_name, reg.object_path) + for reg in self._registrations.values() + ] + invocation.return_value(GLib.Variant("(a(uso))", (menus,))) + + except Exception as e: + logger.error(f"[Registrar] D-Bus method {method_name} failed: {e}") + invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e)) + + +def _get_bus() -> Gio.DBusConnection: + """Get or create the shared session bus connection.""" + return Gio.bus_get_sync(Gio.BusType.SESSION, None) diff --git a/src/window/globalmenu/service.py b/src/window/globalmenu/service.py index 84d7e3f..92fd7cc 100644 --- a/src/window/globalmenu/service.py +++ b/src/window/globalmenu/service.py @@ -20,6 +20,7 @@ from fabric.utils import ( from window.globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus from window.globalmenu.environment import setup_global_menu_environment from window.globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient +from window.globalmenu.registrar import Registrar _NODE_NAME_RE = re.compile(r' int: return 0 -REGISTRAR_XML = """ - - - - - - - - - - - - - - - - - - - -""" - - class GlobalMenuService(Service): """Service that extracts and provides application menus for the global menu bar.""" @@ -189,9 +167,7 @@ class GlobalMenuService(Service): self._registry_lock = threading.Lock() self._state_lock = threading.Lock() - self._registered_menus: dict[str, tuple[str, str]] = {} - - self._setup_registrar() + Registrar.get_instance().start() self._subscribe_name_owner_changed() threading.Thread(target=self._setup_environment, daemon=True).start() threading.Thread(target=self._scan_initial_services, daemon=True).start() @@ -269,7 +245,6 @@ class GlobalMenuService(Service): return if entry.pid: self._pid_to_service.pop(entry.pid, None) - self._registered_menus.pop(service_name, None) if isinstance(entry.importer, DBusMenuClient): try: entry.importer.disconnect_signals() @@ -288,79 +263,9 @@ class GlobalMenuService(Service): f"[GlobalMenuService] Cleaned up disappeared service: {service_name}" ) - def _setup_registrar(self): - try: - self._bus = _get_bus() - self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML) - self._bus.register_object( - "/com/canonical/AppMenu/Registrar", - self._node.interfaces[0], - self._handle_registrar_method, - None, - None, - ) - Gio.bus_own_name_on_connection( - self._bus, - "com.canonical.AppMenu.Registrar", - Gio.BusNameOwnerFlags.NONE, - None, - None, - ) - logger.info("[GlobalMenuService] AppMenu Registrar DBus service started") - except Exception as e: - logger.error(f"[GlobalMenuService] Failed to start Registrar: {e}") - def _setup_environment(self): setup_global_menu_environment() - def _handle_registrar_method( - self, - connection, - sender, - object_path, - _interface_name, - method_name, - parameters, - invocation, - ): - try: - if method_name == "RegisterWindow": - window_id, menu_path = parameters.unpack() - logger.debug( - f"[GlobalMenuService] App {sender} registered menu {menu_path} for window {window_id}" - ) - with self._registry_lock: - self._registered_menus[sender] = (sender, menu_path) - invocation.return_value(None) - - elif method_name == "UnregisterWindow": - with self._registry_lock: - self._registered_menus.pop(sender, None) - invocation.return_value(None) - - elif method_name == "GetMenuForWindow": - window_id = parameters.unpack()[0] - with self._registry_lock: - entry = self._registered_menus.get(sender) - if entry: - svc_name, svc_path = entry - invocation.return_value(GLib.Variant("(so)", (svc_name, svc_path))) - else: - invocation.return_value( - GLib.Variant("(so)", (sender, "/com/canonical/menu/0")) - ) - - elif method_name == "GetMenus": - with self._registry_lock: - menus = [ - (0, svc, path) - for sender, (svc, path) in self._registered_menus.items() - ] - invocation.return_value(GLib.Variant("(a(uso))", (menus,))) - except Exception as e: - logger.error(f"[GlobalMenuService] Registrar error {method_name}: {e}") - invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e)) - def _uid(self, wm_class: str, pid: int) -> str: return f"{wm_class}:{pid}" @@ -657,9 +562,10 @@ class GlobalMenuService(Service): def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]: bus = _get_bus() - with self._registry_lock: - registered = dict(self._registered_menus) - for sender, (service, path) in registered.items(): + registrar = Registrar.get_instance() + registered = registrar.get_all_registrations() + for window_id, reg in registered.items(): + service, path = reg.bus_name, reg.object_path try: pid = _dbus_get_pid(bus, service, _DBUS_TIMEOUT_FAST) if pid == target_pid or self._is_same_app(pid, target_pid): @@ -678,8 +584,7 @@ class GlobalMenuService(Service): return client except Exception as e: if "NameHasNoOwner" in str(e): - with self._registry_lock: - self._registered_menus.pop(sender, None) + registrar.unregister_window(window_id) return None def _find_fallback_scan( @@ -719,14 +624,17 @@ class GlobalMenuService(Service): if not (pid == target_pid or self._is_same_app(pid, target_pid)): continue - with self._registry_lock: - if reg_entry := self._registered_menus.get(svc): - logger.info( - f"[GlobalMenuService] Found Registrar path {reg_entry[1]} for {svc}" - ) - client = DBusMenuClient(reg_entry[0], reg_entry[1]) - self._cache_discovery(svc, reg_entry[1], pid, wm_class, client) - return client + registrar = Registrar.get_instance() + reg_entry = registrar.get_menu_for_bus_name(svc) + if reg_entry: + logger.info( + f"[GlobalMenuService] Found Registrar path {reg_entry.object_path} for {svc}" + ) + client = DBusMenuClient(reg_entry.bus_name, reg_entry.object_path) + self._cache_discovery( + svc, reg_entry.object_path, pid, wm_class, client + ) + return client _safe_cls = re.sub(r"[^A-Za-z0-9_]", "", wm_class) if wm_class else "" _safe_cap = _safe_cls.capitalize() if _safe_cls else "" @@ -1090,6 +998,34 @@ class GlobalMenuService(Service): ) return False + def get_current_dbusmenu_info(self) -> tuple[Optional[str], Optional[str]]: + """Return (unique_bus_name, object_path) for the current app if it has a DBusMenu server.""" + entry, _pid = self._resolve_current_entry() + if entry and entry.importer and isinstance(entry.importer, DBusMenuClient): + unique_name = self._resolve_unique_name(entry.importer.service_name) + return unique_name, entry.importer.object_path + return None, None + + def _resolve_unique_name(self, well_known_name: str) -> Optional[str]: + """Resolve a well-known D-Bus name to its unique name (e.g. :1.123).""" + try: + bus = _get_bus() + res = bus.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetNameOwner", + GLib.Variant("(s)", (well_known_name,)), + GLib.VariantType("(s)"), + Gio.DBusCallFlags.NONE, + _DBUS_TIMEOUT_FAST, + None, + ) + return res.get_child_value(0).get_string() if res else well_known_name + except Exception as e: + logger.warning(f"[service] GetNameOwner failed for {well_known_name}: {e}") + return well_known_name + def about_to_show(self, item_id: int) -> bool: entry, _pid = self._resolve_current_entry() if entry and entry.importer: diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py index da51101..2a63d0b 100644 --- a/src/window/panel/components/globalmenu.py +++ b/src/window/panel/components/globalmenu.py @@ -2,11 +2,9 @@ from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow from fabric.utils import FormattedString, exec_shell_command_async, logger from fabric.widgets.box import Box from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label +from gi.repository import Gtk from shared.dialogs.about import AboutApp, get_about_window -from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns from utils.app_name_resolver import format_window from utils.gtk_utils import setup_cursor_hover from utils.roam import modus_service @@ -21,215 +19,224 @@ def has_active_window(): ) -def create_menu_button(label, on_clicked=None): - button_kwargs = { - "label": label, - "name": "global-menu", - } - - if on_clicked is not None: - button_kwargs["on_clicked"] = on_clicked - - button = Button(**button_kwargs) - setup_cursor_hover(button, "pointer") - return button - - -def create_dropdown_with_capture(dropdown_id, parent, dropdown_children): - dropdown = ModusDropdown( - dropdown_id=dropdown_id, - parent=parent, - dropdown_children=dropdown_children, - ) - return dropdown - - -def manage_button_style_classes(buttons, active_button=None, style_class="active"): - for button in buttons: - if button: - button.remove_style_class(style_class) - - if active_button: - active_button.add_style_class(style_class) - - def show_about_app(_=None): if not has_active_window(): return - app_name = modus_service.current_active_app_name wmclass = getattr(modus_service, "current_active_wm_class", "") about_window = AboutApp(app_name=app_name, wmclass=wmclass) about_window.toggle(None) -def on_click_subthread(button, on_clicked, on_click): - if on_clicked: - on_clicked(button) - else: - exec_shell_command_async(on_click, lambda *_: None) - - for dropdown in dropdowns: - if dropdown.is_visible(): - dropdown.hide() - break - - -def dropdown_option( - label: str = "", - keybind: str = "", - on_click='echo "ModusPanelDropdown Action"', - on_clicked=None, -): - btn = Button( - child=CenterBox( - start_children=[ - Label(label=label, h_align="start", name="dropdown-option-label"), - ], - end_children=[ - Label(label=keybind, h_align="end", name="dropdown-option-keybind") - ], - orientation="horizontal", - h_align="fill", - h_expand=True, - v_expand=True, - ), - name="dropdown-option", - h_align="fill", - on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click), - h_expand=True, - v_expand=True, - ) - setup_cursor_hover(btn, "pointer") - return btn +def _clean_label(label): + return label.replace("_", "") def _make_menu_item_click(item_id): - """Create a click handler for a menu item.""" - def handler(_, __=None): - global_menu_svc = get_global_menu_service() - if global_menu_svc: - global_menu_svc.click_item(item_id) + svc = get_global_menu_service() + if svc: + svc.click_item(item_id) return handler -def _menu_item_to_dropdown(item): - """Convert a DBusMenuItem to a dropdown_option widget.""" - if item.type == "separator": - return dropdown_divider("---------------------") +def _on_click_action( + menu_item, on_clicked=None, on_click='echo "ModusPanelDropdown Action"' +): + if on_clicked: + on_clicked(menu_item) + else: + exec_shell_command_async(on_click, lambda *_: None) + from shared.window.dropdown import dropdowns - keybind = item.shortcut if item.shortcut else "" - - return dropdown_option( - item.label if item.label else "", - keybind, - on_click=None, - on_clicked=_make_menu_item_click(item.id), - ) + for dd in dropdowns: + if dd.is_visible(): + dd.hide() + break -class SystemDropdown(ModusDropdown): - def __init__(self, parent, **kwargs): - super().__init__( - dropdown_id="os-menu", - parent=parent, - dropdown_children=[ - dropdown_option( - "About this PC", on_clicked=lambda _: get_about_window().toggle() - ), - dropdown_divider("---------------------"), - dropdown_option( - "System Settings...", - on_clicked=lambda _: get_settings_window().toggle(), - ), - dropdown_divider("---------------------"), - dropdown_option( - "Force Quit", "", "hyprctl dispatch 'hl.dsp.window.kill()'" - ), - dropdown_divider("---------------------"), - dropdown_option("Sleep", "", "systemctl suspend"), - dropdown_option("Restart", "", "systemctl reboot"), - dropdown_option("Shut Down", "", "shutdown now"), - dropdown_divider("---------------------"), - dropdown_option( - "Lock Screen", - "󰘳 L", - 'fabric-cli exec modus "lock_screen.lock()"', - ), - ], - **kwargs, - ) +# ========== GtkMenu builder ========== + + +def _build_gtk_menu_from_children(children): + """Recursively build a Gtk.Menu from DBusMenuItem children.""" + menu = Gtk.Menu() + menu.set_name("dropdown-options") + + for child in children: + if not getattr(child, "visible", True): + continue + + child_type = getattr(child, "type", "standard") + if child_type == "separator": + sep = Gtk.SeparatorMenuItem() + sep.set_name("dropdown-divider") + menu.append(sep) + continue + + label = getattr(child, "label", "") + if not label: + continue + + cleaned = _clean_label(label) + enabled = getattr(child, "enabled", True) + sub_children = child.children if child.children else [] + has_sub = getattr(child, "has_submenu", False) or bool(sub_children) + + if has_sub and sub_children: + item = Gtk.MenuItem.new_with_label(f"{cleaned} ▸") + item.set_name("dropdown-option") + sub_menu = _build_gtk_menu_from_children(sub_children) + sub_menu.show_all() + item.set_submenu(sub_menu) + else: + item = Gtk.MenuItem.new_with_label(cleaned) + item.set_name("dropdown-option") + if not enabled: + item.set_sensitive(False) + item.connect("activate", _make_menu_item_click(child.id)) + + menu.append(item) + + return menu + + +def _make_simple_gtk_menu(items): + """Build a simple Gtk.Menu from a list of (label, on_activate, sensitive) tuples.""" + menu = Gtk.Menu() + menu.set_name("dropdown-options") + + for entry in items: + if entry is None: + sep = Gtk.SeparatorMenuItem() + sep.set_name("dropdown-divider") + menu.append(sep) + continue + + label, on_activate, *rest = entry + sensitive = rest[0] if rest else True + item = Gtk.MenuItem.new_with_label(label) + item.set_name("dropdown-option") + if not sensitive: + item.set_sensitive(False) + else: + item.connect("activate", on_activate) + menu.append(item) + + return menu + + +# ========== GlobalMenuDropdowns ========== class GlobalMenuDropdowns: def __init__(self, parent, menu_box=None): self.parent = parent - self._menu_box = menu_box # The GlobalMenu Box widget to update + self._menu_box = menu_box self._imac_button = None - # Get the global menu service self._global_menu_svc = get_global_menu_service() self._has_extracted_menu = False - self.system_dropdown = SystemDropdown(parent=parent) + self._gtk_menu_buttons: list[Gtk.MenuButton] = [] + self._gtk_menus: list[Gtk.Menu] = [] - self.global_title_menu_about = dropdown_option( - f"About {modus_service.current_active_app_name}", - on_clicked=show_about_app, + # System dropdown (imac button) + self._system_menu = _make_simple_gtk_menu( + [ + ("About this PC", lambda _: get_about_window().toggle()), + None, + ("System Settings...", lambda _: get_settings_window().toggle()), + None, + ( + "Force Quit", + lambda _: exec_shell_command_async( + "hyprctl dispatch 'hl.dsp.window.kill()'", lambda *_: None + ), + ), + None, + ( + "Sleep", + lambda _: exec_shell_command_async( + "systemctl suspend", lambda *_: None + ), + ), + ( + "Restart", + lambda _: exec_shell_command_async( + "systemctl reboot", lambda *_: None + ), + ), + ( + "Shut Down", + lambda _: exec_shell_command_async("shutdown now", lambda *_: None), + ), + None, + ( + "Lock Screen", + lambda _: exec_shell_command_async( + 'fabric-cli exec modus "lock_screen.lock()"', lambda *_: None + ), + ), + ] ) - self.global_title_menu_quit = dropdown_option( - f"Quit {modus_service.current_active_app_name}", - on_click="hyprctl dispatch 'hl.dsp.window.close()'", - ) - self.global_menu_title = create_dropdown_with_capture( - "global-menu-title", - parent, - [self.global_title_menu_about, self.global_title_menu_quit], + self._system_menu.show_all() + + # Title dropdown (About / Quit app) + self._title_menu = _make_simple_gtk_menu( + [ + ( + f"About {modus_service.current_active_app_name}", + lambda _: show_about_app(), + ), + ( + f"Quit {modus_service.current_active_app_name}", + lambda _: exec_shell_command_async( + "hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None + ), + ), + ] ) + self._title_menu.show_all() - # Dynamic menu dropdowns (populated from extracted app menus) - self._dynamic_dropdowns: list[ModusDropdown] = [] + # Active window title (clickable) + self.global_menu_button_title = Gtk.MenuButton(name="global-menu") + self.global_menu_button_title.set_popup(self._title_menu) + self.global_menu_button_title.show_all() + setup_cursor_hover(self.global_menu_button_title, "pointer") - modus_service.connect( - "current-active-app-name-changed", self._on_active_app_changed - ) - - # Connect to global menu service signals (Moved to the bottom of __init__) - - self.global_menu_button_title = ActiveWindow( + self._active_window_ref = ActiveWindow( formatter=FormattedString( "{ format_window(win_title, win_class) }", format_window=format_window, ), name="global-menu", - on_clicked=self._on_title_button_clicked, ) - setup_cursor_hover(self.global_menu_button_title, "pointer") + self._active_window_ref.connect( + "window_activated", self._on_window_label_update + ) + self._active_window_ref.do_initialize() - self.global_menu_title.set_pointing_to(self.global_menu_button_title) - - # Start with only title self.all_menu_buttons = [ self.global_menu_button_title, ] - self.dropdown_button_map = { - "global-menu-title": self.global_menu_button_title, - "os-menu": None, - } + modus_service.connect( + "current-active-app-name-changed", self._on_active_app_changed + ) - modus_service.connect("current-dropdown-changed", self.changed_dropdown) - modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) - - # Connect to global menu service signals if self._global_menu_svc: self._global_menu_svc.connect("menu-changed", self._on_menu_changed) + def _on_window_label_update(self, _, win_class, win_title): + label = format_window(win_title, win_class) + self.global_menu_button_title.set_label(label or "") + def set_imac_button(self, button): self._imac_button = button - self.dropdown_button_map["os-menu"] = button + self._system_menu.show_all() + button.set_popup(self._system_menu) def _get_all_buttons(self): buttons = list(self.all_menu_buttons) @@ -237,216 +244,126 @@ class GlobalMenuDropdowns: buttons.append(self._imac_button) return buttons - def hide_dropdowns(self, *_): - manage_button_style_classes(self._get_all_buttons()) - - def changed_dropdown(self, _, dropdown_id): - active_button = self.dropdown_button_map.get(dropdown_id) - manage_button_style_classes(self._get_all_buttons(), active_button) - - def _on_title_button_clicked(self, _): - if has_active_window(): - self.global_menu_title.toggle() - def _on_active_app_changed(self, _, value): logger.info(f"[GlobalMenu] Active app changed: {value}") - try: - self.global_title_menu_about.get_child().get_start_children()[0].set_label( - f"About {value}" - ) - self.global_title_menu_quit.get_child().get_start_children()[0].set_label( - f"Quit {value}" - ) - except Exception as e: - logger.warning( - f"[globalmenu] self.global_title_menu_about.get_child().get_start_childr... failed: {e}" - ) - # Notify the global menu service about the active window change + # Rebuild title menu with new app name + old_menu = self._title_menu + self._title_menu = _make_simple_gtk_menu( + [ + (f"About {value}", lambda _: show_about_app()), + ( + f"Quit {value}", + lambda _: exec_shell_command_async( + "hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None + ), + ), + ] + ) + self._title_menu.show_all() + self.global_menu_button_title.set_popup(self._title_menu) + if old_menu: + old_menu.destroy() + if self._global_menu_svc: wm_class = getattr(modus_service, "current_active_wm_class", "") - logger.info( - f"[GlobalMenu] Calling update_active_window('{value}', '{wm_class}')" - ) self._global_menu_svc.update_active_window(value, wm_class) def _on_menu_changed(self, _, menu_items: list): - """Handle menu changes from the GlobalMenuService.""" self._rebuild_dynamic_menus(menu_items) - def _rebuild_dynamic_menus(self, menu_items: list): - """Rebuild the menu bar buttons based on extracted menu items. + def _cleanup_dynamic(self): + for btn in self.all_menu_buttons: + if btn not in (self.global_menu_button_title,): + try: + if btn.get_parent(): + btn.get_parent().remove(btn) + btn.destroy() + except Exception as e: + logger.warning(f"[globalmenu] cleanup failed: {e}") - If the app has a menu bar → show extracted menus (File, Edit, etc.) - If not → show fallback Hyprland window controls (View, Window, Help) - """ + for mb in self._gtk_menu_buttons: + try: + mb.destroy() + except Exception: + pass + self._gtk_menu_buttons.clear() + + for m in self._gtk_menus: + try: + m.destroy() + except Exception: + pass + self._gtk_menus.clear() + + def _rebuild_dynamic_menus(self, menu_items: list): logger.info(f"[GlobalMenu] _rebuild_dynamic_menus: {len(menu_items)} items") - # Destroy old buttons to prevent massive GTK memory leaks - if hasattr(self, "all_menu_buttons"): - for btn in self.all_menu_buttons: - if btn not in (self.global_menu_button_title,): - try: - if btn.get_parent(): - btn.get_parent().remove(btn) - btn.destroy() - except Exception as e: - logger.warning( - f"[globalmenu] if btn.get_parent(): btn.get_parent().remove(btn) failed: {e}" - ) + self._cleanup_dynamic() - # Clean up any previous dynamic dropdowns - for dd in self._dynamic_dropdowns: - try: - dd.destroy() - except Exception as e: - logger.warning(f"[globalmenu] dd.destroy() failed: {e}") - self._dynamic_dropdowns.clear() - - # Filter to visible top-level menu items only top_level = [ item for item in menu_items if hasattr(item, "label") and item.label and getattr(item, "visible", True) ] - logger.info(f"[GlobalMenu] top_level after filter: {len(top_level)} items") if not top_level: - # No extracted menu → show only title button self._has_extracted_menu = False - self.all_menu_buttons = [ - self.global_menu_button_title, - ] + self.all_menu_buttons = [self.global_menu_button_title] else: - # Has extracted menu → build dynamic buttons self._has_extracted_menu = True - new_buttons = [ - self.global_menu_button_title, - ] + new_buttons = [self.global_menu_button_title] for item in top_level: - is_action = not item.has_submenu and not item.children + children = getattr(item, "children", None) or [] + has_children = getattr(item, "has_submenu", False) or bool(children) - if is_action: - # Top-level item without children (e.g., VLC Play/Stop) - btn = create_menu_button( - item.label, - _make_menu_item_click(item.id), - ) + if not has_children: + btn = Button(label=item.label, name="global-menu") + setup_cursor_hover(btn, "pointer") + btn.connect("clicked", _make_menu_item_click(item.id)) new_buttons.append(btn) else: - # Build initial children for this dropdown - dropdown_children = [] - for child in item.children if item.children else []: - if not getattr(child, "visible", True): - continue - dropdown_children.append(_menu_item_to_dropdown(child)) + menu_btn = Gtk.MenuButton(label=item.label, name="global-menu") + menu_btn.get_style_context().add_class("global-menu") + setup_cursor_hover(menu_btn, "pointer") - if not dropdown_children: - dropdown_children = [ - dropdown_option(f"No items in {item.label}") - ] + gtk_menu = _build_gtk_menu_from_children(children) + gtk_menu.show_all() + menu_btn.set_popup(gtk_menu) + menu_btn.show_all() - dropdown_id = f"global-menu-{item.label.lower()}" - dropdown = create_dropdown_with_capture( - dropdown_id, self.parent, dropdown_children - ) - - btn = create_menu_button( - item.label, - lambda _, dd=dropdown: dd.toggle(), - ) - dropdown.set_pointing_to(btn) - - # Dynamically fetch submenu on hover - def make_on_enter(menu_item, dd): - def on_enter(widget, event): - if not menu_item.has_submenu: - return False - svc = get_global_menu_service() - if svc: - svc.about_to_show(menu_item.id) - new_menu = svc.refresh_menu_sync() - if new_menu: - # find the updated item - new_item = next( - (i for i in new_menu if i.id == menu_item.id), - None, - ) - if new_item: - children = [] - for child in ( - new_item.children - if new_item.children - else [] - ): - if not getattr(child, "visible", True): - continue - children.append( - _menu_item_to_dropdown(child) - ) - if not children: - children = [ - dropdown_option( - f"No items in {new_item.label}" - ) - ] - dd.dropdown.children = children - return False - - return on_enter - - btn.connect("enter-notify-event", make_on_enter(item, dropdown)) - - self._dynamic_dropdowns.append(dropdown) - self.dropdown_button_map[dropdown_id] = btn - new_buttons.append(btn) + self._gtk_menu_buttons.append(menu_btn) + self._gtk_menus.append(gtk_menu) + new_buttons.append(menu_btn) self.all_menu_buttons = new_buttons - # Update the panel (the actual GTK Box widget) if self._menu_box is not None: self._menu_box.children = self.all_menu_buttons + self._menu_box.show_all() def destroy(self): - """Clean up all dropdowns and signal connections""" try: modus_service.disconnect_by_func(self._on_active_app_changed) - modus_service.disconnect_by_func(self.changed_dropdown) - modus_service.disconnect_by_func(self.hide_dropdowns) except Exception as e: - logger.error(f"An error occurred: {e}") + logger.error(f"[globalmenu] disconnect failed: {e}") - # Disconnect global menu service if self._global_menu_svc: try: self._global_menu_svc.disconnect_by_func(self._on_menu_changed) except Exception as e: - logger.warning( - f"[globalmenu] self._global_menu_svc.disconnect_by_func(self._on_menu_ch... failed: {e}" - ) + logger.warning(f"[globalmenu] svc disconnect failed: {e}") - # Destroy dynamic dropdowns - for dd in self._dynamic_dropdowns: - try: - dd.destroy() - except Exception as e: - logger.error(f"An error occurred: {e}") - self._dynamic_dropdowns.clear() + self._cleanup_dynamic() - # Destroy static dropdown captures - dropdown_captures = [ - getattr(self, "system_dropdown", None), - getattr(self, "global_menu_title", None), - ] - for capture in dropdown_captures: - try: - if capture and hasattr(capture, "destroy"): - capture.destroy() - except Exception as e: - logger.error(f"An error occurred: {e}") - - super().destroy() + for name in ("_system_menu", "_title_menu"): + obj = getattr(self, name, None) + if obj: + try: + obj.destroy() + except Exception as e: + logger.error(f"[globalmenu] destroy failed: {e}") class GlobalMenu(Box): @@ -459,7 +376,6 @@ class GlobalMenu(Box): ) self.dropdown_system = GlobalMenuDropdowns(parent=parent_window, menu_box=self) - self.children = self.dropdown_system.all_menu_buttons def set_imac_button(self, button): @@ -467,14 +383,11 @@ class GlobalMenu(Box): def show_system_dropdown(self, imac_button): self.dropdown_system.set_imac_button(imac_button) - self.dropdown_system.system_dropdown.set_pointing_to(imac_button) - self.dropdown_system.system_dropdown.toggle() def destroy(self): - """Clean up the global menu and its dropdowns""" if hasattr(self, "dropdown_system") and self.dropdown_system: try: self.dropdown_system.destroy() except Exception as e: - logger.error(f"An error occurred: {e}") + logger.error(f"[globalmenu] destroy failed: {e}") super().destroy() diff --git a/src/window/panel/main.py b/src/window/panel/main.py index 945ce7c..7d59e15 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -6,6 +6,7 @@ from fabric.widgets.centerbox import CenterBox from fabric.widgets.datetime import DateTime from fabric.widgets.revealer import Revealer from fabric.widgets.wayland import WaylandWindow as Window +from gi.repository import Gtk from services.config import get_config_all, on_config_change from services.modus import notification_service @@ -40,11 +41,8 @@ class Panel(Window): ) self.globalmenu = GlobalMenu(parent_window=self) - self.imac = Button( - name="panel-button", - child=svg_file("misc/logo.svg", size=18), - on_clicked=lambda *_: self.globalmenu.show_system_dropdown(self.imac), - ) + self.imac = Gtk.MenuButton(name="panel-button") + self.imac.add(svg_file("misc/logo.svg", size=18)) setup_cursor_hover(self.imac, "pointer") self.globalmenu.set_imac_button(self.imac) -- 2.43.0 From 8407c9685c332fa4d4cb9dd3d365e3273c6368de Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 09:59:31 +0545 Subject: [PATCH 02/28] refactor: rewrite ModusDropdown to use native Gtk.Menu Replace layer-shell AppletWindow-based dropdown with Gtk.Menu approach. Adds hide signal for button state reset, dropdown_option factory, append/clear methods, and popup_at_widget/popup aliases. --- src/shared/window/dropdown.py | 169 +++++++++++++++++++++------------- 1 file changed, 106 insertions(+), 63 deletions(-) diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py index 0ae9f5f..a18b0bc 100644 --- a/src/shared/window/dropdown.py +++ b/src/shared/window/dropdown.py @@ -1,81 +1,124 @@ -from fabric.utils import GLib, logger -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.eventbox import EventBox +from functools import partial -from shared.window.applet_window import AppletWindow -from utils.roam import modus_service +from fabric.utils import logger +from gi.repository import Gdk, Gtk -dropdowns = [] +from utils.functions import run_command, thread -def dropdown_divider(comment): - return Box( - children=[Box(name="dropdown-divider", h_expand=True)], - name="dropdown-divider-box", - h_align="fill", - h_expand=True, - v_expand=True, - ) +class ModusDropdown: + """Reusable Gtk.Menu-based dropdown.""" + def __init__(self, items=None): + self._menu = Gtk.Menu() + self._source_button = None + self._menu.connect("hide", self._on_menu_hide) -class ModusDropdown(AppletWindow): - def __init__( - self, dropdown_children=None, dropdown_id=None, parent_dropdown=None, **kwargs - ): - super().__init__( - layer="top", - exclusivity="auto", - name="dropdown-menu", - title="modus-dropdown", - visible=False, - parent_dropdown=parent_dropdown, - **kwargs, - ) + if items: + self._build(items) - self.id = dropdown_id or str(len(dropdowns)) - dropdowns.append(self) + @property + def menu(self): + return self._menu - self.connect("notify::visible", self._on_visible_changed) - modus_service.connect("dropdowns-hide-changed", self.hide_dropdown) + def _build(self, items): + for entry in items: + item = self._create_item(entry) + if item is not None: + self._menu.append(item) + self._menu.show_all() - self.dropdown = Box( - children=dropdown_children or [], - h_expand=True, - name="dropdown-options", - orientation="vertical", - ) + def _create_item(self, entry): + if entry is None: + return Gtk.SeparatorMenuItem() - self.child_box = CenterBox(start_children=[self.dropdown]) + if isinstance(entry, Gtk.Widget): + return entry - self.event_box = EventBox( - events=["enter-notify", "leave-notify"], - child=self.child_box, - all_visible=True, - ) + if isinstance(entry, dict): + return self._make_item(entry) - self.children = [self.event_box] - self.add_keybinding("escape", self.hide_dropdown) + label, callback = entry[0], entry[1] + sensitive = entry[2] if len(entry) > 2 else True + item = Gtk.MenuItem.new_with_label(label) + if sensitive and callback: + item.connect("activate", callback) + elif not sensitive: + item.set_sensitive(False) + return item - def hide_dropdown(self, *_): - if self.is_visible(): - GLib.idle_add(lambda: self.hide()) + def _make_item(self, entry): + label = entry.get("label", "") + accel = entry.get("accel") + command = entry.get("command") + callback = entry.get("callback") + sensitive = entry.get("sensitive", True) - def _on_visible_changed(self, *_): - if self.is_visible(): - modus_service.current_dropdown = self.id - elif str(modus_service.current_dropdown) == str(self.id): - modus_service.current_dropdown = None + if accel: + label = f"{label} {accel}" - def destroy(self): - """Clean up resources and global references""" - global dropdowns - if self in dropdowns: - dropdowns.remove(self) + item = Gtk.MenuItem.new_with_label(label) + if not sensitive: + item.set_sensitive(False) + elif callback or command: + item.connect( + "activate", + partial(self._activate, callback, command), + ) + return item + @staticmethod + def _activate(callback, command, *_): + if callback: + callback() + if command: + thread(run_command, ["sh", "-c", command], timeout=30) + + def _on_menu_hide(self, *_): + if not self._source_button: + return try: - modus_service.disconnect_by_func(self.hide_dropdown) - except Exception as e: - logger.error(f"An error occurred: {e}") + self._source_button.unset_state_flags(Gtk.StateFlags.ACTIVE) + self._source_button.queue_draw() + except Exception: + logger.exception("[ModusDropdown] failed to reset button state") + self._source_button = None - super().destroy() + def append(self, item): + created = self._create_item(item) + if created is not None: + self._menu.append(created) + self._menu.show_all() + + def clear(self): + for child in self._menu.get_children(): + child.destroy() + + def popup_at_widget(self, widget, source_button=None): + self._source_button = source_button or widget + self._menu.popup_at_widget( + widget, + Gdk.Gravity.SOUTH_WEST, + Gdk.Gravity.NORTH_WEST, + None, + ) + + def popup(self, widget, source_button=None): + self.popup_at_widget(widget, source_button) + + +def dropdown_option(label, accel=None, command=None, callback=None, sensitive=True): + """Create a dropdown menu option dict. + + Usage: + dropdown_option("Lock Screen", "󰘳 L", "fabric-cli exec modus 'lock_screen.lock()'") + dropdown_option("Settings", callback=my_func) + dropdown_option("Disabled Item", sensitive=False) + """ + return { + "label": label, + "accel": accel, + "command": command, + "callback": callback, + "sensitive": sensitive, + } -- 2.43.0 From efcf54a6d2922230fa1e8a0e91f9fbec944a7f04 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 09:59:38 +0545 Subject: [PATCH 03/28] style: update dropdown CSS for native Gtk.Menu approach Replace ID-based selectors with global menu/menuitem/separator selectors matching fabric-shell's CSS strategy. --- src/styles/widgets/dropdown.css | 97 +++++++-------------------------- 1 file changed, 19 insertions(+), 78 deletions(-) diff --git a/src/styles/widgets/dropdown.css b/src/styles/widgets/dropdown.css index c82746d..7ec69ba 100644 --- a/src/styles/widgets/dropdown.css +++ b/src/styles/widgets/dropdown.css @@ -1,107 +1,48 @@ -#dropdown-menu { - background-color: transparent; +/* ── GtkMenu global styles ── */ - border-radius: 0.75rem; - margin: 0; +.background.popup { + background: transparent; } -#dropdown-options { - background-color: @glass-menu; +/* GtkMenu container */ +menu { + background-color: @glass-popup; border: 1px solid @glass-border; border-radius: 0.75rem; padding: 0.5rem; } -#dropdown-option { - background-color: transparent; +/* GtkMenuItem */ +menu menuitem { border-radius: apply(radius-xs); padding: 0px 10px; margin: 1px 2px; + color: @glass-text; transition: all 0ms ease-in-out; } -#dropdown-option:hover { - color: @glass-text; - transition: all 20ms ease-in-out; +/* GtkMenuItem hover */ +menu menuitem:hover { background-color: @glass-hover; -} - -#dropdown-option:hover #dropdown-option-label { color: @glass-text; } -#dropdown-option-label { +/* GtkMenuItem label */ +menu menuitem label { font-size: apply(font-md); margin: 0px; - color: @glass-text; + color: inherit; font-weight: 400; } -#dropdown-option-label:first-child { - padding-right: 5rem; -} - -#dropdown-option:hover #dropdown-option-keybind { - color: @glass-text; -} - -#dropdown-option-keybind { - font-weight: 500; +/* Submenu arrow */ +menu menuitem arrow { color: @glass-text-secondary; } -/* Divider */ - -#dropdown-divider-box { - background-color: transparent; - border-radius: apply(radius-xs); - padding: 0px 10px; - margin: 2px; -} -#dropdown-divider { - border-bottom: 1px solid @glass-divider; - margin: 2px 0; -} - -/* ── GtkMenu submenus — same IDs as ModusDropdown ── */ - -/* Popup window transparent background */ -.background.popup { - background: transparent; -} - -/* Hide GtkMenuButton default arrow indicator */ -#global-menu indicator, -#panel-button indicator { - min-width: 0; - min-height: 0; - margin: 0; - padding: 0; - -gtk-icon-source: none; -} - -/* Menu item label text */ -#dropdown-option label { - font-size: apply(font-md); - margin: 0px; - color: @glass-text; - font-weight: 400; -} - -/* Menu item submenu arrow */ -#dropdown-option arrow { - color: @glass-text-secondary; -} - -/* Disabled — GTK3 uses :insensitive */ -#dropdown-option:insensitive { +/* Disabled */ +menu menuitem:disabled { color: @glass-text-disabled; + opacity: 0.5; } -/* Separator line inside GtkSeparatorMenuItem */ -#dropdown-divider menuitem *, -#dropdown-divider * { - background: @glass-divider; - min-height: 1px; - margin: 4px 0; -} -- 2.43.0 From 1ddde651dd4918b971db44d5626390d649bd8735 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 09:59:42 +0545 Subject: [PATCH 04/28] style: update panel CSS for Gtk.MenuButton Add #global-menu reset rules, hide indicator, and transparent :active state for global menu and panel buttons. --- src/styles/window/panel.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/styles/window/panel.css b/src/styles/window/panel.css index 7badc4c..8af59c3 100644 --- a/src/styles/window/panel.css +++ b/src/styles/window/panel.css @@ -34,6 +34,19 @@ margin: 5px 2px; padding: 0 5px; border-radius: apply(radius-xs); + background: none; + border: none; + outline: none; + min-height: 0; + min-width: 0; +} + +#global-menu indicator { + min-width: 0; + min-height: 0; + margin: 0; + padding: 0; + -gtk-icon-source: none; } #global-menu:active { -- 2.43.0 From 925971a258eb6a13f0d175f126aeed1c156f1350 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 09:59:48 +0545 Subject: [PATCH 05/28] refactor: rewrite globalmenu with menu factories and GtkMenu Extract _create_system_menu and _create_title_menu factories. Use ModusDropdown and dropdown_option for system menu. Convert _build_gtk_menu_from_children to show_all. Add _make_click_handler method, remove dead _has_extracted_menu. Use logger.exception for all error logging. --- src/window/panel/components/globalmenu.py | 294 +++++++++------------- 1 file changed, 113 insertions(+), 181 deletions(-) diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py index 2a63d0b..d666e8a 100644 --- a/src/window/panel/components/globalmenu.py +++ b/src/window/panel/components/globalmenu.py @@ -1,10 +1,11 @@ from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow -from fabric.utils import FormattedString, exec_shell_command_async, logger +from fabric.utils import FormattedString, GLib, exec_shell_command_async, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from gi.repository import Gtk from shared.dialogs.about import AboutApp, get_about_window +from shared.window.dropdown import ModusDropdown, dropdown_option from utils.app_name_resolver import format_window from utils.gtk_utils import setup_cursor_hover from utils.roam import modus_service @@ -13,10 +14,8 @@ from window.settings.main import get_settings_window def has_active_window(): - return ( - modus_service.current_active_app_name - and modus_service.current_active_app_name != "Modus" - ) + app_name = modus_service.current_active_app_name + return app_name and app_name != "Modus" def show_about_app(_=None): @@ -28,41 +27,76 @@ def show_about_app(_=None): about_window.toggle(None) +# Menu factories +def _create_system_menu(): + return [ + dropdown_option( + "About this PC", callback=lambda _: get_about_window().toggle() + ), + None, + dropdown_option( + "System Settings...", + callback=lambda _: get_settings_window().toggle(), + ), + None, + dropdown_option( + "Force Quit", + callback=lambda _: exec_shell_command_async( + "hyprctl dispatch 'hl.dsp.window.kill()'", lambda *_: None + ), + ), + None, + dropdown_option( + "Sleep", + callback=lambda _: exec_shell_command_async( + "systemctl suspend", lambda *_: None + ), + ), + dropdown_option( + "Restart", + callback=lambda _: exec_shell_command_async( + "systemctl reboot", lambda *_: None + ), + ), + dropdown_option( + "Shut Down", + callback=lambda _: exec_shell_command_async( + "shutdown now", lambda *_: None + ), + ), + None, + dropdown_option( + "Lock Screen", + "󰘳 L", + 'fabric-cli exec modus "lock_screen.lock()"', + ), + ] + + +def _create_title_menu(app_name): + menu = Gtk.Menu() + for label, handler in [ + (f"About {app_name}", lambda *_: show_about_app()), + ( + f"Quit {app_name}", + lambda *_: exec_shell_command_async( + "hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None + ), + ), + ]: + item = Gtk.MenuItem.new_with_label(label) + item.connect("activate", handler) + menu.append(item) + menu.show_all() + return menu + + def _clean_label(label): return label.replace("_", "") -def _make_menu_item_click(item_id): - def handler(_, __=None): - svc = get_global_menu_service() - if svc: - svc.click_item(item_id) - - return handler - - -def _on_click_action( - menu_item, on_clicked=None, on_click='echo "ModusPanelDropdown Action"' -): - if on_clicked: - on_clicked(menu_item) - else: - exec_shell_command_async(on_click, lambda *_: None) - from shared.window.dropdown import dropdowns - - for dd in dropdowns: - if dd.is_visible(): - dd.hide() - break - - -# ========== GtkMenu builder ========== - - -def _build_gtk_menu_from_children(children): - """Recursively build a Gtk.Menu from DBusMenuItem children.""" +def _build_gtk_menu_from_children(children, click_handler): menu = Gtk.Menu() - menu.set_name("dropdown-options") for child in children: if not getattr(child, "visible", True): @@ -70,9 +104,7 @@ def _build_gtk_menu_from_children(children): child_type = getattr(child, "type", "standard") if child_type == "separator": - sep = Gtk.SeparatorMenuItem() - sep.set_name("dropdown-divider") - menu.append(sep) + menu.append(Gtk.SeparatorMenuItem()) continue label = getattr(child, "label", "") @@ -85,51 +117,23 @@ def _build_gtk_menu_from_children(children): has_sub = getattr(child, "has_submenu", False) or bool(sub_children) if has_sub and sub_children: - item = Gtk.MenuItem.new_with_label(f"{cleaned} ▸") - item.set_name("dropdown-option") - sub_menu = _build_gtk_menu_from_children(sub_children) + item = Gtk.MenuItem.new_with_label(f"{cleaned} \u25b8") + sub_menu = _build_gtk_menu_from_children(sub_children, click_handler) sub_menu.show_all() item.set_submenu(sub_menu) else: item = Gtk.MenuItem.new_with_label(cleaned) - item.set_name("dropdown-option") if not enabled: item.set_sensitive(False) - item.connect("activate", _make_menu_item_click(child.id)) + item.connect("activate", click_handler, child.id) menu.append(item) + menu.show_all() return menu -def _make_simple_gtk_menu(items): - """Build a simple Gtk.Menu from a list of (label, on_activate, sensitive) tuples.""" - menu = Gtk.Menu() - menu.set_name("dropdown-options") - - for entry in items: - if entry is None: - sep = Gtk.SeparatorMenuItem() - sep.set_name("dropdown-divider") - menu.append(sep) - continue - - label, on_activate, *rest = entry - sensitive = rest[0] if rest else True - item = Gtk.MenuItem.new_with_label(label) - item.set_name("dropdown-option") - if not sensitive: - item.set_sensitive(False) - else: - item.connect("activate", on_activate) - menu.append(item) - - return menu - - -# ========== GlobalMenuDropdowns ========== - - +# GlobalMenuDropdowns class GlobalMenuDropdowns: def __init__(self, parent, menu_box=None): self.parent = parent @@ -137,71 +141,17 @@ class GlobalMenuDropdowns: self._imac_button = None self._global_menu_svc = get_global_menu_service() - self._has_extracted_menu = False self._gtk_menu_buttons: list[Gtk.MenuButton] = [] self._gtk_menus: list[Gtk.Menu] = [] - # System dropdown (imac button) - self._system_menu = _make_simple_gtk_menu( - [ - ("About this PC", lambda _: get_about_window().toggle()), - None, - ("System Settings...", lambda _: get_settings_window().toggle()), - None, - ( - "Force Quit", - lambda _: exec_shell_command_async( - "hyprctl dispatch 'hl.dsp.window.kill()'", lambda *_: None - ), - ), - None, - ( - "Sleep", - lambda _: exec_shell_command_async( - "systemctl suspend", lambda *_: None - ), - ), - ( - "Restart", - lambda _: exec_shell_command_async( - "systemctl reboot", lambda *_: None - ), - ), - ( - "Shut Down", - lambda _: exec_shell_command_async("shutdown now", lambda *_: None), - ), - None, - ( - "Lock Screen", - lambda _: exec_shell_command_async( - 'fabric-cli exec modus "lock_screen.lock()"', lambda *_: None - ), - ), - ] - ) - self._system_menu.show_all() + self._system_dropdown = ModusDropdown(items=_create_system_menu()) + self._system_menu = self._system_dropdown.menu - # Title dropdown (About / Quit app) - self._title_menu = _make_simple_gtk_menu( - [ - ( - f"About {modus_service.current_active_app_name}", - lambda _: show_about_app(), - ), - ( - f"Quit {modus_service.current_active_app_name}", - lambda _: exec_shell_command_async( - "hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None - ), - ), - ] - ) - self._title_menu.show_all() + self._title_menu = _create_title_menu(modus_service.current_active_app_name) - # Active window title (clickable) self.global_menu_button_title = Gtk.MenuButton(name="global-menu") + self.global_menu_button_title.get_style_context().add_class("flat") self.global_menu_button_title.set_popup(self._title_menu) self.global_menu_button_title.show_all() setup_cursor_hover(self.global_menu_button_title, "pointer") @@ -238,32 +188,17 @@ class GlobalMenuDropdowns: self._system_menu.show_all() button.set_popup(self._system_menu) - def _get_all_buttons(self): - buttons = list(self.all_menu_buttons) - if self._imac_button and self._imac_button not in buttons: - buttons.append(self._imac_button) - return buttons + def _make_click_handler(self, _, item_id): + if self._global_menu_svc: + self._global_menu_svc.click_item(item_id) def _on_active_app_changed(self, _, value): logger.info(f"[GlobalMenu] Active app changed: {value}") - # Rebuild title menu with new app name old_menu = self._title_menu - self._title_menu = _make_simple_gtk_menu( - [ - (f"About {value}", lambda _: show_about_app()), - ( - f"Quit {value}", - lambda _: exec_shell_command_async( - "hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None - ), - ), - ] - ) - self._title_menu.show_all() + self._title_menu = _create_title_menu(value) self.global_menu_button_title.set_popup(self._title_menu) - if old_menu: - old_menu.destroy() + GLib.idle_add(old_menu.destroy) if self._global_menu_svc: wm_class = getattr(modus_service, "current_active_wm_class", "") @@ -272,28 +207,28 @@ class GlobalMenuDropdowns: def _on_menu_changed(self, _, menu_items: list): self._rebuild_dynamic_menus(menu_items) + def _destroy_widgets(self, widgets): + for widget in widgets: + try: + widget.destroy() + except Exception: + logger.exception("[globalmenu] widget destroy failed") + def _cleanup_dynamic(self): for btn in self.all_menu_buttons: - if btn not in (self.global_menu_button_title,): - try: - if btn.get_parent(): - btn.get_parent().remove(btn) - btn.destroy() - except Exception as e: - logger.warning(f"[globalmenu] cleanup failed: {e}") - - for mb in self._gtk_menu_buttons: + if btn is self.global_menu_button_title: + continue try: - mb.destroy() + if btn.get_parent(): + btn.get_parent().remove(btn) + btn.destroy() except Exception: - pass + logger.exception("[globalmenu] cleanup failed") + + self._destroy_widgets(self._gtk_menu_buttons) self._gtk_menu_buttons.clear() - for m in self._gtk_menus: - try: - m.destroy() - except Exception: - pass + self._destroy_widgets(self._gtk_menus) self._gtk_menus.clear() def _rebuild_dynamic_menus(self, menu_items: list): @@ -302,16 +237,12 @@ class GlobalMenuDropdowns: self._cleanup_dynamic() top_level = [ - item - for item in menu_items - if hasattr(item, "label") and item.label and getattr(item, "visible", True) + item for item in menu_items if item.label and getattr(item, "visible", True) ] if not top_level: - self._has_extracted_menu = False self.all_menu_buttons = [self.global_menu_button_title] else: - self._has_extracted_menu = True new_buttons = [self.global_menu_button_title] for item in top_level: @@ -321,15 +252,16 @@ class GlobalMenuDropdowns: if not has_children: btn = Button(label=item.label, name="global-menu") setup_cursor_hover(btn, "pointer") - btn.connect("clicked", _make_menu_item_click(item.id)) + btn.connect("clicked", self._make_click_handler, item.id) new_buttons.append(btn) else: menu_btn = Gtk.MenuButton(label=item.label, name="global-menu") menu_btn.get_style_context().add_class("global-menu") setup_cursor_hover(menu_btn, "pointer") - gtk_menu = _build_gtk_menu_from_children(children) - gtk_menu.show_all() + gtk_menu = _build_gtk_menu_from_children( + children, self._make_click_handler + ) menu_btn.set_popup(gtk_menu) menu_btn.show_all() @@ -346,14 +278,14 @@ class GlobalMenuDropdowns: def destroy(self): try: modus_service.disconnect_by_func(self._on_active_app_changed) - except Exception as e: - logger.error(f"[globalmenu] disconnect failed: {e}") + except Exception: + logger.exception("[globalmenu] disconnect failed") if self._global_menu_svc: try: self._global_menu_svc.disconnect_by_func(self._on_menu_changed) - except Exception as e: - logger.warning(f"[globalmenu] svc disconnect failed: {e}") + except Exception: + logger.exception("[globalmenu] svc disconnect failed") self._cleanup_dynamic() @@ -362,8 +294,8 @@ class GlobalMenuDropdowns: if obj: try: obj.destroy() - except Exception as e: - logger.error(f"[globalmenu] destroy failed: {e}") + except Exception: + logger.exception(f"[globalmenu] {name} destroy failed") class GlobalMenu(Box): @@ -388,6 +320,6 @@ class GlobalMenu(Box): if hasattr(self, "dropdown_system") and self.dropdown_system: try: self.dropdown_system.destroy() - except Exception as e: - logger.error(f"[globalmenu] destroy failed: {e}") + except Exception: + logger.exception("[globalmenu] destroy failed") super().destroy() -- 2.43.0 From d6d736ac7fde0278656c2e11860baa3d21b31d3a Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 09:59:56 +0545 Subject: [PATCH 06/28] refactor: use Gtk.MenuButton for custom mods with dropdowns Use plain Button for simple click-only mods, Gtk.MenuButton only when dropdown options exist. Adds margin spacing between buttons. --- src/window/panel/components/custom_mods.py | 143 ++++++--------------- 1 file changed, 36 insertions(+), 107 deletions(-) diff --git a/src/window/panel/components/custom_mods.py b/src/window/panel/components/custom_mods.py index 4b9ad3d..b6edab1 100644 --- a/src/window/panel/components/custom_mods.py +++ b/src/window/panel/components/custom_mods.py @@ -1,12 +1,11 @@ from pathlib import Path import tomlkit -from fabric.utils import Gio, GLib, logger +from fabric.utils import Gio, GLib, Gtk, logger from fabric.widgets.box import Box from fabric.widgets.button import Button -from fabric.widgets.label import Label -from shared.window.applet_window import AppletWindow +from shared.window.dropdown import ModusDropdown from utils.functions import clear_children, run_command, thread from utils.gtk_utils import setup_cursor_hover, svg_file, toml_file @@ -14,75 +13,6 @@ MODS_CONFIG_PATH = Path(toml_file("mods.toml")) RELOAD_DELAY_MS = 200 -def dropdown_divider(): - return Box( - children=[Box(name="dropdown-divider", h_expand=True)], - name="dropdown-divider-box", - h_align="fill", - h_expand=True, - ) - - -class ModPopup(AppletWindow): - def __init__( - self, mod_name, options, parent_window, pointing_to, source_button, **kwargs - ): - self._mod_name = mod_name - self._source_button = source_button - - option_widgets = [] - for opt in sorted(options, key=lambda o: o.get("order", 99)): - if opt.get("divider"): - option_widgets.append(dropdown_divider()) - - btn = Button( - name="dropdown-option", - child=Label(label=opt["label"], name="dropdown-option-label"), - h_align="fill", - h_expand=True, - ) - cmd = opt["on-clicked"] - btn.connect("clicked", lambda *_, c=cmd: self._run_command(c)) - setup_cursor_hover(btn, "pointer") - option_widgets.append(btn) - - options_box = Box( - name="dropdown-options", - orientation="vertical", - spacing=0, - children=option_widgets, - ) - - main_box = Box(name="dropdown-menu", children=[options_box]) - - super().__init__( - parent=parent_window, - pointing_to=pointing_to, - name="dropdown-menu", - title="modus-dropdown", - layer="overlay", - visible=False, - **kwargs, - ) - self.children = [main_box] - self.connect("notify::visible", self._on_visible_changed) - - def _on_visible_changed(self, *_): - if self.get_visible(): - self._source_button.add_style_class("active") - else: - self._source_button.remove_style_class("active") - - def _run_command(self, cmd): - self.close_applet() - thread(run_command, ["sh", "-c", cmd], timeout=30) - - def toggle(self, pointing_to=None): - if pointing_to: - self.set_pointing_to(pointing_to) - super().toggle() - - class CustomMods(Box): def __init__(self, parent_window, **kwargs): self._parent = parent_window @@ -128,6 +58,22 @@ class CustomMods(Box): self._rebuild(mods) + def _build_dropdown(self, options, btn): + menu_items = [] + for opt in sorted(options, key=lambda o: o.get("order", 99)): + if opt.get("divider"): + menu_items.append(None) + + cmd = opt["on-clicked"] + + def _on_activate(_, c=cmd): + thread(run_command, ["sh", "-c", c], timeout=30) + + menu_items.append((opt["label"], _on_activate)) + + dropdown = ModusDropdown(items=menu_items) + return dropdown + def _on_btn_press(self, btn, event, mod): button_map = {1: "on-left", 2: "on-middle", 3: "on-right"} button_key = button_map.get(event.button) @@ -135,43 +81,26 @@ class CustomMods(Box): popup = self._mod_popups.get(mod["name"]) if per_button_cmd is not None: - if popup and popup._is_open: - popup.toggle() + if popup and popup.menu.get_visible(): + popup.menu.popdown() thread(run_command, ["sh", "-c", per_button_cmd], timeout=30) return True cmd = mod.get("on-clicked") if cmd: - if popup and popup._is_open: - popup.toggle() + if popup and popup.menu.get_visible(): + popup.menu.popdown() thread(run_command, ["sh", "-c", cmd], timeout=30) return True - if event.button == 3: - if popup: - popup.toggle(pointing_to=btn) - return True - - if event.button == 1: - if popup and popup._is_open: - popup.toggle() - return True - - if popup: - popup.toggle(pointing_to=btn) - return True - - if event.button == 2 and popup: - popup.toggle(pointing_to=btn) - - return True + return False def _rebuild(self, mods): clear_children(self) for popup in self._mod_popups.values(): if popup: - popup.destroy() + popup.menu.destroy() self._mod_popups.clear() self._mod_buttons.clear() self._mods_data.clear() @@ -180,19 +109,19 @@ class CustomMods(Box): for mod in sorted_mods: icon_widget = svg_file(mod["icon"], size=mod["icon_size"]) - btn = Button(name="global-menu", child=icon_widget) - setup_cursor_hover(btn, "pointer") - popup = None if mod["options"]: - popup = ModPopup( - mod_name=mod["name"], - options=mod["options"], - parent_window=self._parent, - pointing_to=btn, - source_button=btn, - ) + btn = Gtk.MenuButton(name="panel-button", child=icon_widget) + btn.get_style_context().add_class("flat") + dropdown = self._build_dropdown(mod["options"], btn) + btn.set_popup(dropdown.menu) + else: + btn = Button(name="panel-button", child=icon_widget) + btn.get_style_context().add_class("flat") + btn.set_margin_start(4) + btn.set_margin_end(4) + setup_cursor_hover(btn, "pointer") btn.connect( "button-press-event", lambda b, e, m=mod: self._on_btn_press(b, e, m), @@ -200,7 +129,7 @@ class CustomMods(Box): self.add(btn) self._mod_buttons[mod["name"]] = btn - self._mod_popups[mod["name"]] = popup + self._mod_popups[mod["name"]] = dropdown if mod["options"] else None self._mods_data[mod["name"]] = mod self.show_all() @@ -248,7 +177,7 @@ class CustomMods(Box): monitor.cancel() for popup in self._mod_popups.values(): if popup: - popup.destroy() + popup.menu.destroy() self._mod_popups.clear() self._mod_buttons.clear() self._mods_data.clear() -- 2.43.0 From 34de9da0d75543ae1279a239b164b8f01ad8e475 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:00:00 +0545 Subject: [PATCH 07/28] refactor: remove LuaHyprlandWorkspaces monkey patch Fabric now handles Lua support natively via does_hyprland_support_lua(). Use HyprlandWorkspaces directly. --- src/window/panel/components/workspace.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py index 791efc6..0ced7da 100644 --- a/src/window/panel/components/workspace.py +++ b/src/window/panel/components/workspace.py @@ -9,25 +9,6 @@ from utils.gtk_utils import setup_cursor_hover # TODO: Support multi-monitor setups -class LuaHyprlandWorkspaces(HyprlandWorkspaces): - def do_action_next(self): - ws = "e" if not self._empty_scroll else "" - return self.connection.send_command( - f"batch/dispatch hl.dsp.focus({{workspace=[[{ws}+1]]}})" - ) - - def do_action_previous(self): - ws = "e" if not self._empty_scroll else "" - return self.connection.send_command( - f"batch/dispatch hl.dsp.focus({{workspace=[[{ws}-1]]}})" - ) - - def do_button_clicked(self, button: WorkspaceButton): - return self.connection.send_command( - f"batch/dispatch hl.dsp.focus({{workspace=[[{button.id}]]}})" - ) - - class WorkspaceIndicator(Box): def __init__(self, **kwargs): Box.__init__( @@ -36,7 +17,7 @@ class WorkspaceIndicator(Box): self._current_config = {"hide_special_workspace": True} on_config_change(self._on_config_changed) - self.workspaces = LuaHyprlandWorkspaces( + self.workspaces = HyprlandWorkspaces( name="workspaces", spacing=4, buttons_factory=self._get_button_factory(), @@ -92,7 +73,7 @@ class WorkspaceIndicator(Box): if hasattr(self, "workspaces") and self.workspaces: self.workspaces.destroy() - self.workspaces = LuaHyprlandWorkspaces( + self.workspaces = HyprlandWorkspaces( name="workspaces", spacing=4, buttons_factory=button_factory, -- 2.43.0 From 3e4bae1a98619702b052357cc233952c68836037 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:00:04 +0545 Subject: [PATCH 08/28] perf: cache failed D-Bus introspection paths Add _dead set to _IntrospectionCache to track paths that timeout. Failed introspections are skipped on future lookups, reducing timeout blocks from 300-500ms to near-zero. --- src/window/globalmenu/service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/window/globalmenu/service.py b/src/window/globalmenu/service.py index 92fd7cc..1f3fc44 100644 --- a/src/window/globalmenu/service.py +++ b/src/window/globalmenu/service.py @@ -48,6 +48,7 @@ class _IntrospectionCache: def __init__(self): self._cache: dict[str, str] = {} + self._dead: set[str] = set() self._lock = threading.Lock() def get(self, service: str, path: str, bus, timeout: int) -> str: @@ -55,12 +56,17 @@ class _IntrospectionCache: return "" key = f"{service}:{path}" with self._lock: + if key in self._dead: + return "" cached = self._cache.get(key) if cached is not None: return cached xml = _dbus_introspect(bus, service, path, timeout) with self._lock: - self._cache[key] = xml + if xml: + self._cache[key] = xml + else: + self._dead.add(key) return xml def invalidate(self, service: str): -- 2.43.0 From 64b4e922d949c4dfdc702dd55052f01bb310a4de Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:00:12 +0545 Subject: [PATCH 09/28] docs: add divider option to mods.toml documentation Document that divider = true can be added before an option to insert a separator in dropdown menus. --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index cb8fe78..6aa8df0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -143,6 +143,7 @@ on-left = "wlogout" - `on-clicked`, `on-left`, `on-right`, `on-middle` — shell commands via `sh -c` - `on-scroll-up`, `on-scroll-down` — scroll event commands - `options` — dropdown menu entries (each with `label` + `on-clicked`) + - Add `divider = true` before an option to insert a separator - Full shell syntax (`&&`, `|`) supported --- -- 2.43.0 From 3f66c5e55c3994fdf470f0b340a07c4698928fbc Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:00:31 +0545 Subject: [PATCH 10/28] style: add flat class to imac MenuButton Ensures consistent styling with other panel buttons. --- src/window/panel/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/window/panel/main.py b/src/window/panel/main.py index 7d59e15..a428d6d 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -42,6 +42,7 @@ class Panel(Window): self.globalmenu = GlobalMenu(parent_window=self) self.imac = Gtk.MenuButton(name="panel-button") + self.imac.get_style_context().add_class("flat") self.imac.add(svg_file("misc/logo.svg", size=18)) setup_cursor_hover(self.imac, "pointer") self.globalmenu.set_imac_button(self.imac) -- 2.43.0 From 88f7f2e125043c8df39006491b916d591e99b182 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:00:41 +0545 Subject: [PATCH 11/28] style: format documentation code examples Align comments in code examples for readability. --- docs/desktop_widgets.md | 9 +++++---- docs/plugins.md | 25 +++++++++++++------------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/desktop_widgets.md b/docs/desktop_widgets.md index 5d62879..314ea8b 100644 --- a/docs/desktop_widgets.md +++ b/docs/desktop_widgets.md @@ -52,10 +52,10 @@ class ExampleClockWidget(Box): DesktopWidgetRegistry.register( - "example", # unique key (used in desktop.toml) - ExampleClockWidget, # GTK widget class - (174, 174), # size (width, height) in pixels - (0.45, 0.45), # default position (px, py) as 0.0–1.0 fractions + "example", # unique key (used in desktop.toml) + ExampleClockWidget, # GTK widget class + (174, 174), # size (width, height) in pixels + (0.45, 0.45), # default position (px, py) as 0.0–1.0 fractions ) ``` @@ -175,6 +175,7 @@ class LiveClockWidget(Box): def _update(self) -> bool: import datetime + now = datetime.datetime.now() self.label.set_label(now.strftime("%H:%M")) return True diff --git a/docs/plugins.md b/docs/plugins.md index 422c160..c3c5856 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -62,17 +62,17 @@ def search(self, query: str, token: Any) -> list[SearchResult]: ```python SearchResult( - id="unique_id", # Unique within your plugin - title="Display Title", # Main text - subtitle="Description", # Secondary text - icon_name="", # GTK icon name (optional) - icon_data=None, # Raw image bytes for custom icons (optional) - score=80.0, # 0-100, higher = better match - render_type="default", # Controls rendering (see below) - action=lambda: ..., # Called when user selects this result - plugin_name="", # Auto-filled by spotlight - plugin_id="", # Auto-filled by spotlight - metadata={}, # Arbitrary data for renderers + id="unique_id", # Unique within your plugin + title="Display Title", # Main text + subtitle="Description", # Secondary text + icon_name="", # GTK icon name (optional) + icon_data=None, # Raw image bytes for custom icons (optional) + score=80.0, # 0-100, higher = better match + render_type="default", # Controls rendering (see below) + action=lambda: ..., # Called when user selects this result + plugin_name="", # Auto-filled by spotlight + plugin_id="", # Auto-filled by spotlight + metadata={}, # Arbitrary data for renderers ) ``` @@ -159,6 +159,7 @@ class HelloPlugin(SpotlightPlugin): ) ] + PLUGIN = HelloPlugin ``` @@ -190,7 +191,7 @@ class GooglePlugin(SpotlightPlugin): q = query.strip() if q: url = f"https://www.google.com/search?q={urllib.parse.quote(q)}" - spawn_detached(["xdg-open", url]) # no shell injection risk + spawn_detached(["xdg-open", url]) # no shell injection risk PLUGIN = GooglePlugin -- 2.43.0 From f0c0e27e825e971014bd3e5c8c451ab733428bde Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:00:55 +0545 Subject: [PATCH 12/28] chore: update dependencies Downgrade tomlkit to 0.15.0, update certifi to 2026.7.22. --- requirement.txt | 2 +- uv.lock | 76 ++++++++++++++++++++++++------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/requirement.txt b/requirement.txt index 1e1c0ae..ba1c7c7 100644 --- a/requirement.txt +++ b/requirement.txt @@ -4,6 +4,6 @@ psutil==7.2.2 python-pam==2.0.2 keyring>=23.0.0 keyrings-alt>=5.0.2 -tomlkit==0.15.1 +tomlkit==0.15.0 ruff==0.15.22 pre-commit==4.6.0 diff --git a/uv.lock b/uv.lock index df2b1e7..295a425 100644 --- a/uv.lock +++ b/uv.lock @@ -16,11 +16,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -157,7 +157,7 @@ wheels = [ [[package]] name = "fabric" version = "0.0.2" -source = { git = "https://github.com/Fabric-Development/fabric.git#b7b0f9975f0cf161ccf81c1adfb181230d553f79" } +source = { git = "https://github.com/Fabric-Development/fabric.git#85cacf660b1a324525a4dd9b512521e55ef7f89b" } dependencies = [ { name = "click" }, { name = "loguru" }, @@ -167,11 +167,11 @@ dependencies = [ [[package]] name = "filelock" -version = "3.31.2" +version = "3.32.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/2c/c603f8aedff281295f7afce455d5c05f33459c7bf684abb46228f844a1f0/filelock-3.31.2.tar.gz", hash = "sha256:e6d35965c709527915a184837a8421826d18bc3f9d7e9a5a0c8114a782475d66", size = 200476, upload-time = "2026-07-21T04:04:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/4c/c0ec6b645dcfefdd158313edb68c676d36c3f93ce8d2d9f0725d6341e506/filelock-3.31.2-py3-none-any.whl", hash = "sha256:18a4179901809ad1905c6631d907f1c3a41806c0d3506f7f9862565576a81a16", size = 97486, upload-time = "2026-07-21T04:04:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, ] [[package]] @@ -371,16 +371,16 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.10.1" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/cd/4f25b2f95b23f5d2c9c1fe43e49841bff5800562149b2666afc09309aa8f/platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695", size = 31678, upload-time = "2026-07-18T03:53:43.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443", size = 22906, upload-time = "2026-07-18T03:53:42.576Z" }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] name = "pre-commit" -version = "4.6.0" +version = "4.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -389,9 +389,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, ] [[package]] @@ -458,15 +458,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/2b/58/d34e67a79631177e3 [[package]] name = "python-discovery" -version = "1.4.4" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/276f964496a5714ab9f320896195639086881c2b39c03b5ad13de84acbb8/python_discovery-1.5.0.tar.gz", hash = "sha256:3e014c6327154d3dda27939a9a0dc9c5c000439f1906d3f303b48f984bd2ecef", size = 72483, upload-time = "2026-07-21T13:14:14.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7b/14882602ddee241d7984a742fcb423cb4a30fb0d6efc546ac3129fba475a/python_discovery-1.5.0-py3-none-any.whl", hash = "sha256:70c4fc61b4e7404e44f01d6fc44a715c4d685ca6cea83d295922f05891877c98", size = 34205, upload-time = "2026-07-21T13:14:13.398Z" }, ] [[package]] @@ -525,27 +525,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] @@ -572,7 +572,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.6.1" +version = "21.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -580,9 +580,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/25/e367a7229b0914772ca8d81b41fde012d9feda68523b52644a571bb21ce8/virtualenv-21.7.0.tar.gz", hash = "sha256:7f9519b9432ff11b6e1a3e94061664efc2ff99ea21780e3cf4f6bd0a5da8b37c", size = 5527510, upload-time = "2026-07-21T13:12:14.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7a/ae29312b1e88a22e81f5d21fc11526d2a114089776c2550d2b205b6c2a47/virtualenv-21.7.0-py3-none-any.whl", hash = "sha256:a8370c1c5530fbabf955e40b8fbbc68a431648b10f9433faa587db30a06e51dd", size = 5507078, upload-time = "2026-07-21T13:12:12.136Z" }, ] [[package]] -- 2.43.0 From 1b4c319fdbc3930dda68e8b4747fd4701c99f9c5 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:01:18 +0545 Subject: [PATCH 13/28] style: format app-capture README code examples Add blank lines between function definitions per PEP 8. --- src/window/switcher/app-capture/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/window/switcher/app-capture/README.md b/src/window/switcher/app-capture/README.md index d146ffc..57921a9 100644 --- a/src/window/switcher/app-capture/README.md +++ b/src/window/switcher/app-capture/README.md @@ -38,9 +38,11 @@ Once compiled, you can easily load the library into any Python script using PyGO ```python import gi + gi.require_version("AppCapture", "1.0") from gi.repository import AppCapture + def on_frame_ready(capture, address, data, width, height, stride): # 'data' is a GLib.Bytes object containing the raw ARGB32 pixel data # Convert it to a Cairo Surface or GdkPixbuf @@ -49,9 +51,11 @@ def on_frame_ready(capture, address, data, width, height, stride): # Example: Request the next frame, but wait for damage (0% CPU idle) capture.capture_by_handle(address, 300, 168, True) + def on_frame_failed(capture, address, reason): print(f"Failed to capture {address}: {reason}") + # 1. Initialize the capture module capture = AppCapture.Capture() capture.connect("frame-ready", on_frame_ready) -- 2.43.0 From 98640d82c28d773f2efac9183631658350ce4d2c Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:08:02 +0545 Subject: [PATCH 14/28] feat: update position of desktop widgets --- src/window/desktop/calendar.py | 2 +- src/window/desktop/date.py | 4 +++- src/window/desktop/system.py | 4 ++-- src/window/desktop/weather.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/window/desktop/calendar.py b/src/window/desktop/calendar.py index 7ffc097..f968a18 100644 --- a/src/window/desktop/calendar.py +++ b/src/window/desktop/calendar.py @@ -144,5 +144,5 @@ class DesktopCalendarContainer(Box): DesktopWidgetRegistry.register( - "calendar", DesktopCalendarContainer, (174, 174), (0.19115, 0.00095) + "calendar", DesktopCalendarContainer, (174, 174), (0.18958, 0.00474) ) diff --git a/src/window/desktop/date.py b/src/window/desktop/date.py index 631a859..26d021a 100644 --- a/src/window/desktop/date.py +++ b/src/window/desktop/date.py @@ -49,4 +49,6 @@ class DesktopDateContainer(Box): ) -DesktopWidgetRegistry.register("date", DesktopDateContainer, (174, 174), (0.0, 0.0)) +DesktopWidgetRegistry.register( + "date", DesktopDateContainer, (174, 174), (0.00417, 0.00474) +) diff --git a/src/window/desktop/system.py b/src/window/desktop/system.py index 5b13ae9..3779ae8 100644 --- a/src/window/desktop/system.py +++ b/src/window/desktop/system.py @@ -169,8 +169,8 @@ class DesktopCpuInfoWidget(SystemInfoBase): DesktopWidgetRegistry.register( - "ram_info", DesktopRamInfoWidget, (174, 174), (0.90521, 0.82732) + "ram_info", DesktopRamInfoWidget, (174, 174), (0.80729, 0.82638) ) DesktopWidgetRegistry.register( - "cpu_info", DesktopCpuInfoWidget, (174, 174), (0.81042, 0.82732) + "cpu_info", DesktopCpuInfoWidget, (174, 174), (0.90573, 0.82638) ) diff --git a/src/window/desktop/weather.py b/src/window/desktop/weather.py index 917766c..58900ac 100644 --- a/src/window/desktop/weather.py +++ b/src/window/desktop/weather.py @@ -130,5 +130,5 @@ class DesktopWeatherWidget(Box): DesktopWidgetRegistry.register( - "weather", DesktopWeatherWidget, (174, 174), (0.0974, 0.0) + "weather", DesktopWeatherWidget, (174, 174), (0.09688, 0.00474) ) -- 2.43.0 From 34f9f71d62f2de5105a024f41a2c668fb592e976 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:23:04 +0545 Subject: [PATCH 15/28] feat: update date widget --- src/window/desktop/date.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/window/desktop/date.py b/src/window/desktop/date.py index 26d021a..e49afd8 100644 --- a/src/window/desktop/date.py +++ b/src/window/desktop/date.py @@ -4,6 +4,28 @@ from fabric.widgets.datetime import DateTime from window.desktop.registry import DesktopWidgetRegistry +class _DraggableDateTime(DateTime): + """DateTime subclass that lets button-press events propagate to the parent + EventBox so GTK drag-and-drop works across the entire widget area. + + GtkButton's class handler (do_button_press_event) returns True at + G_SIGNAL_RUN_FIRST, which stops the accumulator before DateTime's own + connected handler ever runs. Both must return False for the event to + reach the EventBox where drag_source_set lives. + """ + + def do_button_press_event(self, event): + match event.button: + case 1: + self.do_cycle_next() + case 3: + self.do_cycle_prev() + return False + + def do_handle_press(self, _widget, _event, *_args): + return False + + class DesktopDateWidget(Box): def __init__(self, **kwargs): super().__init__( @@ -24,9 +46,13 @@ class DesktopDateWidget(Box): # Since this is just a date widget, we increase the interval to 100000ms (100 seconds) # which easily avoids the continuous redraw glitch while keeping the date accurate. date_interval = 100000 - self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") - self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") - self.datethree = DateTime( + self.dateone = _DraggableDateTime( + formatters=["%a"], interval=date_interval, name="day" + ) + self.datetwo = _DraggableDateTime( + formatters=["%b"], interval=date_interval, name="month" + ) + self.datethree = _DraggableDateTime( formatters=["%-d"], interval=date_interval, name="date" ) self.top.add(self.dateone) -- 2.43.0 From 347e15af35eb1800e05ad0b56520484160e862fd Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:25:35 +0545 Subject: [PATCH 16/28] feat: update desktop widgets position --- src/window/desktop/calendar.py | 2 +- src/window/desktop/date.py | 2 +- src/window/desktop/system.py | 2 +- src/window/desktop/weather.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/window/desktop/calendar.py b/src/window/desktop/calendar.py index f968a18..6fdfe64 100644 --- a/src/window/desktop/calendar.py +++ b/src/window/desktop/calendar.py @@ -144,5 +144,5 @@ class DesktopCalendarContainer(Box): DesktopWidgetRegistry.register( - "calendar", DesktopCalendarContainer, (174, 174), (0.18958, 0.00474) + "calendar", DesktopCalendarContainer, (174, 174), (0.19375, 0.00949) ) diff --git a/src/window/desktop/date.py b/src/window/desktop/date.py index e49afd8..7eca730 100644 --- a/src/window/desktop/date.py +++ b/src/window/desktop/date.py @@ -76,5 +76,5 @@ class DesktopDateContainer(Box): DesktopWidgetRegistry.register( - "date", DesktopDateContainer, (174, 174), (0.00417, 0.00474) + "date", DesktopDateContainer, (174, 174), (0.00573, 0.00949) ) diff --git a/src/window/desktop/system.py b/src/window/desktop/system.py index 3779ae8..44ec6a7 100644 --- a/src/window/desktop/system.py +++ b/src/window/desktop/system.py @@ -169,7 +169,7 @@ class DesktopCpuInfoWidget(SystemInfoBase): DesktopWidgetRegistry.register( - "ram_info", DesktopRamInfoWidget, (174, 174), (0.80729, 0.82638) + "ram_info", DesktopRamInfoWidget, (174, 174), (0.80729, 0.82827) ) DesktopWidgetRegistry.register( "cpu_info", DesktopCpuInfoWidget, (174, 174), (0.90573, 0.82638) diff --git a/src/window/desktop/weather.py b/src/window/desktop/weather.py index 58900ac..8cb1eb7 100644 --- a/src/window/desktop/weather.py +++ b/src/window/desktop/weather.py @@ -130,5 +130,5 @@ class DesktopWeatherWidget(Box): DesktopWidgetRegistry.register( - "weather", DesktopWeatherWidget, (174, 174), (0.09688, 0.00474) + "weather", DesktopWeatherWidget, (174, 174), (0.10052, 0.01044) ) -- 2.43.0 From 0a7cd2cf6d8b559f96720379386c25a0f5229766 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 10:38:26 +0545 Subject: [PATCH 17/28] refactor(desktop): improve registry API and deduplicate fade animators - Replace three synced dicts with single WidgetInfo dataclass - Add type hints, unregister(), is_registered(), keys(), widgets() - Add DEFAULT_SIZE constant, duplicate-registration warning - Extract _make_fade_animator() to remove duplicated animator setup - Use public registry API in main.py instead of accessing _widgets --- src/window/desktop/main.py | 70 +++++++++++++++------------------ src/window/desktop/registry.py | 72 +++++++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 58 deletions(-) diff --git a/src/window/desktop/main.py b/src/window/desktop/main.py index cabd850..95ad36e 100644 --- a/src/window/desktop/main.py +++ b/src/window/desktop/main.py @@ -2,6 +2,7 @@ import importlib import os import pkgutil import sys +from functools import partial import cairo import tomlkit @@ -45,7 +46,7 @@ def _load_user_widgets() -> set[str]: module_name = fname[:-3] full_path = os.path.join(_CONFIG_DESKTOP_DIR, fname) try: - before_keys = set(DesktopWidgetRegistry._widgets) + before_keys = set(DesktopWidgetRegistry.keys()) spec = importlib.util.spec_from_file_location( f"user_widget_{module_name}", full_path ) @@ -53,7 +54,7 @@ def _load_user_widgets() -> set[str]: sys.modules[spec.name] = mod spec.loader.exec_module(mod) _user_widget_modules[module_name] = spec.name - new_keys = set(DesktopWidgetRegistry._widgets) - before_keys + new_keys = set(DesktopWidgetRegistry.keys()) - before_keys for k in new_keys: _user_key_to_file[k] = module_name DesktopWidgetRegistry.register_user(k) @@ -100,13 +101,13 @@ class PositionManager: mid_str = str(monitor_id) if mid_str not in state or not state[mid_str]: state[mid_str] = [] - for key in DesktopWidgetRegistry._widgets: + for key in DesktopWidgetRegistry.widgets(): pos = DesktopWidgetRegistry.get_default_position(key) px, py = pos if pos else (0.0, 0.0) state[mid_str].append({"key": key, "px": px, "py": py}) cls._write(state) else: - registered = set(DesktopWidgetRegistry._widgets) + registered = set(DesktopWidgetRegistry.keys()) before = len(state[mid_str]) state[mid_str] = [e for e in state[mid_str] if e["key"] in registered] for key in registered - {e["key"] for e in state[mid_str]}: @@ -173,25 +174,8 @@ class DesktopWidgetWindow(WaylandWindow): self._win_w = max(1, geom.width) self._win_h = max(1, geom.height) - self._fade_animator = Animator( - bezier_curve=(0.4, 0.0, 0.2, 1.0), - duration=0.2, - min_value=0.0, - max_value=1.0, - tick_widget=self._root, - ) - self._fade_animator.connect("notify::value", self._on_fade_value) - self._fade_animator.connect("finished", self._on_fade_finished) - - self._fade_out_animator = Animator( - bezier_curve=(0.4, 0.0, 0.2, 1.0), - duration=0.2, - min_value=0.0, - max_value=1.0, - tick_widget=self._root, - ) - self._fade_out_animator.connect("notify::value", self._on_fade_out_value) - self._fade_out_animator.connect("finished", self._on_fade_out_finished) + self._fade_in_animator = self._make_fade_animator(1.0) + self._fade_out_animator = self._make_fade_animator(0.0) self._fixed.set_opacity(0.0) super().__init__( @@ -229,34 +213,42 @@ class DesktopWidgetWindow(WaylandWindow): # fade + def _make_fade_animator(self, target_opacity: float) -> Animator: + animator = Animator( + bezier_curve=(0.4, 0.0, 0.2, 1.0), + duration=0.2, + min_value=0.0, + max_value=1.0, + tick_widget=self._root, + ) + animator.connect("notify::value", self._on_fade_value) + animator.connect("finished", partial(self._on_fade_finished, target_opacity)) + return animator + def _on_fade_value(self, animator, _) -> None: self._fixed.set_opacity(animator.value) - def _on_fade_finished(self, animator) -> None: - self._fixed.set_opacity(1.0) + def _on_fade_finished(self, target_opacity: float, _animator) -> None: + self._fixed.set_opacity(target_opacity) def _fade_in(self) -> None: - if self._fade_animator.playing: + if self._fade_in_animator.playing: return self._fade_out_animator.pause() - self._fade_animator.value = self._fixed.get_opacity() - self._fade_animator.min_value = self._fixed.get_opacity() - self._fade_animator.max_value = 1.0 - self._fade_animator.play() + opacity = self._fixed.get_opacity() + self._fade_in_animator.value = opacity + self._fade_in_animator.min_value = opacity + self._fade_in_animator.max_value = 1.0 + self._fade_in_animator.play() def _fade_out(self) -> None: - self._fade_animator.pause() + self._fade_in_animator.pause() + opacity = self._fixed.get_opacity() self._fade_out_animator.min_value = 0.0 - self._fade_out_animator.max_value = self._fixed.get_opacity() - self._fade_out_animator.value = self._fixed.get_opacity() + self._fade_out_animator.max_value = opacity + self._fade_out_animator.value = opacity self._fade_out_animator.play() - def _on_fade_out_value(self, animator, _) -> None: - self._fixed.set_opacity(animator.value) - - def _on_fade_out_finished(self, animator) -> None: - self._fixed.set_opacity(0.0) - # fallback poll — catches deletions that Gio file monitor misses def _poll_user_widgets(self) -> bool: diff --git a/src/window/desktop/registry.py b/src/window/desktop/registry.py index 25bf9ef..a8f6c17 100644 --- a/src/window/desktop/registry.py +++ b/src/window/desktop/registry.py @@ -1,42 +1,76 @@ +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any + +from fabric.utils import logger + + +@dataclass(slots=True, frozen=True) +class WidgetInfo: + widget_cls: type[Any] + size: tuple[int, int] + default_position: tuple[float, float] | None = None + + class DesktopWidgetRegistry: - _widgets = {} - _sizes = {} - _default_positions = {} + DEFAULT_SIZE: tuple[int, int] = (174, 174) + + _registry: dict[str, WidgetInfo] = {} _user_keys: set[str] = set() @classmethod def register( cls, key: str, - widget_cls, + widget_cls: type[Any], size: tuple[int, int], default_position: tuple[float, float] | None = None, - ): - cls._widgets[key] = widget_cls - cls._sizes[key] = size - if default_position is not None: - cls._default_positions[key] = default_position + ) -> None: + if key in cls._registry: + logger.warning("Replacing widget '%s'", key) + cls._registry[key] = WidgetInfo(widget_cls, size, default_position) @classmethod def register_user(cls, key: str) -> None: cls._user_keys.add(key) @classmethod - def unregister_user_keys(cls, keys: set[str]) -> None: - for key in keys: - cls._widgets.pop(key, None) - cls._sizes.pop(key, None) - cls._default_positions.pop(key, None) - cls._user_keys.discard(key) + def unregister(cls, key: str) -> None: + cls._registry.pop(key, None) + cls._user_keys.discard(key) @classmethod - def get_widget_class(cls, key: str): - return cls._widgets.get(key) + def unregister_user_keys(cls, keys: set[str]) -> None: + for key in keys: + cls.unregister(key) + + @classmethod + def clear_user_widgets(cls) -> None: + cls.unregister_user_keys(set(cls._user_keys)) + + @classmethod + def get_widget_class(cls, key: str) -> type[Any] | None: + info = cls._registry.get(key) + return info.widget_cls if info else None @classmethod def get_widget_size(cls, key: str) -> tuple[int, int]: - return cls._sizes.get(key, (174, 174)) + info = cls._registry.get(key) + return info.size if info else cls.DEFAULT_SIZE @classmethod def get_default_position(cls, key: str) -> tuple[float, float] | None: - return cls._default_positions.get(key) + info = cls._registry.get(key) + return info.default_position if info else None + + @classmethod + def is_registered(cls, key: str) -> bool: + return key in cls._registry + + @classmethod + def keys(cls) -> tuple[str, ...]: + return tuple(cls._registry) + + @classmethod + def widgets(cls) -> MappingProxyType[str, WidgetInfo]: + return MappingProxyType(cls._registry) -- 2.43.0 From 260e53aa4794d3941da818791c9223e2e52e8a92 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:02:11 +0545 Subject: [PATCH 18/28] feat(wifi): add DNS service with nmcli backend --- src/services/dns.py | 176 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/services/dns.py diff --git a/src/services/dns.py b/src/services/dns.py new file mode 100644 index 0000000..e7cd459 --- /dev/null +++ b/src/services/dns.py @@ -0,0 +1,176 @@ +import subprocess +import threading + +from fabric.utils import GLib, logger + +DEFAULT_PROVIDERS = [ + {"label": "Cloudflare", "primary": "1.1.1.1", "secondary": "1.0.0.1"}, + {"label": "Google", "primary": "8.8.8.8", "secondary": "8.8.4.4"}, + {"label": "OpenDNS", "primary": "208.67.222.222", "secondary": "208.67.220.220"}, + {"label": "AdGuard", "primary": "94.140.14.14", "secondary": "94.140.15.15"}, + {"label": "Quad9", "primary": "9.9.9.9", "secondary": "149.112.112.112"}, +] + + +def _nmcli(*args, timeout=15): + """Run nmcli and return (returncode, stdout, stderr).""" + try: + r = subprocess.run( + ["nmcli", *args], + capture_output=True, + text=True, + timeout=timeout, + ) + return r.returncode, r.stdout.strip(), r.stderr.strip() + except FileNotFoundError: + logger.warning("[Dns] nmcli not found") + return 127, "", "nmcli not found" + except subprocess.TimeoutExpired: + logger.warning("[Dns] nmcli timed out") + return -1, "", "timeout" + except Exception as e: + logger.error(f"[Dns] nmcli failed: {e}") + return 1, "", str(e) + + +def _active_connection_name(): + """Return the NM connection name (id) of the first active connection.""" + rc, out, _ = _nmcli("-t", "-f", "NAME,DEVICE", "connection", "show", "--active") + if rc != 0 or not out: + return None + for line in out.splitlines(): + parts = line.split(":") + if len(parts) >= 2: + return parts[0] + return None + + +def _active_device(): + """Return the interface name of the first active wifi/ethernet device.""" + rc, out, _ = _nmcli("-t", "-f", "DEVICE,TYPE,STATE", "device", "status") + if rc != 0 or not out: + return None + for line in out.splitlines(): + parts = line.split(":") + if ( + len(parts) >= 3 + and parts[1] in ("wifi", "ethernet") + and parts[2] == "connected" + ): + return parts[0] + return None + + +def get_dns(callback=None): + """Get current IPv4 DNS servers for the active connection. + + Returns an empty list when DHCP-provided DNS is in use (auto mode). + Calls ``callback(dns_servers: list[str])`` on the main thread. + """ + + def _work(): + try: + conn_name = _active_connection_name() + if not conn_name: + GLib.idle_add(callback, []) if callback else None + return + + # Check if custom DNS is enabled + rc, out, _ = _nmcli( + "-t", + "-f", + "ipv4.ignore-auto-dns", + "connection", + "show", + conn_name, + ) + if rc == 0 and out.strip() == "no": + GLib.idle_add(callback, []) if callback else None + return + + rc, out, _ = _nmcli("-t", "-f", "ipv4.dns", "connection", "show", conn_name) + if rc != 0 or not out: + GLib.idle_add(callback, []) if callback else None + return + servers = [] + for line in out.splitlines(): + line = line.strip() + if line and line != "--": + servers.append(line) + GLib.idle_add(callback, servers) if callback else None + except Exception as e: + logger.error(f"[Dns] Failed to get DNS: {e}") + if callback: + GLib.idle_add(callback, []) + + threading.Thread(target=_work, daemon=True).start() + + +def set_dns(dns_servers, callback=None): + """Set IPv4 DNS servers on the active connection and apply. + + Pass an empty list to clear custom DNS (revert to DHCP). + Manual DNS uses ``device reapply`` for instant effect. + Auto mode uses ``connection up`` because DHCP renew is required. + Calls ``callback(success: bool)`` on the main thread. + """ + + def _work(): + try: + conn_name = _active_connection_name() + if not conn_name: + logger.warning("[Dns] No active connection") + if callback: + GLib.idle_add(callback, False) + return + + if dns_servers: + for server in dns_servers: + rc, _, err = _nmcli( + "connection", "modify", conn_name, "ipv4.dns", server + ) + if rc != 0: + logger.warning(f"[Dns] Failed to set dns {server}: {err}") + _nmcli( + "connection", + "modify", + conn_name, + "ipv4.ignore-auto-dns", + "yes", + ) + # Fast apply for manual DNS + device = _active_device() + if device: + rc, _, err = _nmcli("device", "reapply", device, timeout=10) + if rc != 0: + logger.warning( + f"[Dns] reapply failed ({device}): {err}, " + "falling back to connection up" + ) + rc, _, err = _nmcli("connection", "up", conn_name, timeout=20) + else: + rc, _, err = _nmcli("connection", "up", conn_name, timeout=20) + else: + # Revert to DHCP — must reconnect to renew DHCP DNS + _nmcli( + "connection", + "modify", + conn_name, + "ipv4.ignore-auto-dns", + "no", + ) + rc, _, err = _nmcli("connection", "up", conn_name, timeout=20) + + ok = rc == 0 + if ok: + logger.info(f"[Dns] DNS set to {dns_servers or 'auto'}") + else: + logger.warning(f"[Dns] apply failed: {err}") + if callback: + GLib.idle_add(callback, ok) + except Exception as e: + logger.error(f"[Dns] Failed to set DNS: {e}") + if callback: + GLib.idle_add(callback, False) + + threading.Thread(target=_work, daemon=True).start() -- 2.43.0 From a6a34f6e69faa4369b0bdbc73caf753d8976a740 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:02:17 +0545 Subject: [PATCH 19/28] feat(wifi): add DNS icon --- src/assets/icons/misc/dns.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/assets/icons/misc/dns.svg diff --git a/src/assets/icons/misc/dns.svg b/src/assets/icons/misc/dns.svg new file mode 100644 index 0000000..bd34bdc --- /dev/null +++ b/src/assets/icons/misc/dns.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file -- 2.43.0 From f887a5c932090dcdfa6e8979ea483550a9732f22 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:02:22 +0545 Subject: [PATCH 20/28] feat(wifi): export DNS service from services package --- src/services/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/__init__.py b/src/services/__init__.py index 13cbb30..a06b2ca 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -4,6 +4,7 @@ from .battery import Battery # noqa: F401 from .brightness import Brightness # noqa: F401 from .capslock import CapsLock # noqa: F401 from .config import ConfigService, on_config_change, start_config_service # noqa: F401 +from .dns import get_dns, set_dns # noqa: F401 from .keyboard_layout import KeyboardLayout # noqa: F401 from .modus import ModusService # noqa: F401 from .mpris import PlayerManager, PlayerService # noqa: F401 -- 2.43.0 From b76ab78415ee49c98eaaa5c5d6f54db8c32746a2 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:02:31 +0545 Subject: [PATCH 21/28] feat(wifi): add DNS switcher styles --- src/styles/controlpanel/networks.css | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/styles/controlpanel/networks.css b/src/styles/controlpanel/networks.css index cd0f0ad..d57c2f6 100644 --- a/src/styles/controlpanel/networks.css +++ b/src/styles/controlpanel/networks.css @@ -79,3 +79,38 @@ #wifi-other-button:last-child { margin-bottom: 3px; } + +/* DNS Switcher */ +#dns-content { + padding: 2px 0; +} + +#dns-current-label { + font-size: apply(font-sm); + color: @glass-text-secondary; +} + +#dns-provider-button { + background-color: transparent; + border: none; + padding: 6px 8px; + border-radius: apply(radius-sm); +} + +#dns-provider-button:hover { + background-color: @glass-hover; +} + +#dns-provider-name { + font-size: apply(font-base); + color: @glass-text; +} + +#dns-provider-address { + font-size: apply(font-sm); + color: @glass-text-secondary; +} + +#dns-check-icon { + color: @glass-active; +} -- 2.43.0 From 1d66b57ad742d532b62ca9c38e393f7a75cfb9dd Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:03:04 +0545 Subject: [PATCH 22/28] feat(wifi): add DNS switcher to WiFi control center panel --- src/window/controlcenter/wifi.py | 177 +++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py index 8e835d5..2435f8b 100644 --- a/src/window/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -6,6 +6,7 @@ from fabric.widgets.image import Image from fabric.widgets.label import Label 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.smooth_switch import SmoothSwitch @@ -17,6 +18,14 @@ from utils.functions import ( ) from utils.gtk_utils import svg_file +DEFAULT_DNS_PROVIDERS = [ + {"label": "Cloudflare", "primary": "1.1.1.1", "secondary": "1.0.0.1"}, + {"label": "Google", "primary": "8.8.8.8", "secondary": "8.8.4.4"}, + {"label": "OpenDNS", "primary": "208.67.222.222", "secondary": "208.67.220.220"}, + {"label": "AdGuard", "primary": "94.140.14.14", "secondary": "94.140.15.15"}, + {"label": "Quad9", "primary": "9.9.9.9", "secondary": "149.112.112.112"}, +] + class WifiNetworkSlot(Box): def __init__( @@ -239,6 +248,160 @@ class WifiNetworkSlot(Box): return False # Don't repeat if called from GLib.timeout_add +class DnsSwitcher: + """DNS provider switcher with predefined and manual options. + + Builds two widgets: ``button`` (the header row) and ``content_box`` + (the expandable panel). Both should be added as separate children + of the parent container, matching how Other Networks is structured. + """ + + def __init__(self): + self._current_dns = [] + self._provider_buttons = {} + self._is_expanded = False + + self._dns_label = Label( + label="Automatic", + name="dns-current-label", + h_align="end", + ) + + self.button = Button( + child=CenterBox( + start_children=Label("DNS", h_align="start"), + end_children=self._dns_label, + ), + name="wifi-other-button", + on_clicked=self._toggle_expanded, + ) + + self.content_box = Box( + orientation="vertical", + spacing=4, + name="dns-content", + ) + self.content_box.set_visible(False) + + auto_row = self._make_provider_row("Automatic (DHCP)", "", is_auto=True) + self.content_box.pack_start(auto_row, False, False, 0) + + for provider in DEFAULT_DNS_PROVIDERS: + row = self._make_provider_row( + provider["label"], + f"{provider['primary']}, {provider['secondary']}", + is_auto=False, + primary=provider["primary"], + secondary=provider["secondary"], + ) + self.content_box.pack_start(row, False, False, 0) + + self._load_current_dns() + + def _make_provider_row( + self, + label, + subtitle, + is_auto=False, + primary=None, + secondary=None, + ): + check_icon = Image( + icon_name="object-select-symbolic", + size=14, + name="dns-check-icon", + ) + check_icon.set_no_show_all(True) + check_icon.hide() + + title_label = Label( + label=label, h_align="start", h_expand=True, name="dns-provider-name" + ) + + children = [title_label] + if subtitle: + children.append( + Label( + label=subtitle, + h_align="end", + name="dns-provider-address", + ) + ) + children.append(check_icon) + + row_button = Button( + child=Box(orientation="h", spacing=8, children=children), + name="dns-provider-button", + ) + + if is_auto: + row_button.connect("clicked", lambda *_: self._on_auto_selected()) + else: + row_button.connect( + "clicked", + lambda *_: self._on_provider_selected(primary, secondary), + ) + + row_button._dns_check = check_icon + row_button._dns_primary = primary + self._provider_buttons[primary or "auto"] = row_button + return row_button + + def _toggle_expanded(self, *_): + self._is_expanded = not self._is_expanded + self.content_box.set_visible(self._is_expanded) + + def _load_current_dns(self): + dns_service.get_dns(callback=self._on_dns_loaded) + + def _on_dns_loaded(self, dns_servers): + self._current_dns = dns_servers or [] + self._update_checkmarks() + self._update_dns_label() + + def _update_checkmarks(self): + key = self._current_dns[0] if self._current_dns else "auto" + for btn_key, btn in self._provider_buttons.items(): + check = getattr(btn, "_dns_check", None) + if check: + if btn_key == key: + check.show() + else: + check.hide() + + def _update_dns_label(self): + if not self._current_dns: + self._dns_label.set_label("Automatic") + elif len(self._current_dns) == 1: + self._dns_label.set_label(self._current_dns[0]) + else: + self._dns_label.set_label( + f"{self._current_dns[0]} +{len(self._current_dns) - 1}" + ) + + def _on_auto_selected(self): + self._current_dns = [] + self._update_checkmarks() + self._update_dns_label() + dns_service.set_dns([], callback=self._on_dns_applied) + + def _on_provider_selected(self, primary, secondary): + dns = [primary] + if secondary: + dns.append(secondary) + self._current_dns = dns + self._update_checkmarks() + self._update_dns_label() + dns_service.set_dns(dns, callback=self._on_dns_applied) + + def _on_dns_applied(self, success): + if not success: + self._load_current_dns() + + def refresh_dns(self): + self._load_current_dns() + + class WifiConnections(Box): def __init__(self, parent, show_back_button=True, network_service=None, **kwargs): super().__init__( @@ -352,6 +515,9 @@ class WifiConnections(Box): on_clicked=self.open_network_settings, ) + # DNS Switcher + self.dns_switcher = DnsSwitcher() + self.children = [ CenterBox( start_children=self.title, @@ -366,6 +532,9 @@ class WifiConnections(Box): self.other_networks_button, self.other_networks_scrolled, Separator(orientation="h", name="separator"), + self.dns_switcher.button, + self.dns_switcher.content_box, + Separator(orientation="h", name="separator"), self.more_settings_button, ] @@ -378,6 +547,8 @@ class WifiConnections(Box): if self.wifi_service: self.wifi_service.scan() self.force_network_refresh() + if self.dns_switcher: + self.dns_switcher.refresh_dns() def on_hide(self, *_): """Called when the widget is hidden (popup closed)""" @@ -385,6 +556,9 @@ class WifiConnections(Box): if self.other_networks.get_visible(): self.other_networks.set_visible(False) self.other_networks_scrolled.snap_to_size(0) + if self.dns_switcher and self.dns_switcher._is_expanded: + self.dns_switcher._is_expanded = False + self.dns_switcher.content_box.set_visible(False) def toggle_other_networks(self, *_): """Toggle the visibility of other networks section""" @@ -729,3 +903,6 @@ class WifiConnections(Box): if self.other_networks.get_visible(): self.other_networks.set_visible(False) self.other_networks_scrolled.snap_to_size(0) + if self.dns_switcher and self.dns_switcher._is_expanded: + self.dns_switcher._is_expanded = False + self.dns_switcher.content_box.set_visible(False) -- 2.43.0 From f4689b8076a1d7f4257896bc69e58743563e7ef9 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:34:23 +0545 Subject: [PATCH 23/28] refactor(config): reorganize config.toml into sections - Move all settings into [general], [dock], [panel], [switcher], [notification] - Remove [desktop_widgets] section, move items to panel/general - Add dot notation support to config service for nested keys - Update all config references across codebase --- src/main.py | 2 +- src/services/config.py | 38 +++++++++- src/services/custom_notification.py | 14 ++-- src/services/keyboard_layout.py | 5 +- src/services/nightlight.py | 10 ++- src/services/weather.py | 6 +- src/shared/data.py | 2 +- src/utils/constants.py | 74 +++++++++++-------- src/window/dock/canvas.py | 2 +- src/window/dock/main.py | 20 ++--- src/window/notification/notification.py | 27 ++++--- src/window/osd/main.py | 8 +- .../panel/components/enhanced_system_tray.py | 2 +- src/window/panel/components/workspace.py | 22 ++---- src/window/panel/main.py | 40 ++++++---- src/window/settings/main.py | 62 ++++++++-------- src/window/switcher/main.py | 18 ++--- 17 files changed, 204 insertions(+), 148 deletions(-) diff --git a/src/main.py b/src/main.py index c5fab38..d4d5e02 100644 --- a/src/main.py +++ b/src/main.py @@ -45,7 +45,7 @@ def setup_logger(): ]: logger.disable(log) - _debug = config().get("debug", False) + _debug = config().get("general.debug", False) if not _debug: logger.disable("modus_plugin_builtin_emoji") else: diff --git a/src/services/config.py b/src/services/config.py index 8562d3b..1fdbbd7 100644 --- a/src/services/config.py +++ b/src/services/config.py @@ -47,17 +47,47 @@ class ConfigService: self._initialized = True def get(self, key: str, default: Any = None) -> Any: - return self._config.get(key, default) + """Get a config value. Supports dot notation for nested keys (e.g. 'dock.enabled').""" + parts = key.split(".") + current = self._config + for part in parts: + if isinstance(current, dict): + current = current.get(part) + if current is None: + return default + else: + return default + return current def get_all(self) -> Dict[str, Any]: return self._config.copy() def has_changed(self, key: str, old_config: Dict[str, Any]) -> bool: - return self._config.get(key) != old_config.get(key) + """Check if a config value changed. Supports dot notation for nested keys.""" + + def _get_nested(d, k): + parts = k.split(".") + current = d + for part in parts: + if isinstance(current, dict): + current = current.get(part) + if current is None: + return None + else: + return None + return current + + return _get_nested(self._config, key) != _get_nested(old_config, key) def set(self, key: str, value: Any) -> None: - """Set a config value (local state only).""" - self._config[key] = value + """Set a config value (local state only). Supports dot notation for nested keys.""" + parts = key.split(".") + current = self._config + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + current[parts[-1]] = value def save(self) -> None: """Persist current in-memory config to disk.""" diff --git a/src/services/custom_notification.py b/src/services/custom_notification.py index 8a620ab..62c90ac 100644 --- a/src/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -238,9 +238,11 @@ class CachedNotifications(Notifications): on_config_change(self._on_config_changed) def _on_config_changed(self, new_config, old_config): + new_notif = new_config.get("notification", {}) + # If ignored apps changed, remove them from history - if config().has_changed("notification_ignored_apps", old_config): - ignored_apps = new_config.get("notification_ignored_apps", []) + if config().has_changed("notification.ignored_apps", old_config): + ignored_apps = new_notif.get("ignored_apps", []) ids_to_remove = [ cid for cid, cnotif in self._cached_notifications.items() @@ -254,8 +256,8 @@ class CachedNotifications(Notifications): ) # If limited apps changed, prune duplicates - if config().has_changed("notification_limited_apps_history", old_config): - limited_apps = new_config.get("notification_limited_apps_history", []) + if config().has_changed("notification.limited_apps_history", old_config): + limited_apps = new_notif.get("limited_apps_history", []) for app in limited_apps: app_notifs = [ (cid, cnotif.timestamp) @@ -355,7 +357,7 @@ class CachedNotifications(Notifications): ) # Check if this app should be ignored for history (don't cache) - ignored_apps = config().get("notification_ignored_apps", []) + ignored_apps = config().get("notification.ignored_apps", []) if notification.app_name in ignored_apps: # Don't cache notifications from ignored apps, but still allow popup display logger.debug( @@ -395,7 +397,7 @@ class CachedNotifications(Notifications): ) # Handle limited history apps - remove previous notifications from same app - limited_apps = config().get("notification_limited_apps_history", []) + limited_apps = config().get("notification.limited_apps_history", []) if notification.app_name in limited_apps: ids_to_remove = [ cid diff --git a/src/services/keyboard_layout.py b/src/services/keyboard_layout.py index b01bd03..3224f15 100644 --- a/src/services/keyboard_layout.py +++ b/src/services/keyboard_layout.py @@ -94,7 +94,7 @@ class KeyboardLayout(Service): self._set_current(0, emit=False) def _load_layouts_from_config(self) -> list[str]: - layouts = get_config("keyboard_layouts", DEFAULT_LAYOUTS) + layouts = get_config("general.keyboard_layouts", DEFAULT_LAYOUTS) if not isinstance(layouts, list) or not layouts: return list(DEFAULT_LAYOUTS) return [str(layout).strip() for layout in layouts if str(layout).strip()] @@ -199,7 +199,8 @@ class KeyboardLayout(Service): # config changes def _on_config_change(self, new_config, old_config): - new_layouts = new_config.get("keyboard_layouts") + new_general = new_config.get("general", {}) + new_layouts = new_general.get("keyboard_layouts") if not isinstance(new_layouts, list) or not new_layouts: return new_layouts = [str(item).strip() for item in new_layouts if str(item).strip()] diff --git a/src/services/nightlight.py b/src/services/nightlight.py index d37089b..d30e479 100644 --- a/src/services/nightlight.py +++ b/src/services/nightlight.py @@ -5,7 +5,7 @@ from fabric.utils import Gio, GLib, logger from services.config import get_config, on_config_change -_CONFIG_KEY = "night_light_temperature" +_CONFIG_KEY = "panel.night_light_temperature" _DEFAULT_TEMPERATURE = 4500 _NEUTRAL_TEMPERATURE = 6000 _PROFILE_TEMP_RE = re.compile(r"(\d{3,5})") @@ -106,9 +106,13 @@ class NightLightService(Service): self._sync_state() def _on_config_change(self, new_config, old_config): - if new_config.get(_CONFIG_KEY) == old_config.get(_CONFIG_KEY): + new_panel = new_config.get("panel", {}) + old_panel = old_config.get("panel", {}) + new_temp = new_panel.get("night_light_temperature") + old_temp = old_panel.get("night_light_temperature") + if new_temp == old_temp: return - self.set_temperature(_coerce_temperature(new_config.get(_CONFIG_KEY))) + self.set_temperature(_coerce_temperature(new_temp)) def _set_active(self, value: bool): if value != self._active: diff --git a/src/services/weather.py b/src/services/weather.py index 6d049cf..e877056 100644 --- a/src/services/weather.py +++ b/src/services/weather.py @@ -372,7 +372,9 @@ class Weather(Service): self.emit("ready") def _on_config_changed(self, new_config: dict, old_config: dict) -> None: - if new_config.get("weather_location") != old_config.get("weather_location"): + new_general = new_config.get("general", {}) + old_general = old_config.get("general", {}) + if new_general.get("weather_location") != old_general.get("weather_location"): self.refresh() def _start_fetch_thread(self) -> None: @@ -387,7 +389,7 @@ class Weather(Service): from services.config import config - target_loc = config().get("weather_location", "").strip() + target_loc = config().get("general.weather_location", "").strip() # Invalidate location cache if the config target changed if location_data and location_data.get("config_location", "") != target_loc: diff --git a/src/shared/data.py b/src/shared/data.py index 91a1113..5754aba 100644 --- a/src/shared/data.py +++ b/src/shared/data.py @@ -20,7 +20,7 @@ def _get_wallpaper_path() -> str: try: from services.config import get_config - custom = get_config("wallpapers_dir") + custom = get_config("general.wallpapers_dir") if custom: expanded = os.path.expanduser(str(custom)) if not os.path.isabs(expanded): diff --git a/src/utils/constants.py b/src/utils/constants.py index 95d72ad..bfc79b3 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -1,34 +1,44 @@ DEFAULT = { - "debug": False, - "dock_enabled": True, - "dock_auto_hide": True, - "dock_always_occluded": False, - "dock_icon_size": 52, - "hide_special_workspace": True, - "dock_hide_special_workspace_apps": True, - "notification_timeout": "5s", - "notification_ignored_apps": ["Hyprshot"], - "notification_limited_apps_history": ["Spotify"], - "imac_button": True, - "systray": True, - "control_center": True, - "search": True, - "global_menu": True, - "network": True, - "battery": True, - "notification_center": True, - "workspace_indicator": True, - "bluetooth": True, - "date_time": True, - "night_light_temperature": 4500, - "notch": True, - "custom_mods": True, - "keyboard_layouts": ["us"], - "switcher_live_preview": True, - "switcher_live_preview_delay_ms": 200, - "systray_ignore": ["blueman", "network"], - "wallpapers_dir": "src/assets/wallpaper_example/", - "osd": True, - "window_switcher": True, - "weather_location": "", + "general": { + "debug": False, + "wallpapers_dir": "src/assets/wallpaper_example/", + "keyboard_layouts": ["us"], + "weather_location": "", + }, + "dock": { + "enabled": True, + "auto_hide": True, + "always_occluded": False, + "icon_size": 52, + "hide_special_workspace_apps": True, + }, + "panel": { + "imac_button": True, + "systray": True, + "systray_ignore": ["blueman", "network"], + "control_center": True, + "search": True, + "global_menu": True, + "network": True, + "battery": True, + "notification_center": True, + "workspace_indicator": True, + "bluetooth": True, + "date_time": True, + "night_light_temperature": 4500, + "notch": True, + "osd": True, + "hide_special_workspace": True, + "custom_mods": True, + }, + "switcher": { + "live_preview": True, + "live_preview_delay_ms": 200, + "window_switcher": True, + }, + "notification": { + "timeout": "5s", + "ignored_apps": ["Hyprshot"], + "limited_apps_history": ["Spotify"], + }, } diff --git a/src/window/dock/canvas.py b/src/window/dock/canvas.py index 9cfa186..5c3d827 100644 --- a/src/window/dock/canvas.py +++ b/src/window/dock/canvas.py @@ -123,7 +123,7 @@ class DockCanvas(Gtk.DrawingArea): self.set_visual(visual) def _base_icon_size(self) -> int: - return int(config().get("dock_icon_size", 52)) + return int(config().get("dock.icon_size", 52)) def _icon_cache_size(self) -> int: return int(self._base_icon_size() * MAX_SCALE) diff --git a/src/window/dock/main.py b/src/window/dock/main.py index 75960de..4c02cd6 100644 --- a/src/window/dock/main.py +++ b/src/window/dock/main.py @@ -69,7 +69,7 @@ class Dock(Window): on_config_change(self._on_config_change) - if config().get("dock_auto_hide", True): + if config().get("dock.auto_hide", True): self._setup_occlusion_signals() self._check_occlusion_deferred() @@ -104,7 +104,7 @@ class Dock(Window): try: if not config().get("dock_auto_hide", True): return False - is_occ = config().get("dock_always_occluded", False) or check_occlusion( + is_occ = config().get("dock.always_occluded", False) or check_occlusion( ("bottom", self.dock_height) ) if is_occ and not self.is_hovered and self.revealer.get_reveal_child(): @@ -119,24 +119,24 @@ class Dock(Window): return False def _on_config_change(self, new_config, old_config) -> None: - if config().has_changed("dock_enabled", old_config): + if config().has_changed("dock.enabled", old_config): self._update_visibility() - if config().has_changed("dock_icon_size", old_config): + 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): + if config().has_changed("dock.auto_hide", old_config): + if new_config.get("dock.auto_hide", True): self._setup_occlusion_signals() else: self._disconnect_occlusion_signals() self.revealer.set_reveal_child(True) - if config().has_changed("dock_hide_special_workspace_apps", old_config): + 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): + if config().get("dock.enabled", True): self.show() self.revealer.set_reveal_child(True) else: @@ -154,9 +154,9 @@ class Dock(Window): def delayed_hide(t): if t == self.hide_ticket and not self.is_hovered: - if config().get("dock_auto_hide", True): + if config().get("dock.auto_hide", True): is_occ = config().get( - "dock_always_occluded", False + "dock.always_occluded", False ) or check_occlusion(("bottom", self.dock_height)) if is_occ: self.revealer.set_reveal_child(False) diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index 6acc116..f72186c 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -837,7 +837,7 @@ class NotificationWidget(Box): """Get the current notification timeout from config manager.""" try: timeout_str = get_config( - "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + "notification.timeout", data.NOTIFICATION_TIMEOUT_STR ) from shared.data import parse_timeout_string @@ -1472,13 +1472,13 @@ class ModusNoti(Window): def _apply_initial_config(self): try: timeout_val = get_config( - "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + "notification.timeout", data.NOTIFICATION_TIMEOUT_STR ) ignored_apps = get_config( - "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY + "notification.ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY ) limited_apps = get_config( - "notification_limited_apps_history", + "notification.limited_apps_history", data.NOTIFICATION_LIMITED_APPS_HISTORY, ) initial = { @@ -1493,14 +1493,19 @@ class ModusNoti(Window): def _on_config_changed(self, new_config: dict, old_config: dict): try: + new_notif = new_config.get("notification", {}) + old_notif = old_config.get("notification", {}) changes = {} - for key in ( - "notification_timeout", - "notification_ignored_apps", - "notification_limited_apps_history", - ): - if key in new_config and new_config.get(key) != old_config.get(key): - changes[key] = new_config.get(key) + key_map = { + "timeout": "notification_timeout", + "ignored_apps": "notification_ignored_apps", + "limited_apps_history": "notification_limited_apps_history", + } + for new_key, local_key in key_map.items(): + if new_key in new_notif and new_notif.get(new_key) != old_notif.get( + new_key + ): + changes[local_key] = new_notif.get(new_key) if changes: self.update_config(changes) diff --git a/src/window/osd/main.py b/src/window/osd/main.py index 51cbe45..14c291d 100644 --- a/src/window/osd/main.py +++ b/src/window/osd/main.py @@ -40,14 +40,14 @@ class OSD(Box): def show_audio_osd(self): from services.config import get_config - if not get_config("osd", True): + if not get_config("panel.osd", True): return self.show_container(self.containers.get("audio")) def show_brightness_osd(self): from services.config import get_config - if not get_config("osd", True): + if not get_config("panel.osd", True): return self.show_container(self.containers.get("brightness")) @@ -71,13 +71,13 @@ class OSDWindow(Window): def osd_show_audio(self): from services.config import get_config - if not get_config("osd", True): + if not get_config("panel.osd", True): return self.osd.containers["audio"].update() def osd_show_brightness(self): from services.config import get_config - if not get_config("osd", True): + if not get_config("panel.osd", True): return self.osd.containers["brightness"].update() diff --git a/src/window/panel/components/enhanced_system_tray.py b/src/window/panel/components/enhanced_system_tray.py index c5f3a3e..ebf04de 100644 --- a/src/window/panel/components/enhanced_system_tray.py +++ b/src/window/panel/components/enhanced_system_tray.py @@ -12,7 +12,7 @@ _tracked_items = weakref.WeakSet() def should_hide(item) -> bool: """Check if a tray item should be hidden based on config.""" - ignore_list = get_config("systray_ignore", []) + ignore_list = get_config("panel.systray_ignore", []) if not ignore_list: return False diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py index 0ced7da..c41ac40 100644 --- a/src/window/panel/components/workspace.py +++ b/src/window/panel/components/workspace.py @@ -29,25 +29,19 @@ class WorkspaceIndicator(Box): self._apply_initial_config() def _apply_initial_config(self): - new_value = get_config("hide_special_workspace", True) + new_value = get_config("panel.hide_special_workspace", True) if new_value != self._current_config["hide_special_workspace"]: self._current_config["hide_special_workspace"] = new_value self.update_config({"hide_special_workspace": new_value}) def _on_config_changed(self, new_config: dict, old_config: dict): - if "hide_special_workspace" in new_config and new_config.get( - "hide_special_workspace" - ) != old_config.get("hide_special_workspace"): - self._current_config["hide_special_workspace"] = new_config.get( - "hide_special_workspace", True - ) - self.update_config( - { - "hide_special_workspace": self._current_config[ - "hide_special_workspace" - ] - } - ) + new_panel = new_config.get("panel", {}) + old_panel = old_config.get("panel", {}) + new_val = new_panel.get("hide_special_workspace", True) + old_val = old_panel.get("hide_special_workspace", True) + if new_val != old_val: + self._current_config["hide_special_workspace"] = new_val + self.update_config({"hide_special_workspace": new_val}) def _get_button_factory(self): if self._current_config.get("hide_special_workspace", True): diff --git a/src/window/panel/main.py b/src/window/panel/main.py index a428d6d..33e9b88 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -253,10 +253,12 @@ class Panel(Window): if config_data is None: config_data = get_config_all() + panel = config_data.get("panel", {}) + # Update indicator visibility directly - battery_visible = config_data.get("battery", True) - network_visible = config_data.get("network", True) - bluetooth_visible = config_data.get("bluetooth", True) + battery_visible = panel.get("battery", True) + network_visible = panel.get("network", True) + bluetooth_visible = panel.get("bluetooth", True) self.battery_indicator.set_visible(battery_visible) self.network_indicator.set_visible(network_visible) @@ -271,16 +273,16 @@ class Panel(Window): self.bluetooth_indicator.update_state() # Center (notch) - if config_data.get("notch", True): + if panel.get("notch", True): self.center_box.children = [self.notch] else: self.center_box.children = [] # Left left_children = [] - if config_data.get("imac_button", True): + if panel.get("imac_button", True): left_children.append(self.imac) - if config_data.get("global_menu", True): + if panel.get("global_menu", True): left_children.append(self.globalmenu) for child in left_children: @@ -289,23 +291,23 @@ class Panel(Window): # Right right_children = [] - if config_data.get("custom_mods", True): + if panel.get("custom_mods", True): right_children.append(self.custom_mods) - if config_data.get("workspace_indicator", True): + if panel.get("workspace_indicator", True): right_children.append(self.workspace_indicator) - if config_data.get("systray", True): + if panel.get("systray", True): right_children.extend([self.tray_revealer, self.chevron_button]) right_children.append(self.indicators) - if config_data.get("search", True): + if panel.get("search", True): right_children.append(self.search) - if config_data.get("control_center", True): + if panel.get("control_center", True): right_children.append(self.control_center_btn) - if config_data.get("date_time", True): + if panel.get("date_time", True): right_children.append(self.datetime_btn) - if config_data.get("notification_center", True): + if panel.get("notification_center", True): right_children.append(self.notification_center_btn) for child in right_children: @@ -321,11 +323,13 @@ class Panel(Window): def _on_config_changed(self, new_config, old_config): logger.debug("Config changed, checking keys...") - keys = { + new_panel = new_config.get("panel", {}) + old_panel = old_config.get("panel", {}) + + panel_keys = { "imac_button", "global_menu", "notch", - "custom_mods", "workspace_indicator", "systray", "battery", @@ -335,8 +339,12 @@ class Panel(Window): "control_center", "date_time", "notification_center", + "custom_mods", + "hide_special_workspace", } - changed_keys = [k for k in keys if new_config.get(k) != old_config.get(k)] + + changed_keys = [k for k in panel_keys if new_panel.get(k) != old_panel.get(k)] + if changed_keys: logger.debug(f"Rebuilding due to changes in: {changed_keys}") self._rebuild_layout_from_config(new_config) diff --git a/src/window/settings/main.py b/src/window/settings/main.py index 4ff925a..103b84f 100644 --- a/src/window/settings/main.py +++ b/src/window/settings/main.py @@ -335,37 +335,37 @@ class SettingsWindow(Gtk.Window): [ SettingsRow( "Debug Mode", - SettingsSwitch("debug"), + SettingsSwitch("general.debug"), "Enable verbose logging for development", ), SettingsRow( "Weather Location", - SettingsEntry("weather_location"), + SettingsEntry("general.weather_location"), "City name for weather updates", ), SettingsRow( "Keyboard Layouts", - SettingsList("keyboard_layouts"), + SettingsList("general.keyboard_layouts"), "Manage active keyboard input languages", ), SettingsRow( "Hide Special Workspace", - SettingsSwitch("hide_special_workspace"), + SettingsSwitch("panel.hide_special_workspace"), "Don't show special workspace in indicators", ), SettingsRow( "Night Light Temperature", - SettingsEntry("night_light_temperature"), + SettingsEntry("panel.night_light_temperature"), "Color temperature in Kelvin (e.g. 4500)", ), SettingsRow( "Live Preview", - SettingsSwitch("switcher_live_preview"), + SettingsSwitch("switcher.live_preview"), "Show live window previews in switcher", ), SettingsRow( "Preview Delay (ms)", - SettingsEntry("switcher_live_preview_delay_ms"), + SettingsEntry("switcher.live_preview_delay_ms"), "Delay before live preview appears", ), ], @@ -377,27 +377,27 @@ class SettingsWindow(Gtk.Window): [ SettingsRow( "Enabled", - SettingsSwitch("dock_enabled"), + SettingsSwitch("dock.enabled"), "Show the application dock", ), SettingsRow( "Auto Hide", - SettingsSwitch("dock_auto_hide"), + SettingsSwitch("dock.auto_hide"), "Hide dock when not in use", ), SettingsRow( "Always Occluded", - SettingsSwitch("dock_always_occluded"), + SettingsSwitch("dock.always_occluded"), "Keep dock behind other windows", ), SettingsRow( "Icon Size", - SettingsEntry("dock_icon_size"), + SettingsEntry("dock.icon_size"), "Size of dock icons in pixels", ), SettingsRow( "Hide Special Apps", - SettingsSwitch("dock_hide_special_workspace_apps"), + SettingsSwitch("dock.hide_special_workspace_apps"), "Hide apps from special workspace in dock", ), ], @@ -407,72 +407,72 @@ class SettingsWindow(Gtk.Window): rows = [ SettingsRow( "Global Menu", - SettingsSwitch("global_menu"), + SettingsSwitch("panel.global_menu"), "Show application menu in the panel", ), SettingsRow( "iMac Button", - SettingsSwitch("imac_button"), + SettingsSwitch("panel.imac_button"), ), SettingsRow( "Systray", - SettingsSwitch("systray"), + SettingsSwitch("panel.systray"), ), SettingsRow( "Control Center", - SettingsSwitch("control_center"), + SettingsSwitch("panel.control_center"), ), SettingsRow( "Search", - SettingsSwitch("search"), + SettingsSwitch("panel.search"), ), SettingsRow( "Network", - SettingsSwitch("network"), + SettingsSwitch("panel.network"), ), SettingsRow( "Battery", - SettingsSwitch("battery"), + SettingsSwitch("panel.battery"), ), SettingsRow( "Bluetooth", - SettingsSwitch("bluetooth"), + SettingsSwitch("panel.bluetooth"), ), SettingsRow( "Date & Time", - SettingsSwitch("date_time"), + SettingsSwitch("panel.date_time"), ), SettingsRow( "Workspace Indicator", - SettingsSwitch("workspace_indicator"), + SettingsSwitch("panel.workspace_indicator"), ), SettingsRow( "Notification Center", - SettingsSwitch("notification_center"), + SettingsSwitch("panel.notification_center"), ), SettingsRow( "Custom Mods", - SettingsSwitch("custom_mods"), + SettingsSwitch("panel.custom_mods"), "Enable custom Hyprland mods in panel", ), SettingsRow( "Window Switcher", - SettingsSwitch("window_switcher"), + SettingsSwitch("switcher.window_switcher"), "Enable Alt+Tab window switcher", ), SettingsRow( "OSD", - SettingsSwitch("osd"), + SettingsSwitch("panel.osd"), "Enable on-screen display for volume/brightness", ), SettingsRow( "Wallpapers Directory", - SettingsEntry("wallpapers_dir"), + SettingsEntry("general.wallpapers_dir"), "Path to wallpapers folder", ), SettingsRow( "Systray Ignore", - SettingsList("systray_ignore"), + SettingsList("panel.systray_ignore"), "Icons to hide from the system tray", ), ] @@ -485,17 +485,17 @@ class SettingsWindow(Gtk.Window): [ SettingsRow( "Timeout", - SettingsEntry("notification_timeout"), + SettingsEntry("notification.timeout"), "How long notifications stay on screen (e.g. 5s)", ), SettingsRow( "Ignored Apps", - SettingsList("notification_ignored_apps"), + SettingsList("notification.ignored_apps"), "Apps that won't show notifications", ), SettingsRow( "Limited History", - SettingsList("notification_limited_apps_history"), + SettingsList("notification.limited_apps_history"), "Apps with only the latest notification shown", ), ], diff --git a/src/window/switcher/main.py b/src/window/switcher/main.py index d5aa56b..a3c4171 100644 --- a/src/window/switcher/main.py +++ b/src/window/switcher/main.py @@ -120,9 +120,9 @@ class ApplicationSwitcher(Window): self.set_visible(False) def _on_config_changed(self, new_config, old_config): - preview_changed = config().has_changed("switcher_live_preview", old_config) + preview_changed = config().has_changed("switcher.live_preview", old_config) if ( - config().has_changed("hide_special_workspace", old_config) + config().has_changed("panel.hide_special_workspace", old_config) or preview_changed ): if preview_changed: @@ -133,7 +133,7 @@ class ApplicationSwitcher(Window): self._rebuild() def show_switcher(self) -> None: - if not config().get("window_switcher", True): + if not config().get("switcher.window_switcher", True): return self._rebuild() if not self.windows: @@ -180,7 +180,7 @@ class ApplicationSwitcher(Window): return pixbuf def _create_item(self) -> _SwitcherItem: - use_preview = config().get("switcher_live_preview", True) + use_preview = config().get("switcher.live_preview", True) if use_preview: app_icon = Image(name="app-switcher-app-icon") @@ -300,7 +300,7 @@ class ApplicationSwitcher(Window): item.button.set_visible(False) return - hide_special = config().get("hide_special_workspace", True) + hide_special = config().get("panel.hide_special_workspace", True) filtered = [] for c in clients: if c.get("hidden", False): @@ -333,7 +333,7 @@ class ApplicationSwitcher(Window): ) self.current_index = idx - use_preview = config().get("switcher_live_preview", True) + use_preview = config().get("switcher.live_preview", True) display = Gdk.Display.get_default() monitor = display.get_primary_monitor() if display else None @@ -430,7 +430,7 @@ class ApplicationSwitcher(Window): self._update_selection() self._addr_to_idx = {w["address"]: i for i, w in enumerate(self.windows)} - use_preview = config().get("switcher_live_preview", True) + use_preview = config().get("switcher.live_preview", True) if use_preview: self._capture_queue = [w["address"] for w in self.windows] self._current_capture_addr = None @@ -574,7 +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) + fps_delay = config().get("switcher.live_preview_delay_ms", 200) invoke_repeater( fps_delay, lambda: ( @@ -604,7 +604,7 @@ class ApplicationSwitcher(Window): ) def _pump_capture_queue(self): - use_preview = config().get("switcher_live_preview", True) + use_preview = config().get("switcher.live_preview", True) if not use_preview: return -- 2.43.0 From abf831e4aede602e72450308ba3dd0a8ee5c9d5b Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:38:38 +0545 Subject: [PATCH 24/28] docs: update documentation for reorganized config.toml - Update configuration.md with new section-based format - Update architecture.md with new config key paths - Update faqs_tips.md with new config key paths - Update README.md to mention config sections --- README.md | 2 ++ docs/architecture.md | 2 +- docs/configuration.md | 43 +++++++++++++++++++++++++------------------ docs/faqs_tips.md | 10 +++++----- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 45bb8c3..3bce041 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ Three TOML files in `config/`: | `mods.toml` | Custom panel buttons | Instant | | `dock.toml` | Pinned dock apps | Restart | +Settings are organized into sections: `[general]`, `[dock]`, `[panel]`, `[switcher]`, `[notification]`. + You can also toggle most settings from the **Settings window** (`Super + I`). See [Configuration Guide](docs/configuration.md) for full reference. diff --git a/docs/architecture.md b/docs/architecture.md index fd2491d..d7baa3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Python DBus polling is too slow, so Modus uses a C-extension at from Hyprland - Compiled to `libappcapture.so`, exposed to Python via GObject Introspection - Framerate throttled by Python using `invoke_repeater`, controlled by - `switcher_live_preview_delay_ms` in `config.toml` + `switcher.live_preview_delay_ms` in `config.toml` ## 4. Global Menu (DBus) diff --git a/docs/configuration.md b/docs/configuration.md index 6aa8df0..cb72c01 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,34 +35,34 @@ fabric-cli exec modus "settings.toggle()" Auto-generated on first run with sensible defaults. Most changes require restarting Modus (`uv run start`). -### General +Config is organized into sections. Values are accessed via dot notation (e.g. `panel.battery`). + +### `[general]` | Key | Type | Default | Description | |-----|------|---------|-------------| | `debug` | bool | `false` | Enable verbose logging | | `weather_location` | string | `""` | City for weather widget (e.g., `"London, UK"`) | | `keyboard_layouts` | array | `["us"]` | Active keyboard layouts | -| `night_light_temperature` | int | `4500` | Night light color temp in Kelvin | | `wallpapers_dir` | string | `"src/assets/wallpaper_example/"` | Path to wallpapers folder | -| `switcher_live_preview` | bool | `true` | Live window previews in switcher | -| `switcher_live_preview_delay_ms` | int | `200` | Preview framerate delay (lower = smoother) | -### Dock +### `[dock]` | Key | Type | Default | Description | |-----|------|---------|-------------| -| `dock_enabled` | bool | `true` | Show the dock | -| `dock_auto_hide` | bool | `true` | Hide when windows overlap | -| `dock_always_occluded` | bool | `false` | Keep dock behind windows | -| `dock_icon_size` | int | `52` | Icon size in pixels | -| `dock_hide_special_workspace_apps` | bool | `true` | Hide special workspace apps | +| `enabled` | bool | `true` | Show the dock | +| `auto_hide` | bool | `true` | Hide when windows overlap | +| `always_occluded` | bool | `false` | Keep dock behind windows | +| `icon_size` | int | `52` | Icon size in pixels | +| `hide_special_workspace_apps` | bool | `true` | Hide special workspace apps | -### Panel Widgets +### `[panel]` | Key | Type | Default | Description | |-----|------|---------|-------------| | `imac_button` | bool | `true` | Apple logo button | | `systray` | bool | `true` | System tray | +| `systray_ignore` | array | `["blueman", "network"]` | Icons hidden from system tray | | `control_center` | bool | `true` | Control center widget | | `search` | bool | `true` | Search/spotlight button | | `global_menu` | bool | `true` | App menus in panel | @@ -73,19 +73,26 @@ Auto-generated on first run with sensible defaults. Most changes require restart | `workspace_indicator` | bool | `true` | Workspace indicator | | `notification_center` | bool | `true` | Notification center | | `notch` | bool | `true` | Center notch (player, indicators, recording) | -| `custom_mods` | bool | `true` | Custom panel buttons from `mods.toml` | -| `window_switcher` | bool | `true` | Alt-Tab window switcher | | `osd` | bool | `true` | Volume/brightness on-screen display | +| `night_light_temperature` | int | `4500` | Night light color temp in Kelvin | | `hide_special_workspace` | bool | `true` | Hide special workspace from indicators | +| `custom_mods` | bool | `true` | Custom panel buttons from `mods.toml` | -### Notifications +### `[switcher]` | Key | Type | Default | Description | |-----|------|---------|-------------| -| `notification_timeout` | string | `"5s"` | Auto-dismiss duration | -| `notification_ignored_apps` | array | `["Hyprshot"]` | Apps with suppressed notifications | -| `notification_limited_apps_history` | array | `["Spotify"]` | Apps with limited history | -| `systray_ignore` | array | `["blueman", "network"]` | Icons hidden from system tray | +| `live_preview` | bool | `true` | Live window previews in switcher | +| `live_preview_delay_ms` | int | `200` | Preview framerate delay (lower = smoother) | +| `window_switcher` | bool | `true` | Alt-Tab window switcher | + +### `[notification]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `timeout` | string | `"5s"` | Auto-dismiss duration | +| `ignored_apps` | array | `["Hyprshot"]` | Apps with suppressed notifications | +| `limited_apps_history` | array | `["Spotify"]` | Apps with limited history | --- diff --git a/docs/faqs_tips.md b/docs/faqs_tips.md index 7181632..974a852 100644 --- a/docs/faqs_tips.md +++ b/docs/faqs_tips.md @@ -15,22 +15,22 @@ export GTK menus over DBus. The App Switcher uses a custom C-backend to capture live window previews. - 4K windows with many open can spike CPU -- **Fix**: Increase `switcher_live_preview_delay_ms` in `config.toml` (e.g., `200` - for ~5 FPS) or set `switcher_live_preview = false` to disable previews entirely +- **Fix**: Increase `switcher.live_preview_delay_ms` in `config.toml` (e.g., `200` + for ~5 FPS) or set `switcher.live_preview = false` to disable previews entirely ### 3. Dock covering fullscreen games/videos? -Set `dock_auto_hide = true` in `config.toml`. The dock hides automatically +Set `dock.auto_hide = true` in `config.toml`. The dock hides automatically when a window is maximized or fullscreen. ### 4. Volume/Brightness OSD not showing? -Set `osd = true` in `config.toml`. Requires `brightnessctl` for brightness and +Set `panel.osd = true` in `config.toml`. Requires `brightnessctl` for brightness and Pipewire/Wireplumber for audio. ### 5. Weather widget shows wrong location? -Update `weather_location` in `config.toml` (e.g., `"London, UK"` or +Update `general.weather_location` in `config.toml` (e.g., `"London, UK"` or `"Patan, Nepal"`). ### 6. Spotlight plugin isn't showing up? -- 2.43.0 From 006a9dc97efd6af83123d870c6cdd8ce39a54d2e Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 13:43:14 +0545 Subject: [PATCH 25/28] chore: remove unused dns.svg icon --- src/assets/icons/misc/dns.svg | 3 --- src/styles/widgets/dropdown.css | 1 - 2 files changed, 4 deletions(-) delete mode 100644 src/assets/icons/misc/dns.svg diff --git a/src/assets/icons/misc/dns.svg b/src/assets/icons/misc/dns.svg deleted file mode 100644 index bd34bdc..0000000 --- a/src/assets/icons/misc/dns.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/styles/widgets/dropdown.css b/src/styles/widgets/dropdown.css index 7ec69ba..e250fef 100644 --- a/src/styles/widgets/dropdown.css +++ b/src/styles/widgets/dropdown.css @@ -45,4 +45,3 @@ menu menuitem:disabled { color: @glass-text-disabled; opacity: 0.5; } - -- 2.43.0 From 36158ffbbb4cf034c3c90789fb69f098ec9f6433 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 28 Jul 2026 21:13:54 +0545 Subject: [PATCH 26/28] fix(spotlight): return empty for unrelated caffeine queries Add had_prefix flag so caffeine.search() returns [] for queries without 'caffeine' or 'caff' prefix instead of always showing help. Add debug logging. --- src/window/spotlight/plugins/caffeine.py | 31 +++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/window/spotlight/plugins/caffeine.py b/src/window/spotlight/plugins/caffeine.py index 4e78cfc..5b7b908 100644 --- a/src/window/spotlight/plugins/caffeine.py +++ b/src/window/spotlight/plugins/caffeine.py @@ -212,29 +212,38 @@ class CaffeinePlugin(SpotlightPlugin): def search(self, query: str, token) -> list[SearchResult]: q = query.strip().lower() + had_prefix = False for kw in ("caffeine ", "caff "): if q.startswith(kw): q = q[len(kw) :].strip() + had_prefix = True break active = self.is_active() + logger.info( + f"[Caffeine] search query={query!r} q={q!r} had_prefix={had_prefix} active={active}" + ) if not q or q in ("caffeine", "caff"): + logger.info("[Caffeine] returning menu (keyword match)") return self._menu(active) if q == "off": + logger.info("[Caffeine] returning off") return [ self._mk( "off", "Disable Caffeine", "Allow idle and sleep again", active ) ] if q == "on": + logger.info("[Caffeine] returning on") return [ self._mk("on", "Enable Caffeine", "Stay awake until turned off", active) ] secs = self._parse_duration(q) if secs is not None: + logger.info(f"[Caffeine] returning duration secs={secs}") return [ self._mk( q, @@ -244,15 +253,19 @@ class CaffeinePlugin(SpotlightPlugin): ) ] - return [ - self._result( - "caffeine_help", - "Caffeine", - "Usage: caffeine on | off |