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 |