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/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/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 cb8fe78..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 | --- @@ -143,6 +150,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 --- 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/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? 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 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/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/__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 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/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() 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/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..a18b0bc 100644 --- a/src/shared/window/dropdown.py +++ b/src/shared/window/dropdown.py @@ -1,78 +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, **kwargs): - super().__init__( - layer="top", - exclusivity="auto", - name="dropdown-menu", - title="modus-dropdown", - visible=False, - **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, + } 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; +} diff --git a/src/styles/widgets/dropdown.css b/src/styles/widgets/dropdown.css index 813fd10..e250fef 100644 --- a/src/styles/widgets/dropdown.css +++ b/src/styles/widgets/dropdown.css @@ -1,64 +1,47 @@ -#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; +/* Disabled */ +menu menuitem:disabled { + color: @glass-text-disabled; + opacity: 0.5; } diff --git a/src/styles/window/panel.css b/src/styles/window/panel.css index 4913bd3..8af59c3 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,15 +23,39 @@ 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 { 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 { + background-color: transparent; +} + +#battery-label, +#date-time { + font-weight: 500; } #date-time { @@ -73,15 +92,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/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/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) diff --git a/src/window/desktop/calendar.py b/src/window/desktop/calendar.py index 7ffc097..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.19115, 0.00095) + "calendar", DesktopCalendarContainer, (174, 174), (0.19375, 0.00949) ) diff --git a/src/window/desktop/date.py b/src/window/desktop/date.py index 631a859..7eca730 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) @@ -49,4 +75,6 @@ class DesktopDateContainer(Box): ) -DesktopWidgetRegistry.register("date", DesktopDateContainer, (174, 174), (0.0, 0.0)) +DesktopWidgetRegistry.register( + "date", DesktopDateContainer, (174, 174), (0.00573, 0.00949) +) 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) diff --git a/src/window/desktop/system.py b/src/window/desktop/system.py index 5b13ae9..44ec6a7 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.82827) ) 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..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.0974, 0.0) + "weather", DesktopWeatherWidget, (174, 174), (0.10052, 0.01044) ) 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/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..1f3fc44 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' str: @@ -54,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): @@ -133,29 +140,6 @@ def _dbus_get_pid(bus, service: str, timeout: int) -> int: return 0 -REGISTRAR_XML = """ - - - - - - - - - - - - - - - - - - - -""" - - class GlobalMenuService(Service): """Service that extracts and provides application menus for the global menu bar.""" @@ -189,9 +173,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 +251,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 +269,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 +568,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 +590,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 +630,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 +1004,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/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/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() 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/globalmenu.py b/src/window/panel/components/globalmenu.py index da51101..d666e8a 100644 --- a/src/window/panel/components/globalmenu.py +++ b/src/window/panel/components/globalmenu.py @@ -1,12 +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 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 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 @@ -15,438 +14,288 @@ 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" - ) - - -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) + app_name = modus_service.current_active_app_name + return app_name and app_name != "Modus" 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, +# Menu factories +def _create_system_menu(): + return [ + dropdown_option( + "About this PC", callback=lambda _: get_about_window().toggle() ), - 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 + 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 _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) - - return handler +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 _menu_item_to_dropdown(item): - """Convert a DBusMenuItem to a dropdown_option widget.""" - if item.type == "separator": - return dropdown_divider("---------------------") - - 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), - ) +def _clean_label(label): + return label.replace("_", "") -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, - ) +def _build_gtk_menu_from_children(children, click_handler): + menu = Gtk.Menu() + + for child in children: + if not getattr(child, "visible", True): + continue + + child_type = getattr(child, "type", "standard") + if child_type == "separator": + menu.append(Gtk.SeparatorMenuItem()) + 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} \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) + if not enabled: + item.set_sensitive(False) + item.connect("activate", click_handler, child.id) + + menu.append(item) + + menu.show_all() + 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, - ) - 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_dropdown = ModusDropdown(items=_create_system_menu()) + self._system_menu = self._system_dropdown.menu - # Dynamic menu dropdowns (populated from extracted app menus) - self._dynamic_dropdowns: list[ModusDropdown] = [] + self._title_menu = _create_title_menu(modus_service.current_active_app_name) - modus_service.connect( - "current-active-app-name-changed", self._on_active_app_changed - ) + 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") - # 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) - if self._imac_button and self._imac_button not in buttons: - 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 _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}") - 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 + old_menu = self._title_menu + self._title_menu = _create_title_menu(value) + self.global_menu_button_title.set_popup(self._title_menu) + GLib.idle_add(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 _destroy_widgets(self, widgets): + for widget in widgets: + try: + widget.destroy() + except Exception: + logger.exception("[globalmenu] widget destroy failed") - If the app has a menu bar → show extracted menus (File, Edit, etc.) - If not → show fallback Hyprland window controls (View, Window, Help) - """ + def _cleanup_dynamic(self): + for btn in self.all_menu_buttons: + if btn is self.global_menu_button_title: + continue + try: + if btn.get_parent(): + btn.get_parent().remove(btn) + btn.destroy() + except Exception: + logger.exception("[globalmenu] cleanup failed") + + self._destroy_widgets(self._gtk_menu_buttons) + self._gtk_menu_buttons.clear() + + self._destroy_widgets(self._gtk_menus) + 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) + item for item in menu_items if 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", self._make_click_handler, 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}") - ] - - dropdown_id = f"global-menu-{item.label.lower()}" - dropdown = create_dropdown_with_capture( - dropdown_id, self.parent, dropdown_children + gtk_menu = _build_gtk_menu_from_children( + children, self._make_click_handler ) + menu_btn.set_popup(gtk_menu) + menu_btn.show_all() - 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}") + except Exception: + logger.exception("[globalmenu] disconnect failed") - # 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}" - ) + except Exception: + logger.exception("[globalmenu] svc disconnect failed") - # 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: + logger.exception(f"[globalmenu] {name} destroy failed") class GlobalMenu(Box): @@ -459,7 +308,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 +315,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}") + except Exception: + logger.exception("[globalmenu] destroy failed") super().destroy() diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py index 791efc6..c41ac40 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(), @@ -48,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): @@ -92,7 +67,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, diff --git a/src/window/panel/main.py b/src/window/panel/main.py index 945ce7c..33e9b88 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,9 @@ 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.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) @@ -254,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) @@ -272,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: @@ -290,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: @@ -322,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", @@ -336,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/spotlight/main.py b/src/window/spotlight/main.py index c85b7e1..5e42787 100644 --- a/src/window/spotlight/main.py +++ b/src/window/spotlight/main.py @@ -13,7 +13,6 @@ from shared.widgets.clipping_box import ClippingBox from shared.window.animated_scrollwindow import AnimatedScrollable from utils.debounce import debounce from utils.functions import get_children_height_limit -from utils.gtk_utils import svg_file from window.spotlight.api.context import PluginContext from window.spotlight.api.result import SearchResult from window.spotlight.core.loader import PluginLoader @@ -21,33 +20,6 @@ from window.spotlight.core.manager import PluginManager from window.spotlight.core.registry import PluginRegistry from window.spotlight.core.search import SearchPipeline -# Map GTK theme icon names to SVG asset paths -_ICON_SVG_MAP = { - "system-search-symbolic": "misc/search.svg", - "system-shutdown-symbolic": "misc/shutdown.svg", - "system-reboot-symbolic": "misc/reboot.svg", - "system-suspend-symbolic": "misc/suspend.svg", - "system-hibernate-symbolic": "misc/hibernate.svg", - "system-log-out-symbolic": "misc/logout.svg", - "system-lock-screen-symbolic": "misc/lock.svg", - "edit-find-symbolic": "misc/search.svg", - "edit-paste-symbolic": "misc/paste.svg", - "video-x-generic-symbolic": "misc/video.svg", - "insert-link-symbolic": "misc/link.svg", - "face-smile-symbolic": "misc/emoji.svg", - "accessories-calculator-symbolic": "misc/calculator.svg", - "user-available-symbolic": "misc/coffee-on.svg", - "user-away-symbolic": "misc/coffee-off.svg", -} - - -def _icon_to_svg(icon_name: str, size: int = 32): - """Return an Svg widget for a GTK theme icon name, or None if unmapped.""" - path = _ICON_SVG_MAP.get(icon_name) - if path: - return svg_file(path, size=size) - return None - class PluginService: """Owns plugin lifecycle and search pipeline. Plain class, no GObject.""" @@ -90,7 +62,9 @@ class Spotlight(Box): self.plugin_service = PluginService() - self.header_icon = svg_file("misc/search.svg", size=16) + self.header_icon = Image( + icon_name="system-search-symbolic", icon_size=16, size=16 + ) self.header_entry = Entry( placeholder="Search...", style_classes="app-search-entry", @@ -146,15 +120,14 @@ class Spotlight(Box): if not text.strip(): self._clear_viewport() - self.header_icon.set_from_file("misc/search.svg") + self.header_icon.set_from_icon_name("system-search-symbolic", 16) return for pe in self.plugin_service.manager.registry.get_searchable(): inst = pe.instance if inst and inst.detect(text): icon = getattr(inst, "icon", "") or "system-search-symbolic" - svg_path = _ICON_SVG_MAP.get(icon, "misc/search.svg") - self.header_icon.set_from_file(svg_path) + self.header_icon.set_from_icon_name(icon, 16) # Scope to the detected plugin only — a global search would # mix in unrelated plugins' results. self.plugin_service.pipeline.search_single( @@ -162,7 +135,7 @@ class Spotlight(Box): ) return - self.header_icon.set_from_file("misc/search.svg") + self.header_icon.set_from_icon_name("system-search-symbolic", 16) self.plugin_service.pipeline.search(text, self._on_search_results) def _activate_keyword_plugin(self, entry, keyword: str, query: str): @@ -182,8 +155,7 @@ class Spotlight(Box): keyword_icons.get(keyword, getattr(inst, "icon", "")) or "system-search-symbolic" ) - svg_path = _ICON_SVG_MAP.get(icon_name, "misc/search.svg") - self.header_icon.set_from_file(svg_path) + self.header_icon.set_from_icon_name(icon_name, 16) if getattr(inst, "full_viewport_clear", False): self._clear_viewport() @@ -740,7 +712,7 @@ class SpotlightWindow(Window): self.spotlight_box.scrolled_window.animate_size(0) self.spotlight_box.scrolled_window.hide() self.spotlight_box.scrolled_clip.hide() - self.spotlight_box.header_icon.set_from_file("misc/search.svg") + self.spotlight_box.header_icon.set_from_icon_name("system-search-symbolic", 16) self.spotlight_box._deactivate_keyword_plugin() self.spotlight_box.plugin_service.manager.release_memory_all() self.hide() 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 |