-
+
-
-## Installation
+## Installation
> [!CAUTION]
+>
> - You need a working installation of hyprland and knowledge of how it works
> - There may not be all packages in your system install them accordingly
@@ -47,21 +47,23 @@ cd ~/.config/Modus
```
> [!TIP]
+>
> ## Post Installation
+>
> - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors)
> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config
> - For Lock Screen Bind keys to `uv run lock.py`
Todo
-## Manual Installation (WIP)
+## Manual Installation
```bash
-paru -S glace-git gtk-session-lock uv apple-fonts cinnamon-desktop --needed
+paru -S fabric-cli-git gtk-session-lock uv apple-fonts cinnamon-desktop hyprshot hypridle hyprpicker grim slurp gnome-bluetooth-3.0 cliphist matugen-bin awww swappy wl-clipboard webp-pixbuf-loader acpi wf-recorder brightnessctl power-profile-daemon uwsm libnotify playerctl ffmpeg --needed
git clone https://github.com/S4NKALP/Modus ~/.config/Modus
cd ~/.config/Modus
uv sync
-uv run main.py
+uv run start
```
- [x] Launcher
diff --git a/config.json b/config.json
deleted file mode 100644
index 1f35478..0000000
--- a/config.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "wallpapers_dir": "~/Pictures/Wallpapers/",
- "dock_position": "Bottom",
- "terminal_command": "kitty -e",
- "dock_enabled": true,
- "dock_auto_hide": true,
- "dock_always_occluded": false,
- "dock_icon_size": 52,
- "window_switcher_items_per_row": 10,
- "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,
- "keyboard_layouts": [
- "us",
- "np"
- ],
- "dock": true,
- "window_switcher": true,
- "osd": true,
- "systray_ignore": ["blueman", "network"]
-}
diff --git a/debug_memory.py b/debug_memory.py
deleted file mode 100644
index 2e175e7..0000000
--- a/debug_memory.py
+++ /dev/null
@@ -1,165 +0,0 @@
-#!/usr/bin/env python3
-"""
-Real-time memory monitor for debugging expanded player memory leaks.
-This module provides functions to track memory usage in real-time.
-"""
-
-import psutil
-import os
-import gc
-import threading
-import time
-from loguru import logger
-
-
-class MemoryMonitor:
- """Real-time memory monitoring for debugging memory leaks."""
-
- def __init__(self):
- self.process = psutil.Process(os.getpid())
- self.baseline_memory = None
- self.last_memory = None
- self.monitoring = False
- self.monitor_thread = None
-
- def get_memory_usage(self):
- """Get current memory usage in MB."""
- return self.process.memory_info().rss / 1024 / 1024
-
- def get_memory_details(self):
- """Get detailed memory information."""
- memory_info = self.process.memory_info()
- memory_percent = self.process.memory_percent()
-
- return {
- "rss_mb": memory_info.rss / 1024 / 1024,
- "vms_mb": memory_info.vms / 1024 / 1024,
- "percent": memory_percent,
- "num_threads": self.process.num_threads(),
- }
-
- def set_baseline(self, label="Baseline"):
- """Set the baseline memory usage."""
- self.baseline_memory = self.get_memory_usage()
- logger.info(f"🎯 {label} memory: {self.baseline_memory:.1f} MB")
- return self.baseline_memory
-
- def log_memory_change(self, label="Memory Check", force_gc=True):
- """Log current memory usage and change from baseline."""
- if force_gc:
- gc.collect()
-
- current_memory = self.get_memory_usage()
- details = self.get_memory_details()
-
- if self.baseline_memory:
- delta = current_memory - self.baseline_memory
- logger.info(
- f"📊 {label}: {current_memory:.1f} MB (Δ: {delta:+.1f} MB) | Threads: {
- details['num_threads']
- }"
- )
- else:
- logger.info(
- f"📊 {label}: {current_memory:.1f} MB | Threads: {
- details['num_threads']
- }"
- )
-
- self.last_memory = current_memory
- return current_memory
-
- def log_memory_spike(self, threshold_mb=10):
- """Log if there's a significant memory increase."""
- if self.last_memory:
- current = self.get_memory_usage()
- increase = current - self.last_memory
- if increase > threshold_mb:
- logger.warning(
- f"🚨 MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)"
- )
- return True
- return False
-
- def start_continuous_monitoring(self, interval_seconds=2):
- """Start continuous background monitoring."""
- if self.monitoring:
- return
-
- self.monitoring = True
-
- def monitor_loop():
- while self.monitoring:
- try:
- self.log_memory_change("Continuous Monitor", force_gc=False)
- time.sleep(interval_seconds)
- except Exception as e:
- logger.error(f"Memory monitor error: {e}")
- break
-
- self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
- self.monitor_thread.start()
- logger.info(
- f"🔍 Started continuous memory monitoring (every {interval_seconds}s)"
- )
-
- def stop_continuous_monitoring(self):
- """Stop continuous monitoring."""
- if self.monitoring:
- self.monitoring = False
- logger.info("🛑 Stopped continuous memory monitoring")
-
-
-# Global memory monitor instance
-memory_monitor = MemoryMonitor()
-
-# Convenience functions for easy use
-
-
-def set_memory_baseline(label="Baseline"):
- """Set memory baseline."""
- return memory_monitor.set_baseline(label)
-
-
-def log_memory(label="Memory Check"):
- """Log current memory usage."""
- return memory_monitor.log_memory_change(label)
-
-
-def start_memory_monitoring():
- """Start continuous memory monitoring."""
- memory_monitor.start_continuous_monitoring()
-
-
-def stop_memory_monitoring():
- """Stop continuous memory monitoring."""
- memory_monitor.stop_continuous_monitoring()
-
-
-def check_memory_spike():
- """Check for memory spike."""
- return memory_monitor.log_memory_spike()
-
-
-# Test function
-
-
-def test_memory_monitor():
- """Test the memory monitor."""
- print("Testing Memory Monitor...")
- monitor = MemoryMonitor()
-
- monitor.set_baseline("Test Start")
-
- # Simulate some memory usage
- big_list = [i for i in range(100000)]
- monitor.log_memory_change("After creating big list")
-
- del big_list
- monitor.log_memory_change("After deleting big list")
-
- print("Memory monitor test complete!")
-
-
-if __name__ == "__main__":
- test_memory_monitor()
diff --git a/install.sh b/install.sh
index 599cd4e..49ac6ab 100755
--- a/install.sh
+++ b/install.sh
@@ -30,6 +30,8 @@ PACKAGES=(
hypridle
hyprsunset
hyprpicker
+ hyprshot
+ grim
libnotify
matugen-bin
playerctl
@@ -283,20 +285,6 @@ else
success "All packages are up-to-date"
fi
-# Configuration
-# progress "Running configuration"
-# if [ -f "$INSTALL_DIR/config/config.py" ]; then
-# step "Initializing Modus configuration..."
-# if python "$INSTALL_DIR/config/config.py" 2>/dev/null; then
-# success "Configuration completed"
-# else
-# warn "Configuration step failed or was skipped"
-# fi
-# else
-# info "No configuration file found, skipping"
-# fi
-
-# Hyprland configuration
progress "Configuring Hyprland"
HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf"
@@ -330,10 +318,10 @@ else
fi
step "Starting Modus..."
-if uwsm app -- uv run "$INSTALL_DIR/main.py" >/dev/null 2>&1 & then
+if uwsm app -- uv run --project "$INSTALL_DIR" start >/dev/null 2>&1 & then
disown
- sleep 1
- if pgrep -f "python.*main.py" >/dev/null; then
+ sleep 2
+ if pgrep -x "modus" >/dev/null; then
success "Modus is now running"
else
warn "Modus may not have started correctly"
@@ -347,7 +335,7 @@ fi
echo ""
echo -e "${GREEN}${BOLD}╔════════════════════════════════════════╗${RESET}"
echo -e "${GREEN}${BOLD}║ ║${RESET}"
-echo -e "${GREEN}${BOLD}║ Installation completed! 🎉 ║${RESET}"
+echo -e "${GREEN}${BOLD}║ Installation completed! ║${RESET}"
echo -e "${GREEN}${BOLD}║ ║${RESET}"
echo -e "${GREEN}${BOLD}╚════════════════════════════════════════╝${RESET}"
echo ""
diff --git a/main.py b/main.py
deleted file mode 100644
index 0ccbee3..0000000
--- a/main.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from fabric import Application
-from fabric.utils import get_relative_path, logger, monitor_file
-
-from config.data import APP_NAME
-from modules.desktop.widget import Deskwidgets
-from modules.launcher.main import Launcher
-from modules.notification.notification import ModusNoti
-from modules.osd.main import OSDWindow
-from modules.panel.main import Panel
-from modules.switcher import ApplicationSwitcher
-from modules.dock import Dock
-from utils.functions import set_process_name
-
-for log in [
- "fabric.hyprland.widgets",
- "fabric.audio.service",
- "fabric.bluetooth.service",
- "services.network",
- "utils.wayland",
-]:
- logger.disable(log)
-
-if __name__ == "__main__":
- set_process_name(APP_NAME)
-
- # Load configuration
- from config.data import load_config
-
- # About().toggle(None)
- config = load_config()
- switcher = ApplicationSwitcher()
- panel = Panel()
- modusnoti = ModusNoti()
- launcher = Launcher()
- deskwidget = Deskwidgets()
- dock = Dock()
- osd = OSDWindow()
- # Monitor CSS files for changes
- css_file = monitor_file(get_relative_path("styles"))
- _ = css_file.connect("changed", lambda *_: set_css())
-
- app = Application(
- f"{APP_NAME}",
- panel,
- modusnoti,
- deskwidget.top_left,
- deskwidget.bottom_left,
- osd,
- launcher,
- switcher,
- dock,
- )
-
- def set_css():
- app.set_stylesheet_from_file(
- get_relative_path("styles/main.css"),
- )
-
- app.set_css = set_css
-
- app.set_css()
-
- app.run()
diff --git a/pyproject.toml b/pyproject.toml
index d5d3a53..59ebccb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,3 +23,17 @@ dev = [
[tool.uv.sources]
fabric = { git = "https://github.com/Fabric-Development/fabric.git" }
+
+[project.scripts]
+start = "main:main"
+lock = "lock:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/shared", "src/utils", "src/window", "src/services", "src/main.py"]
+
+[tool.hatch.build.targets.wheel.sources]
+"src" = ""
diff --git a/styles/todo.css b/src/__init__.py
similarity index 100%
rename from styles/todo.css
rename to src/__init__.py
diff --git a/src/assets/icons/applets/bluetooth-clear.svg b/src/assets/icons/applets/bluetooth-clear.svg
new file mode 100644
index 0000000..6348bc5
--- /dev/null
+++ b/src/assets/icons/applets/bluetooth-clear.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/assets/icons/applets/bluetooth-off-clear.svg b/src/assets/icons/applets/bluetooth-off-clear.svg
new file mode 100644
index 0000000..cea2aa5
--- /dev/null
+++ b/src/assets/icons/applets/bluetooth-off-clear.svg
@@ -0,0 +1,25 @@
+
diff --git a/src/assets/icons/applets/bluetooth-off.svg b/src/assets/icons/applets/bluetooth-off.svg
new file mode 100644
index 0000000..308fba6
--- /dev/null
+++ b/src/assets/icons/applets/bluetooth-off.svg
@@ -0,0 +1,76 @@
+
+
diff --git a/src/assets/icons/applets/bluetooth-paired.svg b/src/assets/icons/applets/bluetooth-paired.svg
new file mode 100644
index 0000000..c400923
--- /dev/null
+++ b/src/assets/icons/applets/bluetooth-paired.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/applets/bluetooth.svg b/src/assets/icons/applets/bluetooth.svg
new file mode 100644
index 0000000..d3a0b61
--- /dev/null
+++ b/src/assets/icons/applets/bluetooth.svg
@@ -0,0 +1,80 @@
+
+
diff --git a/src/assets/icons/applets/brightness.svg b/src/assets/icons/applets/brightness.svg
new file mode 100755
index 0000000..bcc61ef
--- /dev/null
+++ b/src/assets/icons/applets/brightness.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/applets/caffeine-off.svg b/src/assets/icons/applets/caffeine-off.svg
new file mode 100644
index 0000000..b59dfe0
--- /dev/null
+++ b/src/assets/icons/applets/caffeine-off.svg
@@ -0,0 +1,104 @@
+
+
diff --git a/src/assets/icons/applets/caffeine-on.svg b/src/assets/icons/applets/caffeine-on.svg
new file mode 100644
index 0000000..2432b4a
--- /dev/null
+++ b/src/assets/icons/applets/caffeine-on.svg
@@ -0,0 +1,103 @@
+
+
diff --git a/src/assets/icons/applets/dnd-clear.svg b/src/assets/icons/applets/dnd-clear.svg
new file mode 100644
index 0000000..19b053f
--- /dev/null
+++ b/src/assets/icons/applets/dnd-clear.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/applets/dnd-off.svg b/src/assets/icons/applets/dnd-off.svg
new file mode 100644
index 0000000..fe6a37c
--- /dev/null
+++ b/src/assets/icons/applets/dnd-off.svg
@@ -0,0 +1,53 @@
+
+
diff --git a/src/assets/icons/applets/dnd.svg b/src/assets/icons/applets/dnd.svg
new file mode 100644
index 0000000..4ecdd99
--- /dev/null
+++ b/src/assets/icons/applets/dnd.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/applets/flight-off.svg b/src/assets/icons/applets/flight-off.svg
new file mode 100644
index 0000000..d59785c
--- /dev/null
+++ b/src/assets/icons/applets/flight-off.svg
@@ -0,0 +1,82 @@
+
+
diff --git a/src/assets/icons/applets/flight-on.svg b/src/assets/icons/applets/flight-on.svg
new file mode 100644
index 0000000..5a492f1
--- /dev/null
+++ b/src/assets/icons/applets/flight-on.svg
@@ -0,0 +1,86 @@
+
+
diff --git a/src/assets/icons/applets/network-wired-offline.svg b/src/assets/icons/applets/network-wired-offline.svg
new file mode 100644
index 0000000..c1734ca
--- /dev/null
+++ b/src/assets/icons/applets/network-wired-offline.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/applets/network-wired.svg b/src/assets/icons/applets/network-wired.svg
new file mode 100644
index 0000000..7b6881e
--- /dev/null
+++ b/src/assets/icons/applets/network-wired.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/applets/redshift-status-off.svg b/src/assets/icons/applets/redshift-status-off.svg
new file mode 100644
index 0000000..e81d366
--- /dev/null
+++ b/src/assets/icons/applets/redshift-status-off.svg
@@ -0,0 +1,94 @@
+
+
diff --git a/src/assets/icons/applets/redshift-status-on.svg b/src/assets/icons/applets/redshift-status-on.svg
new file mode 100644
index 0000000..aaa2959
--- /dev/null
+++ b/src/assets/icons/applets/redshift-status-on.svg
@@ -0,0 +1,86 @@
+
+
diff --git a/src/assets/icons/applets/wifi-clear.svg b/src/assets/icons/applets/wifi-clear.svg
new file mode 100644
index 0000000..9276ebe
--- /dev/null
+++ b/src/assets/icons/applets/wifi-clear.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/applets/wifi-off-clear.svg b/src/assets/icons/applets/wifi-off-clear.svg
new file mode 100644
index 0000000..d19de29
--- /dev/null
+++ b/src/assets/icons/applets/wifi-off-clear.svg
@@ -0,0 +1,44 @@
+
diff --git a/src/assets/icons/applets/wifi-off.svg b/src/assets/icons/applets/wifi-off.svg
new file mode 100644
index 0000000..ba5c100
--- /dev/null
+++ b/src/assets/icons/applets/wifi-off.svg
@@ -0,0 +1,89 @@
+
+
diff --git a/src/assets/icons/applets/wifi.svg b/src/assets/icons/applets/wifi.svg
new file mode 100644
index 0000000..6a0dd08
--- /dev/null
+++ b/src/assets/icons/applets/wifi.svg
@@ -0,0 +1,99 @@
+
+
diff --git a/src/assets/icons/battery/battery-000-charging.svg b/src/assets/icons/battery/battery-000-charging.svg
new file mode 100644
index 0000000..ac8c28f
--- /dev/null
+++ b/src/assets/icons/battery/battery-000-charging.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/icons/battery/battery-000.svg b/src/assets/icons/battery/battery-000.svg
new file mode 100644
index 0000000..c49fb45
--- /dev/null
+++ b/src/assets/icons/battery/battery-000.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/battery/battery-010-charging.svg b/src/assets/icons/battery/battery-010-charging.svg
new file mode 100644
index 0000000..c9ea308
--- /dev/null
+++ b/src/assets/icons/battery/battery-010-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-010.svg b/src/assets/icons/battery/battery-010.svg
new file mode 100644
index 0000000..b88e8fb
--- /dev/null
+++ b/src/assets/icons/battery/battery-010.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-020-charging.svg b/src/assets/icons/battery/battery-020-charging.svg
new file mode 100644
index 0000000..5824ad4
--- /dev/null
+++ b/src/assets/icons/battery/battery-020-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-020.svg b/src/assets/icons/battery/battery-020.svg
new file mode 100644
index 0000000..87375ae
--- /dev/null
+++ b/src/assets/icons/battery/battery-020.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-030-charging.svg b/src/assets/icons/battery/battery-030-charging.svg
new file mode 100644
index 0000000..c0088ce
--- /dev/null
+++ b/src/assets/icons/battery/battery-030-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-030.svg b/src/assets/icons/battery/battery-030.svg
new file mode 100644
index 0000000..312ab1b
--- /dev/null
+++ b/src/assets/icons/battery/battery-030.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-040-charging.svg b/src/assets/icons/battery/battery-040-charging.svg
new file mode 100644
index 0000000..1327b10
--- /dev/null
+++ b/src/assets/icons/battery/battery-040-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-040.svg b/src/assets/icons/battery/battery-040.svg
new file mode 100644
index 0000000..ff8a28a
--- /dev/null
+++ b/src/assets/icons/battery/battery-040.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-050-charging.svg b/src/assets/icons/battery/battery-050-charging.svg
new file mode 100644
index 0000000..016985f
--- /dev/null
+++ b/src/assets/icons/battery/battery-050-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-050.svg b/src/assets/icons/battery/battery-050.svg
new file mode 100644
index 0000000..0e6af06
--- /dev/null
+++ b/src/assets/icons/battery/battery-050.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-060-charging.svg b/src/assets/icons/battery/battery-060-charging.svg
new file mode 100644
index 0000000..e23c733
--- /dev/null
+++ b/src/assets/icons/battery/battery-060-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-060.svg b/src/assets/icons/battery/battery-060.svg
new file mode 100644
index 0000000..f9c44b9
--- /dev/null
+++ b/src/assets/icons/battery/battery-060.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/src/assets/icons/battery/battery-070-charging.svg b/src/assets/icons/battery/battery-070-charging.svg
new file mode 100644
index 0000000..cf18c55
--- /dev/null
+++ b/src/assets/icons/battery/battery-070-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-070.svg b/src/assets/icons/battery/battery-070.svg
new file mode 100644
index 0000000..abd1a8e
--- /dev/null
+++ b/src/assets/icons/battery/battery-070.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-080-charging.svg b/src/assets/icons/battery/battery-080-charging.svg
new file mode 100644
index 0000000..672c08f
--- /dev/null
+++ b/src/assets/icons/battery/battery-080-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-080.svg b/src/assets/icons/battery/battery-080.svg
new file mode 100644
index 0000000..0a78582
--- /dev/null
+++ b/src/assets/icons/battery/battery-080.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-090-charging.svg b/src/assets/icons/battery/battery-090-charging.svg
new file mode 100644
index 0000000..2e81ab9
--- /dev/null
+++ b/src/assets/icons/battery/battery-090-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-090.svg b/src/assets/icons/battery/battery-090.svg
new file mode 100644
index 0000000..8cc1219
--- /dev/null
+++ b/src/assets/icons/battery/battery-090.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/battery/battery-100-charging.svg b/src/assets/icons/battery/battery-100-charging.svg
new file mode 100644
index 0000000..22471ae
--- /dev/null
+++ b/src/assets/icons/battery/battery-100-charging.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/battery/battery-100.svg b/src/assets/icons/battery/battery-100.svg
new file mode 100644
index 0000000..9d9104b
--- /dev/null
+++ b/src/assets/icons/battery/battery-100.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/brightness/brightness-0.svg b/src/assets/icons/brightness/brightness-0.svg
new file mode 100755
index 0000000..d6065c7
--- /dev/null
+++ b/src/assets/icons/brightness/brightness-0.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/brightness/brightness-1.svg b/src/assets/icons/brightness/brightness-1.svg
new file mode 100755
index 0000000..e5b4101
--- /dev/null
+++ b/src/assets/icons/brightness/brightness-1.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/brightness/brightness-2.svg b/src/assets/icons/brightness/brightness-2.svg
new file mode 100755
index 0000000..df7cfba
--- /dev/null
+++ b/src/assets/icons/brightness/brightness-2.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/brightness/brightness-3.svg b/src/assets/icons/brightness/brightness-3.svg
new file mode 100755
index 0000000..c3b390b
--- /dev/null
+++ b/src/assets/icons/brightness/brightness-3.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/brightness/brightness.svg b/src/assets/icons/brightness/brightness.svg
new file mode 100755
index 0000000..bcc61ef
--- /dev/null
+++ b/src/assets/icons/brightness/brightness.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/mic/microphone-0.svg b/src/assets/icons/mic/microphone-0.svg
new file mode 100755
index 0000000..ef1f97a
--- /dev/null
+++ b/src/assets/icons/mic/microphone-0.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/mic/microphone-1.svg b/src/assets/icons/mic/microphone-1.svg
new file mode 100755
index 0000000..dfa3008
--- /dev/null
+++ b/src/assets/icons/mic/microphone-1.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/mic/microphone-2.svg b/src/assets/icons/mic/microphone-2.svg
new file mode 100755
index 0000000..0b47426
--- /dev/null
+++ b/src/assets/icons/mic/microphone-2.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/assets/icons/mic/microphone-3.svg b/src/assets/icons/mic/microphone-3.svg
new file mode 100755
index 0000000..f48bd31
--- /dev/null
+++ b/src/assets/icons/mic/microphone-3.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/mic/microphone.svg b/src/assets/icons/mic/microphone.svg
new file mode 100755
index 0000000..dfa3008
--- /dev/null
+++ b/src/assets/icons/mic/microphone.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/misc/caps-lock-off.svg b/src/assets/icons/misc/caps-lock-off.svg
new file mode 100644
index 0000000..8dd8dbf
--- /dev/null
+++ b/src/assets/icons/misc/caps-lock-off.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/misc/caps-lock.svg b/src/assets/icons/misc/caps-lock.svg
new file mode 100644
index 0000000..41c18aa
--- /dev/null
+++ b/src/assets/icons/misc/caps-lock.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/misc/chevron-left.svg b/src/assets/icons/misc/chevron-left.svg
new file mode 100644
index 0000000..8cade29
--- /dev/null
+++ b/src/assets/icons/misc/chevron-left.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/icons/misc/chevron-right.svg b/src/assets/icons/misc/chevron-right.svg
new file mode 100644
index 0000000..f81b1e6
--- /dev/null
+++ b/src/assets/icons/misc/chevron-right.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/icons/misc/control-center.svg b/src/assets/icons/misc/control-center.svg
new file mode 100644
index 0000000..24f2195
--- /dev/null
+++ b/src/assets/icons/misc/control-center.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/icons/misc/control.svg b/src/assets/icons/misc/control.svg
new file mode 100644
index 0000000..ed4e9d7
--- /dev/null
+++ b/src/assets/icons/misc/control.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/misc/imac.svg b/src/assets/icons/misc/imac.svg
new file mode 100644
index 0000000..47719db
--- /dev/null
+++ b/src/assets/icons/misc/imac.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/misc/keyboard-layout.svg b/src/assets/icons/misc/keyboard-layout.svg
new file mode 100644
index 0000000..b20cca7
--- /dev/null
+++ b/src/assets/icons/misc/keyboard-layout.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/misc/logo.svg b/src/assets/icons/misc/logo.svg
new file mode 100644
index 0000000..9fa578d
--- /dev/null
+++ b/src/assets/icons/misc/logo.svg
@@ -0,0 +1,46 @@
+
+
diff --git a/src/assets/icons/misc/media-record.svg b/src/assets/icons/misc/media-record.svg
new file mode 100644
index 0000000..8f71f09
--- /dev/null
+++ b/src/assets/icons/misc/media-record.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/assets/icons/misc/search.svg b/src/assets/icons/misc/search.svg
new file mode 100644
index 0000000..ef84365
--- /dev/null
+++ b/src/assets/icons/misc/search.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/music.svg b/src/assets/icons/music.svg
new file mode 100644
index 0000000..a95e0cb
--- /dev/null
+++ b/src/assets/icons/music.svg
@@ -0,0 +1,29 @@
+
diff --git a/src/assets/icons/notification.png b/src/assets/icons/notification.png
new file mode 100644
index 0000000..41d66d6
Binary files /dev/null and b/src/assets/icons/notification.png differ
diff --git a/src/assets/icons/notifications/notification-active.svg b/src/assets/icons/notifications/notification-active.svg
new file mode 100644
index 0000000..63452f8
--- /dev/null
+++ b/src/assets/icons/notifications/notification-active.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/icons/notifications/notification-disabled.svg b/src/assets/icons/notifications/notification-disabled.svg
new file mode 100644
index 0000000..d4cb163
--- /dev/null
+++ b/src/assets/icons/notifications/notification-disabled.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/assets/icons/notifications/notification-inactive.svg b/src/assets/icons/notifications/notification-inactive.svg
new file mode 100644
index 0000000..108e755
--- /dev/null
+++ b/src/assets/icons/notifications/notification-inactive.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/assets/icons/player/Pause.svg b/src/assets/icons/player/Pause.svg
new file mode 100644
index 0000000..f95a5b8
--- /dev/null
+++ b/src/assets/icons/player/Pause.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/player/Rewind.svg b/src/assets/icons/player/Rewind.svg
new file mode 100644
index 0000000..e52bc1d
--- /dev/null
+++ b/src/assets/icons/player/Rewind.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/player/audio-switcher.svg b/src/assets/icons/player/audio-switcher.svg
new file mode 100644
index 0000000..d50264f
--- /dev/null
+++ b/src/assets/icons/player/audio-switcher.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icons/player/fwd.svg b/src/assets/icons/player/fwd.svg
new file mode 100644
index 0000000..57f16c3
--- /dev/null
+++ b/src/assets/icons/player/fwd.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/player/play.svg b/src/assets/icons/player/play.svg
new file mode 100644
index 0000000..d3356bf
--- /dev/null
+++ b/src/assets/icons/player/play.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/power_modes/battery-balanced.svg b/src/assets/icons/power_modes/battery-balanced.svg
new file mode 100644
index 0000000..9d9104b
--- /dev/null
+++ b/src/assets/icons/power_modes/battery-balanced.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/power_modes/battery-performance.svg b/src/assets/icons/power_modes/battery-performance.svg
new file mode 100644
index 0000000..3bd7b42
--- /dev/null
+++ b/src/assets/icons/power_modes/battery-performance.svg
@@ -0,0 +1,116 @@
+
+
diff --git a/src/assets/icons/power_modes/battery-power.svg b/src/assets/icons/power_modes/battery-power.svg
new file mode 100644
index 0000000..87375ae
--- /dev/null
+++ b/src/assets/icons/power_modes/battery-power.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/icons/todo/checkbox-check.svg b/src/assets/icons/todo/checkbox-check.svg
new file mode 100644
index 0000000..198f1dd
--- /dev/null
+++ b/src/assets/icons/todo/checkbox-check.svg
@@ -0,0 +1,58 @@
+
+
+
+
diff --git a/src/assets/icons/todo/checkbox-uncheck.svg b/src/assets/icons/todo/checkbox-uncheck.svg
new file mode 100644
index 0000000..a0ee941
--- /dev/null
+++ b/src/assets/icons/todo/checkbox-uncheck.svg
@@ -0,0 +1,53 @@
+
+
+
+
diff --git a/src/assets/icons/todo/delete-symbolic.svg b/src/assets/icons/todo/delete-symbolic.svg
new file mode 100644
index 0000000..180f034
--- /dev/null
+++ b/src/assets/icons/todo/delete-symbolic.svg
@@ -0,0 +1,43 @@
+
+
diff --git a/src/assets/icons/todo/edit.svg b/src/assets/icons/todo/edit.svg
new file mode 100644
index 0000000..42b5282
--- /dev/null
+++ b/src/assets/icons/todo/edit.svg
@@ -0,0 +1,59 @@
+
+
diff --git a/src/assets/icons/todo/plus-symbolic.svg b/src/assets/icons/todo/plus-symbolic.svg
new file mode 100644
index 0000000..af065bd
--- /dev/null
+++ b/src/assets/icons/todo/plus-symbolic.svg
@@ -0,0 +1,51 @@
+
+
diff --git a/src/assets/icons/todo/to-do-app-symbolic.svg b/src/assets/icons/todo/to-do-app-symbolic.svg
new file mode 100644
index 0000000..17cbcdf
--- /dev/null
+++ b/src/assets/icons/todo/to-do-app-symbolic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/volume/audio-volume-0.svg b/src/assets/icons/volume/audio-volume-0.svg
new file mode 100644
index 0000000..2728d98
--- /dev/null
+++ b/src/assets/icons/volume/audio-volume-0.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/assets/icons/volume/audio-volume-1.svg b/src/assets/icons/volume/audio-volume-1.svg
new file mode 100644
index 0000000..8378bed
--- /dev/null
+++ b/src/assets/icons/volume/audio-volume-1.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/volume/audio-volume-2.svg b/src/assets/icons/volume/audio-volume-2.svg
new file mode 100644
index 0000000..927462a
--- /dev/null
+++ b/src/assets/icons/volume/audio-volume-2.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/volume/audio-volume-3.svg b/src/assets/icons/volume/audio-volume-3.svg
new file mode 100644
index 0000000..abd00cb
--- /dev/null
+++ b/src/assets/icons/volume/audio-volume-3.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/volume/audio-volume.svg b/src/assets/icons/volume/audio-volume.svg
new file mode 100644
index 0000000..abd00cb
--- /dev/null
+++ b/src/assets/icons/volume/audio-volume.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/icons/weather/weather-clear-night.svg b/src/assets/icons/weather/weather-clear-night.svg
new file mode 100644
index 0000000..1c56d8c
--- /dev/null
+++ b/src/assets/icons/weather/weather-clear-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-clear.svg b/src/assets/icons/weather/weather-clear.svg
new file mode 100644
index 0000000..7da66b9
--- /dev/null
+++ b/src/assets/icons/weather/weather-clear.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-clouds-night.svg b/src/assets/icons/weather/weather-clouds-night.svg
new file mode 100644
index 0000000..c6cf30c
--- /dev/null
+++ b/src/assets/icons/weather/weather-clouds-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-clouds.svg b/src/assets/icons/weather/weather-clouds.svg
new file mode 100644
index 0000000..cc2e2b5
--- /dev/null
+++ b/src/assets/icons/weather/weather-clouds.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-few-clouds-night.svg b/src/assets/icons/weather/weather-few-clouds-night.svg
new file mode 100644
index 0000000..7448ada
--- /dev/null
+++ b/src/assets/icons/weather/weather-few-clouds-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-few-clouds.svg b/src/assets/icons/weather/weather-few-clouds.svg
new file mode 100644
index 0000000..23f386a
--- /dev/null
+++ b/src/assets/icons/weather/weather-few-clouds.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-fog.svg b/src/assets/icons/weather/weather-fog.svg
new file mode 100644
index 0000000..0838f14
--- /dev/null
+++ b/src/assets/icons/weather/weather-fog.svg
@@ -0,0 +1,29 @@
+
diff --git a/src/assets/icons/weather/weather-freezing-rain.svg b/src/assets/icons/weather/weather-freezing-rain.svg
new file mode 100644
index 0000000..ac8a18b
--- /dev/null
+++ b/src/assets/icons/weather/weather-freezing-rain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-hail.svg b/src/assets/icons/weather/weather-hail.svg
new file mode 100644
index 0000000..13f4cdf
--- /dev/null
+++ b/src/assets/icons/weather/weather-hail.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-many-clouds.svg b/src/assets/icons/weather/weather-many-clouds.svg
new file mode 100644
index 0000000..adf556d
--- /dev/null
+++ b/src/assets/icons/weather/weather-many-clouds.svg
@@ -0,0 +1,15 @@
+
diff --git a/src/assets/icons/weather/weather-none-available.svg b/src/assets/icons/weather/weather-none-available.svg
new file mode 100644
index 0000000..cc31231
--- /dev/null
+++ b/src/assets/icons/weather/weather-none-available.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/weather/weather-overcast-night.svg b/src/assets/icons/weather/weather-overcast-night.svg
new file mode 100644
index 0000000..e830d22
--- /dev/null
+++ b/src/assets/icons/weather/weather-overcast-night.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/icons/weather/weather-overcast.svg b/src/assets/icons/weather/weather-overcast.svg
new file mode 100644
index 0000000..27d0593
--- /dev/null
+++ b/src/assets/icons/weather/weather-overcast.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/weather/weather-severe-alert.svg b/src/assets/icons/weather/weather-severe-alert.svg
new file mode 100644
index 0000000..9812e23
--- /dev/null
+++ b/src/assets/icons/weather/weather-severe-alert.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/icons/weather/weather-showers-day.svg b/src/assets/icons/weather/weather-showers-day.svg
new file mode 100644
index 0000000..de8527e
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-night.svg b/src/assets/icons/weather/weather-showers-night.svg
new file mode 100644
index 0000000..85b1dd9
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-scattered-day.svg b/src/assets/icons/weather/weather-showers-scattered-day.svg
new file mode 100644
index 0000000..07e7c05
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-scattered-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-scattered-night.svg b/src/assets/icons/weather/weather-showers-scattered-night.svg
new file mode 100644
index 0000000..9a03c61
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-scattered-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-scattered.svg b/src/assets/icons/weather/weather-showers-scattered.svg
new file mode 100644
index 0000000..66e03d9
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-scattered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers.svg b/src/assets/icons/weather/weather-showers.svg
new file mode 100644
index 0000000..b7cb07a
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-night.svg b/src/assets/icons/weather/weather-snow-night.svg
new file mode 100644
index 0000000..d577ca2
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-night.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/icons/weather/weather-snow-rain.svg b/src/assets/icons/weather/weather-snow-rain.svg
new file mode 100644
index 0000000..518c9cd
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-rain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-scattered-day.svg b/src/assets/icons/weather/weather-snow-scattered-day.svg
new file mode 100644
index 0000000..fba0379
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-scattered-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-scattered-night.svg b/src/assets/icons/weather/weather-snow-scattered-night.svg
new file mode 100644
index 0000000..52c7945
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-scattered-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-scattered.svg b/src/assets/icons/weather/weather-snow-scattered.svg
new file mode 100644
index 0000000..e88f712
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-scattered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow.svg b/src/assets/icons/weather/weather-snow.svg
new file mode 100644
index 0000000..a27de54
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-storm-night.svg b/src/assets/icons/weather/weather-storm-night.svg
new file mode 100644
index 0000000..2231272
--- /dev/null
+++ b/src/assets/icons/weather/weather-storm-night.svg
@@ -0,0 +1,19 @@
+
diff --git a/src/assets/icons/weather/weather-storm-tornado.svg b/src/assets/icons/weather/weather-storm-tornado.svg
new file mode 100644
index 0000000..41b7c6b
--- /dev/null
+++ b/src/assets/icons/weather/weather-storm-tornado.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/weather/weather-storm.svg b/src/assets/icons/weather/weather-storm.svg
new file mode 100644
index 0000000..b752ca5
--- /dev/null
+++ b/src/assets/icons/weather/weather-storm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-windy.svg b/src/assets/icons/weather/weather-windy.svg
new file mode 100644
index 0000000..e76d9da
--- /dev/null
+++ b/src/assets/icons/weather/weather-windy.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/icons/wifi/network-wireless-0.svg b/src/assets/icons/wifi/network-wireless-0.svg
new file mode 100644
index 0000000..79a7d67
--- /dev/null
+++ b/src/assets/icons/wifi/network-wireless-0.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/wifi/network-wireless-100.svg b/src/assets/icons/wifi/network-wireless-100.svg
new file mode 100644
index 0000000..9276ebe
--- /dev/null
+++ b/src/assets/icons/wifi/network-wireless-100.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/wifi/network-wireless-20.svg b/src/assets/icons/wifi/network-wireless-20.svg
new file mode 100644
index 0000000..5dd4259
--- /dev/null
+++ b/src/assets/icons/wifi/network-wireless-20.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/wifi/network-wireless-40.svg b/src/assets/icons/wifi/network-wireless-40.svg
new file mode 100644
index 0000000..d323c5c
--- /dev/null
+++ b/src/assets/icons/wifi/network-wireless-40.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/wifi/network-wireless-60.svg b/src/assets/icons/wifi/network-wireless-60.svg
new file mode 100644
index 0000000..47c2a37
--- /dev/null
+++ b/src/assets/icons/wifi/network-wireless-60.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/wifi/network-wireless-80.svg b/src/assets/icons/wifi/network-wireless-80.svg
new file mode 100644
index 0000000..6e65071
--- /dev/null
+++ b/src/assets/icons/wifi/network-wireless-80.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/assets/icons/wifi/wifi-connecting.svg b/src/assets/icons/wifi/wifi-connecting.svg
new file mode 100644
index 0000000..6d95e92
--- /dev/null
+++ b/src/assets/icons/wifi/wifi-connecting.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/assets/modus.png b/src/assets/modus.png
new file mode 100644
index 0000000..eb680cc
Binary files /dev/null and b/src/assets/modus.png differ
diff --git a/src/assets/screenshots/home.png b/src/assets/screenshots/home.png
new file mode 100644
index 0000000..a98ab7c
Binary files /dev/null and b/src/assets/screenshots/home.png differ
diff --git a/src/assets/screenshots/lock.png b/src/assets/screenshots/lock.png
new file mode 100644
index 0000000..123f456
Binary files /dev/null and b/src/assets/screenshots/lock.png differ
diff --git a/src/assets/wallpaper_example/example-1.png b/src/assets/wallpaper_example/example-1.png
new file mode 100644
index 0000000..0dcf6f3
Binary files /dev/null and b/src/assets/wallpaper_example/example-1.png differ
diff --git a/src/assets/wallpaper_example/example-2.png b/src/assets/wallpaper_example/example-2.png
new file mode 100644
index 0000000..470534f
Binary files /dev/null and b/src/assets/wallpaper_example/example-2.png differ
diff --git a/src/assets/wallpaper_example/example-3.jpg b/src/assets/wallpaper_example/example-3.jpg
new file mode 100644
index 0000000..a9084d5
Binary files /dev/null and b/src/assets/wallpaper_example/example-3.jpg differ
diff --git a/src/assets/wallpaper_example/example-4.png b/src/assets/wallpaper_example/example-4.png
new file mode 100644
index 0000000..61eaafa
Binary files /dev/null and b/src/assets/wallpaper_example/example-4.png differ
diff --git a/src/assets/wallpaper_example/example-5.png b/src/assets/wallpaper_example/example-5.png
new file mode 100644
index 0000000..af9e956
Binary files /dev/null and b/src/assets/wallpaper_example/example-5.png differ
diff --git a/lock.py b/src/lock.py
similarity index 92%
rename from lock.py
rename to src/lock.py
index 5909132..f548990 100644
--- a/lock.py
+++ b/src/lock.py
@@ -1,38 +1,32 @@
import getpass
-import os
-import gi
import pam
from fabric import Application
-from fabric.utils import get_relative_path
+from fabric.utils import Gdk, GLib, get_relative_path, logger, os
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.datetime import DateTime
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.window import Window
-from gi.repository import (
- Gdk, # pyright: ignore[reportMissingModuleSource]
- GLib,
- GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue]
-)
+from gi.repository import GtkSessionLock # pyright: ignore[reportAttributeAccessIssue]
-from modules.panel.components.indicators import (
+from shared.widgets.circle_image import CircleImage as Image
+from utils.functions import set_process_name
+from window.panel.components.indicators import (
BatteryIndicator,
BluetoothIndicator,
NetworkIndicator,
)
-from utils.functions import set_process_name
-from widgets.circle_image import CircleImage as Image
-gi.require_version("Gdk", "3.0")
-gi.require_version("Gtk", "3.0")
-
-
-gi.require_version("GtkSessionLock", "0.1")
-# from fabric.widgets.image import Image
-
-# from widgets.wayland import WaylandWindow as Window
+for log in [
+ "fabric.audio.service",
+ "fabric.core.application",
+ "fabric.bluetooth.service",
+ "fabric.notifications.service",
+ "services.network",
+]:
+ logger.disable(log)
class IndicatorBox(Box):
@@ -218,19 +212,24 @@ def initialize():
lockscreen.show()
-if __name__ == "__main__":
+def main():
set_process_name("lockscreen")
initialize()
lockscreen = LockScreen(GtkSessionLock.Lock())
+ global app
app = Application("lock", lockscreen)
def set_css():
app.set_stylesheet_from_file(
- get_relative_path("styles/main.css"),
+ get_relative_path("shared/styles/main.css"),
)
app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue]
app.set_css() # pyright: ignore[reportAttributeAccessIssue]
app.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..16586da
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,71 @@
+import os
+from fabric import Application
+from fabric.utils import get_relative_path, logger, monitor_file
+
+from shared.data import APP_NAME, load_config
+from utils.functions import set_process_name
+from window.desktop.widget import Deskwidgets
+from window.dock import Dock
+from window.launcher.main import Launcher
+from window.notification.notification import ModusNoti
+from window.osd.main import OSDWindow
+from window.panel.main import Panel
+from window.switcher import ApplicationSwitcher
+
+for log in [
+ "fabric",
+ "services",
+ "window",
+ "utils",
+]:
+ logger.disable(log)
+
+
+def main():
+ # Generate colors.css if it doesn't exist
+ colors_css_path = get_relative_path("shared/styles/colors.css")
+ if not os.path.exists(colors_css_path):
+ from utils.utils import generate_colors_from_wallpaper
+
+ default_wallpaper = get_relative_path("assets/wallpaper_example/example-1.png")
+ if os.path.exists(default_wallpaper):
+ generate_colors_from_wallpaper(default_wallpaper)
+
+ set_process_name(APP_NAME)
+
+ load_config()
+ switcher = ApplicationSwitcher()
+ panel = Panel()
+ modusnoti = ModusNoti()
+ launcher = Launcher()
+ deskwidget = Deskwidgets()
+ dock = Dock()
+ osd = OSDWindow()
+ # Monitor CSS files for changes
+ css_file = monitor_file(get_relative_path("shared/styles"))
+ _ = css_file.connect("changed", lambda *_: set_css())
+
+ app = Application(
+ f"{APP_NAME}",
+ panel,
+ modusnoti,
+ deskwidget.top_left,
+ deskwidget.bottom_left,
+ osd,
+ launcher,
+ switcher,
+ dock,
+ )
+
+ def set_css():
+ app.set_stylesheet_from_file(
+ get_relative_path("shared/styles/main.css"),
+ )
+
+ app.set_css = set_css
+ app.set_css()
+ app.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/services/__init__.py b/src/services/__init__.py
similarity index 100%
rename from services/__init__.py
rename to src/services/__init__.py
diff --git a/services/auth.py b/src/services/auth.py
similarity index 98%
rename from services/auth.py
rename to src/services/auth.py
index c205987..50af801 100644
--- a/services/auth.py
+++ b/src/services/auth.py
@@ -1,5 +1,4 @@
import json
-import os
import subprocess
import time
from datetime import datetime
@@ -7,21 +6,19 @@ from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pyotp
-from fabric.utils import GdkPixbuf
+from fabric.utils import GdkPixbuf, os
from pyzbar.pyzbar import decode
-import config.data as data
+import shared.data as data
def get_otp_file_path():
"""Returns the path to the OTP file inside the cache directory."""
cache_dir = Path(data.CACHE_DIR) / "otp"
- # Create the directory if it doesn't exist
cache_dir.mkdir(parents=True, exist_ok=True)
file_path = cache_dir / "otp_codes.json"
- # Create the file if it doesn't exist
if not file_path.exists():
with open(file_path, "w") as f:
json.dump([], f)
diff --git a/services/battery.py b/src/services/battery.py
similarity index 100%
rename from services/battery.py
rename to src/services/battery.py
diff --git a/services/brightness.py b/src/services/brightness.py
similarity index 100%
rename from services/brightness.py
rename to src/services/brightness.py
diff --git a/services/capslock.py b/src/services/capslock.py
similarity index 100%
rename from services/capslock.py
rename to src/services/capslock.py
diff --git a/services/config.py b/src/services/config.py
similarity index 87%
rename from services/config.py
rename to src/services/config.py
index fa57c9b..653b189 100644
--- a/services/config.py
+++ b/src/services/config.py
@@ -34,7 +34,7 @@ class ConfigService:
self._reload_callbacks: List[
Callable[[Dict[str, Any], Dict[str, Any]], None]
] = []
- self._config_file: str = get_relative_path("../config.json")
+ self._config_file: str = get_relative_path("../../config/config.json")
self._monitors: List[Gio.FileMonitor] = []
self._reload_pending: bool = False
self.RELOAD_DELAY_MS: int = 100
@@ -77,7 +77,21 @@ class ConfigService:
with open(self._config_file, "r") as f:
self._config = json.load(f)
else:
- self._config = {}
+ try:
+ from utils.constants import DEFAULT
+
+ # Ensure the config directory exists
+ os.makedirs(os.path.dirname(self._config_file), exist_ok=True)
+ with open(self._config_file, "w") as f:
+ json.dump(DEFAULT, f, indent=4)
+
+ self._config = DEFAULT.copy()
+ logger.info(
+ f"[ConfigService] Generated default config at {self._config_file}"
+ )
+ except ImportError as e:
+ logger.error(f"[ConfigService] Failed to import defaults: {e}")
+ self._config = {}
except Exception as e:
logger.error(f"[ConfigService] Failed to load config: {e}")
self._config = {}
diff --git a/services/custom_notification.py b/src/services/custom_notification.py
similarity index 97%
rename from services/custom_notification.py
rename to src/services/custom_notification.py
index 1558924..c75e89c 100644
--- a/services/custom_notification.py
+++ b/src/services/custom_notification.py
@@ -1,6 +1,4 @@
-# Standard library imports
import json
-import time
from typing import List
from fabric.core.service import Property, Service, Signal
@@ -10,11 +8,9 @@ from fabric.notifications import (
NotificationImagePixmap,
Notifications,
)
+from fabric.utils import GdkPixbuf, logger, os, time
-# Fabric imports
-from fabric.utils import GdkPixbuf, os, logger
-
-import config.data as data
+import shared.data as data
NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json"
@@ -101,7 +97,7 @@ class CachedNotification(Service):
@Property(dict, "readable")
def serialized(self) -> dict:
"""Enhanced serialization with cache metadata - stores only cache keys"""
- from modules.notification.notification import (
+ from window.notification.notification import (
get_cache_key,
get_notification_image_cache_key,
)
@@ -273,7 +269,7 @@ class CachedNotifications(Notifications):
def clear_all_cached_notifications(self):
"""Empty the notifications with enhanced cache cleanup"""
# Clean up all cached files before clearing notifications
- from modules.notification.notification import cleanup_all_notification_caches
+ from window.notification.notification import cleanup_all_notification_caches
for cached_notification in self._cached_notifications.values():
handler_id = self._signal_handlers.pop(cached_notification.cache_id, None)
@@ -304,8 +300,7 @@ class CachedNotifications(Notifications):
return
# Import here to avoid circular imports
- from config import data
- from modules.notification.notification import (
+ from window.notification.notification import (
cache_notification_icon,
cache_notification_image,
get_cache_key,
@@ -467,7 +462,7 @@ class CachedNotifications(Notifications):
cache_metadata = cached_notification.cache_metadata
# Clean up specific cached files using stored keys
- from modules.notification.notification import (
+ from window.notification.notification import (
cleanup_notification_specific_caches,
)
diff --git a/utils/gamemode.py b/src/services/gamemode.py
similarity index 100%
rename from utils/gamemode.py
rename to src/services/gamemode.py
diff --git a/services/keyboard_layout.py b/src/services/keyboard_layout.py
similarity index 99%
rename from services/keyboard_layout.py
rename to src/services/keyboard_layout.py
index 70aa140..717672a 100644
--- a/services/keyboard_layout.py
+++ b/src/services/keyboard_layout.py
@@ -6,7 +6,7 @@ from pathlib import Path
from fabric.core.service import Property, Service, Signal
from fabric.utils import GLib, logger, os
-import config.data as data
+import shared.data as data
from services.config import on_config_change
from utils.functions import read_json_file, run_command, write_json_file
diff --git a/services/modus.py b/src/services/modus.py
similarity index 100%
rename from services/modus.py
rename to src/services/modus.py
diff --git a/services/mpris.py b/src/services/mpris.py
similarity index 100%
rename from services/mpris.py
rename to src/services/mpris.py
diff --git a/services/network.py b/src/services/network.py
similarity index 100%
rename from services/network.py
rename to src/services/network.py
diff --git a/src/services/screencapture.py b/src/services/screencapture.py
new file mode 100644
index 0000000..8f6cc58
--- /dev/null
+++ b/src/services/screencapture.py
@@ -0,0 +1,409 @@
+import json
+from datetime import datetime
+from pathlib import Path
+
+from fabric import Service, Signal
+from fabric.core.service import Property
+from fabric.utils import (
+ Gio,
+ exec_shell_command,
+ exec_shell_command_async,
+ logger,
+ time,
+)
+
+from utils.functions import is_app_running, run_command, kill_process
+
+
+class ScreenCapture(Service):
+ """Service for screen capture and recording functionality"""
+
+ @Signal
+ def screenshot_taken(self, path: str) -> None:
+ """Signal emitted when screenshot is taken."""
+
+ @Signal
+ def recording_started(self, path: str) -> None:
+ """Signal emitted when recording starts."""
+
+ @Signal
+ def recording_stopped(self, path: str) -> None:
+ """Signal emitted when recording stops."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.home = Path.home()
+ self.screenshots_dir = self.home / "Pictures" / "Screenshots"
+ self.recordings_dir = self.home / "Videos" / "Recordings"
+ self.recording_file = Path("/tmp/recording.txt")
+ self.recording_start_time_file = Path("/tmp/recording_start_time.txt")
+
+ self.screenshots_dir.mkdir(parents=True, exist_ok=True)
+ self.recordings_dir.mkdir(parents=True, exist_ok=True)
+
+ def notify_send(self, title, message, icon=None, actions=None):
+ cmd = ["notify-send", "-a", "Modus"]
+
+ if icon:
+ cmd.extend(["-i", str(icon)])
+
+ if actions:
+ for action in actions:
+ cmd.extend(["-A", f"{action}={action}"])
+
+ cmd.extend([title, message])
+ return run_command(cmd)
+
+ def send_screenshot_notification(self, file_path=None):
+ cmd = ["notify-send"]
+ cmd.extend(
+ [
+ "-A",
+ "files=Show in Files",
+ "-A",
+ "view=View",
+ "-A",
+ "edit=Edit",
+ "-i",
+ "camera-photo-symbolic",
+ "-a",
+ "Fabric Screenshot Utility",
+ "-h",
+ f"STRING:image-path:{file_path}",
+ "Screenshot Saved",
+ f"Saved Screenshot at {file_path}",
+ ]
+ if file_path
+ else ["Screenshot Sent to Clipboard"]
+ )
+
+ proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE)
+
+ def do_callback(process: Gio.Subprocess, task: Gio.Task):
+ try:
+ _, stdout, stderr = process.communicate_utf8_finish(task)
+ except Exception:
+ logger.error(
+ f"[SCREENSHOT] Failed read notification action with error {stderr}"
+ )
+ return
+
+ match stdout.strip("\n"):
+ case "files":
+ exec_shell_command_async(f"xdg-open {self.screenshots_dir}")
+ case "view":
+ exec_shell_command_async(f"xdg-open {file_path}")
+ case "edit":
+ exec_shell_command_async(f"satty -f {file_path}")
+
+ proc.communicate_utf8_async(None, None, do_callback)
+ self.screenshot_taken(file_path)
+
+ def send_recording_notification(self, file_path):
+ cmd = ["notify-send"]
+ cmd.extend(
+ [
+ "-A",
+ "files=Show in Files",
+ "-A",
+ "view=View",
+ "-i",
+ "camera-video-symbolic",
+ "-a",
+ "Fabric Screenshot Utility",
+ "Screencast Saved",
+ f"Saved Screencast at {file_path}",
+ ]
+ )
+
+ proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE)
+
+ def do_callback(process: Gio.Subprocess, task: Gio.Task):
+ try:
+ _, stdout, stderr = process.communicate_utf8_finish(task)
+ except Exception:
+ logger.error(
+ f"[SCREENCAST] Failed read notification action with error {stderr}"
+ )
+ return
+
+ match stdout.strip("\n"):
+ case "files":
+ exec_shell_command_async(f"xdg-open {self.recordings_dir}")
+ case "view":
+ exec_shell_command_async(f"xdg-open {file_path}")
+
+ proc.communicate_utf8_async(None, None, do_callback)
+ self.recording_stopped(file_path)
+
+ def check_wf_recorder(self):
+ if not is_app_running("wf-recorder"):
+ return False
+
+ kill_process("wf-recorder")
+
+ if self.recording_file.exists():
+ recording_file = self.recording_file.read_text().strip()
+ self.send_recording_notification(recording_file)
+
+ if self.recording_start_time_file.exists():
+ self.recording_start_time_file.unlink()
+
+ return True
+
+ def record_video(self, output_file, *args):
+ cmd = [
+ "wf-recorder",
+ *args,
+ "-f",
+ str(output_file),
+ "-c",
+ "libvpx-vp9",
+ "--pixel-format",
+ "yuv420p",
+ "-F",
+ "eq=brightness=0.12:contrast=1.1",
+ ]
+ return run_command(cmd)
+
+ def record_video_noaudio(self, output_file, *args):
+ cmd = [
+ "wf-recorder",
+ *args,
+ "-f",
+ str(output_file),
+ "-c",
+ "libvpx-vp9",
+ "--pixel-format",
+ "yuv420p",
+ "-F",
+ "eq=brightness=0.12:contrast=1.1",
+ "--no-audio",
+ ]
+ return run_command(cmd)
+
+ def _get_active_monitor(self):
+ """Get the name of the currently focused monitor."""
+ try:
+ monitors_json = exec_shell_command("hyprctl monitors -j")
+ if monitors_json:
+ monitors = json.loads(monitors_json)
+ for monitor in monitors:
+ if monitor.get("focused"):
+ return monitor.get("name")
+ except Exception as e:
+ logger.error(f"[SCREENSHOT] Failed to get active monitor: {e}")
+ return "eDP-1" # Fallback
+
+ def screenshot(self, target="region"):
+ """
+ Take a screenshot.
+ :param target: 'region', 'active', 'output', 'both', or a specific display name like 'eDP-1'
+ """
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ screenshot_path = self.screenshots_dir / f"{timestamp}.png"
+
+ if target == "both":
+ # Special case for multiple displays, similar to the shell script
+ temp_edp = self.screenshots_dir / f"{timestamp}_eDP-1.png"
+ temp_hdmi = self.screenshots_dir / f"{timestamp}_HDMI-A-1.png"
+
+ cmd = f"grim -c -o eDP-1 {temp_edp} && grim -c -o HDMI-A-1 {temp_hdmi} && montage {temp_edp} {temp_hdmi} -tile 2x1 -geometry +0+0 {screenshot_path} && rm {temp_edp} {temp_hdmi}"
+ exec_shell_command_async(
+ cmd, lambda *_: self.send_screenshot_notification(str(screenshot_path))
+ )
+ return True
+
+ command = [
+ "hyprshot",
+ "-s",
+ "-o",
+ str(self.screenshots_dir),
+ "-f",
+ f"{timestamp}.png",
+ ]
+
+ if target == "region":
+ command.extend(["-m", "region"])
+ elif target == "active":
+ active_monitor = self._get_active_monitor()
+ command.extend(["-m", "output", "-m", active_monitor])
+ elif target == "output":
+ command.extend(["-m", "output"])
+ else:
+ # Specific display name
+ command.extend(["-m", "output", "-m", target])
+
+ command.append("-- ls")
+
+ try:
+ exec_shell_command_async(
+ " ".join(command),
+ self._after_screenshot,
+ )
+ except Exception as e:
+ logger.error(f"Screenshot failed: {e}")
+ return False
+
+ return True
+
+ def _after_screenshot(self, *_):
+ try:
+ screenshot_files = list(self.screenshots_dir.glob("*.png"))
+ if screenshot_files:
+ latest_file = max(screenshot_files, key=lambda f: f.stat().st_mtime)
+ self.send_screenshot_notification(file_path=str(latest_file))
+ time.sleep(0.2)
+ else:
+ # No file found after command: likely user cancelled the selection
+ self.notify_send(
+ "Screenshot cancelled",
+ "Selection was cancelled",
+ icon="camera-photo-symbolic",
+ )
+ except Exception as e:
+ logger.error(f"Screenshot notification failed: {e}")
+
+ def record(self, target="selection", no_audio=False, mode="standard"):
+ """
+ Start recording.
+ :param target: 'selection', 'eDP-1', 'HDMI-A-1', etc.
+ :param no_audio: bool
+ :param mode: 'standard', 'hq', 'gif'
+ """
+ if self.is_recording:
+ logger.error(
+ "[SCREENRECORD] Another instance of wf-recorder is already running"
+ )
+ return False
+
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ ext = "mp4" if mode == "hq" else "gif" if mode == "gif" else "mkv"
+ output_file = self.recordings_dir / f"{timestamp}.{ext}"
+
+ self._current_recording_path = str(output_file)
+ self.recording_file.write_text(str(output_file))
+ self.recording_start_time_file.write_text(str(int(time.time())))
+
+ area = ""
+ if target == "selection":
+ geometry = exec_shell_command("slurp")
+ if not geometry or not str(geometry).strip():
+ self.notify_send(
+ "Recording cancelled",
+ "Selection was cancelled",
+ icon="camera-video-symbolic",
+ )
+ return False
+ area = f"-g '{geometry}'"
+ elif target == "active":
+ active_monitor = self._get_active_monitor()
+ area = f"-o {active_monitor}"
+ else:
+ area = f"-o {target}"
+
+ if mode == "gif":
+ # GIF optimized recording (lower fps, temp mkv then convert)
+ temp_video = f"/tmp/gif_recording_{int(time.time())}.mkv"
+ self.recording_file.write_text(temp_video) # Update tracking to temp file
+ command = f"wf-recorder -f {temp_video} -c libvpx-vp9 -r 15 --pixel-format yuv420p --no-audio {area}"
+
+ def after_gif_recording(*_):
+ if Path(temp_video).exists():
+ self.notify_send("Converting to GIF", "Processing recording...")
+ conv_cmd = f"ffmpeg -i {temp_video} -vf 'fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_file}"
+ exec_shell_command_async(
+ conv_cmd,
+ lambda *_: (
+ Path(temp_video).unlink(),
+ self.send_recording_notification(str(output_file)),
+ ),
+ )
+
+ exec_shell_command_async(command, after_gif_recording)
+ elif mode == "hq":
+ # High quality preset
+ preset_flags = "-c h264_vaapi -p 'preset=slow' -p 'crf=18' -r 60 -b 8000000 -B 192000 -g 30"
+ if no_audio:
+ preset_flags += " --no-audio"
+ command = f"wf-recorder --file={output_file} --pixel-format yuv420p {preset_flags} {area}"
+ exec_shell_command_async(command)
+ else:
+ # Standard mode
+ audio_flag = "--no-audio" if no_audio else ""
+ command = f"wf-recorder --file={output_file} --pixel-format yuv420p {audio_flag} {area}"
+ exec_shell_command_async(command)
+
+ self.recording_started(str(output_file))
+ return True
+
+ def stop_recording(self):
+ kill_process("wf-recorder")
+ if hasattr(self, "_current_recording_path"):
+ self.recording_stopped(self._current_recording_path)
+ return True
+
+ def convert(self, format_type, file_path=None):
+ """
+ Convert video to specified format.
+ :param format_type: 'webm', 'iphone', 'youtube', 'gif'
+ :param file_path: Optional path to specific file
+ """
+ if not file_path:
+ # Find latest recording
+ files = list(self.recordings_dir.glob("*.[mkv|mp4]*"))
+ if not files:
+ self.notify_send("Conversion Error", "No recordings found to convert")
+ return False
+ file_path = str(max(files, key=lambda f: f.stat().st_mtime))
+
+ file_path_obj = Path(file_path)
+ if not file_path_obj.exists():
+ self.notify_send("Conversion Error", f"File not found: {file_path}")
+ return False
+
+ output_path = file_path_obj.with_suffix(f".{format_type}")
+ if format_type == "iphone":
+ output_path = file_path_obj.with_name(f"{file_path_obj.stem}-iphone.mp4")
+ elif format_type == "youtube":
+ output_path = file_path_obj.with_name(f"{file_path_obj.stem}-youtube.mp4")
+
+ self.notify_send(
+ f"Converting to {format_type.upper()}", f"Processing: {file_path_obj.name}"
+ )
+
+ if format_type == "webm":
+ cmd = f"ffmpeg -y -i {file_path} -c:v libvpx -b:v 1M -c:a libvorbis {output_path}"
+ elif format_type == "iphone":
+ cmd = f"ffmpeg -y -i {file_path} -vcodec h264 -acodec aac {output_path}"
+ elif format_type == "youtube":
+ cmd = f"ffmpeg -y -i {file_path} -c:v libx264 -profile:v high -preset slow -crf 18 -pix_fmt yuv420p -c:a aac -b:a 384k -movflags +faststart {output_path}"
+ elif format_type == "gif":
+ cmd = f"ffmpeg -i {file_path} -vf 'fps=15,scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_path}"
+ else:
+ return False
+
+ def on_done(*_):
+ self.notify_send(
+ f"{format_type.upper()} Conversion Success",
+ f"Saved to {output_path.name}",
+ )
+ if format_type == "gif":
+ exec_shell_command_async(f"wl-copy < {output_path}")
+
+ exec_shell_command_async(cmd, on_done)
+ return True
+
+ @Property(bool, "readable", default_value=False)
+ def is_recording(self):
+ return is_app_running("wf-recorder") or is_app_running("gpu-screen-recorder")
+
+
+screen_capture_service = ScreenCapture()
diff --git a/services/todo.py b/src/services/todo.py
similarity index 99%
rename from services/todo.py
rename to src/services/todo.py
index c440381..97365f8 100644
--- a/services/todo.py
+++ b/src/services/todo.py
@@ -8,7 +8,7 @@ from pathlib import Path
from fabric.core.service import Property, Service
# Local imports
-import config.data as data
+import shared.data as data
class TodoService(Service):
diff --git a/src/shared/__init__.py b/src/shared/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/shared/data.py b/src/shared/data.py
new file mode 100644
index 0000000..43f9241
--- /dev/null
+++ b/src/shared/data.py
@@ -0,0 +1,116 @@
+import json
+
+from fabric.utils import Gdk, GLib, get_relative_path, os
+
+from utils.functions import parse_timeout_string
+
+APP_NAME = "modus1"
+APP_NAME_CAP = "Modus"
+
+
+CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
+USERNAME = os.getlogin()
+HOSTNAME = os.uname().nodename
+HOME_DIR = GLib.get_home_dir()
+
+CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}")
+
+screen = Gdk.Screen.get_default()
+CURRENT_WIDTH = screen.get_width()
+CURRENT_HEIGHT = screen.get_height()
+
+
+WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/")
+CONFIG_FILE = get_relative_path("../../config/config.json")
+MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
+
+
+def load_config():
+ """Load the configuration from config.json"""
+ try:
+ from services.config import start_config_service
+
+ service = start_config_service()
+ return service.get_all()
+ except ImportError:
+ # Fallback to direct file loading
+ config = {}
+
+ if os.path.exists(CONFIG_FILE):
+ try:
+ with open(CONFIG_FILE, "r") as f:
+ config = json.load(f)
+ except Exception as e:
+ print(f"Error loading config: {e}")
+
+ return config
+
+
+if os.path.exists(CONFIG_FILE):
+ with open(CONFIG_FILE, "r") as f:
+ config = json.load(f)
+ wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT)
+ WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config)
+ DOCK_POSITION = config.get("dock_position", "Bottom")
+ DOCK_ENABLED = config.get("dock_enabled", True)
+ DOCK_AUTO_HIDE = config.get("dock_auto_hide", True)
+ DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False)
+ DOCK_ICON_SIZE = config.get("dock_icon_size", 60)
+ WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 10)
+ HIDE_SPECIAL_WORKSPACE = config.get("hide_special_workspace", True)
+ DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(
+ "dock_hide_special_workspace_apps", True
+ )
+
+ NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s")
+ NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
+ NOTIFICATION_IGNORED_APPS_HISTORY = config.get(
+ "notification_ignored_apps_history", ["Hyprshot"]
+ )
+ NOTIFICATION_LIMITED_APPS_HISTORY = config.get(
+ "notification_limited_apps_history", ["Spotify"]
+ )
+
+ PANEL_COMPONENTS_VISIBILITY = {
+ "imac_button": config.get("imac_button_visible", True),
+ "systray": config.get("systray_visible", True),
+ "control_center": config.get("control_center_visible", True),
+ "search": config.get("search_visible", True),
+ "global_menu": config.get("global_menu_visible", True),
+ "network": config.get("network_visible", True),
+ "battery": config.get("battery_visible", True),
+ "notification_center": config.get("notification_center_visible", True),
+ "workspace_indicator": config.get("workspace_indicator_visible", True),
+ "bluetooth": config.get("bluetooth_visible", True),
+ "date_time": config.get("date_time_visible", True),
+ }
+
+else:
+ WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT
+ DOCK_POSITION = "Bottom"
+ DOCK_ENABLED = True
+ DOCK_ALWAYS_OCCLUDED = False
+ DOCK_AUTO_HIDE = True
+ DOCK_ICON_SIZE = 52
+ WINDOW_SWITCHER_ITEMS_PER_ROW = 10
+ HIDE_SPECIAL_WORKSPACE = True
+ DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
+
+ NOTIFICATION_TIMEOUT_STR = "5s"
+ NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
+ NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"]
+ NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"]
+
+ PANEL_COMPONENTS_VISIBILITY = {
+ "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,
+ }
diff --git a/widgets/about.py b/src/shared/dialogs/about.py
similarity index 99%
rename from widgets/about.py
rename to src/shared/dialogs/about.py
index e48f11f..e203369 100644
--- a/widgets/about.py
+++ b/src/shared/dialogs/about.py
@@ -1,7 +1,6 @@
-import re
import subprocess
-from fabric.utils import GdkPixbuf, Gtk, get_relative_path, os
+from fabric.utils import GdkPixbuf, Gtk, get_relative_path, os, re
from utils.functions import escape_markup_text
from utils.icon_resolver import IconResolver
diff --git a/widgets/wifi_password_dialog.py b/src/shared/dialogs/wifi_password_dialog.py
similarity index 99%
rename from widgets/wifi_password_dialog.py
rename to src/shared/dialogs/wifi_password_dialog.py
index 2397f9a..566b3de 100644
--- a/widgets/wifi_password_dialog.py
+++ b/src/shared/dialogs/wifi_password_dialog.py
@@ -1,13 +1,9 @@
from fabric.utils import Gdk, GLib
-
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.image import Image
from fabric.widgets.label import Label
-
-# from fabric.widgets.wayland import WaylandWindow as Window
-
from fabric.widgets.window import Window
diff --git a/styles/about.css b/src/shared/styles/about.css
similarity index 100%
rename from styles/about.css
rename to src/shared/styles/about.css
diff --git a/styles/battery-widget.css b/src/shared/styles/battery-widget.css
similarity index 100%
rename from styles/battery-widget.css
rename to src/shared/styles/battery-widget.css
diff --git a/src/shared/styles/colors.css b/src/shared/styles/colors.css
new file mode 100644
index 0000000..fa722c3
--- /dev/null
+++ b/src/shared/styles/colors.css
@@ -0,0 +1,32 @@
+:vars {
+ --foreground: #e2e2e9;
+ --background: #121318;
+ --cursor: #e2e2e9;
+ --primary: #b0c6ff;
+ --on-primary: #152e60;
+ --secondary: #c0c6dc;
+ --on-secondary: #293042;
+ --tertiary: #e0bbdd;
+ --on-tertiary: #412742;
+ --surface: #121318;
+ --surface-bright: #38393f;
+ --error: #ffb4ab;
+ --error-dim: #ff8677;
+ --on-error: #690005;
+ --error-container: #93000a;
+ --outline: #8f9099;
+ --shadow: #000000;
+ --red: #ffb2b9;
+ --red-dim: #ff7f8a;
+ --green: #95d5a7;
+ --green-dim: #70c688;
+ --yellow: #b8cf84;
+ --yellow-dim: #a2c05f;
+ --blue: #b7c4ff;
+ --blue-dim: #849afe;
+ --magenta: #e4b7f3;
+ --magenta-dim: #d38beb;
+ --cyan: #82d3e2;
+ --cyan-dim: #58c4d8;
+ --white: #82d3e0;
+}
diff --git a/styles/constant.css b/src/shared/styles/constant.css
similarity index 100%
rename from styles/constant.css
rename to src/shared/styles/constant.css
diff --git a/styles/controlcenter.css b/src/shared/styles/controlcenter.css
similarity index 100%
rename from styles/controlcenter.css
rename to src/shared/styles/controlcenter.css
diff --git a/styles/dock.css b/src/shared/styles/dock.css
similarity index 100%
rename from styles/dock.css
rename to src/shared/styles/dock.css
diff --git a/styles/dropdown.css b/src/shared/styles/dropdown.css
similarity index 100%
rename from styles/dropdown.css
rename to src/shared/styles/dropdown.css
diff --git a/styles/launcher.css b/src/shared/styles/launcher.css
similarity index 100%
rename from styles/launcher.css
rename to src/shared/styles/launcher.css
diff --git a/styles/lock.css b/src/shared/styles/lock.css
similarity index 100%
rename from styles/lock.css
rename to src/shared/styles/lock.css
diff --git a/styles/main.css b/src/shared/styles/main.css
similarity index 100%
rename from styles/main.css
rename to src/shared/styles/main.css
diff --git a/styles/notification-center.css b/src/shared/styles/notification-center.css
similarity index 100%
rename from styles/notification-center.css
rename to src/shared/styles/notification-center.css
diff --git a/styles/notification.css b/src/shared/styles/notification.css
similarity index 100%
rename from styles/notification.css
rename to src/shared/styles/notification.css
diff --git a/styles/osd.css b/src/shared/styles/osd.css
similarity index 100%
rename from styles/osd.css
rename to src/shared/styles/osd.css
diff --git a/styles/panel.css b/src/shared/styles/panel.css
similarity index 100%
rename from styles/panel.css
rename to src/shared/styles/panel.css
diff --git a/styles/password-dialog.css b/src/shared/styles/password-dialog.css
similarity index 100%
rename from styles/password-dialog.css
rename to src/shared/styles/password-dialog.css
diff --git a/styles/player.css b/src/shared/styles/player.css
similarity index 100%
rename from styles/player.css
rename to src/shared/styles/player.css
diff --git a/styles/switcher.css b/src/shared/styles/switcher.css
similarity index 100%
rename from styles/switcher.css
rename to src/shared/styles/switcher.css
diff --git a/src/shared/styles/todo.css b/src/shared/styles/todo.css
new file mode 100644
index 0000000..e69de29
diff --git a/styles/tray.css b/src/shared/styles/tray.css
similarity index 100%
rename from styles/tray.css
rename to src/shared/styles/tray.css
diff --git a/styles/widgets.css b/src/shared/styles/widgets.css
similarity index 100%
rename from styles/widgets.css
rename to src/shared/styles/widgets.css
diff --git a/utils/animator.py b/src/shared/widgets/animator.py
similarity index 100%
rename from utils/animator.py
rename to src/shared/widgets/animator.py
diff --git a/widgets/circle_image.py b/src/shared/widgets/circle_image.py
similarity index 97%
rename from widgets/circle_image.py
rename to src/shared/widgets/circle_image.py
index fb68587..8488219 100644
--- a/widgets/circle_image.py
+++ b/src/shared/widgets/circle_image.py
@@ -1,11 +1,7 @@
-import math
from typing import Literal
-
-import cairo
-
from fabric.core.service import Property
+from fabric.utils import Gdk, GdkPixbuf, Gtk, cairo, math # noqa: E402
from fabric.widgets.widget import Widget
-from fabric.utils import Gdk, GdkPixbuf, Gtk # noqa: E402
class CircleImage(Gtk.DrawingArea, Widget):
diff --git a/widgets/clipping_box.py b/src/shared/widgets/clipping_box.py
similarity index 100%
rename from widgets/clipping_box.py
rename to src/shared/widgets/clipping_box.py
diff --git a/widgets/custom_image.py b/src/shared/widgets/custom_image.py
similarity index 100%
rename from widgets/custom_image.py
rename to src/shared/widgets/custom_image.py
diff --git a/widgets/customrevealer.py b/src/shared/widgets/customrevealer.py
similarity index 98%
rename from widgets/customrevealer.py
rename to src/shared/widgets/customrevealer.py
index 0f26f86..2ca10a3 100644
--- a/widgets/customrevealer.py
+++ b/src/shared/widgets/customrevealer.py
@@ -1,6 +1,6 @@
-from fabric.utils import Gtk
-import math
-from utils.animator import Animator
+from fabric.utils import Gtk, math
+
+from shared.widgets.animator import Animator
# macOS-style easing functions for natural motion
diff --git a/widgets/battery_widget.py b/src/shared/window/battery_widget.py
similarity index 99%
rename from widgets/battery_widget.py
rename to src/shared/window/battery_widget.py
index 8fe05fc..beba25a 100644
--- a/widgets/battery_widget.py
+++ b/src/shared/window/battery_widget.py
@@ -5,7 +5,7 @@ from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.separator import Separator
-from utils.gamemode import check_gamemode, toggle_gamemode
+from services.gamemode import check_gamemode, toggle_gamemode
from services.battery import BatteryService, DeviceState
from utils.functions import clear_children, format_duration
diff --git a/widgets/dropdown.py b/src/shared/window/dropdown.py
similarity index 98%
rename from widgets/dropdown.py
rename to src/shared/window/dropdown.py
index e4e97cb..5a9a595 100644
--- a/widgets/dropdown.py
+++ b/src/shared/window/dropdown.py
@@ -2,7 +2,7 @@ from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from utils.roam import modus_service
-from widgets.popup_window import PopupWindow
+from shared.window.popup_window import PopupWindow
dropdowns = []
diff --git a/widgets/mousecapture.py b/src/shared/window/mousecapture.py
similarity index 99%
rename from widgets/mousecapture.py
rename to src/shared/window/mousecapture.py
index b7373c8..1de5ff9 100644
--- a/widgets/mousecapture.py
+++ b/src/shared/window/mousecapture.py
@@ -1,7 +1,4 @@
-from typing import Any
-
-import cairo
-from fabric.utils import Gdk
+from fabric.utils import Any, Gdk, cairo
from fabric.widgets.eventbox import EventBox
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.widgets.widget import Widget
diff --git a/widgets/popup_window.py b/src/shared/window/popup_window.py
similarity index 100%
rename from widgets/popup_window.py
rename to src/shared/window/popup_window.py
diff --git a/utils/__init__.py b/src/utils/__init__.py
similarity index 100%
rename from utils/__init__.py
rename to src/utils/__init__.py
diff --git a/utils/app_name_resolver.py b/src/utils/app_name_resolver.py
similarity index 100%
rename from utils/app_name_resolver.py
rename to src/utils/app_name_resolver.py
diff --git a/src/utils/constants.py b/src/utils/constants.py
new file mode 100644
index 0000000..fe159be
--- /dev/null
+++ b/src/utils/constants.py
@@ -0,0 +1,31 @@
+DEFAULT = {
+ "wallpapers_dir": "~/Pictures/Wallpapers/",
+ "dock_position": "Bottom",
+ "terminal_command": "kitty -e",
+ "dock_enabled": True,
+ "dock_auto_hide": True,
+ "dock_always_occluded": False,
+ "dock_icon_size": 52,
+ "window_switcher_items_per_row": 10,
+ "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,
+ "keyboard_layouts": ["us", "np"],
+ "dock": True,
+ "window_switcher": True,
+ "osd": True,
+ "systray_ignore": ["blueman", "network"],
+}
diff --git a/utils/conversion.py b/src/utils/conversion.py
similarity index 100%
rename from utils/conversion.py
rename to src/utils/conversion.py
diff --git a/utils/dbus_helper.py b/src/utils/dbus_helper.py
similarity index 100%
rename from utils/dbus_helper.py
rename to src/utils/dbus_helper.py
diff --git a/utils/debounce.py b/src/utils/debounce.py
similarity index 100%
rename from utils/debounce.py
rename to src/utils/debounce.py
diff --git a/utils/functions.py b/src/utils/functions.py
similarity index 100%
rename from utils/functions.py
rename to src/utils/functions.py
diff --git a/utils/icon_resolver.py b/src/utils/icon_resolver.py
similarity index 98%
rename from utils/icon_resolver.py
rename to src/utils/icon_resolver.py
index 011059c..ae3a0c8 100644
--- a/utils/icon_resolver.py
+++ b/src/utils/icon_resolver.py
@@ -1,7 +1,8 @@
import json
-from fabric.utils import GLib, Gtk, logger, os # noqa: E402
-import config.data as data # noqa: E402
+from fabric.utils import GLib, Gtk, logger, os
+
+import shared.data as data
ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json"
if not os.path.exists(data.CACHE_DIR):
diff --git a/utils/inhibit.py b/src/utils/inhibit.py
similarity index 99%
rename from utils/inhibit.py
rename to src/utils/inhibit.py
index 0c88bfe..9899483 100644
--- a/utils/inhibit.py
+++ b/src/utils/inhibit.py
@@ -14,7 +14,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from signal import SIGINT, SIGTERM, signal
from threading import Event, Timer
-from utils.functions import set_process_name
from pywayland.client.display import Display
from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (
ZwpIdleInhibitManagerV1,
@@ -23,6 +22,8 @@ from pywayland.protocol.wayland.wl_compositor import WlCompositor
from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
from pywayland.protocol.wayland.wl_surface import WlSurface
+from utils.functions import set_process_name
+
@dataclass
class GlobalRegistry:
diff --git a/utils/monitors.py b/src/utils/monitors.py
similarity index 100%
rename from utils/monitors.py
rename to src/utils/monitors.py
diff --git a/utils/occlusion.py b/src/utils/occlusion.py
similarity index 99%
rename from utils/occlusion.py
rename to src/utils/occlusion.py
index b81ee75..a7b8602 100644
--- a/utils/occlusion.py
+++ b/src/utils/occlusion.py
@@ -1,7 +1,7 @@
import json
import subprocess
-import config.data as data
+import shared.data as data
def get_current_workspace():
diff --git a/utils/roam.py b/src/utils/roam.py
similarity index 97%
rename from utils/roam.py
rename to src/utils/roam.py
index 6fe0e1e..20a791e 100644
--- a/utils/roam.py
+++ b/src/utils/roam.py
@@ -1,10 +1,9 @@
from fabric.audio import Audio
from fabric.utils import logger
+# ruff: noqa: I001
from services.modus import (
ModusService,
-)
-from services.modus import (
notification_service as notification_service_instance,
)
diff --git a/utils/utils.py b/src/utils/utils.py
similarity index 89%
rename from utils/utils.py
rename to src/utils/utils.py
index bc33a1b..44baf23 100644
--- a/utils/utils.py
+++ b/src/utils/utils.py
@@ -1,11 +1,11 @@
from pathlib import Path
from typing import Literal
-from fabric.utils import Gdk, bulk_connect
+from fabric.utils import Gdk, bulk_connect, exec_shell_command, exec_shell_command_async
from fabric.widgets.scale import Scale, ScaleMark
from fabric.widgets.svg import Svg
-from utils.animator import Animator
+from shared.widgets.animator import Animator
class AnimatedScale(Scale):
@@ -29,6 +29,10 @@ class AnimatedScale(Scale):
self.animator.play()
+def is_app_running(app_name: str) -> bool:
+ return len(exec_shell_command(f"pidof {app_name}")) != 0
+
+
# Function to set up cursor hover
def setup_cursor_hover(
widget, cursor_name: Literal["pointer", "crosshair", "grab"] = "pointer"
@@ -103,7 +107,7 @@ def svg_file(relative_path: str, **kwargs) -> Svg:
def _resolve_path(path_like):
s = str(path_like)
- marker = "/config/assets/icons/"
+ marker = "/src/assets/icons/"
if marker in s:
rel = s.split(marker, 1)[1]
return (BASE_SVG_PATH / rel).resolve()
@@ -145,3 +149,8 @@ def svg_file(relative_path: str, **kwargs) -> Svg:
svg.dynamic_style = _dynamic_style # type: ignore[attr-defined]
return svg
+
+
+def generate_colors_from_wallpaper(image_path: str) -> bool:
+ cmd = f'matugen image "{image_path}" --source-color-index 0'
+ return bool(exec_shell_command_async(cmd))
diff --git a/src/window/__init__.py b/src/window/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/window/controlcenter/__init__.py b/src/window/controlcenter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/window/controlcenter/battery.py b/src/window/controlcenter/battery.py
new file mode 100644
index 0000000..e8c32ec
--- /dev/null
+++ b/src/window/controlcenter/battery.py
@@ -0,0 +1,615 @@
+import subprocess
+
+from fabric.bluetooth import BluetoothClient, BluetoothDevice
+from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.separator import Separator
+
+from utils.utils import svg_file
+
+
+def get_battery_icon_file(
+ percentage: int, is_charging: bool, icon_path: str = ""
+) -> str:
+ """
+ Get the battery icon file path based on percentage and charging status.
+
+ Args:
+ percentage: Battery percentage (0-100)
+ is_charging: Whether the device is charging
+ icon_path: Base path for icons (unused, kept for compatibility)
+
+ Returns:
+ Relative path to the battery icon file
+ """
+ clamped = max(0, min(100, percentage))
+ step = (clamped // 10) * 10
+ filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
+ return f"battery/{filename}"
+
+
+def set_bluetooth_enabled_with_fallback(client, enabled: bool):
+ try:
+ # Try fabric bluetooth first
+ client.set_enabled(enabled)
+ except Exception as e:
+ logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
+
+ # Fallback to rfkill to unblock/block bluetooth
+ if enabled:
+ command = "rfkill unblock bluetooth"
+ else:
+ command = "rfkill block bluetooth"
+
+ result = exec_shell_command(command)
+ if result is False:
+ logger.error(f"rfkill fallback failed: command '{command}' returned False")
+ elif isinstance(result, str) and result.strip():
+ # If result is a non-empty string, it might be an error message
+ logger.warning(f"rfkill command output: {result.strip()}")
+ else:
+ logger.info(f"rfkill fallback succeeded: {command}")
+
+
+class BluetoothDeviceSlot(CenterBox):
+ def __init__(self, device: BluetoothDevice, **kwargs):
+ super().__init__(h_expand=True, name="device-button", **kwargs)
+ self.device = device
+ self.device.connect("changed", self.on_changed)
+ self.device.connect(
+ "notify::closed", lambda *_: self.device.closed and self.destroy()
+ )
+
+ self.styles = [
+ "connected" if self.device.connected else "",
+ "paired" if self.device.paired else "",
+ ]
+
+ self.dimage = Image(
+ icon_name=device.icon_name + "-symbolic", # type: ignore
+ size=5,
+ name="device-icon",
+ style_classes=" ".join(self.styles),
+ )
+
+ self.device_button = Button(
+ on_clicked=lambda *_: self.toggle_connecting(),
+ child=Box(
+ orientation="h",
+ h_expand=True,
+ children=[self.dimage, Label(label=device.name)],
+ ), # type: ignore
+ )
+ self.start_children = [self.device_button]
+
+ # # Add battery info if available
+ if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
+ battery_box = Box(orientation="h", spacing=4)
+
+ # Create battery icon
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Create battery percentage label
+ battery_label = Label(
+ label=f"{device.battery_percentage:.0f}%", name="battery-label"
+ )
+
+ battery_box.children = [battery_icon, battery_label]
+ self.end_children = [battery_box]
+
+ self.device_button.connect("enter-notify-event", self.on_button_enter)
+ self.device_button.connect("leave-notify-event", self.on_button_leave)
+ self.device.emit("changed") # to update display status
+
+ def on_button_enter(self, widget, event):
+ self.add_style_class("button-hovered")
+
+ def on_button_leave(self, widget, event):
+ self.remove_style_class("button-hovered")
+
+ def toggle_connecting(self):
+ self.device.emit("changed")
+ self.device.set_connecting(not self.device.connected)
+
+ def on_changed(self, *_):
+ try:
+ # Update connection and pairing status
+ new_styles = [
+ "connected" if self.device.connected else "",
+ "paired" if self.device.paired else "",
+ ]
+
+ self.styles = new_styles
+ self.dimage.set_property("style-classes", " ".join(self.styles))
+ except Exception:
+ return
+
+ # Update battery info if available
+ if (
+ hasattr(self.device, "battery_percentage")
+ and self.device.battery_percentage > 0
+ ):
+ if not self.end_children: # Add battery display if not already present
+ battery_box = Box(orientation="h", spacing=4)
+
+ # Create battery icon
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Create battery percentage label
+ battery_label = Label(
+ label=f"{self.device.battery_percentage:.0f}%", name="battery-label"
+ )
+
+ battery_box.children = [battery_icon, battery_label]
+ self.end_children = [battery_box]
+ else: # Update existing battery display
+ battery_box = self.end_children[0]
+ if hasattr(battery_box, "children") and len(battery_box.children) >= 2:
+ battery_icon = battery_box.children[0]
+ battery_label = battery_box.children[1]
+
+ # Update battery icon
+ battery_icon.dynamic_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Update battery percentage
+ battery_label.set_label(f"{self.device.battery_percentage:.0f}%")
+ elif self.end_children: # Remove battery display if no longer available
+ self.end_children = []
+
+ return
+
+
+class BluetoothConnections(Box):
+ def __init__(
+ self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
+ ):
+ super().__init__(
+ spacing=8,
+ orientation="vertical",
+ name="bluetooth-connections",
+ **kwargs,
+ )
+
+ self.parent = parent
+ self.show_hidden_devices = show_hidden_devices
+ self.is_scanning = False # Track scanning state
+ self.refresh_timer = None # Timer for periodic device refresh
+ self._update_in_progress = False # Prevent concurrent updates
+ self._destroyed = False # Track if widget is destroyed
+
+ self.client = BluetoothClient(on_device_added=self.on_device_added)
+
+ # Create pull-to-refresh indicator
+ self.refresh_indicator = Label(
+ name="bluetooth-refresh-indicator",
+ label="↓ Pull to scan for devices",
+ h_align="center",
+ visible=False,
+ style="color: #fff; font-size: 12px; padding: 5px;",
+ )
+
+ # Create title with optional back button
+ title_children = []
+ if show_back_button:
+ title_children.append(
+ Button(
+ image=Image(icon_name="back", size=10),
+ on_clicked=lambda *_: self.parent.close_bluetooth(),
+ )
+ )
+ title_children.append(Label("Bluetooth", name="bluetooth-title"))
+
+ self.title = Box(
+ orientation="h",
+ children=title_children,
+ )
+
+ self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
+
+ # Safely set initial state
+ self.toggle_button.set_active(self.client.enabled)
+ self.toggle_button.connect(
+ "notify::active",
+ lambda *_: set_bluetooth_enabled_with_fallback(
+ self.client, self.toggle_button.get_active()
+ ),
+ )
+
+ # Connect client signals
+ self.client.connect(
+ "notify::enabled",
+ lambda *_: self.toggle_button.set_active(self.client.enabled),
+ )
+ self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
+
+ # Connect to device changes
+ self.client.connect("device-added", self.update_devices)
+ self.client.connect("device-removed", self.update_devices)
+
+ # Connect to additional signals for better real-time monitoring
+ self.client.connect("changed", self.on_client_changed)
+
+ # Create Devices section
+ self.paired_devices_label = Label(
+ label="Devices", h_align="start", name="networks-title"
+ )
+ self.paired_devices = Box(
+ spacing=4, orientation="vertical", name="known-networks"
+ )
+
+ # Create "No devices available" message
+ self.no_devices_label = Label(
+ label="No devices available",
+ h_align="center",
+ name="no-networks-label",
+ visible=False,
+ )
+
+ # Create Other Devices section with clickable title
+ self.other_devices_button = Button(
+ child=Label("Other Devices", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.toggle_other_devices,
+ )
+ self.other_devices = Box(spacing=4, orientation="vertical")
+
+ # Create scrolled window for other devices
+ self.other_devices_scrolled = ScrolledWindow(
+ min_content_size=(303, 150),
+ child=self.other_devices,
+ overlay_scroll=True,
+ )
+
+ # Add pull-to-refresh functionality to scrolled window
+ self.setup_pull_to_refresh()
+
+ # Create revealer for Other Devices section
+ self.other_devices_revealer = Revealer(
+ child=self.other_devices_scrolled,
+ transition_type="slide-down",
+ transition_duration=100,
+ child_revealed=False,
+ )
+
+ # Create More Settings button (same style as Other Devices button)
+ self.more_settings_button = Button(
+ child=Label("More Settings", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.open_bluetooth_settings,
+ )
+
+ self.children = [
+ CenterBox(
+ start_children=self.title,
+ end_children=self.toggle_button,
+ name="bluetooth-widget-top",
+ ),
+ self.refresh_indicator,
+ Separator(orientation="h", name="separator"),
+ self.paired_devices_label,
+ self.paired_devices,
+ self.no_devices_label,
+ Separator(orientation="h", name="separator"),
+ self.other_devices_button,
+ self.other_devices_revealer,
+ Separator(orientation="h", name="separator"),
+ self.more_settings_button,
+ ]
+
+ # Connect cleanup on destroy
+ self.connect("destroy", self.on_destroy)
+
+ self.client.notify("scanning")
+ self.client.notify("enabled")
+
+ # Initial device update
+ self.update_devices()
+
+ # Start periodic device monitoring for real-time updates
+ self.start_device_monitoring()
+
+ def toggle_other_devices(self, *_):
+ """Toggle the visibility of other devices section"""
+ current_state = self.other_devices_revealer.child_revealed
+ self.other_devices_revealer.child_revealed = not current_state
+
+ # Handle scanning based on section visibility
+ if self.client:
+ if self.other_devices_revealer.child_revealed:
+ # Start scanning when revealing other devices section
+ if not self.client.scanning:
+ self.client.toggle_scan()
+ # Also force an immediate device refresh to catch any missed connections
+ self.force_device_refresh()
+ else:
+ # Stop scanning when hiding other devices section
+ if self.client.scanning:
+ self.client.toggle_scan()
+
+ def open_bluetooth_settings(self, *_):
+ """Open Blueman bluetooth manager"""
+ try:
+ subprocess.Popen(["blueman-manager"], start_new_session=True)
+ if self.parent and hasattr(self.parent, "hide_controlcenter"):
+ self.parent.hide_controlcenter()
+ except FileNotFoundError:
+ pass
+ except Exception:
+ pass
+
+ def update_scan_label(self):
+ """Update scanning state appearance"""
+ if self.client.scanning:
+ # Show scanning feedback in refresh indicator
+ self.refresh_indicator.set_label("Scanning for devices...")
+ self.refresh_indicator.set_visible(True)
+ self.refresh_indicator.add_style_class("scanning")
+ else:
+ # Hide scanning feedback
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("scanning")
+
+ def update_devices(self, *_):
+ """Update the list of available devices"""
+ # Prevent concurrent updates and check if destroyed
+ if self._update_in_progress or self._destroyed or not self.client:
+ return
+
+ self._update_in_progress = True
+
+ try:
+ # Store current device addresses to detect changes
+ current_paired_addresses = {
+ child.device.address
+ for child in self.paired_devices.get_children()
+ if hasattr(child, "device")
+ }
+ current_other_addresses = {
+ child.device.address
+ for child in self.other_devices.get_children()
+ if hasattr(child, "device")
+ }
+
+ # Get current devices safely
+ devices = self.client.devices
+ paired_devices = []
+ other_devices = []
+ new_paired_addresses = set()
+ new_other_addresses = set()
+
+ for device in devices:
+ try:
+ if device.name and device.name != "Unknown":
+ # Categorize devices: paired devices go to "Devices (Paired)"
+ # All others go to "Other Devices"
+ if device.paired:
+ paired_devices.append(device)
+ new_paired_addresses.add(device.address)
+ else:
+ other_devices.append(device)
+ new_other_addresses.add(device.address)
+ except Exception:
+ continue
+
+ # Check if we need to update (devices added/removed)
+ paired_changed = current_paired_addresses != new_paired_addresses
+ other_changed = current_other_addresses != new_other_addresses
+
+ # Only rebuild if something actually changed
+ if paired_changed or other_changed:
+ # Clear existing devices safely
+ for child in list(self.paired_devices.get_children()):
+ if not self._destroyed:
+ child.destroy()
+ for child in list(self.other_devices.get_children()):
+ if not self._destroyed:
+ child.destroy()
+
+ # Add paired devices
+ for device in paired_devices:
+ if not self._destroyed:
+ device_slot = BluetoothDeviceSlot(device)
+ self.paired_devices.add(device_slot)
+
+ # Add other devices
+ for device in other_devices:
+ if not self._destroyed:
+ device_slot = BluetoothDeviceSlot(device)
+ self.other_devices.add(device_slot)
+
+ # Show/hide sections based on available devices
+ if not self._destroyed:
+ has_paired_devices = len(paired_devices) > 0
+ has_other_devices = len(other_devices) > 0
+ has_any_devices = has_paired_devices or has_other_devices
+
+ # Show paired devices section only if there are paired devices
+ self.paired_devices_label.set_visible(has_paired_devices)
+ self.paired_devices.set_visible(has_paired_devices)
+
+ # Show "No devices available" message if no devices at all
+ self.no_devices_label.set_visible(not has_any_devices)
+
+ # Always show the other devices button, regardless of available devices
+ self.other_devices_button.set_visible(True) # Always visible
+
+ except Exception:
+ pass
+ finally:
+ self._update_in_progress = False
+
+ def start_device_monitoring(self):
+ """Start periodic monitoring for device changes"""
+ # Monitor for device changes every 5 seconds (less aggressive)
+ # This helps catch devices that connect from external sources
+ self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
+
+ def stop_device_monitoring(self):
+ """Stop periodic monitoring"""
+ if self.refresh_timer:
+ GLib.source_remove(self.refresh_timer)
+ self.refresh_timer = None
+
+ def periodic_device_refresh(self):
+ """Periodically refresh device list to catch external connections"""
+ # Skip if update in progress, destroyed, or client not available
+ if (
+ self._update_in_progress
+ or self._destroyed
+ or not self.client
+ or not self.client.enabled
+ ):
+ return True # Continue monitoring
+
+ try:
+ # Simple check - just trigger update_devices which has its own safety checks
+ # Don't force signal emissions as that can cause race conditions
+ self.update_devices()
+
+ except Exception:
+ pass
+
+ return True # Continue monitoring
+
+ def force_device_refresh(self):
+ """Force an immediate refresh of the device list"""
+ if self._update_in_progress or self._destroyed:
+ return
+
+ try:
+ # Simply trigger update_devices which has its own safety checks
+ # Avoid forcing signal emissions to prevent race conditions
+ self.update_devices()
+ except Exception:
+ pass
+
+ def on_client_changed(self, *_):
+ """Handle when the bluetooth client state changes"""
+ # Update devices when client state changes
+ self.update_devices()
+
+ def on_destroy(self, widget):
+ """Cleanup when widget is destroyed"""
+ # Mark as destroyed to prevent further updates
+ self._destroyed = True
+ # Stop monitoring
+ self.stop_device_monitoring()
+ # Make sure other devices revealer is collapsed when closing
+ try:
+ self.other_devices_revealer.child_revealed = False
+ except Exception:
+ pass # Widget might already be destroyed
+
+ def close_bluetooth(self):
+ """Called when Bluetooth panel is being closed"""
+ # Collapse the other devices section when closing
+ self.other_devices_revealer.child_revealed = False
+
+ def setup_pull_to_refresh(self):
+ """Setup pull-to-refresh gesture for the scrolled window"""
+ # Get the scrolled window's vertical adjustment
+ self.vadjustment = self.other_devices_scrolled.get_vadjustment()
+
+ # Track gesture state
+ self.pull_start_y = 0
+ self.is_pulling = False
+ self.pull_threshold = 50 # pixels to trigger refresh
+
+ # Connect to scroll events
+ self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event)
+ self.other_devices_scrolled.connect("button-press-event", self.on_button_press)
+ self.other_devices_scrolled.connect(
+ "button-release-event", self.on_button_release
+ )
+ self.other_devices_scrolled.connect(
+ "motion-notify-event", self.on_motion_notify
+ )
+
+ # Enable events
+ self.other_devices_scrolled.set_events(
+ Gdk.EventMask.SCROLL_MASK
+ | Gdk.EventMask.BUTTON_PRESS_MASK
+ | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK
+ )
+
+ def on_scroll_event(self, widget, event):
+ """Handle scroll events for pull-to-refresh"""
+ # Only handle pull-to-refresh when at the top
+ if self.vadjustment.get_value() <= 0:
+ if event.direction == Gdk.ScrollDirection.UP:
+ # Scrolling up at the top - toggle scan and force refresh
+ self.client.toggle_scan()
+ self.force_device_refresh()
+ return True # Consume the event
+ return False # Let normal scrolling continue
+
+ def on_button_press(self, widget, event):
+ """Handle button press for touch/drag gestures"""
+ if self.vadjustment.get_value() <= 0:
+ self.pull_start_y = event.y
+ self.is_pulling = True
+ return False
+
+ def on_button_release(self, widget, event):
+ """Handle button release for touch/drag gestures"""
+ if self.is_pulling:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > self.pull_threshold:
+ # Toggle scan and force refresh
+ self.client.toggle_scan()
+ self.force_device_refresh()
+ # Hide refresh indicator
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ self.is_pulling = False
+ return False
+
+ def on_motion_notify(self, widget, event):
+ """Handle motion events for visual feedback during pull"""
+ if self.is_pulling and self.vadjustment.get_value() <= 0:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > 0:
+ # Show refresh indicator when pulling down
+ self.refresh_indicator.set_visible(True)
+ if pull_distance >= self.pull_threshold:
+ if self.client.scanning:
+ self.refresh_indicator.set_label("↑ Release to stop scanning")
+ else:
+ self.refresh_indicator.set_label("↑ Release to scan")
+ self.refresh_indicator.add_style_class("ready-to-refresh")
+ else:
+ if self.client.scanning:
+ self.refresh_indicator.set_label("↓ Pull to stop scanning")
+ else:
+ self.refresh_indicator.set_label("↓ Pull to scan for devices")
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ else:
+ self.refresh_indicator.set_visible(False)
+ return False
+
+ def on_device_added(self, client: BluetoothClient, address: str):
+ """Handle when a new device is added"""
+ # Update the device list when devices are added
+ self.update_devices()
diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py
new file mode 100644
index 0000000..10fc050
--- /dev/null
+++ b/src/window/controlcenter/bluetooth.py
@@ -0,0 +1,615 @@
+import subprocess
+
+from fabric.bluetooth import BluetoothClient, BluetoothDevice
+from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.separator import Separator
+
+from utils.utils import svg_file
+
+
+def get_battery_icon_file(
+ percentage: int, is_charging: bool, icon_path: str = ""
+) -> str:
+ """
+ Get the battery icon file path based on percentage and charging status.
+
+ Args:
+ percentage: Battery percentage (0-100)
+ is_charging: Whether the device is charging
+ icon_path: Base path for icons (unused, kept for compatibility)
+
+ Returns:
+ Relative path to the battery icon file
+ """
+ clamped = max(0, min(100, percentage))
+ step = int((clamped // 10) * 10)
+ filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
+ return f"battery/{filename}"
+
+
+def set_bluetooth_enabled_with_fallback(client, enabled: bool):
+ try:
+ # Try fabric bluetooth first
+ client.set_enabled(enabled)
+ except Exception as e:
+ logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
+
+ # Fallback to rfkill to unblock/block bluetooth
+ if enabled:
+ command = "rfkill unblock bluetooth"
+ else:
+ command = "rfkill block bluetooth"
+
+ result = exec_shell_command(command)
+ if result is False:
+ logger.error(f"rfkill fallback failed: command '{command}' returned False")
+ elif isinstance(result, str) and result.strip():
+ # If result is a non-empty string, it might be an error message
+ logger.warning(f"rfkill command output: {result.strip()}")
+ else:
+ logger.info(f"rfkill fallback succeeded: {command}")
+
+
+class BluetoothDeviceSlot(CenterBox):
+ def __init__(self, device: BluetoothDevice, **kwargs):
+ super().__init__(h_expand=True, name="device-button", **kwargs)
+ self.device = device
+ self.device.connect("changed", self.on_changed)
+ self.device.connect(
+ "notify::closed", lambda *_: self.device.closed and self.destroy()
+ )
+
+ self.styles = [
+ "connected" if self.device.connected else "",
+ "paired" if self.device.paired else "",
+ ]
+
+ self.dimage = Image(
+ icon_name=device.icon_name + "-symbolic", # type: ignore
+ size=5,
+ name="device-icon",
+ style_classes=" ".join(self.styles),
+ )
+
+ self.device_button = Button(
+ on_clicked=lambda *_: self.toggle_connecting(),
+ child=Box(
+ orientation="h",
+ h_expand=True,
+ children=[self.dimage, Label(label=device.name)],
+ ), # type: ignore
+ )
+ self.start_children = [self.device_button]
+
+ # # Add battery info if available
+ if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
+ battery_box = Box(orientation="h", spacing=4)
+
+ # Create battery icon
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Create battery percentage label
+ battery_label = Label(
+ label=f"{device.battery_percentage:.0f}%", name="battery-label"
+ )
+
+ battery_box.children = [battery_icon, battery_label]
+ self.end_children = [battery_box]
+
+ self.device_button.connect("enter-notify-event", self.on_button_enter)
+ self.device_button.connect("leave-notify-event", self.on_button_leave)
+ self.device.emit("changed") # to update display status
+
+ def on_button_enter(self, widget, event):
+ self.add_style_class("button-hovered")
+
+ def on_button_leave(self, widget, event):
+ self.remove_style_class("button-hovered")
+
+ def toggle_connecting(self):
+ self.device.emit("changed")
+ self.device.set_connecting(not self.device.connected)
+
+ def on_changed(self, *_):
+ try:
+ # Update connection and pairing status
+ new_styles = [
+ "connected" if getattr(self.device, "connected", False) else "",
+ "paired" if getattr(self.device, "paired", False) else "",
+ ]
+
+ self.styles = new_styles
+ self.dimage.set_property("style-classes", " ".join(self.styles))
+ except Exception:
+ return
+
+ # Update battery info if available
+ if (
+ hasattr(self.device, "battery_percentage")
+ and self.device.battery_percentage > 0
+ ):
+ if not self.end_children: # Add battery display if not already present
+ battery_box = Box(orientation="h", spacing=4)
+
+ # Create battery icon
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Create battery percentage label
+ battery_label = Label(
+ label=f"{self.device.battery_percentage:.0f}%", name="battery-label"
+ )
+
+ battery_box.children = [battery_icon, battery_label]
+ self.end_children = [battery_box]
+ else: # Update existing battery display
+ battery_box = self.end_children[0]
+ if hasattr(battery_box, "children") and len(battery_box.children) >= 2:
+ battery_icon = battery_box.children[0]
+ battery_label = battery_box.children[1]
+
+ # Update battery icon
+ battery_icon.dynamic_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Update battery percentage
+ battery_label.set_label(f"{self.device.battery_percentage:.0f}%")
+ elif self.end_children: # Remove battery display if no longer available
+ self.end_children = []
+
+ return
+
+
+class BluetoothConnections(Box):
+ def __init__(
+ self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
+ ):
+ super().__init__(
+ spacing=8,
+ orientation="vertical",
+ name="bluetooth-connections",
+ **kwargs,
+ )
+
+ self.parent = parent
+ self.show_hidden_devices = show_hidden_devices
+ self.is_scanning = False # Track scanning state
+ self.refresh_timer = None # Timer for periodic device refresh
+ self._update_in_progress = False # Prevent concurrent updates
+ self._destroyed = False # Track if widget is destroyed
+
+ self.client = BluetoothClient(on_device_added=self.on_device_added)
+
+ # Create pull-to-refresh indicator
+ self.refresh_indicator = Label(
+ name="bluetooth-refresh-indicator",
+ label="↓ Pull to scan for devices",
+ h_align="center",
+ visible=False,
+ style="color: #fff; font-size: 12px; padding: 5px;",
+ )
+
+ # Create title with optional back button
+ title_children = []
+ if show_back_button:
+ title_children.append(
+ Button(
+ image=Image(icon_name="back", size=10),
+ on_clicked=lambda *_: self.parent.close_bluetooth(),
+ )
+ )
+ title_children.append(Label("Bluetooth", name="bluetooth-title"))
+
+ self.title = Box(
+ orientation="h",
+ children=title_children,
+ )
+
+ self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
+
+ # Safely set initial state
+ self.toggle_button.set_active(self.client.enabled)
+ self.toggle_button.connect(
+ "notify::active",
+ lambda *_: set_bluetooth_enabled_with_fallback(
+ self.client, self.toggle_button.get_active()
+ ),
+ )
+
+ # Connect client signals
+ self.client.connect(
+ "notify::enabled",
+ lambda *_: self.toggle_button.set_active(self.client.enabled),
+ )
+ self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
+
+ # Connect to device changes
+ self.client.connect("device-added", self.update_devices)
+ self.client.connect("device-removed", self.update_devices)
+
+ # Connect to additional signals for better real-time monitoring
+ self.client.connect("changed", self.on_client_changed)
+
+ # Create Devices section
+ self.paired_devices_label = Label(
+ label="Devices", h_align="start", name="networks-title"
+ )
+ self.paired_devices = Box(
+ spacing=4, orientation="vertical", name="known-networks"
+ )
+
+ # Create "No devices available" message
+ self.no_devices_label = Label(
+ label="No devices available",
+ h_align="center",
+ name="no-networks-label",
+ visible=False,
+ )
+
+ # Create Other Devices section with clickable title
+ self.other_devices_button = Button(
+ child=Label("Other Devices", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.toggle_other_devices,
+ )
+ self.other_devices = Box(spacing=4, orientation="vertical")
+
+ # Create scrolled window for other devices
+ self.other_devices_scrolled = ScrolledWindow(
+ min_content_size=(303, 150),
+ child=self.other_devices,
+ overlay_scroll=True,
+ )
+
+ # Add pull-to-refresh functionality to scrolled window
+ self.setup_pull_to_refresh()
+
+ # Create revealer for Other Devices section
+ self.other_devices_revealer = Revealer(
+ child=self.other_devices_scrolled,
+ transition_type="slide-down",
+ transition_duration=100,
+ child_revealed=False,
+ )
+
+ # Create More Settings button (same style as Other Devices button)
+ self.more_settings_button = Button(
+ child=Label("More Settings", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.open_bluetooth_settings,
+ )
+
+ self.children = [
+ CenterBox(
+ start_children=self.title,
+ end_children=self.toggle_button,
+ name="bluetooth-widget-top",
+ ),
+ self.refresh_indicator,
+ Separator(orientation="h", name="separator"),
+ self.paired_devices_label,
+ self.paired_devices,
+ self.no_devices_label,
+ Separator(orientation="h", name="separator"),
+ self.other_devices_button,
+ self.other_devices_revealer,
+ Separator(orientation="h", name="separator"),
+ self.more_settings_button,
+ ]
+
+ # Connect cleanup on destroy
+ self.connect("destroy", self.on_destroy)
+
+ self.client.notify("scanning")
+ self.client.notify("enabled")
+
+ # Initial device update
+ self.update_devices()
+
+ # Start periodic device monitoring for real-time updates
+ self.start_device_monitoring()
+
+ def toggle_other_devices(self, *_):
+ """Toggle the visibility of other devices section"""
+ current_state = self.other_devices_revealer.child_revealed
+ self.other_devices_revealer.child_revealed = not current_state
+
+ # Handle scanning based on section visibility
+ if self.client:
+ if self.other_devices_revealer.child_revealed:
+ # Start scanning when revealing other devices section
+ if not self.client.scanning:
+ self.client.toggle_scan()
+ # Also force an immediate device refresh to catch any missed connections
+ self.force_device_refresh()
+ else:
+ # Stop scanning when hiding other devices section
+ if self.client.scanning:
+ self.client.toggle_scan()
+
+ def open_bluetooth_settings(self, *_):
+ """Open Blueman bluetooth manager"""
+ try:
+ subprocess.Popen(["blueman-manager"], start_new_session=True)
+ if self.parent and hasattr(self.parent, "hide_controlcenter"):
+ self.parent.hide_controlcenter()
+ except FileNotFoundError:
+ pass
+ except Exception:
+ pass
+
+ def update_scan_label(self):
+ """Update scanning state appearance"""
+ if self.client.scanning:
+ # Show scanning feedback in refresh indicator
+ self.refresh_indicator.set_label("Scanning for devices...")
+ self.refresh_indicator.set_visible(True)
+ self.refresh_indicator.add_style_class("scanning")
+ else:
+ # Hide scanning feedback
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("scanning")
+
+ def update_devices(self, *_):
+ """Update the list of available devices"""
+ # Prevent concurrent updates and check if destroyed
+ if self._update_in_progress or self._destroyed or not self.client:
+ return
+
+ self._update_in_progress = True
+
+ try:
+ # Store current device addresses to detect changes
+ current_paired_addresses = {
+ child.device.address
+ for child in self.paired_devices.get_children()
+ if hasattr(child, "device")
+ }
+ current_other_addresses = {
+ child.device.address
+ for child in self.other_devices.get_children()
+ if hasattr(child, "device")
+ }
+
+ # Get current devices safely
+ devices = self.client.devices
+ paired_devices = []
+ other_devices = []
+ new_paired_addresses = set()
+ new_other_addresses = set()
+
+ for device in devices:
+ try:
+ if device.name and device.name != "Unknown":
+ # Categorize devices: paired devices go to "Devices (Paired)"
+ # All others go to "Other Devices"
+ if device.paired:
+ paired_devices.append(device)
+ new_paired_addresses.add(device.address)
+ else:
+ other_devices.append(device)
+ new_other_addresses.add(device.address)
+ except Exception:
+ continue
+
+ # Check if we need to update (devices added/removed)
+ paired_changed = current_paired_addresses != new_paired_addresses
+ other_changed = current_other_addresses != new_other_addresses
+
+ # Only rebuild if something actually changed
+ if paired_changed or other_changed:
+ # Clear existing devices safely
+ for child in list(self.paired_devices.get_children()):
+ if not self._destroyed:
+ child.destroy()
+ for child in list(self.other_devices.get_children()):
+ if not self._destroyed:
+ child.destroy()
+
+ # Add paired devices
+ for device in paired_devices:
+ if not self._destroyed:
+ device_slot = BluetoothDeviceSlot(device)
+ self.paired_devices.add(device_slot)
+
+ # Add other devices
+ for device in other_devices:
+ if not self._destroyed:
+ device_slot = BluetoothDeviceSlot(device)
+ self.other_devices.add(device_slot)
+
+ # Show/hide sections based on available devices
+ if not self._destroyed:
+ has_paired_devices = len(paired_devices) > 0
+ has_other_devices = len(other_devices) > 0
+ has_any_devices = has_paired_devices or has_other_devices
+
+ # Show paired devices section only if there are paired devices
+ self.paired_devices_label.set_visible(has_paired_devices)
+ self.paired_devices.set_visible(has_paired_devices)
+
+ # Show "No devices available" message if no devices at all
+ self.no_devices_label.set_visible(not has_any_devices)
+
+ # Always show the other devices button, regardless of available devices
+ self.other_devices_button.set_visible(True) # Always visible
+
+ except Exception:
+ pass
+ finally:
+ self._update_in_progress = False
+
+ def start_device_monitoring(self):
+ """Start periodic monitoring for device changes"""
+ # Monitor for device changes every 5 seconds (less aggressive)
+ # This helps catch devices that connect from external sources
+ self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
+
+ def stop_device_monitoring(self):
+ """Stop periodic monitoring"""
+ if self.refresh_timer:
+ GLib.source_remove(self.refresh_timer)
+ self.refresh_timer = None
+
+ def periodic_device_refresh(self):
+ """Periodically refresh device list to catch external connections"""
+ # Skip if update in progress, destroyed, or client not available
+ if (
+ self._update_in_progress
+ or self._destroyed
+ or not self.client
+ or not self.client.enabled
+ ):
+ return True # Continue monitoring
+
+ try:
+ # Simple check - just trigger update_devices which has its own safety checks
+ # Don't force signal emissions as that can cause race conditions
+ self.update_devices()
+
+ except Exception:
+ pass
+
+ return True # Continue monitoring
+
+ def force_device_refresh(self):
+ """Force an immediate refresh of the device list"""
+ if self._update_in_progress or self._destroyed:
+ return
+
+ try:
+ # Simply trigger update_devices which has its own safety checks
+ # Avoid forcing signal emissions to prevent race conditions
+ self.update_devices()
+ except Exception:
+ pass
+
+ def on_client_changed(self, *_):
+ """Handle when the bluetooth client state changes"""
+ # Update devices when client state changes
+ self.update_devices()
+
+ def on_destroy(self, widget):
+ """Cleanup when widget is destroyed"""
+ # Mark as destroyed to prevent further updates
+ self._destroyed = True
+ # Stop monitoring
+ self.stop_device_monitoring()
+ # Make sure other devices revealer is collapsed when closing
+ try:
+ self.other_devices_revealer.child_revealed = False
+ except Exception:
+ pass # Widget might already be destroyed
+
+ def close_bluetooth(self):
+ """Called when Bluetooth panel is being closed"""
+ # Collapse the other devices section when closing
+ self.other_devices_revealer.child_revealed = False
+
+ def setup_pull_to_refresh(self):
+ """Setup pull-to-refresh gesture for the scrolled window"""
+ # Get the scrolled window's vertical adjustment
+ self.vadjustment = self.other_devices_scrolled.get_vadjustment()
+
+ # Track gesture state
+ self.pull_start_y = 0
+ self.is_pulling = False
+ self.pull_threshold = 50 # pixels to trigger refresh
+
+ # Connect to scroll events
+ self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event)
+ self.other_devices_scrolled.connect("button-press-event", self.on_button_press)
+ self.other_devices_scrolled.connect(
+ "button-release-event", self.on_button_release
+ )
+ self.other_devices_scrolled.connect(
+ "motion-notify-event", self.on_motion_notify
+ )
+
+ # Enable events
+ self.other_devices_scrolled.set_events(
+ Gdk.EventMask.SCROLL_MASK
+ | Gdk.EventMask.BUTTON_PRESS_MASK
+ | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK
+ )
+
+ def on_scroll_event(self, widget, event):
+ """Handle scroll events for pull-to-refresh"""
+ # Only handle pull-to-refresh when at the top
+ if self.vadjustment.get_value() <= 0:
+ if event.direction == Gdk.ScrollDirection.UP:
+ # Scrolling up at the top - toggle scan and force refresh
+ self.client.toggle_scan()
+ self.force_device_refresh()
+ return True # Consume the event
+ return False # Let normal scrolling continue
+
+ def on_button_press(self, widget, event):
+ """Handle button press for touch/drag gestures"""
+ if self.vadjustment.get_value() <= 0:
+ self.pull_start_y = event.y
+ self.is_pulling = True
+ return False
+
+ def on_button_release(self, widget, event):
+ """Handle button release for touch/drag gestures"""
+ if self.is_pulling:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > self.pull_threshold:
+ # Toggle scan and force refresh
+ self.client.toggle_scan()
+ self.force_device_refresh()
+ # Hide refresh indicator
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ self.is_pulling = False
+ return False
+
+ def on_motion_notify(self, widget, event):
+ """Handle motion events for visual feedback during pull"""
+ if self.is_pulling and self.vadjustment.get_value() <= 0:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > 0:
+ # Show refresh indicator when pulling down
+ self.refresh_indicator.set_visible(True)
+ if pull_distance >= self.pull_threshold:
+ if self.client.scanning:
+ self.refresh_indicator.set_label("↑ Release to stop scanning")
+ else:
+ self.refresh_indicator.set_label("↑ Release to scan")
+ self.refresh_indicator.add_style_class("ready-to-refresh")
+ else:
+ if self.client.scanning:
+ self.refresh_indicator.set_label("↓ Pull to stop scanning")
+ else:
+ self.refresh_indicator.set_label("↓ Pull to scan for devices")
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ else:
+ self.refresh_indicator.set_visible(False)
+ return False
+
+ def on_device_added(self, client: BluetoothClient, address: str):
+ """Handle when a new device is added"""
+ # Update the device list when devices are added
+ self.update_devices()
diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py
new file mode 100644
index 0000000..f3c7014
--- /dev/null
+++ b/src/window/controlcenter/expanded_player.py
@@ -0,0 +1,1337 @@
+# Standard library imports
+import gc
+import re
+import tempfile
+import threading
+import urllib.parse
+import urllib.request
+import weakref
+from typing import Dict, List, Optional
+
+from fabric.utils import (
+ bulk_connect,
+ cooldown,
+ invoke_repeater,
+ GLib,
+ GObject,
+ logger,
+ os,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+
+# Fabric imports
+from fabric.widgets.scale import Scale
+from fabric.widgets.stack import Stack
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
+
+# Local imports
+from services.mpris import MprisPlayer, MprisPlayerManager
+from utils.utils import svg_file
+
+CACHE_DIR = f"{data.CACHE_DIR}/media"
+
+# Global memory management
+_shared_mpris_manager = None
+_widget_cache = weakref.WeakValueDictionary()
+_artwork_cache = {}
+_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat
+
+
+def get_shared_mpris_manager():
+ """Get shared MPRIS manager instance to reduce memory usage."""
+ global _shared_mpris_manager
+ if _shared_mpris_manager is None:
+ _shared_mpris_manager = MprisPlayerManager()
+ return _shared_mpris_manager
+
+
+def cleanup_artwork_cache():
+ """Clean up artwork cache immediately when size limit is reached."""
+ global _artwork_cache
+ if len(_artwork_cache) > _max_artwork_cache_size:
+ # Keep only the most recent items immediately
+ items = list(_artwork_cache.items())
+ _artwork_cache = dict(items[-_max_artwork_cache_size:])
+ # Immediate garbage collection
+ gc.collect()
+
+
+def cleanup_old_cache_files():
+ """Clean up old artwork cache files immediately (no lazy loading)."""
+ try:
+ if not os.path.exists(CACHE_DIR):
+ return
+
+ import time
+
+ current_time = time.time()
+ # Clean files older than 2 hours (more aggressive)
+ two_hours_ago = current_time - (2 * 60 * 60)
+
+ for filename in os.listdir(CACHE_DIR):
+ filepath = os.path.join(CACHE_DIR, filename)
+ try:
+ if os.path.isfile(filepath):
+ file_mod_time = os.path.getmtime(filepath)
+ if file_mod_time < two_hours_ago:
+ os.remove(filepath)
+ logger.debug(f"Cleaned up old cache file: {filename}")
+ except Exception as e:
+ logger.warning(f"Failed to clean up cache file {filename}: {e}")
+
+ except Exception as e:
+ logger.error(f"Error during cache cleanup: {e}")
+
+
+def get_artwork_cached(url: str) -> Optional[str]:
+ """Get artwork with memory-efficient caching."""
+ if not url:
+ return None
+
+ # Check memory cache first
+ if url in _artwork_cache:
+ return _artwork_cache[url]
+
+ # Clean cache immediately if too large (no lazy loading)
+ cleanup_artwork_cache()
+
+ try:
+ # Create cache directory if it doesn't exist
+ os.makedirs(CACHE_DIR, exist_ok=True)
+
+ # Generate cache filename
+ safe_filename = re.sub(
+ r"[^\w\-_.]", "_", urllib.parse.urlparse(url).path.split("/")[-1]
+ )
+ if not safe_filename:
+ safe_filename = f"artwork_{hash(url)}"
+
+ cache_file = os.path.join(CACHE_DIR, safe_filename)
+
+ # Check if cached file exists and is recent (within 2 hours)
+ if os.path.exists(cache_file):
+ import time
+
+ if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours
+ _artwork_cache[url] = cache_file
+ return cache_file
+
+ # Download and cache
+ urllib.request.urlretrieve(url, cache_file)
+ _artwork_cache[url] = cache_file
+ return cache_file
+
+ except Exception as e:
+ logger.warning(f"Failed to cache artwork from {url}: {e}")
+ return None
+
+
+class EmbeddedExpandedPlayer(Box):
+ """Embedded expanded player widget for use inside control center."""
+
+ def __init__(self, control_center, **kwargs):
+ super().__init__(
+ orientation="vertical",
+ h_expand=True,
+ name="embedded-expanded-player",
+ **kwargs,
+ )
+
+ self.control_center = control_center
+ self.mpris_manager = get_shared_mpris_manager()
+
+ # Create back button (hidden in header)
+ self.back_button = Button(
+ name="back-button",
+ child=Label(label="← Back"),
+ on_clicked=self._on_back_clicked,
+ )
+
+ # Create expanded player content
+ self.player_content = PlayerBoxStack(self.mpris_manager)
+
+ self.children = [
+ Box(
+ orientation="horizontal",
+ h_expand=True,
+ style_classes="menu",
+ visible=False, # Hide header to remove title and back button
+ children=[
+ self.back_button,
+ Box(h_expand=True), # Spacer
+ Label(label="Now Playing", style_classes="title"),
+ Box(h_expand=True), # Spacer
+ ],
+ ),
+ self.player_content,
+ ]
+
+ def _on_back_clicked(self, *_):
+ """Handle back button click"""
+ if self.control_center and hasattr(
+ self.control_center, "close_expanded_player"
+ ):
+ self.control_center.close_expanded_player()
+
+ def refresh(self):
+ """Refresh the player content"""
+ # This will automatically update as MPRIS players change
+ pass
+
+ def destroy(self):
+ """Clean up resources and prevent memory leaks"""
+ logger.debug("🗑️ EmbeddedExpandedPlayer cleanup starting")
+
+ try:
+ # Destroy player content (PlayerBoxStack)
+ if hasattr(self, "player_content") and hasattr(
+ self.player_content, "destroy"
+ ):
+ self.player_content.destroy()
+
+ # Clean up back button
+ if hasattr(self, "back_button") and hasattr(self.back_button, "destroy"):
+ self.back_button.destroy()
+
+ # Clean up any other widgets we might have
+ for child in list(self.get_children()):
+ try:
+ child.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy child widget: {e}")
+
+ # Aggressively clean global caches
+ global _widget_cache, _artwork_cache
+ _widget_cache.clear()
+ _artwork_cache.clear()
+ logger.debug("🗑️ Cleared global widget and artwork caches")
+
+ # Clear references
+ if hasattr(self, "control_center"):
+ self.control_center = None
+ if hasattr(self, "mpris_manager"):
+ self.mpris_manager = None
+
+ # Force garbage collection
+ gc.collect()
+ logger.debug("🗑️ EmbeddedExpandedPlayer cleanup completed")
+
+ except Exception as e:
+ logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}")
+ finally:
+ super().destroy()
+
+
+class PlayerBoxStack(Box):
+ """Memory-optimized widget that displays current player information."""
+
+ def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
+ # Clean up old cache files immediately (no lazy loading)
+ cleanup_old_cache_files()
+
+ # The player stack with memory-efficient settings
+ self.player_stack = Stack(
+ name="player-stack",
+ # Disable transitions to reduce memory usage
+ transition_type="none",
+ )
+ self.current_stack_pos = 0
+
+ # Store player buttons - cleaned up via explicit removal
+ self.player_buttons: list[Button] = []
+ self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary()
+
+ # Track signal connections for cleanup using weak references
+ self._signal_connections = []
+
+ # Create a lightweight "No media playing" placeholder
+ self.no_media_box = self._create_no_media_box()
+
+ super().__init__(orientation="v", name="media", children=[self.player_stack])
+
+ # Show the no media box initially
+ self.player_stack.children = [self.no_media_box]
+ self.set_visible(True)
+
+ self.mpris_manager = mpris_manager
+
+ # Track connections for cleanup
+ connections = bulk_connect(
+ self.mpris_manager,
+ {
+ "player-appeared": self.on_new_player,
+ "player-vanished": self.on_lost_player,
+ },
+ )
+ for handler_id in connections:
+ self._signal_connections.append((self.mpris_manager, handler_id))
+
+ # Process existing players
+ for player in self.mpris_manager.players.values(): # type: ignore
+ logger.info(f"[PLAYER MANAGER] player found: {player.player_name}")
+ self.on_new_player(self.mpris_manager, player)
+
+ # No periodic cleanup - immediate cleanup when needed
+
+ def destroy(self):
+ """Clean up resources when the widget is destroyed."""
+ try:
+ # No periodic cleanup timer to cancel (removed lazy loading)
+
+ # Disconnect all signal connections
+ for obj, handler_id in self._signal_connections:
+ try:
+ if obj and hasattr(obj, "disconnect"):
+ obj.disconnect(handler_id)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect signal: {e}")
+ self._signal_connections.clear()
+
+ # Clean up player widgets
+ for widget in list(self._player_widgets.values()):
+ try:
+ if widget and hasattr(widget, "destroy"):
+ widget.destroy()
+ except Exception:
+ pass
+ self._player_widgets.clear()
+
+ # Clean up player buttons explicitly
+ for button in list(self.player_buttons):
+ try:
+ if button and hasattr(button, "destroy"):
+ button.destroy()
+ except Exception:
+ pass
+
+ # Clean up stack children
+ for child in self.player_stack.get_children():
+ if hasattr(child, "destroy") and child != self.no_media_box:
+ try:
+ child.destroy()
+ except Exception:
+ pass
+
+ # Force final garbage collection
+ gc.collect()
+
+ except Exception as e:
+ logger.error(f"Error during PlayerBoxStack cleanup: {e}")
+ finally:
+ super().destroy()
+
+ def _create_no_media_box(self):
+ """Create a placeholder box for when no media is playing."""
+ fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
+
+ # Album cover with fallback image using Image widget
+
+ album_cover = Box(
+ name="macos-album-image-no",
+ )
+ album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
+
+ image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
+ image_stack.children = [album_cover]
+
+ # Track info showing "No media playing"
+ track_title = Label(
+ label="No media playing",
+ name="player-title-no",
+ justification="left",
+ max_chars_width=30,
+ ellipsization="end",
+ h_align="start",
+ )
+
+ track_artist = Label(
+ label="",
+ name="player-artist",
+ justification="left",
+ max_chars_width=12,
+ ellipsization="end",
+ h_align="start",
+ visible=False, # Hide artist and album when no media
+ )
+
+ track_album = Label(
+ label="",
+ name="player-album",
+ justification="left",
+ max_chars_width=12,
+ ellipsization="end",
+ h_align="start",
+ visible=False, # Hide artist and album when no media
+ )
+
+ track_info = Box(
+ name="track-info",
+ spacing=5,
+ orientation="v",
+ v_align="start",
+ h_align="start",
+ children=[track_title, track_artist, track_album],
+ )
+
+ # No control buttons for no media state - just an empty box
+ controls_box = Box(
+ name="player-controls",
+ visible=False, # Hide controls when no media
+ )
+
+ player_info_box = Box(
+ name="player-info-box",
+ v_align="center",
+ h_align="start",
+ orientation="v",
+ h_expand=True,
+ children=[track_info, controls_box],
+ )
+
+ inner_box = Box(
+ name="inner-player-box",
+ v_align="center",
+ h_align="start",
+ )
+
+ outer_box = Box(
+ name="outer-player-box",
+ h_align="start",
+ )
+
+ overlay_box = Overlay(
+ child=outer_box,
+ overlays=[
+ inner_box,
+ player_info_box,
+ image_stack,
+ ],
+ )
+
+ no_media_box = Box(
+ h_align="center",
+ name="player-box",
+ h_expand=True,
+ children=[overlay_box],
+ )
+
+ return no_media_box
+
+ def on_player_clicked(self, type):
+ # unset active from prev active button
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].remove_style_class("active")
+
+ if type == "next":
+ self.current_stack_pos = (
+ self.current_stack_pos + 1
+ if self.current_stack_pos != len(self.player_stack.get_children()) - 1
+ else 0
+ )
+ elif type == "prev":
+ self.current_stack_pos = (
+ self.current_stack_pos - 1
+ if self.current_stack_pos != 0
+ else len(self.player_stack.get_children()) - 1
+ )
+
+ # set new active button
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].add_style_class("active")
+ self.player_stack.set_visible_child(
+ self.player_stack.get_children()[self.current_stack_pos],
+ )
+
+ def on_player_clicked_by_index(self, index):
+ """Switch to player at given index"""
+ if 0 <= index < len(self.player_buttons):
+ # unset active from prev active button
+ if self.player_buttons and self.current_stack_pos < len(
+ self.player_buttons
+ ):
+ self.player_buttons[self.current_stack_pos].remove_style_class("active")
+ # set new position
+ self.current_stack_pos = index
+ # set new active button
+ if self.player_buttons and self.current_stack_pos < len(
+ self.player_buttons
+ ):
+ self.player_buttons[self.current_stack_pos].add_style_class("active")
+ self.player_stack.set_visible_child(
+ self.player_stack.get_children()[self.current_stack_pos],
+ )
+ # Update all player boxes with new button state
+ self._update_all_player_buttons()
+
+ def on_new_player(self, mpris_manager, player):
+ # if player_name in self.config.get("ignore", []):
+ # return
+
+ # Remove the no media box if it's the only child
+ if (
+ len(self.player_stack.get_children()) == 1
+ and self.player_stack.get_children()[0] == self.no_media_box
+ ):
+ self.player_stack.children = []
+ self.current_stack_pos = 0
+
+ self.set_visible(True)
+
+ new_player_box = PlayerBox(player=player, player_stack=self)
+ self.player_stack.children = [
+ *self.player_stack.children,
+ new_player_box,
+ ]
+
+ self.make_new_player_button(self.player_stack.get_children()[-1])
+ logger.info(
+ f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}",
+ )
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
+
+ # Update all player boxes with current button state
+ self._update_all_player_buttons()
+
+ def on_lost_player(self, mpris_manager, player_name):
+ # the playerBox is automatically removed from mprisbox children on being removed
+ logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}")
+ players: List[PlayerBox] = self.player_stack.get_children()
+
+ # Find and properly destroy the player box
+ player_box_to_remove = None
+ for player_box in players:
+ if (
+ hasattr(player_box, "player")
+ and player_box.player.bus_name == player_name
+ ):
+ player_box_to_remove = player_box
+ break
+
+ if player_box_to_remove:
+ try:
+ player_box_to_remove.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy player box: {e}")
+
+ # Check if this was the last player
+ remaining_players = [
+ p for p in self.player_stack.get_children() if p != player_box_to_remove
+ ]
+ if len(remaining_players) == 0:
+ # Show the no media box instead of hiding
+ self.player_stack.children = [self.no_media_box]
+ self.current_stack_pos = 0
+ self.player_buttons = [] # Clear player buttons
+ return
+
+ # Adjust current position if needed
+ if self.current_stack_pos >= len(self.player_stack.get_children()):
+ self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
+
+ # Set active button if we have buttons and a valid position
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
+ if self.player_stack.get_children():
+ self.player_stack.set_visible_child(
+ self.player_stack.get_children()[self.current_stack_pos],
+ )
+
+ # Update all player boxes with current button state
+ self._update_all_player_buttons()
+
+ def make_new_player_button(self, player_box):
+ new_button = Button(name="player-stack-button")
+
+ def on_player_button_click(button: Button):
+ if self.player_buttons and self.current_stack_pos < len(
+ self.player_buttons
+ ):
+ self.player_buttons[self.current_stack_pos].remove_style_class("active")
+ if button in self.player_buttons:
+ self.current_stack_pos = self.player_buttons.index(button)
+ button.add_style_class("active")
+ self.player_stack.set_visible_child(player_box)
+
+ new_button.connect(
+ "clicked",
+ on_player_button_click,
+ )
+ self.player_buttons.append(new_button)
+
+ # This will automatically destroy our used button
+ def cleanup_button(*_):
+ try:
+ if new_button in self.player_buttons:
+ self.player_buttons.remove(new_button)
+ new_button.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to cleanup button: {e}")
+
+ player_box.connect("destroy", cleanup_button)
+
+ def _update_all_player_buttons(self):
+ """Update all player boxes with the current button state"""
+ players: List[PlayerBox] = self.player_stack.get_children()
+ logger.info(
+ f"[PlayerBoxStack] Updating buttons for {len(players)} players, {
+ len(self.player_buttons)
+ } buttons"
+ )
+ for player_box in players:
+ if hasattr(player_box, "update_buttons"):
+ player_box.update_buttons(self.player_buttons, len(players) > 1)
+ else:
+ logger.warning(
+ "[PlayerBoxStack] PlayerBox missing update_buttons method"
+ )
+
+
+class PlayerBox(Box):
+ """A widget that displays the current player information."""
+
+ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
+ super().__init__(
+ h_align="center",
+ name="player-box",
+ **kwargs,
+ h_expand=True,
+ )
+ # Setup
+ self.player: MprisPlayer = player
+ self.player_stack = player_stack
+ self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
+
+ self.icon_size = 15
+
+ # State
+ self.exit = False
+ self.skipped = False
+ self._user_seeking = False # Flag to prevent choppy seeking
+ self._seekbar_timer_id = None # Track timer ID for cleanup
+
+ # Memory management
+ self.temp_artwork_files = [] # Track temp files for cleanup
+ self.current_download_thread = None # Track current download thread
+ self._download_cancelled = False # Flag to cancel downloads
+ self._signal_connections = [] # Track signal connections
+
+ # Use same CSS background approach as small player for consistency
+ self.album_cover = Box(
+ name="macos-album-image",
+ )
+ self.album_cover.set_style(
+ f"background-image:url('{self.fallback_cover_path}')"
+ )
+ self.album_cover.set_size_request(70, 70)
+
+ self.image_stack = Box(
+ h_align="start", v_align="center", name="player-image-stack"
+ )
+ self.image_stack.children = [*self.image_stack.children, self.album_cover]
+
+ # Track Info
+ self.track_title = Label(
+ label="No Title",
+ name="macos-player-title",
+ justification="left",
+ max_chars_width=30,
+ ellipsization="end",
+ h_align="start",
+ h_expand=True,
+ )
+
+ self.track_artist = Label(
+ label="No Artist",
+ name="macos-player-artist",
+ justification="left",
+ max_chars_width=25,
+ ellipsization="end",
+ h_align="start",
+ h_expand=True,
+ visible=True,
+ )
+ self.track_album = Label(
+ label="No Album",
+ name="macos-player-album",
+ justification="left",
+ max_chars_width=25,
+ ellipsization="end",
+ h_align="start",
+ visible=True, # Hide artist and album when no media
+ )
+
+ self.app_icon = Box(
+ children=Image(
+ icon_name=self.player.player_name, name="player-app-icon", icon_size=20
+ ),
+ h_align="end",
+ v_align="end",
+ tooltip_text=self.player.player_name, # type: ignore
+ )
+ self.image = Overlay(
+ child=self.image_stack,
+ overlays=[
+ self.app_icon,
+ ],
+ )
+ # Seek bar should not update automatically during user interaction
+ self._user_seeking = False
+
+ self.seek_bar = Scale(
+ value=0,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ name="expanded-seek-bar",
+ size=1,
+ h_expand=True,
+ )
+ self.seek_bar.connect("value-changed", self._on_scale_value_changed)
+ self.seek_bar.connect("button-press-event", self._on_seek_start)
+ self.seek_bar.connect("button-release-event", self._on_seek_end)
+ self.player.bind_property("can_seek", self.seek_bar, "sensitive")
+
+ # Position and length labels for seek bar
+ self.position_label = Label(
+ label="0:00",
+ name="macos-position-label",
+ justification="left",
+ h_align="start",
+ )
+
+ self.length_label = Label(
+ label="0:00",
+ name="macos-length-label",
+ justification="right",
+ h_align="end",
+ )
+
+ # Labels box for position and length below seek bar
+ self.labels_box = Box(
+ name="macos-labels-box",
+ orientation="h",
+ children=[
+ self.position_label,
+ Box(h_expand=True), # Spacer to push labels to ends
+ self.length_label,
+ ],
+ )
+
+ # Seek bar with position labels below
+ self.seek_box = Box(
+ name="macos-seek-box",
+ orientation="v",
+ spacing=2,
+ children=[
+ self.seek_bar,
+ self.labels_box,
+ ],
+ )
+
+ # Define buttons first
+ self.button_box = Box(
+ name="macos-button-box",
+ h_align="center",
+ h_expand=True,
+ spacing=10, # Reduced spacing for macOS look
+ )
+
+ # Define controls_box BEFORE using it in track_info
+ self.controls_box = Box(
+ name="macos-player-controls",
+ orientation="v",
+ h_expand=True,
+ spacing=6,
+ h_align="center",
+ children=[self.button_box],
+ )
+
+ # Track info with inline controls - expands to fill available space
+ self.track_info = Box(
+ name="macos-track-info",
+ spacing=4, # Reduced spacing
+ orientation="v",
+ v_align="start",
+ h_align="fill", # Fill all available horizontal space
+ h_expand=True, # Expand horizontally to take maximum space
+ v_expand=True, # Also expand vertically
+ children=[
+ self.track_title,
+ self.track_artist,
+ self.track_album,
+ ],
+ )
+
+ # Bind player properties
+ self.player.bind_property(
+ "title",
+ self.track_title,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
+ ), # type: ignore
+ )
+ self.player.bind_property(
+ "artist",
+ self.track_artist,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", ", ".join(x))
+ if isinstance(x, list) and len(x) > 0
+ else "No Artist"
+ ), # type: ignore
+ )
+ self.player.bind_property(
+ "album",
+ self.track_album,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album"
+ ), # type: ignore
+ )
+
+ # Player switcher buttons box (compact, minimal space)
+ self.stack_buttons_box = Box(
+ h_expand=False, # Fixed width, don't expand
+ v_expand=True,
+ name="macos-stack-buttons-box",
+ spacing=4, # Reduced spacing
+ orientation="h", # Vertical layout for compactness
+ h_align="center",
+ v_align="end",
+ )
+ self.stack_buttons_box.hide() # Initially hidden
+
+ # Create SVG icons from player directory
+ self.skip_next_icon = svg_file("player/fwd.svg", size=22)
+ self.skip_prev_icon = svg_file("player/Rewind.svg", size=22)
+ self.play_pause_icon = svg_file("player/Pause.svg", size=22)
+
+ self.play_pause_button = Button(
+ style_classes=["control-buttons"],
+ name="macos-play-button",
+ child=self.play_pause_icon,
+ on_clicked=self.player.play_pause,
+ )
+
+ self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
+
+ self.next_button = Button(
+ style_classes=["control-buttons"],
+ name="macos-control-button",
+ child=self.skip_next_icon,
+ on_clicked=self._on_player_next,
+ )
+ self.player.bind_property("can_go_next", self.next_button, "sensitive")
+
+ self.prev_button = Button(
+ name="macos-control-button",
+ child=self.skip_prev_icon,
+ style_classes=["control-buttons"],
+ on_clicked=self._on_player_prev,
+ )
+ self.player.bind_property("can_go_previous", self.prev_button, "sensitive")
+ self.button_box.children = (
+ self.prev_button,
+ self.play_pause_button,
+ self.next_button,
+ )
+
+ self.box = Box(
+ orientation="horizontal",
+ children=[
+ self.image, # Album art on left (fixed width)
+ # Contains title, artist, seek bar AND controls (expands)
+ self.track_info,
+ ],
+ )
+ self.inner_box = Box(
+ orientation="v",
+ h_expand=True,
+ h_align="fill", # Fill available space
+ children=[
+ self.box, # Track info and album art
+ self.seek_box, # Seek bar with position labels
+ self.controls_box, # Controls now inline with track info
+ ],
+ )
+ # Compact macOS layout: album art on left, expanded track info+controls, minimal switcher
+ self.outer_box = Box(
+ name="macos-outer-player-box",
+ orientation="v",
+ spacing=10, # Reduced spacing between elements
+ h_expand=True,
+ v_expand=True,
+ v_align="center",
+ h_align="fill", # Fill available space
+ children=[
+ self.inner_box, # Track info and controls
+ # Compact switcher in corner (fixed width)
+ self.stack_buttons_box,
+ ],
+ )
+
+ self.children = [*self.children, self.outer_box]
+
+ # Track signal connections for cleanup - store (object, handler_id) tuples
+ connections = bulk_connect(
+ self.player,
+ {
+ "closed": self._on_player_exit,
+ "notify::playback-status": self._on_playback_change,
+ "notify::metadata": self._on_metadata,
+ },
+ )
+ # Store as (object, handler_id) tuples
+ for handler_id in connections:
+ self._signal_connections.append((self.player, handler_id))
+
+ # Start seek bar timer immediately for live updates (regardless of playback status)
+ self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
+
+ # Connect to realize signal to initialize seek bar properly
+ self.seek_bar.connect("realize", self._on_seek_bar_realized)
+
+ def destroy(self):
+ """Clean up all resources when the widget is destroyed."""
+ # Set exit flag FIRST to stop any running timers
+ self.exit = True
+
+ # Cancel any ongoing downloads immediately
+ self._download_cancelled = True
+
+ # Cancel seek bar timer
+ if self._seekbar_timer_id:
+ try:
+ GLib.source_remove(self._seekbar_timer_id)
+ except Exception:
+ pass
+ self._seekbar_timer_id = None
+
+ # Wait for download thread to finish (with timeout)
+ if self.current_download_thread and self.current_download_thread.is_alive():
+ try:
+ self.current_download_thread.join(timeout=1.0) # 1 second timeout
+ except Exception:
+ pass
+
+ # Disconnect all signal connections
+ for obj, handler_id in self._signal_connections:
+ try:
+ obj.disconnect(handler_id)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect signal: {e}")
+ self._signal_connections.clear()
+
+ # Clean up temp files aggressively
+ self._cleanup_temp_files()
+
+ # Clear image references
+ if hasattr(self, "album_cover_image"):
+ try:
+ self.album_cover_image.set_from_pixbuf(None)
+ except Exception:
+ pass
+
+ super().destroy()
+
+ def __del__(self):
+ """Ensure cleanup happens even if player exits unexpectedly."""
+ try:
+ self._cleanup_temp_files()
+ except Exception:
+ pass # Ignore errors during cleanup in destructor
+
+ def update_buttons(self, player_buttons, show_buttons):
+ """Update the stack switcher buttons in this player box"""
+ logger.info(
+ f"[PlayerBox] update_buttons called: show_buttons={
+ show_buttons
+ }, num_buttons={len(player_buttons)}"
+ )
+
+ # Clear existing buttons
+ for child in self.stack_buttons_box.get_children():
+ try:
+ child.destroy()
+ except Exception:
+ pass
+
+ if show_buttons and len(player_buttons) > 1:
+ logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons")
+ # Create macOS-style dot indicators for each player
+ for i, button in enumerate(player_buttons):
+ # Create a macOS-style dot button
+ dot_button = Button(
+ name="macos-player-switcher-dot",
+ style_classes=["macos-switcher-dot"],
+ )
+
+ # Set active state based on original button
+ if button.get_style_context().has_class("active"):
+ dot_button.add_style_class("active")
+
+ # Connect click handler to switch to this player
+ def make_click_handler(index):
+ return lambda *_: self.player_stack.on_player_clicked_by_index(
+ index
+ )
+
+ dot_button.connect("clicked", make_click_handler(i))
+ self.stack_buttons_box.children = [
+ *self.stack_buttons_box.children,
+ dot_button,
+ ]
+ logger.info(f"[PlayerBox] Added dot button {i}")
+
+ self.stack_buttons_box.show_all()
+ logger.info("[PlayerBox] Stack buttons box shown")
+ else:
+ self.stack_buttons_box.hide()
+ logger.info("[PlayerBox] Stack buttons box hidden")
+
+ def length_str(self, length):
+ """Convert length in microseconds to MM:SS or H:MM:SS format like real media players."""
+ if length is None or length <= 0:
+ return "0:00"
+
+ # Convert microseconds to seconds
+ length_seconds = length / 1000000
+
+ hours = int(length_seconds // 3600)
+ minutes = int((length_seconds % 3600) // 60)
+ seconds = int(length_seconds % 60)
+
+ if hours > 0:
+ return f"{hours}:{minutes:02d}:{seconds:02d}"
+ else:
+ return f"{minutes}:{seconds:02d}"
+
+ def _on_metadata(self, *_):
+ self._set_image()
+ duration = self.player.length
+
+ if duration is None:
+ duration = 0
+
+ if duration and duration > 0:
+ self.length_label.set_label(self.length_str(duration))
+ # Clamp duration to avoid 32-bit integer overflow in the scale widget
+ max_int32 = 2147483647 # 2^31 - 1
+ safe_duration = min(max_int32, duration)
+
+ # Only set range if seek bar is ready
+ if self.seek_bar.get_realized():
+ self.seek_bar.set_range(0, safe_duration)
+ else:
+ self.length_label.set_label("0:00")
+ if self.seek_bar.get_realized():
+ self.seek_bar.set_range(0, 100)
+
+ # Restart timer to ensure it's running with updated metadata
+ if self._seekbar_timer_id:
+ try:
+ GLib.source_remove(self._seekbar_timer_id)
+ except Exception:
+ pass
+ self._seekbar_timer_id = None
+
+ # Start new timer and store its ID
+ self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
+
+ def _cleanup_temp_files(self):
+ """Clean up temporary artwork files."""
+ for temp_file in self.temp_artwork_files:
+ try:
+ if os.path.exists(temp_file):
+ os.unlink(temp_file)
+ except Exception as e:
+ logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
+ self.temp_artwork_files.clear()
+
+ def _on_player_exit(self, _, value):
+ self.exit = value
+ self._cleanup_temp_files() # Clean up temp files before destroying
+ self.destroy()
+
+ def _on_player_next(self, *_):
+ self.player.next()
+
+ def _on_player_prev(self, *_):
+ self.player.previous()
+
+ def _on_playback_change(self, player, status):
+ status = player.playback_status
+
+ if status == "Paused":
+ self.play_pause_icon.dynamic_file("player/play.svg")
+ # Keep timer running for live updates even when paused
+
+ if status == "Playing":
+ self.play_pause_icon.dynamic_file("player/Pause.svg")
+ # Ensure timer is running for live updates
+ if not self._seekbar_timer_id:
+ self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
+
+ def _update_image(self, image_path):
+ if image_path and os.path.isfile(image_path):
+ self.album_cover.set_style(f"background-image:url('{image_path}')")
+ else:
+ self.album_cover.set_style(
+ f"background-image:url('{self.fallback_cover_path}')"
+ )
+
+ def _set_image(self, *_):
+ art_url = self.player.arturl
+
+ # If no art URL or empty/None, use fallback
+ if not art_url:
+ self._update_image(None)
+ return
+
+ parsed = urllib.parse.urlparse(art_url)
+ if parsed.scheme == "file":
+ local_arturl = urllib.parse.unquote(parsed.path)
+ self._update_image(local_arturl)
+ elif parsed.scheme in ("http", "https"):
+ # Cancel any existing download to prevent memory buildup
+ self._download_cancelled = True
+
+ # Use threading.Thread instead of GLib.Thread for better control
+ if self.current_download_thread and self.current_download_thread.is_alive():
+ # Thread will check _download_cancelled flag and exit early
+ pass
+
+ self._download_cancelled = False
+ self.current_download_thread = threading.Thread(
+ target=self._download_and_set_artwork,
+ args=(art_url,),
+ daemon=True, # Dies with main thread
+ )
+ self.current_download_thread.start()
+ else:
+ print(art_url)
+ self._update_image(art_url)
+
+ def _download_and_set_artwork(self, arturl):
+ """
+ Download the artwork from the given URL asynchronously and update the cover
+ using GLib.idle_add to ensure UI updates occur on the main thread.
+ """
+ local_arturl = self.fallback_cover_path
+ temp_file_path = None
+
+ try:
+ # Check if download was cancelled
+ if self._download_cancelled:
+ return
+
+ # Clean up old temp files first (keep only last 1 to reduce memory)
+ if len(self.temp_artwork_files) > 1:
+ old_files = self.temp_artwork_files[:-1]
+ for old_file in old_files:
+ try:
+ if os.path.exists(old_file):
+ os.unlink(old_file)
+ except Exception:
+ pass
+ self.temp_artwork_files = self.temp_artwork_files[-1:]
+
+ # Check again if cancelled
+ if self._download_cancelled:
+ return
+
+ # Download artwork
+ parsed = urllib.parse.urlparse(arturl)
+ suffix = os.path.splitext(parsed.path)[1] or ".png"
+
+ with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
+ if self._download_cancelled:
+ return
+ data = response.read()
+
+ # Check one more time if cancelled
+ if self._download_cancelled:
+ return
+
+ # Create temp file in cache directory instead of system temp
+ os.makedirs(CACHE_DIR, exist_ok=True)
+ with tempfile.NamedTemporaryFile(
+ delete=False, suffix=suffix, dir=CACHE_DIR
+ ) as temp_file:
+ temp_file.write(data)
+ temp_file_path = temp_file.name
+ local_arturl = temp_file_path
+
+ # Track temp file for cleanup
+ if temp_file_path and not self._download_cancelled:
+ self.temp_artwork_files.append(temp_file_path)
+
+ except Exception as e:
+ if not self._download_cancelled:
+ logger.warning(f"Failed to download artwork from {arturl}: {e}")
+ # Clean up failed temp file
+ if temp_file_path and os.path.exists(temp_file_path):
+ try:
+ os.unlink(temp_file_path)
+ except Exception:
+ pass
+ return
+
+ # Only update UI if not cancelled
+ if not self._download_cancelled:
+ GLib.idle_add(self._update_image, local_arturl)
+ return None
+
+ def _move_seekbar(self, *_):
+ if self.player is None or self.exit or self._user_seeking:
+ return True # Continue the timer but don't update while user is seeking
+
+ # Additional safety checks to prevent GTK errors
+ if not hasattr(self, "seek_bar") or self.seek_bar is None:
+ return False # Stop the timer
+
+ try:
+ # Check if seek bar is realized and has a valid adjustment
+ if not self.seek_bar.get_realized():
+ return True # Continue timer, widget not ready yet
+
+ position = self.player.position
+ if position is None:
+ position = 0
+
+ self.position_label.set_label(self.length_str(position))
+
+ # Only update seek bar if user is not currently seeking
+ if not self._user_seeking:
+ # Clamp position to avoid 32-bit integer overflow
+ max_int32 = 2147483647 # 2^31 - 1
+ safe_position = min(max_int32, position) if position else 0
+
+ # Only set value if seek bar has a valid range
+ if (
+ self.seek_bar.get_adjustment()
+ and self.seek_bar.get_adjustment().get_upper() > 0
+ ):
+ self.seek_bar.set_value(safe_position)
+
+ except Exception as e:
+ # If any error occurs (widget destroyed, etc), stop the timer
+ logger.warning(f"Seek bar update failed, stopping timer: {e}")
+ return False
+
+ return True
+
+ def _on_seek_start(self, widget, event):
+ """User started seeking - disable automatic updates"""
+ self._user_seeking = True
+ return False
+
+ def _on_seek_end(self, widget, event):
+ """User finished seeking - re-enable automatic updates"""
+ self._user_seeking = False
+ return False
+
+ def _on_seek_bar_realized(self, widget):
+ """Initialize seek bar when it's realized"""
+ try:
+ duration = self.player.length
+ if duration is None:
+ duration = 0
+
+ if duration and duration > 0:
+ max_int32 = 2147483647 # 2^31 - 1
+ safe_duration = min(max_int32, duration)
+ self.seek_bar.set_range(0, safe_duration)
+ else:
+ self.seek_bar.set_range(0, 100)
+ except Exception as e:
+ logger.warning(f"Failed to initialize seek bar: {e}")
+
+ def _on_scale_value_changed(self, scale: Scale):
+ """Handle seek bar value changes - only when user is seeking"""
+ if self.player and not self.exit and self._user_seeking:
+ try:
+ new_position = int(scale.get_value())
+ # Clamp to 32-bit signed integer range to avoid overflow
+ max_int32 = 2147483647 # 2^31 - 1
+ min_int32 = -2147483648 # -2^31
+ new_position = max(min_int32, min(max_int32, new_position))
+ self.player.position = new_position
+ self.position_label.set_label(self.length_str(new_position))
+ except Exception as e:
+ # If setting position fails, just update the label
+ try:
+ self.position_label.set_label(self.length_str(new_position))
+ except Exception:
+ logger.warning(f"Failed to update position label: {e}")
+
+ @cooldown(0.1)
+ def _on_scale_move(self, scale: Scale, event, pos: int):
+ try:
+ if not self.exit and self.player:
+ self.player.position = pos
+ self.position_label.set_label(self.length_str(pos))
+ self.seek_bar.set_value(pos)
+ except Exception as e:
+ logger.warning(f"Failed to update seek position: {e}")
+
+
+class Thing(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="thing",
+ size=(480, 160),
+ orientation="vertical",
+ spacing=0,
+ children=[
+ Label(
+ name="thing-label",
+ label="This is a thing",
+ style="font-size: 16px; padding: 10px;",
+ ),
+ ],
+ **kwargs,
+ )
+
+
+class ExpandedPlayer(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="expanded-player",
+ title="modus",
+ anchor="top right",
+ layer="top",
+ exclusivity="auto",
+ child=PlayerBoxStack(get_shared_mpris_manager()),
+ visible=False,
+ )
+ self.add_keybinding("Escape", self.set_child_visible(False))
+
+ def destroy(self):
+ """Clean up resources when the window is destroyed."""
+ # Clean up the child PlayerBoxStack
+ if hasattr(self, "child") and hasattr(self.child, "destroy"):
+ try:
+ self.child.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy child PlayerBoxStack: {e}")
+
+ super().destroy()
+
+ def _init_mousecapture(self, mousecapture):
+ self._mousecapture_parent = mousecapture
+
+ def hide_controlcenter(self, *_):
+ # self._mousecapture_parent.toggle_mousecapture()
+ self.set_visible(False)
diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py
new file mode 100644
index 0000000..fe8646a
--- /dev/null
+++ b/src/window/controlcenter/main.py
@@ -0,0 +1,1197 @@
+import subprocess
+
+from fabric.utils import Gdk, GLib, get_relative_path, idle_add, 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 fabric.widgets.scale import Scale
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from window.controlcenter.bluetooth import (
+ BluetoothConnections,
+ set_bluetooth_enabled_with_fallback,
+)
+from window.controlcenter.expanded_player import EmbeddedExpandedPlayer
+from window.controlcenter.nightlight import create_night_light_widget
+from window.controlcenter.per_app_volume import PerAppVolumeControl
+from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager
+from window.controlcenter.wifi import WifiConnections
+from services.brightness import Brightness
+from services.network import NetworkClient
+from utils.roam import audio_service, modus_service
+from utils.utils import svg_file
+
+brightness_service = Brightness.get_initial()
+
+
+class ModusControlCenter(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ layer="top",
+ title="modus",
+ anchor="top right",
+ margin="2px 10px 0px 0px",
+ exclusivity="auto",
+ keyboard_mode="on-demand",
+ name="control-center-menu",
+ visible=False,
+ **kwargs,
+ )
+ self.focus_mode = modus_service.dont_disturb
+ self._updating_brightness = False
+ self._updating_volume = False
+
+ # Flight mode and caffeine states
+ self.flight_mode = False
+ self.caffeine_mode = False
+ self._caffeine_process = None
+
+ # Loading flags - music and expanded player are no longer lazy loaded
+ self._per_app_volume_initialized = False
+ self._signals_connected = False # Track if signals are connected
+ self._resources_initialized = False # Track if resources are initialized
+
+ # Store references for cleanup - initialize all as None
+ self._signal_connections = []
+ self._music_widget_content = None
+ self._per_app_volume_widget = None
+ self._expanded_player_widget = None
+ self._mpris_manager = None # Shared MPRIS manager instance
+
+ # Initialize network service for WiFi toggle - lazy load
+ self.network_service = None
+ self.wifi_service = None
+
+ # Initialize flight mode and caffeine states - lazy load
+ self.caffeine_mode = False
+ self.flight_mode = False
+
+ # Add keybinding immediately (this is fast)
+ self.add_keybinding("Escape", self.hide_controlcenter)
+
+ volume = 100
+ # Get initial values and connect signals - store connection IDs for cleanup
+ wlan = modus_service.wlan if modus_service.wlan else "No Connection"
+ bluetooth = modus_service.bluetooth if modus_service.bluetooth else "Off"
+
+ self.wlan_label = Label(
+ label=wlan,
+ name="wifi-widget-label",
+ max_chars_width=15,
+ h_align="start",
+ ellipsization="end",
+ )
+ if bluetooth != "disabled":
+ if bluetooth.startswith("connected:"):
+ parts = bluetooth.split(":")
+ bluetooth_display = parts[1] if len(parts) >= 2 else "Connected"
+ else:
+ bluetooth_display = "On"
+ else:
+ bluetooth_display = "Off"
+
+ self.bluetooth_label = Label(
+ label=bluetooth_display,
+ name="bluetooth-widget-label",
+ max_chars_width=15,
+ ellipsization="end",
+ h_align="start",
+ )
+ self.volume_scale = Scale(
+ value=volume,
+ min_value=0,
+ max_value=100,
+ increments=(5, 5),
+ name="volume-widget-slider",
+ size=30,
+ h_expand=True,
+ )
+
+ current_brightness = brightness_service.screen_brightness
+ brightness_percentage = (
+ int((current_brightness / brightness_service.max_screen) * 100)
+ if brightness_service.max_screen > 0
+ else 50
+ )
+
+ self.brightness_scale = Scale(
+ value=brightness_percentage,
+ min_value=0,
+ max_value=100,
+ increments=(5, 5),
+ name="brightness-widget-slider",
+ size=30,
+ h_expand=True,
+ )
+
+ # Only connect brightness controls if brightness service is available
+ if brightness_service.max_screen > 0:
+ pass
+ else:
+ # Disable brightness scale if no backlight device available
+ self.brightness_scale.set_sensitive(False)
+
+ self._mpris_manager = get_shared_mpris_manager()
+ self.music_widget = PlayerBoxStack(self._mpris_manager, control_center=self)
+
+ self.has_bluetooth_open = False
+ self.has_wifi_open = False
+ self.has_per_app_volume_open = False
+ self.has_expanded_player_open = False
+
+ self.bluetooth_svg = svg_file(
+ (
+ "applets/bluetooth.svg"
+ if bluetooth != "disabled"
+ else "applets/bluetooth-off.svg"
+ ),
+ size=42,
+ )
+ self.wifi_svg = svg_file(
+ "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg",
+ size=42,
+ )
+
+ self.bluetooth_widget = Box(
+ name="bluetooth-widget",
+ orientation="h",
+ children=[
+ Button(
+ name="bluetooth-icon-button",
+ child=self.bluetooth_svg,
+ on_clicked=self.toggle_bluetooth,
+ ),
+ Button(
+ name="bluetooth-info-button",
+ child=Box(
+ name="bluetooth-widget-info",
+ orientation="vertical",
+ children=[
+ Label(
+ name="bluetooth-widget-name",
+ label="Bluetooth",
+ style_classes="ct",
+ h_align="start",
+ ),
+ self.bluetooth_label,
+ ],
+ ),
+ on_clicked=self.open_bluetooth,
+ ),
+ ],
+ )
+
+ self.wlan_widget = Box(
+ name="wifi-widget",
+ orientation="h",
+ children=[
+ Button(
+ name="wifi-icon-button",
+ child=self.wifi_svg,
+ on_clicked=self.toggle_wifi,
+ ),
+ Button(
+ name="wifi-info-button",
+ child=Box(
+ name="wifi-widget-info",
+ orientation="vertical",
+ children=[
+ Label(
+ name="wifi-widget-name",
+ label="Wi-Fi",
+ style_classes="ct",
+ h_align="start",
+ ),
+ self.wlan_label,
+ ],
+ ),
+ on_clicked=self.open_wifi,
+ ),
+ ],
+ )
+
+ self.focus_icon = svg_file(
+ "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg",
+ size=42,
+ )
+
+ self.focus_status_label = Label(
+ label="On" if self.focus_mode else "Off",
+ style_classes="status-label",
+ h_align="start",
+ )
+
+ self.focus_widget = Button(
+ name="focus-widget",
+ child=Box(
+ spacing=4,
+ orientation="h",
+ children=[
+ self.focus_icon,
+ Box(
+ v_expand=True,
+ h_expand=True,
+ v_align="center",
+ orientation="v",
+ h_align="start",
+ children=[
+ Label(
+ label="Focus",
+ style_classes="title-widget",
+ h_align="start",
+ ),
+ self.focus_status_label,
+ ],
+ ),
+ ],
+ ),
+ on_clicked=self.set_dont_disturb,
+ )
+
+ self.flight_icon = svg_file(
+ "applets/flight-on.svg" if self.flight_mode else "applets/flight-off.svg",
+ size=42,
+ )
+
+ self.flight_widget = Button(
+ name="flight-widget",
+ child=Box(
+ orientation="v",
+ h_expand=True,
+ v_expand=True,
+ spacing=8,
+ h_align="center",
+ v_align="center",
+ children=[
+ self.flight_icon,
+ Label(
+ label="Flight",
+ style_classes="title-widget",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=self.toggle_flight_mode,
+ )
+
+ self.caffeine_icon = svg_file(
+ (
+ "applets/caffeine-on.svg"
+ if self.caffeine_mode
+ else "applets/caffeine-off.svg"
+ ),
+ size=42,
+ )
+
+ self.caffeine_status_label = Label(
+ label="On" if self.caffeine_mode else "Off",
+ style_classes="status-label",
+ h_align="start",
+ )
+
+ self.caffeine_widget = Button(
+ name="caffeine-widget",
+ child=Box(
+ orientation="v",
+ spacing=8,
+ h_expand=True,
+ v_expand=True,
+ h_align="center",
+ v_align="center",
+ children=[
+ self.caffeine_icon,
+ Label(
+ label="Caffeine",
+ style_classes="title-widget",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=self.toggle_caffeine,
+ )
+
+ # Create night light widget
+ self.night_light_widget = create_night_light_widget(self)
+
+ # Create main widgets directly without XML - defer heavy operations
+ self.widgets = Box(
+ orientation="vertical",
+ h_expand=True,
+ name="control-center-widgets",
+ children=[
+ # Top section: Wi-Fi + Bluetooth on left, DND + Flight Mode + Caffeine on right
+ Box(
+ orientation="horizontal",
+ h_expand=True,
+ name="top-widget",
+ children=[
+ # Left side: Wi-Fi and Bluetooth
+ Box(
+ orientation="vertical",
+ name="wb-widget",
+ style_classes="menu",
+ spacing=5,
+ children=[
+ self.wlan_widget,
+ self.bluetooth_widget,
+ self.night_light_widget,
+ ],
+ ),
+ # Right side: DND, Flight Mode, and Caffeine
+ Box(
+ orientation="vertical",
+ h_expand=True,
+ name="right-side-widget",
+ children=[
+ # DND widget
+ Box(
+ orientation="vertical",
+ name="dnd-widget",
+ style_classes="menu",
+ children=[
+ self.focus_widget,
+ ],
+ ),
+ # Flight Mode and Caffeine row
+ Box(
+ orientation="horizontal",
+ name="flight-caffeine-row",
+ children=[
+ Box(
+ orientation="vertical",
+ name="flight-widget-container",
+ style_classes="menu",
+ h_expand=True,
+ v_expand=True,
+ children=[
+ self.flight_widget,
+ ],
+ ),
+ Box(
+ orientation="vertical",
+ name="caffeine-widget-container",
+ h_expand=True,
+ v_expand=True,
+ style_classes="menu",
+ children=[
+ self.caffeine_widget,
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ Box(
+ orientation="vertical",
+ name="brightness-widget",
+ style_classes="menu",
+ h_expand=True,
+ children=[
+ Label(label="Display", style_classes="title", h_align="start"),
+ self.brightness_scale,
+ Label(
+ label=" ", name="brightness-widget-icon", h_align="start"
+ ),
+ ],
+ ),
+ Box(
+ orientation="vertical",
+ name="volume-widget",
+ style_classes="menu",
+ h_expand=True,
+ children=[
+ Label(label="Sound", style_classes="title", h_align="start"),
+ Box(
+ name="vol-box",
+ orientation="horizontal",
+ spacing=12,
+ v_expand=True,
+ children=[
+ Box(
+ name="vol-slider-box",
+ h_expand=True,
+ v_align="center",
+ v_expand=False,
+ children=[
+ self.volume_scale,
+ ],
+ ),
+ Button(
+ name="per-app-volume-button",
+ size=(36, 36),
+ child=svg_file(
+ "player/audio-switcher.svg",
+ name="per-app-volume-icon",
+ size=32,
+ ),
+ on_clicked=self.open_per_app_volume,
+ ),
+ ],
+ ),
+ Label(label=" ", name="volume-widget-icon", h_align="start"),
+ ],
+ ),
+ self.music_widget,
+ ],
+ )
+
+ # Initialize managers directly like working version
+ self.wifi_man = WifiConnections(self)
+ self.bluetooth_man = BluetoothConnections(self)
+
+ self.has_bluetooth_open = False
+ self.has_wifi_open = False
+
+ # Create expanded player widgets immediately (no lazy loading)
+ self._expanded_player_widget = EmbeddedExpandedPlayer(self)
+ self.expanded_player_widgets = Box(
+ orientation="vertical",
+ h_expand=True,
+ name="control-center-widgets",
+ children=[
+ self._expanded_player_widget,
+ ],
+ )
+
+ # Lazy-loaded widgets - create placeholders for others
+ self.bluetooth_widgets = None
+ self.wifi_widgets = None
+ self.per_app_volume_widgets = None
+
+ # Create main content boxes
+ self.center_box = CenterBox(start_children=[self.widgets])
+ self.bluetooth_center_box = None
+ self.wifi_center_box = None
+ self.per_app_volume_center_box = None
+ # Create expanded player center box immediately
+ self.expanded_player_center_box = CenterBox(
+ start_children=[self.expanded_player_widgets]
+ )
+ self.expanded_player_center_box.set_size_request(300, -1)
+
+ # Create revealers for crossfade transitions
+
+ self.widgets.set_size_request(300, -1)
+
+ self.children = self.center_box
+
+ # Track current state for smooth transitions
+ self.current_view = "main" # main, expanded_player
+
+ # Connect to visibility changes for cleanup
+ self.connect("notify::visible", self._on_visibility_changed)
+
+ def _on_visibility_changed(self, widget, param):
+ """Handle visibility changes for resource management"""
+ if not self.get_visible():
+ # Just disconnect signals and reset state flags - don't destroy widgets
+ self._disconnect_signals_when_hidden()
+ else:
+ self._initialize_resources()
+
+ def _initialize_resources(self):
+ """Initialize resources and connect signals when the control center becomes visible."""
+ if self._resources_initialized:
+ return
+
+ try:
+ logger.debug("Initializing control center resources...")
+
+ # Initialize network service lazily
+ if self.network_service is None:
+ self.network_service = NetworkClient()
+ # Connect network signal
+ self.network_service.connect("wifi-device-added", self.on_network_ready)
+
+ # Check initial states lazily (only when needed)
+ self._check_initial_states()
+
+ # Store signal connections for cleanup
+ self._signal_connections.extend(
+ [
+ audio_service.connect("changed", self.audio_changed),
+ audio_service.connect("changed", self.volume_changed),
+ modus_service.connect("wlan-changed", self.wlan_changed),
+ modus_service.connect("bluetooth-changed", self.bluetooth_changed),
+ modus_service.connect("dont-disturb-changed", self.dnd_changed),
+ ]
+ )
+
+ # Connect brightness controls if brightness service is available
+ if brightness_service.max_screen > 0:
+ self.brightness_scale.connect("change-value", self.set_brightness)
+ self.brightness_scale.connect("scroll-event", self.on_brightness_scroll)
+ self._signal_connections.append(
+ brightness_service.connect("screen", self.brightness_changed)
+ )
+
+ # Connect volume scale signals
+ self.volume_scale.connect("change-value", self.set_volume)
+ self.volume_scale.connect("scroll-event", self.on_volume_scroll)
+
+ # Mark signals as connected
+ self._signals_connected = True
+ self._resources_initialized = True
+
+ logger.debug("Control center resources initialized successfully")
+
+ except Exception as e:
+ logger.error(f"Failed to initialize control center resources: {e}")
+ # Reset flags on failure
+ self._signals_connected = False
+ self._resources_initialized = False
+
+ def _disconnect_signals_when_hidden(self):
+ """Disconnect signals when hidden to reduce resource usage, but keep widgets intact"""
+ try:
+ # Just disconnect signals and reset flags - don't destroy widgets
+ self._signals_connected = False
+ self._resources_initialized = False
+
+ # Reset state flags
+ self.has_bluetooth_open = False
+ self.has_wifi_open = False
+ self.has_per_app_volume_open = False
+ self.has_expanded_player_open = False
+
+ # Reset current view
+ self.current_view = "main"
+
+ except Exception as e:
+ logger.warning(f"Control center signal disconnection failed: {e}")
+
+ def _ensure_bluetooth_widgets(self):
+ """Lazy load bluetooth widgets"""
+ if self.bluetooth_widgets is None:
+ self.bluetooth_widgets = Box(
+ orientation="vertical",
+ h_expand=True,
+ v_expand=True,
+ children=[self.bluetooth_man],
+ )
+ self.bluetooth_center_box = Box(
+ h_expand=True, v_expand=True, children=[self.bluetooth_widgets]
+ )
+ self.bluetooth_center_box.set_size_request(350, -1)
+
+ def _ensure_wifi_widgets(self):
+ """Lazy load wifi widgets"""
+ if self.wifi_widgets is None:
+ self.wifi_widgets = Box(
+ orientation="vertical",
+ h_expand=True,
+ v_expand=True,
+ children=[
+ self.wifi_man,
+ ],
+ )
+ self.wifi_center_box = Box(
+ h_expand=True, v_expand=True, children=[self.wifi_widgets]
+ )
+ self.wifi_center_box.set_size_request(350, -1)
+
+ def _ensure_per_app_volume_widgets(self):
+ """Lazy load per-app volume widgets"""
+ if self.per_app_volume_widgets is None:
+ if self._per_app_volume_widget is None:
+ self._per_app_volume_widget = PerAppVolumeControl(self)
+
+ self.per_app_volume_widgets = Box(
+ orientation="vertical",
+ h_expand=True,
+ name="control-center-widgets",
+ children=[
+ self._per_app_volume_widget,
+ ],
+ )
+ self.per_app_volume_center_box = CenterBox(
+ start_children=[self.per_app_volume_widgets]
+ )
+ self.per_app_volume_center_box.set_size_request(300, -1)
+
+ def _check_initial_states(self):
+ # Check if caffeine is already running - use faster method
+ try:
+ # Use faster check with timeout
+ result = subprocess.run(
+ ["pgrep", "-f", "modus-inhibit"],
+ capture_output=True,
+ text=True,
+ timeout=0.5, # Add timeout to prevent hanging
+ )
+ self.caffeine_mode = bool(result.stdout.strip())
+ except (subprocess.TimeoutExpired, Exception):
+ self.caffeine_mode = False
+
+ # Flight mode starts as False (normal mode)
+ self.flight_mode = False
+
+ # Update initial labels (will be set after widgets are created)
+ GLib.timeout_add(100, self._update_initial_labels)
+
+ def _update_initial_labels(self):
+ return False
+
+ def set_dont_disturb(self, *_):
+ self.focus_mode = not self.focus_mode
+ modus_service.dont_disturb = self.focus_mode
+ self.focus_icon.dynamic_file(
+ "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg"
+ )
+ self.focus_status_label.set_label("On" if self.focus_mode else "Off")
+
+ def toggle_flight_mode(self, *_):
+ try:
+ self.flight_mode = not self.flight_mode
+
+ if self.flight_mode:
+ # Turn off WiFi and Bluetooth
+ if self.wifi_service:
+ self.wifi_service.wireless_enabled = False
+ if hasattr(self, "bluetooth_man") and hasattr(
+ self.bluetooth_man, "client"
+ ):
+ self.bluetooth_man.client.set_enabled(False)
+ else:
+ # Turn on WiFi and Bluetooth
+ if self.wifi_service:
+ self.wifi_service.wireless_enabled = True
+ if hasattr(self, "bluetooth_man") and hasattr(
+ self.bluetooth_man, "client"
+ ):
+ self.bluetooth_man.client.set_enabled(True)
+
+ # Update icon
+ self.flight_icon.dynamic_file(
+ "applets/flight-on.svg"
+ if self.flight_mode
+ else "applets/flight-off.svg"
+ )
+
+ except Exception as e:
+ logger.warning(f"Failed to toggle flight mode: {e}")
+
+ def toggle_caffeine(self, *_):
+ try:
+ if self.caffeine_mode:
+ inhibit_script = get_relative_path("../../utils/inhibit.py")
+ subprocess.run(["python3", inhibit_script, "off"], check=False)
+ self.caffeine_mode = False
+ if self._caffeine_process:
+ try:
+ self._caffeine_process.terminate()
+ except Exception:
+ pass
+ self._caffeine_process = None
+ else:
+ inhibit_script = get_relative_path("../../utils/inhibit.py")
+ self._caffeine_process = subprocess.Popen(
+ ["python3", inhibit_script, "on"], start_new_session=True
+ )
+ self.caffeine_mode = True
+
+ self.caffeine_icon.dynamic_file(
+ "applets/caffeine-on.svg"
+ if self.caffeine_mode
+ else "applets/caffeine-off.svg"
+ )
+ self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off")
+
+ except Exception as e:
+ logger.warning(f"Failed to toggle caffeine: {e}")
+
+ def set_volume(self, _, __, volume):
+ if not self._signals_connected:
+ return
+ self._updating_volume = True
+ audio_service.speaker.volume = round(volume)
+ self._updating_volume = False
+
+ def set_brightness(self, _, __, brightness):
+ if not self._signals_connected:
+ return
+ self._updating_brightness = True
+ brightness_value = int((brightness / 100) * brightness_service.max_screen)
+ brightness_service.screen_brightness = brightness_value
+ self._updating_brightness = False
+
+ def brightness_changed(self, _, brightness_value):
+ if not self._signals_connected or self._updating_brightness:
+ return
+
+ if brightness_service.max_screen > 0:
+ brightness_percentage = int(
+ (brightness_value / brightness_service.max_screen) * 100
+ )
+
+ GLib.idle_add(
+ lambda: self.brightness_scale.set_value(brightness_percentage)
+ )
+
+ def on_volume_scroll(self, widget, event):
+ if not self._signals_connected:
+ return False
+ current_value = self.volume_scale.get_value()
+ scroll_step = 5
+ if event.direction == Gdk.ScrollDirection.UP:
+ new_value = min(100, current_value + scroll_step)
+ elif event.direction == Gdk.ScrollDirection.DOWN:
+ new_value = max(0, current_value - scroll_step)
+ else:
+ return False
+
+ self.volume_scale.set_value(new_value)
+ return True
+
+ def on_brightness_scroll(self, widget, event):
+ if not self._signals_connected:
+ return False
+ current_value = self.brightness_scale.get_value()
+ scroll_step = 5
+ if event.direction == Gdk.ScrollDirection.UP:
+ new_value = min(100, current_value + scroll_step)
+ elif event.direction == Gdk.ScrollDirection.DOWN:
+ new_value = max(0, current_value - scroll_step)
+ else:
+ return False
+
+ self.brightness_scale.set_value(new_value)
+ return True
+
+ def toggle_bluetooth(self, *_):
+ if not self._resources_initialized:
+ return
+ try:
+ if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"):
+ current_state = self.bluetooth_man.client.enabled
+ set_bluetooth_enabled_with_fallback(
+ self.bluetooth_man.client, not current_state
+ )
+ else:
+ logger.warning("Bluetooth client not available for toggling")
+ except Exception as e:
+ logger.warning(f"Failed to toggle bluetooth: {e}")
+
+ def on_network_ready(self, *_):
+ self.wifi_service = self.network_service.wifi_device
+ if self.wifi_service:
+ self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon)
+
+ def update_wifi_icon(self, *_):
+ try:
+ if self.wifi_service and hasattr(self, "wifi_svg"):
+ is_enabled = self.wifi_service.wireless_enabled
+ self.wifi_svg.dynamic_file(
+ "applets/wifi.svg" if is_enabled else "applets/wifi-off.svg"
+ )
+ except Exception as e:
+ logger.warning(f"Failed to update WiFi icon: {e}")
+
+ def toggle_wifi(self, *_):
+ if not self._resources_initialized:
+ return
+ try:
+ if self.wifi_service:
+ self.wifi_service.toggle_wifi()
+ GLib.timeout_add(100, self.update_wifi_icon)
+ else:
+ logger.warning("WiFi device not available for toggling")
+ except Exception as e:
+ logger.warning(f"Failed to toggle wifi: {e}")
+
+ def set_children(self, children):
+ self.children = children
+
+ def open_bluetooth(self, *_):
+ if not self._resources_initialized:
+ return
+ self._ensure_bluetooth_widgets()
+ idle_add(lambda *_: self.set_children(self.bluetooth_center_box))
+ self.has_bluetooth_open = True
+
+ def open_wifi(self, *_):
+ if not self._resources_initialized:
+ return
+ self._ensure_wifi_widgets()
+ idle_add(lambda *_: self.set_children(self.wifi_center_box))
+ self.has_wifi_open = True
+
+ def close_bluetooth(self, *_):
+ if self.current_view == "expanded_player":
+ self._crossfade_to_view("main")
+ else:
+ idle_add(lambda *_: self.set_children(self.center_box))
+ self.has_bluetooth_open = False
+
+ def close_wifi(self, *_):
+ if self.current_view == "expanded_player":
+ self._crossfade_to_view("main")
+ else:
+ idle_add(lambda *_: self.set_children(self.center_box))
+ self.has_wifi_open = False
+
+ def open_per_app_volume(self, *_):
+ if not self._resources_initialized:
+ return
+ self._ensure_per_app_volume_widgets()
+ if self.current_view == "expanded_player":
+ # If coming from expanded player, use crossfade
+ self._crossfade_to_view("main")
+ GLib.timeout_add(
+ 250,
+ lambda: idle_add(
+ lambda *_: self.set_children(self.per_app_volume_center_box)
+ ),
+ )
+ else:
+ idle_add(lambda *_: self.set_children(self.per_app_volume_center_box))
+ self.has_per_app_volume_open = True
+ # Refresh the app list when opening
+ if self._per_app_volume_widget:
+ self._per_app_volume_widget.refresh()
+
+ def close_per_app_volume(self, *_):
+ if self.current_view == "expanded_player":
+ self._crossfade_to_view("main")
+ else:
+ idle_add(lambda *_: self.set_children(self.center_box))
+ self.has_per_app_volume_open = False
+
+ def open_expanded_player(self, *_):
+ if not self._resources_initialized:
+ return
+ self._crossfade_to_view("expanded_player")
+ self.has_expanded_player_open = True
+ if self._expanded_player_widget:
+ self._expanded_player_widget.refresh()
+
+ def close_expanded_player(self, *_):
+ # Just hide the expanded player widget, don't destroy it
+ self._crossfade_to_view("main")
+ self.has_expanded_player_open = False
+
+ def _crossfade_to_view(self, view_name):
+ """Handle transitions between views"""
+ if view_name == "expanded_player":
+ idle_add(lambda *_: self.set_children(self.expanded_player_center_box))
+ self.current_view = "expanded_player"
+ elif view_name == "main":
+ idle_add(lambda *_: self.set_children(self.center_box))
+ self.current_view = "main"
+
+ def _set_mousecapture(self, visible: bool):
+ if visible:
+ GLib.idle_add(self._initialize_resources)
+
+ self.set_visible(visible)
+ if not visible:
+ self.close_bluetooth()
+ self.close_wifi()
+ self.close_per_app_volume()
+ self.close_expanded_player()
+
+ def volume_changed(
+ self,
+ _,
+ ):
+ if not self._signals_connected or self._updating_volume:
+ return
+
+ GLib.idle_add(
+ lambda: self.volume_scale.set_value(int(audio_service.speaker.volume))
+ )
+
+ def wlan_changed(self, _, wlan):
+ if not self._signals_connected:
+ return
+ self.wifi_svg.dynamic_file(
+ "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg"
+ )
+ if wlan != "No Connection":
+ if wlan.startswith("connected:"):
+ parts = wlan.split(":")
+ if len(parts) >= 2:
+ wifi_name = parts[1]
+ GLib.idle_add(
+ lambda: self.wlan_label.set_property("label", wifi_name)
+ )
+ else:
+ GLib.idle_add(
+ lambda: self.wlan_label.set_property("label", "Connected")
+ )
+ else:
+ GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
+ else:
+ GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
+
+ def bluetooth_changed(self, _, bluetooth):
+ if not self._signals_connected:
+ return
+ self.bluetooth_svg.dynamic_file(
+ "applets/bluetooth.svg"
+ if bluetooth != "disabled"
+ else "applets/bluetooth-off.svg"
+ )
+ if bluetooth != "disabled":
+ if bluetooth.startswith("connected:"):
+ parts = bluetooth.split(":")
+ if len(parts) >= 2:
+ device_name = parts[1]
+ GLib.idle_add(lambda: self.bluetooth_label.set_label(device_name))
+ else:
+ GLib.idle_add(lambda: self.bluetooth_label.set_label("Connected"))
+ elif bluetooth == "enabled":
+ GLib.idle_add(lambda: self.bluetooth_label.set_label("On"))
+ else:
+ GLib.idle_add(lambda: self.bluetooth_label.set_label("On"))
+ else:
+ GLib.idle_add(lambda: self.bluetooth_label.set_label("Off"))
+
+ def audio_changed(self, *_):
+ if not self._signals_connected:
+ return
+ pass
+
+ def dnd_changed(self, _, dnd_state):
+ if not self._signals_connected:
+ return
+ self.focus_mode = dnd_state
+ self.focus_icon.dynamic_file(
+ "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg"
+ )
+ self.focus_status_label.set_label("On" if self.focus_mode else "Off")
+
+ def _init_mousecapture(self, mousecapture):
+ self._mousecapture_parent = mousecapture
+
+ def _disconnect_all_signals(self):
+ """Disconnect all signal connections to prevent memory leaks"""
+ try:
+ for connection in self._signal_connections:
+ try:
+ if connection and hasattr(connection, "disconnect"):
+ connection.disconnect()
+ except Exception as e:
+ logger.warning(f"Failed to disconnect signal: {e}")
+
+ self._signal_connections.clear()
+
+ try:
+ self.disconnect_by_func(self._on_visibility_changed)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect visibility signal: {e}")
+
+ try:
+ self.disconnect_by_func(self.hide_controlcenter)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect keybinding: {e}")
+
+ if hasattr(self, "network_service") and self.network_service:
+ try:
+ self.network_service.disconnect_by_func(self.on_network_ready)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect network signal: {e}")
+
+ if hasattr(self, "brightness_service") and self.brightness_service:
+ try:
+ self.brightness_service.disconnect_by_func(self.brightness_changed)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect brightness signal: {e}")
+
+ if hasattr(self, "audio_service") and audio_service:
+ try:
+ audio_service.disconnect_by_func(self.audio_changed)
+ audio_service.disconnect_by_func(self.volume_changed)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect audio signals: {e}")
+
+ if hasattr(self, "modus_service") and modus_service:
+ try:
+ modus_service.disconnect_by_func(self.wlan_changed)
+ modus_service.disconnect_by_func(self.bluetooth_changed)
+ modus_service.disconnect_by_func(self.dnd_changed)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect modus signals: {e}")
+
+ if hasattr(self, "volume_scale") and self.volume_scale:
+ try:
+ self.volume_scale.disconnect_by_func(self.set_volume)
+ self.volume_scale.disconnect_by_func(self.on_volume_scroll)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect volume scale signals: {e}")
+
+ if hasattr(self, "brightness_scale") and self.brightness_scale:
+ try:
+ self.brightness_scale.disconnect_by_func(self.set_brightness)
+ self.brightness_scale.disconnect_by_func(self.on_brightness_scroll)
+ except Exception as e:
+ logger.warning(
+ f"Failed to disconnect brightness scale signals: {e}"
+ )
+
+ self._signals_connected = False
+
+ logger.debug("All signals disconnected successfully")
+
+ except Exception as e:
+ logger.warning(f"Signal disconnection failed: {e}")
+
+ def _cleanup_managers(self):
+ """Clean up all manager instances"""
+ try:
+ if hasattr(self, "wifi_man") and self.wifi_man:
+ try:
+ self.wifi_man.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy WiFi manager: {e}")
+ self.wifi_man = None
+
+ if hasattr(self, "bluetooth_man") and self.bluetooth_man:
+ try:
+ self.bluetooth_man.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy Bluetooth manager: {e}")
+ self.bluetooth_man = None
+
+ if hasattr(self, "network_service") and self.network_service:
+ try:
+ self.network_service.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy network service: {e}")
+ self.network_service = None
+
+ if hasattr(self, "wifi_service") and self.wifi_service:
+ try:
+ self.wifi_service.disconnect_by_func(self.update_wifi_icon)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect WiFi service: {e}")
+ self.wifi_service = None
+
+ self._resources_initialized = False
+
+ logger.debug("All managers cleaned up successfully")
+
+ except Exception as e:
+ logger.warning(f"Manager cleanup failed: {e}")
+
+ def _cleanup_widgets(self):
+ try:
+ # Clean up main widgets
+ if hasattr(self, "widgets") and self.widgets:
+ try:
+ self.widgets.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy main widgets: {e}")
+ self.widgets = None
+
+ # Clean up center box
+ if hasattr(self, "center_box") and self.center_box:
+ try:
+ self.center_box.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy center box: {e}")
+ self.center_box = None
+
+ # Clean up individual widgets
+ widget_attrs = [
+ "wlan_widget",
+ "bluetooth_widget",
+ "focus_widget",
+ "flight_widget",
+ "caffeine_widget",
+ "night_light_widget",
+ "volume_scale",
+ "brightness_scale",
+ "wlan_label",
+ "bluetooth_label",
+ "focus_status_label",
+ "caffeine_status_label",
+ "wlan_svg",
+ "bluetooth_svg",
+ "focus_icon",
+ "flight_icon",
+ "caffeine_icon",
+ ]
+
+ for attr in widget_attrs:
+ if hasattr(self, attr) and getattr(self, attr):
+ try:
+ widget = getattr(self, attr)
+ if hasattr(widget, "destroy"):
+ widget.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy {attr}: {e}")
+ setattr(self, attr, None)
+
+ logger.debug("All widgets cleaned up successfully")
+
+ except Exception as e:
+ logger.warning(f"Widget cleanup failed: {e}")
+
+ def _cleanup_processes(self):
+ """Clean up any running processes"""
+ try:
+ # Clean up caffeine process
+ if self._caffeine_process:
+ try:
+ self._caffeine_process.terminate()
+ self._caffeine_process.wait(timeout=1)
+ except Exception as e:
+ logger.warning(f"Failed to terminate caffeine process: {e}")
+ finally:
+ self._caffeine_process = None
+
+ logger.debug("All processes cleaned up successfully")
+
+ except Exception as e:
+ logger.warning(f"Process cleanup failed: {e}")
+
+ def _complete_cleanup(self):
+ """Perform complete cleanup of all resources"""
+ try:
+ logger.debug("Starting complete cleanup...")
+ self._disconnect_all_signals()
+ self._cleanup_managers()
+ self._cleanup_widgets()
+ self._cleanup_processes()
+ self._disconnect_signals_when_hidden()
+
+ # Force garbage collection
+ import gc
+
+ gc.collect()
+
+ logger.debug("Complete cleanup finished successfully")
+
+ except Exception as e:
+ logger.error(f"Complete cleanup failed: {e}")
+
+ def hide_controlcenter(self, *_):
+ try:
+ # Just disconnect signals when hiding, don't destroy widgets
+ self._disconnect_signals_when_hidden()
+
+ # Hide the control center
+ if hasattr(self, "_mousecapture_parent"):
+ self._mousecapture_parent.toggle_mousecapture()
+ self.set_visible(False)
+
+ except Exception as e:
+ logger.error(f"Failed to hide control center: {e}")
+ # Still try to hide even if cleanup fails
+ self.set_visible(False)
+
+ def destroy(self):
+ try:
+ self._complete_cleanup()
+ super().destroy()
+ logger.debug("Control center destroyed successfully")
+ except Exception as e:
+ logger.error(f"Failed to destroy control center: {e}")
+ try:
+ super().destroy()
+ except Exception:
+ pass
diff --git a/src/window/controlcenter/nightlight.py b/src/window/controlcenter/nightlight.py
new file mode 100644
index 0000000..d1ab0d4
--- /dev/null
+++ b/src/window/controlcenter/nightlight.py
@@ -0,0 +1,138 @@
+import subprocess
+
+from fabric.utils import logger
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from utils.utils import svg_file
+
+
+class NightLightControl:
+ """Control night light using hyprsunset"""
+
+ def __init__(self):
+ self.is_active = False
+ self._check_initial_state()
+
+ def _check_initial_state(self):
+ """Check if hyprsunset is currently running"""
+ try:
+ result = subprocess.run(
+ ["pgrep", "-f", "hyprsunset"], capture_output=True, text=True
+ )
+ self.is_active = bool(result.stdout.strip())
+ except Exception as e:
+ logger.warning(f"Failed to check hyprsunset status: {e}")
+ self.is_active = False
+
+ def toggle(self):
+ """Toggle night light on/off"""
+ try:
+ if self.is_active:
+ # Turn off night light by killing hyprsunset
+ subprocess.run(["pkill", "hyprsunset"], check=False)
+ self.is_active = False
+ logger.debug("Night light turned off")
+ else:
+ # Turn on night light with default temperature (3000K)
+ subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True)
+ self.is_active = True
+ logger.debug("Night light turned on")
+ return True
+ except Exception as e:
+ logger.warning(f"Failed to toggle night light: {e}")
+ return False
+
+ def set_temperature(self, temperature: int):
+ """Set night light temperature (1000-6500K)"""
+ try:
+ # Kill existing process
+ subprocess.run(["pkill", "hyprsunset"], check=False)
+
+ # Start with new temperature
+ subprocess.Popen(
+ ["hyprsunset", "-t", str(temperature)], start_new_session=True
+ )
+ self.is_active = True
+ logger.debug(f"Night light temperature set to {temperature}K")
+ return True
+ except Exception as e:
+ logger.warning(f"Failed to set night light temperature: {e}")
+ return False
+
+
+def create_night_light_widget(control_center):
+ """Create night light widget for control center"""
+
+ # Initialize night light control
+ night_light = NightLightControl()
+
+ # Create icon
+ night_light_icon = svg_file(
+ (
+ "applets/redshift-status-on.svg"
+ if night_light.is_active
+ else "applets/redshift-status-off.svg"
+ ),
+ size=42,
+ )
+
+ # Create status label
+ night_light_status_label = Label(
+ label="On" if night_light.is_active else "Off",
+ name="nightlight-widget-label",
+ style_classes="status-label",
+ max_chars_width=15,
+ ellipsization="end",
+ h_align="start",
+ )
+
+ def toggle_night_light(*_):
+ """Toggle night light and update UI"""
+ if night_light.toggle():
+ # Update icon
+ night_light_icon.dynamic_file(
+ "applets/redshift-status-on.svg"
+ if night_light.is_active
+ else "applets/redshift-status-off.svg"
+ )
+ # Update status label
+ night_light_status_label.set_label("On" if night_light.is_active else "Off")
+
+ # Create widget box (similar to bluetooth_widget structure)
+ night_light_widget = Box(
+ name="nightlight-widget",
+ orientation="h",
+ h_expand=True,
+ children=[
+ Button(
+ name="nightlight-icon-button",
+ child=night_light_icon,
+ on_clicked=toggle_night_light,
+ ),
+ Button(
+ name="nightlight-info-button",
+ child=Box(
+ name="nightlight-widget-info",
+ h_expand=True,
+ v_expand=True,
+ v_align="center",
+ h_align="start",
+ orientation="vertical",
+ children=[
+ Label(
+ name="nightlight-widget-name",
+ label="Night Light",
+ style_classes="ct",
+ h_align="start",
+ ),
+ night_light_status_label,
+ ],
+ ),
+ on_clicked=toggle_night_light,
+ ),
+ ],
+ )
+
+ return night_light_widget
diff --git a/src/window/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py
new file mode 100644
index 0000000..bf7223b
--- /dev/null
+++ b/src/window/controlcenter/per_app_volume.py
@@ -0,0 +1,332 @@
+from fabric.utils import GLib
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.scale import Scale
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.separator import Separator
+
+# Local imports
+from utils.roam import audio_service
+
+
+class PerAppVolumeControl(Box):
+ """Per-application volume control widget"""
+
+ def __init__(self, control_center, **kwargs):
+ super().__init__(
+ orientation="vertical",
+ name="per-app-volume-control",
+ spacing=5,
+ **kwargs,
+ )
+
+ self.control_center = control_center
+ self._updating_volumes = set()
+ self._app_widgets = {}
+ self._signal_connections = []
+
+ # Header with back button
+ self.header = Box(
+ orientation="horizontal",
+ name="per-app-volume-header",
+ style_classes="menu-header",
+ children=[
+ Button(
+ image=Image(icon_name="back", size=10),
+ on_clicked=lambda *_: self.control_center.close_per_app_volume(),
+ ),
+ Label("App Volume", name="app-volume-header"),
+ ],
+ )
+
+ # Scrollable container for app volume controls
+ self.apps_container = Box(
+ orientation="vertical",
+ name="apps-scrolled-container",
+ spacing=2,
+ )
+
+ self.scrolled_window = ScrolledWindow(
+ name="apps-scrolled-window",
+ child=self.apps_container,
+ size=(300, 500),
+ )
+
+ # Add escape key binding for navigation back
+ try:
+ if hasattr(self.control_center, "add_keybinding"):
+ self.control_center.add_keybinding("Escape", self._go_back)
+ except Exception:
+ pass # Ignore if keybinding fails
+
+ self.children = [self.header, self.scrolled_window]
+
+ # Connect to audio service changes
+ if audio_service:
+ self._signal_connections.append(
+ audio_service.connect("stream-added", self._on_stream_changed)
+ )
+ self._signal_connections.append(
+ audio_service.connect("stream-removed", self._on_stream_changed)
+ )
+
+ # Initial population
+ self._populate_apps()
+
+ # Set up auto-refresh timer for audio streams
+ self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh)
+
+ def _auto_refresh(self):
+ """Auto-refresh the application list every 2 seconds"""
+ self._populate_apps()
+ return True # Continue the timer
+
+ def _go_back(self, *_):
+ """Return to main control center view"""
+ self.control_center.close_per_app_volume()
+
+ def _get_app_icon(self, app):
+ """Get icon for application"""
+ # Don't use fabric's generic audio icon, use description/name for better detection
+ fabric_icon = getattr(app, "icon_name", "")
+ if fabric_icon and fabric_icon != "audio":
+ return fabric_icon
+
+ # Use description first (more accurate), then name
+ app_description = getattr(app, "description", "").lower()
+ app_name = app.name.lower()
+
+ # Check description first as it's more reliable
+ search_text = app_description if app_description else app_name
+
+ icon_mapping = {
+ "spotify": "spotify",
+ "firefox": "firefox",
+ "chromium": "chromium-browser",
+ "chrome": "google-chrome",
+ "vlc": "vlc",
+ "discord": "discord",
+ "steam": "steam",
+ "zen": "zen-browser",
+ "code": "vscode",
+ "visual studio code": "vscode",
+ "telegram": "telegram-desktop",
+ "pulse": "audio-card",
+ "pipewire": "audio-card",
+ "alsa": "audio-card",
+ "sink": "audio-speakers",
+ "source": "audio-input-microphone",
+ "youtube": "youtube",
+ "music": "rhythmbox",
+ "media": "multimedia-player",
+ }
+
+ # Check if any key in mapping is contained in search text
+ for key, icon in icon_mapping.items():
+ if key in search_text:
+ return icon
+
+ # Also check app name as fallback
+ if search_text != app_name:
+ for key, icon in icon_mapping.items():
+ if key in app_name:
+ return icon
+
+ # Default audio icon for unknown apps
+ return "audio-volume-high"
+
+ def _format_app_name(self, name):
+ """Format application name with proper capitalization"""
+ if not name:
+ return "Unknown"
+
+ # Handle common app names specially
+ special_names = {
+ "spotify": "Spotify",
+ "firefox": "Firefox",
+ "chromium": "Chromium",
+ "chrome": "Chrome",
+ "vlc": "VLC",
+ "discord": "Discord",
+ "steam": "Steam",
+ "zen": "Zen Browser",
+ "code": "VS Code",
+ "telegram": "Telegram",
+ }
+
+ name_lower = name.lower()
+ if name_lower in special_names:
+ return special_names[name_lower]
+
+ # Default: capitalize first letter
+ return name.capitalize()
+
+ def _populate_apps(self):
+ """Populate the widget with current audio applications"""
+ # Clear existing widgets
+ self.apps_container.children = []
+ self._app_widgets.clear()
+
+ # Use fabric audio service for applications
+ if not audio_service:
+ self._show_no_apps_message()
+ return
+
+ applications = getattr(audio_service, "applications", [])
+
+ if applications:
+ for i, app in enumerate(applications):
+ if hasattr(app, "name") and hasattr(app, "volume"):
+ app_widget = self._create_app_control(app)
+ self.apps_container.children = list(
+ self.apps_container.children
+ ) + [app_widget]
+
+ # Add separator between apps (except for last one)
+ if i < len(applications) - 1:
+ separator = Separator(
+ orientation="horizontal",
+ style_classes="app-volume-separator",
+ )
+ self.apps_container.children = list(
+ self.apps_container.children
+ ) + [separator]
+
+ self._app_widgets[app.name] = (app_widget, app)
+ else:
+ self._show_no_apps_message()
+
+ def _show_no_apps_message(self):
+ """Show message when no apps are playing audio"""
+ message = Label(
+ label="No applications currently using audio",
+ style_classes="subtitle",
+ h_align="center",
+ v_align="center",
+ )
+ self.apps_container.children = [message]
+
+ def _create_app_control(self, app):
+ """Create compact volume control for a single application"""
+ # Format app name (shorter for compact layout)
+ app_name = self._format_app_name(app.name)
+ # Get current volume from fabric audio service
+ current_volume = getattr(app, "volume", 0.0)
+ max_vol = getattr(audio_service, "max_volume", 100) if audio_service else 100
+
+ # Ensure volume is in valid range
+ volume_percent = max(0, min(current_volume, max_vol))
+
+ # App icon - use the same approach as expanded player
+ icon_name = self._get_app_icon(app)
+ app_icon = Image(
+ icon_name=icon_name,
+ name="app-icon",
+ icon_size=16,
+ style_classes="app-icon",
+ )
+
+ # App name label
+ name_label = Label(
+ label=app_name,
+ style_classes="app-name-compact",
+ justification="left",
+ h_align="start",
+ max_chars_width=10,
+ ellipsization="end",
+ )
+
+ # Volume scale (smaller and horizontal)
+ volume_scale = Scale(
+ value=volume_percent,
+ min_value=0,
+ max_value=max_vol,
+ increments=(1, 5),
+ name="compact-volume-slider",
+ size=20,
+ h_expand=True,
+ )
+
+ # Connect volume change handler
+ volume_scale.connect(
+ "change-value",
+ lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value),
+ )
+
+ # Create horizontal compact layout
+ app_control = Box(
+ orientation="horizontal",
+ spacing=5,
+ h_expand=True,
+ style_classes="compact-app-volume-item",
+ children=[
+ Box(
+ orientation="h",
+ spacing=3,
+ v_expand=True,
+ v_align="center",
+ name="app-control-box",
+ children=[
+ app_icon,
+ name_label,
+ ],
+ ),
+ volume_scale,
+ ],
+ )
+ # Add separator after the control
+
+ return app_control
+
+ def _set_app_volume(self, app, volume_value):
+ """Set volume for a specific application using fabric audio service"""
+ if app.name in self._updating_volumes:
+ return
+
+ self._updating_volumes.add(app.name)
+
+ try:
+ # Get max volume from audio service
+ max_vol = (
+ getattr(audio_service, "max_volume", 100) if audio_service else 100
+ )
+
+ # Ensure volume is within bounds
+ volume_value = max(0, min(volume_value, max_vol))
+
+ # Set volume directly - fabric expects the actual volume value, not percentage
+ app.volume = volume_value
+
+ except Exception as e:
+ print(f"Error setting volume for {app.name}: {e}")
+ finally:
+ GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name))
+
+ def _on_stream_changed(self, *_):
+ """Handle when audio streams are added or removed"""
+ GLib.idle_add(self._populate_apps)
+
+ def refresh(self):
+ """Manually refresh the application list"""
+ self._populate_apps()
+
+ def destroy(self):
+ """Clean up resources"""
+ if hasattr(self, "_refresh_timer"):
+ GLib.source_remove(self._refresh_timer)
+
+ # Disconnect fabric audio service signals
+ if audio_service:
+ for connection in self._signal_connections:
+ try:
+ audio_service.disconnect(connection)
+ except Exception:
+ pass
+
+ self._signal_connections.clear()
+ self._app_widgets.clear()
+ self._updating_volumes.clear()
+
+ super().destroy()
diff --git a/src/window/controlcenter/player.py b/src/window/controlcenter/player.py
new file mode 100644
index 0000000..0943b7b
--- /dev/null
+++ b/src/window/controlcenter/player.py
@@ -0,0 +1,1037 @@
+import re
+import time
+from typing import List
+
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from fabric.widgets.stack import Stack
+
+from fabric.utils import GLib, GObject, Gio, logger, bulk_connect, get_relative_path, os
+
+import shared.data as data
+from services.mpris import MprisPlayer, MprisPlayerManager
+from utils.utils import svg_file
+
+CACHE_DIR = f"{data.CACHE_DIR}/media"
+MEDIA_CACHE = CACHE_DIR
+if not os.path.exists(CACHE_DIR):
+ os.makedirs(CACHE_DIR)
+if not os.path.exists(MEDIA_CACHE):
+ os.makedirs(MEDIA_CACHE)
+
+# Global shared MPRIS manager
+_shared_mpris_manager = None
+
+
+def get_shared_mpris_manager():
+ """Get shared MPRIS manager instance to reduce memory usage."""
+ global _shared_mpris_manager
+ if _shared_mpris_manager is None:
+ # Create MPRIS manager immediately but don't wait for DBus discovery
+ _shared_mpris_manager = MprisPlayerManager()
+ return _shared_mpris_manager
+
+
+def cleanup_shared_mpris_manager():
+ """Clean up the shared MPRIS manager to free memory."""
+ global _shared_mpris_manager
+ if _shared_mpris_manager is not None:
+ try:
+ _shared_mpris_manager.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy shared MPRIS manager: {e}")
+ _shared_mpris_manager = None
+
+
+def cleanup_old_cache_files():
+ """Clean up old artwork cache files (older than 1 day) and limit total cache size."""
+ try:
+ if not os.path.exists(CACHE_DIR):
+ return
+
+ current_time = time.time()
+ one_day_ago = current_time - (24 * 60 * 60) # 24 hours
+ cache_files = []
+
+ # Collect all cache files with their modification times
+ for filename in os.listdir(CACHE_DIR):
+ filepath = os.path.join(CACHE_DIR, filename)
+ try:
+ if os.path.isfile(filepath):
+ file_mtime = os.path.getmtime(filepath)
+ file_size = os.path.getsize(filepath)
+ cache_files.append((filepath, file_mtime, file_size))
+ except Exception:
+ pass # Ignore individual file errors
+
+ # Remove files older than 1 day
+ total_size = 0
+ recent_files = []
+ for filepath, file_mtime, file_size in cache_files:
+ if file_mtime < one_day_ago:
+ try:
+ os.unlink(filepath)
+ except Exception:
+ pass
+ else:
+ recent_files.append((filepath, file_mtime, file_size))
+ total_size += file_size
+
+ # If cache is still too large (>50MB), remove oldest files
+ MAX_CACHE_SIZE = 50 * 1024 * 1024 # 50MB
+ if total_size > MAX_CACHE_SIZE:
+ # Sort by modification time (oldest first)
+ recent_files.sort(key=lambda x: x[1])
+ for filepath, _, file_size in recent_files:
+ if total_size <= MAX_CACHE_SIZE:
+ break
+ try:
+ os.unlink(filepath)
+ total_size -= file_size
+ except Exception:
+ pass
+
+ except Exception:
+ pass # Ignore all errors in cleanup
+
+
+class PlayerBoxStack(Box):
+ """A widget that displays the current player information."""
+
+ def __init__(
+ self, mpris_manager: MprisPlayerManager = None, control_center=None, **kwargs
+ ):
+ # Defer cache cleanup to avoid blocking startup
+ GLib.idle_add(cleanup_old_cache_files)
+
+ # The player stack
+ self.player_stack = Stack(
+ # transition_type="slide-left-right",
+ # transition_duration=500,
+ name="player-stack",
+ )
+ self.current_stack_pos = 0
+ self.control_center = control_center
+
+ # List to store player buttons
+ self.player_buttons: list[Button] = []
+
+ # Track signal connections for cleanup
+ self._signal_connections = []
+
+ # Create a "No media playing" placeholder
+ self.no_media_box = self._create_no_media_box()
+
+ super().__init__(orientation="v", name="media", children=[self.player_stack])
+
+ # Show the no media box initially
+ self.player_stack.children = [self.no_media_box]
+ self.set_visible(True)
+
+ # Use shared MPRIS manager if none provided
+ self.mpris_manager = mpris_manager
+
+ # Initialize MPRIS manager if provided, otherwise defer
+ if self.mpris_manager is not None:
+ self._init_mpris_manager()
+ else:
+ # Will be initialized later via _init_mpris_manager()
+ pass
+
+ # Connect to visibility changes for cleanup
+ self.connect("notify::visible", self._on_visibility_changed)
+
+ def _on_visibility_changed(self, widget, param):
+ """Monitor visibility changes to debug hiding issues."""
+ is_visible = self.get_visible()
+ logger.debug(f"[PlayerBoxStack] Visibility changed to: {is_visible}")
+
+ if not is_visible:
+ # Check if we should be visible
+ current_children = self.player_stack.get_children()
+ if len(current_children) > 0:
+ logger.warning(
+ f"[PlayerBoxStack] Widget was hidden but has {len(current_children)} children - forcing visibility"
+ )
+ GLib.idle_add(self.set_visible, True)
+
+ def set_visible(self, visible: bool) -> None:
+ """Override set_visible to ensure the widget is never hidden when it should show content."""
+ # Always ensure the widget is visible if we have content to show
+ if not visible:
+ current_children = self.player_stack.get_children()
+ if len(current_children) > 0:
+ # Don't allow hiding if we have content
+ logger.debug(
+ "[PlayerBoxStack] Preventing widget from being hidden - has content"
+ )
+ return
+
+ super().set_visible(visible)
+
+ def get_visible(self) -> bool:
+ """Override get_visible to ensure the widget always reports as visible when it has content."""
+ # Check if we have content to show
+ current_children = self.player_stack.get_children()
+ if len(current_children) > 0:
+ # If we have content, we should always be visible
+ return True
+
+ # Otherwise, use the parent's visibility state
+ return super().get_visible()
+
+ def _init_mpris_manager(self):
+ """Initialize MPRIS manager and connect signals"""
+ if self.mpris_manager is None:
+ return
+
+ try:
+ # Track connections for cleanup - store (object, handler_id) tuples
+ connections = bulk_connect(
+ self.mpris_manager,
+ {
+ "player-appeared": self.on_new_player,
+ "player-vanished": self.on_lost_player,
+ },
+ )
+ # Store as (object, handler_id) tuples
+ for handler_id in connections:
+ self._signal_connections.append((self.mpris_manager, handler_id))
+
+ # Process existing players asynchronously to avoid blocking
+ def process_existing_players():
+ try:
+ for player in self.mpris_manager.players.values(): # type: ignore
+ self.on_new_player(self.mpris_manager, player)
+ logger.debug("PlayerBoxStack initialized successfully")
+ except Exception as e:
+ logger.error(f"Failed to process existing players: {e}")
+
+ # Use idle_add to process players asynchronously
+ GLib.idle_add(process_existing_players)
+
+ # Also check the playing state after a short delay to ensure consistency
+ GLib.timeout_add(1000, self._check_and_update_playing_state)
+
+ # Ensure the widget is always visible
+ GLib.timeout_add(500, self._ensure_visibility)
+
+ except Exception as e:
+ logger.error(f"Failed to initialize PlayerBoxStack signals: {e}")
+
+ def destroy(self):
+ """Clean up resources when the widget is destroyed."""
+ # Disconnect all signal connections
+ for obj, handler_id in self._signal_connections:
+ try:
+ obj.disconnect(handler_id)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect signal: {e}")
+ self._signal_connections.clear()
+
+ # Clean up player buttons
+ for button in self.player_buttons:
+ try:
+ button.destroy()
+ except Exception:
+ pass
+ self.player_buttons.clear()
+
+ # Clean up player boxes
+ for child in self.player_stack.get_children():
+ if hasattr(child, "destroy") and child != self.no_media_box:
+ try:
+ child.destroy()
+ except Exception:
+ pass
+
+ # Clean up shared MPRIS manager if this is the last instance
+ if (
+ hasattr(self, "mpris_manager")
+ and self.mpris_manager == _shared_mpris_manager
+ ):
+ # Only cleanup if no other widgets are using it
+ cleanup_shared_mpris_manager()
+
+ super().destroy()
+
+ def _periodic_cleanup(self):
+ """Enhanced cleanup for reuse - clean internal state and free memory"""
+ try:
+ # Destroy all player boxes properly to free their resources
+ current_children = list(self.player_stack.get_children())
+ for child in current_children:
+ if hasattr(child, "destroy") and child != self.no_media_box:
+ try:
+ child.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy player child: {e}")
+
+ # Reset to no media state
+ self.player_stack.children = [self.no_media_box]
+
+ # Clear player buttons
+ for button in self.player_buttons:
+ try:
+ button.destroy()
+ except Exception:
+ pass
+ self.player_buttons.clear()
+
+ # Reset stack position
+ self.current_stack_pos = 0
+
+ # Clean up old cache files more aggressively
+ cleanup_old_cache_files()
+
+ except Exception as e:
+ logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}")
+
+ def _create_no_media_box(self):
+ """Create a placeholder box for when no media is playing."""
+ fallback_cover_path = get_relative_path("../../assets/icons/music.svg")
+
+ # Album cover with fallback image
+ album_cover = Box(style_classes="album-image-c")
+ album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
+
+ image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
+ image_stack.children = [album_cover]
+
+ # Track info showing "No media playing"
+ track_title = Label(
+ label="No media playing",
+ name="player-title-c",
+ justification="left",
+ max_chars_width=25,
+ ellipsization="end",
+ h_align="start",
+ )
+
+ track_artist = Label(
+ label="",
+ name="player-artist-c",
+ justification="left",
+ max_chars_width=15,
+ ellipsization="end",
+ h_align="start",
+ visible=False, # Hide artist and album when no media
+ )
+
+ track_info = Box(
+ name="track-info",
+ # spacing=5,
+ h_expand=True,
+ orientation="v",
+ v_align="start",
+ h_align="start",
+ children=[track_title, track_artist],
+ )
+
+ # No control buttons for no media state - just an empty box
+ controls_box = Box(
+ name="player-controls",
+ visible=False, # Hide controls when no media
+ )
+
+ player_info_box = Box(
+ name="player-info-box-c",
+ h_expand=True,
+ v_align="center",
+ h_align="center",
+ orientation="v",
+ children=[track_info, controls_box],
+ )
+
+ inner_box = CenterBox(
+ name="inner-player-box",
+ start_children=[
+ image_stack,
+ ],
+ center_children=[
+ player_info_box,
+ ],
+ )
+ # resize the inner box
+ outer_box = Box(
+ spacing=5,
+ name="outer-no-player-box-c",
+ h_expand=True,
+ h_align="fill",
+ # children=[
+ v_expand=True,
+ children=inner_box,
+ # inner_box,
+ # player_info_box,
+ # image,
+ # ],
+ )
+
+ box = Box(
+ name="box-c",
+ orientation="h",
+ v_expand=True,
+ h_align="fill",
+ h_expand=True,
+ children=[
+ outer_box,
+ ],
+ )
+ no_media_box = Box(
+ h_align="center",
+ name="player-box",
+ h_expand=True,
+ children=[box],
+ )
+
+ return no_media_box
+
+ def _find_playing_player_index(self):
+ """Find the index of the currently playing player."""
+ players: List[PlayerBox] = self.player_stack.get_children()
+ for i, player_box in enumerate(players):
+ if (
+ hasattr(player_box, "player")
+ and str(player_box.player.playback_status).lower() == "playing"
+ ):
+ return i
+ return None
+
+ def _switch_to_playing_player(self):
+ """Switch to the currently playing player if one exists."""
+ playing_index = self._find_playing_player_index()
+ if playing_index is not None and playing_index != self.current_stack_pos:
+ self.on_player_clicked_by_index(playing_index)
+
+ def _check_and_update_playing_state(self):
+ """Check if any players are playing and update the display accordingly."""
+ players: List[PlayerBox] = self.player_stack.get_children()
+ has_playing_player = False
+
+ # Check if any player is currently playing
+ for player_box in players:
+ if (
+ hasattr(player_box, "player")
+ and str(player_box.player.playback_status).lower() == "playing"
+ ):
+ has_playing_player = True
+ break
+
+ # If no players are playing, show the no media box
+ if not has_playing_player:
+ # Check if we already have the no media box visible
+ current_children = self.player_stack.get_children()
+ if len(current_children) == 1 and current_children[0] == self.no_media_box:
+ # Already showing no media, no need to change
+ return
+
+ # Hide all player boxes and show no media
+ for player_box in players:
+ if hasattr(player_box, "destroy"):
+ try:
+ player_box.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy player box: {e}")
+
+ # Clear player buttons
+ self.player_buttons.clear()
+
+ # Show the no media box
+ self.player_stack.children = [self.no_media_box]
+ self.current_stack_pos = 0
+
+ # Ensure the widget is visible
+ self.set_visible(True)
+ self.player_stack.set_visible_child(self.no_media_box)
+
+ logger.debug(
+ "[PlayerBoxStack] No players playing, showing 'No media playing'"
+ )
+
+ def _ensure_visibility(self):
+ """Ensure the widget is always visible and showing appropriate content."""
+ try:
+ # Always ensure the widget is visible
+ self.set_visible(True)
+
+ # Check current state
+ current_children = self.player_stack.get_children()
+
+ # If no children, show no media box
+ if len(current_children) == 0:
+ self.player_stack.children = [self.no_media_box]
+ self.current_stack_pos = 0
+ logger.debug("[PlayerBoxStack] No children found, showing no media box")
+
+ # If only no_media_box is visible, ensure it's the active child
+ elif (
+ len(current_children) == 1 and current_children[0] == self.no_media_box
+ ):
+ self.player_stack.set_visible_child(self.no_media_box)
+ logger.debug("[PlayerBoxStack] Ensuring no media box is visible")
+
+ # Force the widget to be visible and show content
+ self.set_visible(True)
+ self.player_stack.set_visible(True)
+
+ # If we have the no media box, make sure it's visible
+ if self.no_media_box in current_children:
+ self.no_media_box.set_visible(True)
+
+ logger.debug(
+ f"[PlayerBoxStack] Current state: {len(current_children)} children, visible: {self.get_visible()}, stack_visible: {self.player_stack.get_visible()}"
+ )
+
+ except Exception as e:
+ logger.error(f"[PlayerBoxStack] Error in _ensure_visibility: {e}")
+
+ def on_player_playback_changed(self, player_box, status):
+ """Called when a player's playback status changes."""
+ status = str(status).lower() if isinstance(status, str) else status
+ if status == "playing":
+ # Find this player's index and switch to it
+ players: List[PlayerBox] = self.player_stack.get_children()
+ for i, pb in enumerate(players):
+ if pb == player_box:
+ if i != self.current_stack_pos:
+ self.on_player_clicked_by_index(i)
+ break
+ elif status in ["paused", "stopped"]:
+ # Check if any other players are playing
+ self._check_and_update_playing_state()
+
+ def on_player_clicked(self, type):
+ # unset active from prev active button
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].remove_style_class("active")
+
+ if type == "next":
+ self.current_stack_pos = (
+ self.current_stack_pos + 1
+ if self.current_stack_pos != len(self.player_stack.get_children()) - 1
+ else 0
+ )
+ elif type == "prev":
+ self.current_stack_pos = (
+ self.current_stack_pos - 1
+ if self.current_stack_pos != 0
+ else len(self.player_stack.get_children()) - 1
+ )
+
+ # set new active button
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].add_style_class("active")
+ self.player_stack.set_visible_child(
+ self.player_stack.get_children()[self.current_stack_pos],
+ )
+
+ def on_player_clicked_by_index(self, index):
+ """Switch to player at given index"""
+ if 0 <= index < len(self.player_buttons):
+ # unset active from prev active button
+ if self.player_buttons and self.current_stack_pos < len(
+ self.player_buttons
+ ):
+ self.player_buttons[self.current_stack_pos].remove_style_class("active")
+ # set new position
+ self.current_stack_pos = index
+ # set new active button
+ if self.player_buttons and self.current_stack_pos < len(
+ self.player_buttons
+ ):
+ self.player_buttons[self.current_stack_pos].add_style_class("active")
+ self.player_stack.set_visible_child(
+ self.player_stack.get_children()[self.current_stack_pos],
+ )
+ # Update all player boxes with new button state
+ self._update_all_player_buttons()
+
+ def on_new_player(self, mpris_manager, player):
+
+ # if player_name in self.config.get("ignore", []):
+ # return
+
+ # Show all players, but we'll handle their playback status in the UI
+ # if player.playback_status != "playing":
+ # logger.debug(f"[PlayerBoxStack] Player {player_name} is not playing (status: {player.playback_status}), skipping display")
+ # return
+
+ # Remove the no media box if it's the only child
+ if (
+ len(self.player_stack.get_children()) == 1
+ and self.player_stack.get_children()[0] == self.no_media_box
+ ):
+ self.player_stack.children = []
+ self.current_stack_pos = 0
+
+ self.set_visible(True)
+
+ new_player_box = PlayerBox(
+ player=player,
+ player_stack=self,
+ control_center=self.control_center,
+ )
+ self.player_stack.children = [
+ *self.player_stack.children,
+ new_player_box,
+ ]
+
+ self.make_new_player_button(self.player_stack.get_children()[-1])
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
+
+ # Update all player boxes with current button state
+ self._update_all_player_buttons()
+
+ # Check if this new player is playing and switch to it
+ self._switch_to_playing_player()
+
+ def on_lost_player(self, mpris_manager, bus_name):
+ # the playerBox is automatically removed from mprisbox children on being removed
+ players: List[PlayerBox] = self.player_stack.get_children()
+
+ # Find and properly destroy the player box
+ player_box_to_remove = None
+ for player_box in players:
+ if (
+ hasattr(player_box, "player")
+ and getattr(player_box.player, "bus_name", None) == bus_name
+ ):
+ player_box_to_remove = player_box
+ break
+
+ if player_box_to_remove:
+ try:
+ player_box_to_remove.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy player box: {e}")
+
+ # Check if this was the last player
+ remaining_players = [
+ p for p in self.player_stack.get_children() if p != player_box_to_remove
+ ]
+ if len(remaining_players) == 0:
+ # Show the no media box instead of hiding
+ self.player_stack.children = [self.no_media_box]
+ self.current_stack_pos = 0
+ self.player_buttons = [] # Clear player buttons
+ return
+
+ # Adjust current position if needed
+ if self.current_stack_pos >= len(self.player_stack.get_children()):
+ self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
+
+ # Set active button if we have buttons and a valid position
+ if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
+ self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
+ if self.player_stack.get_children():
+ self.player_stack.set_visible_child(
+ self.player_stack.get_children()[self.current_stack_pos],
+ )
+
+ # Update all player boxes with current button state
+ self._update_all_player_buttons()
+
+ # After a player is removed, check if we should switch to a playing player
+ self._switch_to_playing_player()
+
+ def make_new_player_button(self, player_box):
+ new_button = Button(name="player-stack-button")
+
+ def on_player_button_click(button: Button):
+ if self.player_buttons and self.current_stack_pos < len(
+ self.player_buttons
+ ):
+ self.player_buttons[self.current_stack_pos].remove_style_class("active")
+ if button in self.player_buttons:
+ self.current_stack_pos = self.player_buttons.index(button)
+ button.add_style_class("active")
+ self.player_stack.set_visible_child(player_box)
+
+ new_button.connect(
+ "clicked",
+ on_player_button_click,
+ )
+ self.player_buttons.append(new_button)
+
+ # This will automatically destroy our used button
+ def cleanup_button(*_):
+ try:
+ if new_button in self.player_buttons:
+ self.player_buttons.remove(new_button)
+ new_button.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to cleanup button: {e}")
+
+ player_box.connect("destroy", cleanup_button)
+
+ def _update_all_player_buttons(self):
+ """Update all player boxes with the current button state"""
+ players: List[PlayerBox] = self.player_stack.get_children()
+
+ for player_box in players:
+ if hasattr(player_box, "update_buttons"):
+ player_box.update_buttons(self.player_buttons, len(players) > 1)
+ else:
+ logger.warning(
+ "[PlayerBoxStack] PlayerBox missing update_buttons method"
+ )
+
+
+class PlayerBox(Box):
+ """A widget that displays the current player information."""
+
+ def __init__(
+ self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs
+ ):
+ super().__init__(
+ h_align="center",
+ name="player-box",
+ **kwargs,
+ h_expand=True,
+ )
+ # Setup
+ self.player: MprisPlayer = player
+ self.player_stack = player_stack
+ self.control_center = control_center
+ self.cover_path = get_relative_path("../../assets/icons/music.svg")
+
+ # Add controls_box attribute early for compatibility
+ # Temporary placeholder
+ self.controls_box = Box(name="temp-controls-box")
+
+ self.image_size = 50
+ self.icon_size = 15
+
+ # State
+ self.exit = False
+ self.skipped = False
+ self._signal_connections = [] # Track signal connections
+
+ # Memory management
+ self.temp_artwork_files = [] # Track temp files for cleanup
+ self._download_cancelled = False # Flag to cancel downloads
+
+ # Album art with existing Box widget
+ self.album_cover = Box(style_classes="album-image-c")
+ self.album_cover.set_style(f"background-image:url('{self.cover_path}')")
+
+ self.image_stack = Box(
+ h_align="start",
+ v_align="center",
+ name="player-image-stack",
+ )
+ self.image_stack.children = [self.album_cover]
+
+ # Connect to arturl changes
+ self.player.connect("notify::arturl", self.set_image)
+
+ self.app_icon = Box(
+ children=Image(
+ icon_name=self.player.player_name, name="player-app-icon", icon_size=20
+ ),
+ h_align="end",
+ v_align="end",
+ tooltip_text=self.player.player_name, # type: ignore
+ )
+ self.image = Overlay(
+ child=self.image_stack,
+ overlays=[
+ self.app_icon,
+ ],
+ )
+
+ # Track Info
+
+ self.track_title = Label(
+ label="No Title",
+ name="player-title-c",
+ justification="left",
+ max_chars_width=25,
+ ellipsization="end",
+ h_align="start",
+ )
+
+ self.track_artist = Label(
+ label="No Artist",
+ name="player-artist-c",
+ justification="left",
+ max_chars_width=23,
+ ellipsization="end",
+ h_align="start",
+ visible=True,
+ )
+
+ self.player.bind_property(
+ "title",
+ self.track_title,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
+ ), # type: ignore
+ )
+ self.player.bind_property(
+ "artist",
+ self.track_artist,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: ", ".join(x) if x and isinstance(x, list) else "No Artist", # type: ignore
+ )
+
+ self.track_info = Box(
+ name="track-info",
+ spacing=5,
+ orientation="v",
+ v_align="start",
+ h_align="start",
+ children=[
+ self.track_title,
+ self.track_artist,
+ ],
+ )
+
+ # Buttons with fixed sizing for layout stability
+ self.button_box = Box(
+ name="button-box-c",
+ h_expand=False,
+ spacing=2,
+ )
+
+ # Create SVG icons with consistent sizing
+ self.skip_next_icon = svg_file("player/fwd.svg", size=22)
+ self.play_pause_icon = svg_file("player/Pause.svg", size=22)
+
+ # Fixed size buttons to prevent layout shifts
+ self.play_pause_button = Button(
+ name="player-button",
+ child=self.play_pause_icon,
+ on_clicked=self.player.play_pause,
+ )
+ # Set consistent button size
+
+ self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
+
+ self.next_button = Button(
+ name="player-button",
+ child=self.skip_next_icon,
+ on_clicked=self._on_player_next,
+ )
+ # Set consistent button size
+ # self.next_button.set_size_request(32, 32)
+ self.player.bind_property("can_go_next", self.next_button, "sensitive")
+
+ self.button_box.children = (
+ self.play_pause_button,
+ self.next_button,
+ )
+
+ # Assign button_box to controls_box for compatibility
+ self.controls_box = self.button_box
+
+ self.player_info_box = Box(
+ name="player-info-box-c",
+ v_align="center",
+ h_expand=True,
+ h_align="start",
+ orientation="v",
+ children=[
+ self.track_info,
+ ],
+ )
+
+ self.inner_box = Box(
+ name="inner-player-box",
+ h_expand=True,
+ v_align="center",
+ h_align="start",
+ children=[
+ self.image,
+ self.player_info_box,
+ ],
+ )
+ # resize the inner box
+ self.outer_box = Button(
+ spacing=5,
+ name="outer-player-box-c",
+ h_expand=True,
+ on_clicked=self._on_outer_box_clicked,
+ h_align="start",
+ child=self.inner_box,
+ )
+
+ self.box = CenterBox(
+ name="box-c",
+ orientation="h",
+ h_align="center",
+ start_children=[
+ self.outer_box,
+ ],
+ end_children=[
+ self.button_box,
+ ],
+ )
+ self.box.set_size_request(352, -1)
+
+ self.children = [
+ *self.children,
+ self.box,
+ ]
+
+ # Track signal connections for cleanup - store (object, handler_id) tuples
+ connections = bulk_connect(
+ self.player,
+ {
+ "closed": self._on_player_exit,
+ "notify::playback-status": self._on_playback_change,
+ "notify::metadata": self._on_metadata,
+ },
+ )
+ # Store as (object, handler_id) tuples
+ for handler_id in connections:
+ self._signal_connections.append((self.player, handler_id))
+
+ def destroy(self):
+ """Clean up all resources when the widget is destroyed."""
+ # Cancel any ongoing downloads
+ self._download_cancelled = True
+
+ # Disconnect all signal connections
+ for obj, handler_id in self._signal_connections:
+ try:
+ obj.disconnect(handler_id)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect signal: {e}")
+ self._signal_connections.clear()
+
+ # Clean up temp files
+ self._cleanup_temp_files()
+
+ super().destroy()
+
+ def __del__(self):
+ """Ensure cleanup happens even if player exits unexpectedly."""
+ try:
+ self._cleanup_temp_files()
+ except Exception:
+ pass # Ignore errors during cleanup in destructor
+
+ def _on_prev_button_click(self, *_):
+ """Handle prev button click: open expanded player in control center"""
+ try:
+ # Open expanded player in control center instead of new window
+ if self.control_center and hasattr(
+ self.control_center, "open_expanded_player"
+ ):
+ self.control_center.open_expanded_player()
+ except Exception as e:
+ logger.warning(f"Failed to handle prev button click: {e}")
+
+ def _on_outer_box_clicked(self, *_):
+ """Handle outer box click with proper error handling."""
+ try:
+ # Open expanded player in control center instead of new window
+ if self.control_center and hasattr(
+ self.control_center, "open_expanded_player"
+ ):
+ self.control_center.open_expanded_player()
+ except Exception as e:
+ logger.warning(f"Failed to handle outer box click: {e}")
+
+ def update_buttons(self, player_buttons, show_buttons):
+ # """Update the stack switcher buttons in this player box"""
+ pass
+
+ def _on_metadata(self, *_):
+ self.set_image()
+
+ def _cleanup_temp_files(self):
+ """Clean up temporary artwork files."""
+ for temp_file in self.temp_artwork_files:
+ try:
+ if os.path.exists(temp_file):
+ os.unlink(temp_file)
+ except Exception as e:
+ logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
+ self.temp_artwork_files.clear()
+
+ def _on_player_exit(self, _, value):
+ self.exit = value
+ self._cleanup_temp_files() # Clean up temp files before destroying
+ self.destroy()
+
+ def _on_player_next(self, *_):
+ self.player.next()
+
+ def _on_player_prev(self, *_):
+ self.player.previous()
+
+ def _on_playback_change(self, player, status):
+ status = player.get_property("playback-status")
+ status_l = str(status).lower() if isinstance(status, str) else status
+
+ if status_l == "paused":
+ self.play_pause_icon.dynamic_file("player/play.svg")
+
+ if status_l == "playing":
+ self.play_pause_icon.dynamic_file("player/Pause.svg")
+
+ # Always notify the player stack about playback status changes
+ if self.player_stack and hasattr(
+ self.player_stack, "on_player_playback_changed"
+ ):
+ self.player_stack.on_player_playback_changed(self, status_l)
+
+ def img_callback(self, source: Gio.File, result: Gio.AsyncResult):
+ try:
+ if os.path.isfile(self.cover_path):
+ self.update_image()
+ except ValueError:
+ logger.error("[PLAYER] Failed to grab artUrl")
+
+ def update_image(self):
+ self.album_cover.set_style(f"background-image:url('{self.cover_path}')")
+
+ def set_image(self, *args):
+ url = self.player.arturl
+
+ if url is None or url == "":
+ return
+
+ new_cover_path = (
+ (
+ MEDIA_CACHE
+ + "/"
+ # type: ignore
+ + GLib.compute_checksum_for_string(GLib.ChecksumType.SHA1, url, -1)
+ )
+ if "file://" != url[0:7]
+ else url[7:]
+ )
+
+ if new_cover_path == self.cover_path:
+ self.update_image()
+ return
+
+ self.cover_path = new_cover_path
+
+ if os.path.exists(self.cover_path):
+ self.update_image()
+ return
+
+ Gio.File.new_for_uri(uri=url).copy_async(
+ Gio.File.new_for_path(self.cover_path),
+ Gio.FileCopyFlags.OVERWRITE,
+ GLib.PRIORITY_DEFAULT,
+ None,
+ None,
+ self.img_callback,
+ )
+
+ def close_bluetooth(self, *args):
+ """Placeholder method for compatibility"""
+ pass
diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py
new file mode 100644
index 0000000..441d23f
--- /dev/null
+++ b/src/window/controlcenter/wifi.py
@@ -0,0 +1,706 @@
+import subprocess
+
+from fabric.utils import Gdk, GLib, Gtk
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.separator import Separator
+
+from services.network import NetworkClient
+from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength
+from utils.utils import svg_file
+from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog
+from gi.repository import NM
+
+
+class WifiNetworkSlot(Box):
+ def __init__(self, access_point, wifi_service, parent=None, **kwargs):
+ super().__init__(name="wifi-network-slot", **kwargs)
+ self.access_point = access_point
+ self.wifi_service = wifi_service
+ self.parent = parent # Reference to control center
+
+ # Get network info from AccessPoint object
+ self.ssid = access_point.ssid
+ self.bssid = access_point.bssid
+ self.strength = access_point.strength
+ self.icon_name = access_point.icon
+
+ # Check if this network is currently connected
+ self.is_connected = access_point.is_active
+
+ # Initialize styles based on connection state
+ self.styles = [
+ "connected" if self.is_connected else "",
+ ]
+
+ # Create connection status indicator using dynamic WiFi icon based on signal strength
+ wifi_icon_path = get_wifi_icon_for_strength(self.strength)
+ self.dimage = svg_file(wifi_icon_path, size=28)
+ self.wifi_icon_box = Box(
+ children=[self.dimage],
+ style_classes=["wifi-icon-box"],
+ )
+ # if self.is_connected:
+ # self.wifi_icon_box.remove_style_class("wifi-icon-box")
+ # self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
+ #
+ # Set initial style classes
+ if self.is_connected:
+ self.dimage.add_style_class("connected")
+
+ self.network_label = Label(
+ label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True
+ )
+
+ # Create lock icon for secured networks
+ self.lock_icon = None
+ if self.access_point.requires_password:
+ self.lock_icon = Image(
+ icon_name="changes-prevent-symbolic",
+ size=12,
+ name="wifi-lock-icon",
+ )
+
+ # Initialize password dialog
+ self.password_dialog = None
+
+ # Create the start section with WiFi icon and network name
+ start_box = Box(
+ orientation="h",
+ spacing=8,
+ )
+ start_box.children = [self.wifi_icon_box, self.network_label]
+ if self.lock_icon:
+ start_box.children.append(self.lock_icon)
+ # Create end section with lock icon if needed
+
+ self.children = [
+ Button(
+ child=start_box,
+ h_expand=True,
+ name="wifi-network-button",
+ on_clicked=lambda *_: self.toggle_connecting(),
+ )
+ ]
+
+ # Emit initial change to update display
+ self.on_changed()
+
+ def toggle_connecting(self):
+ # Check if this network is currently connected
+ is_currently_connected = self.access_point.is_active
+
+ if is_currently_connected:
+ # Show disconnecting state using connecting icon
+ connecting_icon = get_wifi_connecting_icon()
+ self.dimage.set_from_file(connecting_icon)
+ self.dimage.add_style_class("disconnecting")
+
+ # Disconnect from network
+ self.wifi_service.disconnect_wifi()
+ self.is_connected = False
+ # Remove disconnecting state after a short delay to show feedback
+ GLib.timeout_add(500, lambda: self._reset_disconnect_state())
+ else:
+ # Try to connect - check if password is required
+ if self.access_point.requires_password:
+ # Show password dialog immediately
+ self._show_password_dialog()
+ else:
+ # Try to connect without password (for open networks)
+ connecting_icon = get_wifi_connecting_icon()
+ self.dimage.set_from_file(connecting_icon)
+ self.dimage.add_style_class("connecting")
+
+ def on_open_connection_result(success, message):
+ """Handle the connection result for open networks"""
+ if success:
+ self.is_connected = True
+ # Remove connecting state after a short delay
+ GLib.timeout_add(500, lambda: self._reset_connect_state())
+ else:
+ # Connection failed
+ self._reset_connect_state()
+
+ # Update display after connection attempt
+ self.on_changed()
+
+ try:
+ self.wifi_service.connect_to_wifi(
+ self.access_point, callback=on_open_connection_result
+ )
+ except Exception:
+ # Handle any connection errors gracefully
+ self._reset_connect_state()
+ self.on_changed()
+
+ # Update display after connection attempt
+ self.on_changed()
+
+ def _reset_disconnect_state(self):
+ """Reset visual state after disconnect operation"""
+ self.dimage.remove_style_class("disconnecting")
+ wifi_icon_path = get_wifi_icon_for_strength(self.strength)
+ self.dimage.set_from_file(wifi_icon_path)
+ self.on_changed()
+ return False # Remove timeout
+
+ def _reset_connect_state(self):
+ """Reset visual state after connect operation"""
+ self.dimage.remove_style_class("connecting")
+ wifi_icon_path = get_wifi_icon_for_strength(self.strength)
+ self.dimage.set_from_file(wifi_icon_path)
+ self.on_changed()
+ return False # Remove timeout
+
+ def on_changed(self, *_):
+ # Check if this network is currently connected using the access point's is_active property
+ self.is_connected = self.access_point.is_active
+ self.styles = [
+ "connected" if self.is_connected else "",
+ ]
+ # Update style classes for SVG widget
+ if self.is_connected:
+ self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
+ else:
+ self.wifi_icon_box.remove_style_class("wifi-icon-box-connected")
+ return
+
+ def _show_password_dialog(self):
+ """Show the WiFi password dialog"""
+ # Close the control center first
+ if self.parent and hasattr(self.parent, "hide_controlcenter"):
+ self.parent.hide_controlcenter()
+
+ # Create a new dialog each time to ensure clean state
+ if self.password_dialog:
+ self.password_dialog.destroy_dialog()
+
+ self.password_dialog = WiFiPasswordDialog(
+ ssid=self.ssid,
+ on_connect_callback=self._on_password_connect,
+ on_cancel_callback=self._on_password_cancel,
+ )
+
+ self.password_dialog.show_dialog()
+
+ def _on_password_connect(self, ssid, password):
+ """Handle password dialog connect action"""
+ if password.strip():
+ # Show connecting state using connecting icon
+ connecting_icon = get_wifi_connecting_icon()
+ self.dimage.set_from_file(connecting_icon)
+ self.dimage.add_style_class("connecting")
+
+ # Try to connect with password using callback
+ def on_connection_result(success, message):
+ """Handle the connection result"""
+ if success:
+ self.is_connected = True
+ # Remove connecting state after a short delay
+
+ GLib.timeout_add(500, lambda: self._reset_connect_state())
+
+ # Clear any timeout in the password dialog
+ if (
+ self.password_dialog
+ and self.password_dialog.connection_timeout_id
+ ):
+ GLib.source_remove(self.password_dialog.connection_timeout_id)
+ self.password_dialog.connection_timeout_id = None
+ self.password_dialog.is_connecting = False
+ else:
+ # Connection failed - show error in dialog
+ self._reset_connect_state()
+ if self.password_dialog:
+ self._show_connection_error(message)
+
+ # Update display after connection attempt
+ self.on_changed()
+
+ try:
+ self.wifi_service.connect_to_wifi(
+ self.access_point, password, callback=on_connection_result
+ )
+ except Exception:
+ # Handle any connection errors gracefully
+ self._reset_connect_state()
+ if self.password_dialog:
+ self._show_connection_error("Connection failed. Please try again.")
+ self.on_changed()
+
+ def _show_connection_error(self, message="Incorrect password. Please try again."):
+ """Show connection error in a separate thread to prevent UI blocking"""
+ if self.password_dialog:
+ self.password_dialog.show_error(message)
+ return False # Don't repeat if called from GLib.timeout_add
+
+ def _on_password_cancel(self):
+ """Handle password dialog cancel action"""
+ # Reset any connecting state
+ self._reset_connect_state()
+
+
+class WifiConnections(Box):
+ def __init__(self, parent, show_back_button=True, **kwargs):
+ super().__init__(
+ spacing=8,
+ orientation="vertical",
+ name="wifi-connections",
+ **kwargs,
+ )
+
+ self.parent = parent
+ self.network_service = NetworkClient()
+ self.wifi_service = None
+ self.is_scanning = False # Track scanning state
+ self.refresh_timer = None # Timer for periodic network refresh
+ self._update_in_progress = False # Prevent concurrent updates
+ self._destroyed = False # Track if widget is destroyed
+
+ # Wait for network service to be ready
+ self.network_service.connect("wifi-device-added", self.on_network_ready)
+
+ # Create pull-to-refresh indicator
+ self.refresh_indicator = Label(
+ name="wifi-refresh-indicator",
+ label="↓ Pull to scan for networks",
+ h_align="center",
+ visible=False,
+ style="color: #fff; font-size: 12px; padding: 5px;",
+ )
+
+ # Create title with optional back button
+ title_children = []
+ if show_back_button:
+ title_children.append(
+ Button(
+ image=Image(icon_name="back", size=10),
+ on_clicked=lambda *_: self.parent.close_wifi(),
+ )
+ )
+ title_children.append(Label("Wi-Fi", name="wifi-title"))
+
+ self.title = Box(
+ orientation="h",
+ children=title_children,
+ )
+
+ self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
+
+ # Create Known Network section
+ self.known_networks_label = Label(
+ label="Known Network", h_align="start", name="networks-title"
+ )
+ self.known_networks = Box(
+ spacing=4, orientation="vertical", name="known-networks"
+ )
+
+ # Create "No networks available" message
+ self.no_networks_label = Label(
+ label="No networks available",
+ h_align="center",
+ name="no-networks-label",
+ visible=False,
+ )
+
+ # Create Other Networks section with clickable title
+ self.other_networks_button = Button(
+ child=Label("Other Networks", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.toggle_other_networks,
+ )
+ self.other_networks = Box(spacing=4, orientation="vertical")
+
+ # Create scrolled window for other networks
+ self.other_networks_scrolled = ScrolledWindow(
+ min_content_size=(303, 150),
+ child=self.other_networks,
+ overlay_scroll=True,
+ )
+
+ # Add pull-to-refresh functionality to scrolled window
+ self.setup_pull_to_refresh()
+
+ # Create revealer for Other Networks section
+ self.other_networks_revealer = Revealer(
+ child=self.other_networks_scrolled,
+ transition_type="slide-down",
+ transition_duration=100,
+ child_revealed=False,
+ )
+
+ # Create More Settings button (same style as Other Networks button)
+ self.more_settings_button = Button(
+ child=Label("More Settings", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.open_network_settings,
+ )
+
+ self.children = [
+ CenterBox(
+ start_children=self.title,
+ end_children=self.toggle_button,
+ name="wifi-widget-top",
+ ),
+ self.refresh_indicator,
+ Separator(orientation="h", name="separator"),
+ self.known_networks_label,
+ self.known_networks,
+ self.no_networks_label,
+ Separator(orientation="h", name="separator"),
+ self.other_networks_button,
+ self.other_networks_revealer,
+ Separator(orientation="h", name="separator"),
+ self.more_settings_button,
+ ]
+
+ # Connect cleanup on destroy
+ self.connect("destroy", self.on_destroy)
+
+ # Start periodic network monitoring for real-time updates
+ self.start_network_monitoring()
+
+ def toggle_other_networks(self, *_):
+ """Toggle the visibility of other networks section"""
+ current_state = self.other_networks_revealer.child_revealed
+ self.other_networks_revealer.child_revealed = not current_state
+
+ # Update button text based on state
+ if self.other_networks_revealer.child_revealed:
+ # Trigger a scan when revealing other networks and force refresh
+ if self.wifi_service:
+ self.wifi_service.scan()
+ # Also force an immediate network refresh to catch any missed connections
+ self.force_network_refresh()
+
+ def on_network_ready(self, *_):
+ """Called when network service is ready"""
+ self.wifi_service = self.network_service.wifi_device
+ if self.wifi_service:
+ # Set up WiFi toggle
+ self.toggle_button.set_active(self.wifi_service.wireless_enabled)
+ self.toggle_button.connect("notify::active", self.on_toggle_changed)
+
+ # Connect to WiFi service signals
+ self.wifi_service.connect(
+ "notify::wireless-enabled", self.on_wifi_enabled_changed
+ )
+ self.wifi_service.connect("changed", self.update_networks)
+ self.wifi_service.connect("ap-added", self.update_networks)
+ self.wifi_service.connect("ap-removed", self.update_networks)
+
+ # Initial network update
+ self.update_networks()
+
+ def on_toggle_changed(self, toggle_button, *_):
+ """Handle WiFi toggle button changes"""
+ if self.wifi_service:
+ new_state = toggle_button.get_active()
+ self.wifi_service.wireless_enabled = new_state
+
+ def on_wifi_enabled_changed(self, *_):
+ """Handle WiFi enabled state changes"""
+ if self.wifi_service:
+ self.toggle_button.set_active(self.wifi_service.wireless_enabled)
+
+ def open_network_settings(self, *_):
+ """Open NetworkManager connection editor"""
+ try:
+ subprocess.Popen(["nm-connection-editor"], start_new_session=True)
+ if self.parent and hasattr(self.parent, "hide_controlcenter"):
+ self.parent.hide_controlcenter()
+ except FileNotFoundError:
+ pass
+ except Exception:
+ pass
+
+ def update_networks(self, *_):
+ """Update the list of available networks"""
+ # Prevent concurrent updates and check if destroyed
+ if self._update_in_progress or self._destroyed or not self.wifi_service:
+ return
+
+ self._update_in_progress = True
+
+ try:
+ # Store current network SSIDs to detect changes
+ current_known_ssids = {
+ child.ssid
+ for child in self.known_networks.get_children()
+ if hasattr(child, "ssid")
+ }
+ current_other_ssids = {
+ child.ssid
+ for child in self.other_networks.get_children()
+ if hasattr(child, "ssid")
+ }
+
+ # Get current networks
+ access_points = self.wifi_service.access_points
+ known_networks = []
+ other_networks = []
+ new_known_ssids = set()
+ new_other_ssids = set()
+
+ for access_point in access_points:
+ try:
+ if access_point.ssid and access_point.ssid != "Unknown":
+ # Categorize networks: connected or saved networks go to "Known Network"
+ # All others go to "Other Networks"
+ if access_point.is_active or self._is_saved_network(
+ access_point
+ ):
+ known_networks.append(access_point)
+ new_known_ssids.add(access_point.ssid)
+ else:
+ other_networks.append(access_point)
+ new_other_ssids.add(access_point.ssid)
+ except Exception:
+ continue
+
+ # Check if we need to update (networks added/removed)
+ known_changed = current_known_ssids != new_known_ssids
+ other_changed = current_other_ssids != new_other_ssids
+
+ # Only rebuild if something actually changed
+ if known_changed or other_changed:
+ # Clear existing networks safely
+ for child in list(self.known_networks.get_children()):
+ if not self._destroyed:
+ child.destroy()
+ for child in list(self.other_networks.get_children()):
+ if not self._destroyed:
+ child.destroy()
+
+ # Add known networks
+ for access_point in known_networks:
+ if not self._destroyed:
+ network_slot = WifiNetworkSlot(
+ access_point, self.wifi_service, parent=self.parent
+ )
+ self.known_networks.add(network_slot)
+
+ # Add other networks
+ for access_point in other_networks:
+ if not self._destroyed:
+ network_slot = WifiNetworkSlot(
+ access_point, self.wifi_service, parent=self.parent
+ )
+ self.other_networks.add(network_slot)
+
+ # Show/hide sections based on available networks
+ if not self._destroyed:
+ has_known_networks = len(known_networks) > 0
+ has_other_networks = len(other_networks) > 0
+ has_any_networks = has_known_networks or has_other_networks
+
+ # Show known networks section only if there are known networks
+ self.known_networks_label.set_visible(has_known_networks)
+ self.known_networks.set_visible(has_known_networks)
+
+ # Show "No networks available" message if no networks at all
+ self.no_networks_label.set_visible(not has_any_networks)
+
+ # Always show the other networks button, regardless of available networks
+ self.other_networks_button.set_visible(True) # Always visible
+
+ # Update all network connection states
+ self.refresh_network_states()
+
+ except Exception:
+ pass
+ finally:
+ self._update_in_progress = False
+
+ def _is_saved_network(self, access_point):
+ """Check if a network is saved/known using NetworkManager connections"""
+ if not self.network_service or not self.network_service._client:
+ return False
+
+ try:
+ ssid = access_point.ssid
+ if not ssid or ssid == "Unknown":
+ return False
+
+ # Get all saved connections from NetworkManager
+ connections = self.network_service._client.get_connections()
+
+ for connection in connections:
+ # Check if this is a WiFi connection
+ if connection.get_connection_type() != "802-11-wireless":
+ continue
+
+ # Get the wireless setting
+ wifi_setting = connection.get_setting_wireless()
+ if not wifi_setting:
+ continue
+
+ # Compare SSIDs
+ connection_ssid_bytes = wifi_setting.get_ssid()
+ if connection_ssid_bytes:
+ connection_ssid = NM.utils_ssid_to_utf8(
+ connection_ssid_bytes.get_data()
+ )
+ if connection_ssid == ssid:
+ return True
+
+ except Exception:
+ pass
+
+ return False
+
+ def refresh_network_states(self, *_):
+ """Refresh connection states for all network slots"""
+ # Refresh known networks
+ for child in self.known_networks.get_children():
+ if hasattr(child, "on_changed"):
+ child.on_changed()
+
+ # Refresh other networks
+ for child in self.other_networks.get_children():
+ if hasattr(child, "on_changed"):
+ child.on_changed()
+
+ def start_network_monitoring(self):
+ """Start periodic monitoring for network changes"""
+ # Monitor for network changes every 5 seconds
+ # This helps catch networks that connect from external sources
+ self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
+
+ def stop_network_monitoring(self):
+ """Stop periodic monitoring"""
+ if self.refresh_timer:
+ GLib.source_remove(self.refresh_timer)
+ self.refresh_timer = None
+
+ def periodic_network_refresh(self):
+ """Periodically refresh network list to catch external connections"""
+ # Skip if update in progress, destroyed, or wifi service not available
+ if (
+ self._update_in_progress
+ or self._destroyed
+ or not self.wifi_service
+ or not self.wifi_service.wireless_enabled
+ ):
+ return True # Continue monitoring
+
+ try:
+ # Simple check - just trigger update_networks which has its own safety checks
+ self.update_networks()
+ except Exception:
+ pass
+
+ return True # Continue monitoring
+
+ def force_network_refresh(self):
+ """Force an immediate refresh of the network list"""
+ if self._update_in_progress or self._destroyed:
+ return
+
+ try:
+ # Simply trigger update_networks which has its own safety checks
+ self.update_networks()
+ except Exception:
+ pass
+
+ def setup_pull_to_refresh(self):
+ """Setup pull-to-refresh gesture for the scrolled window"""
+ # Get the scrolled window's vertical adjustment
+ self.vadjustment = self.other_networks_scrolled.get_vadjustment()
+
+ # Track gesture state
+ self.pull_start_y = 0
+ self.is_pulling = False
+ self.pull_threshold = 50 # pixels to trigger refresh
+
+ # Connect to scroll events
+ self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event)
+ self.other_networks_scrolled.connect("button-press-event", self.on_button_press)
+ self.other_networks_scrolled.connect(
+ "button-release-event", self.on_button_release
+ )
+ self.other_networks_scrolled.connect(
+ "motion-notify-event", self.on_motion_notify
+ )
+
+ # Enable events
+ self.other_networks_scrolled.set_events(
+ Gdk.EventMask.SCROLL_MASK
+ | Gdk.EventMask.BUTTON_PRESS_MASK
+ | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK
+ )
+
+ def on_scroll_event(self, widget, event):
+ """Handle scroll events for pull-to-refresh"""
+ # Only handle pull-to-refresh when at the top
+ if self.vadjustment.get_value() <= 0:
+ if event.direction == Gdk.ScrollDirection.UP:
+ # Scrolling up at the top - trigger scan and force refresh
+ if self.wifi_service:
+ self.wifi_service.scan()
+ self.force_network_refresh()
+ return True # Consume the event
+ return False # Let normal scrolling continue
+
+ def on_button_press(self, widget, event):
+ """Handle button press for touch/drag gestures"""
+ if self.vadjustment.get_value() <= 0:
+ self.pull_start_y = event.y
+ self.is_pulling = True
+ return False
+
+ def on_button_release(self, widget, event):
+ """Handle button release for touch/drag gestures"""
+ if self.is_pulling:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > self.pull_threshold:
+ # Trigger scan and force refresh
+ if self.wifi_service:
+ self.wifi_service.scan()
+ self.force_network_refresh()
+ # Hide refresh indicator
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ self.is_pulling = False
+ return False
+
+ def on_motion_notify(self, widget, event):
+ """Handle motion events for visual feedback during pull"""
+ if self.is_pulling and self.vadjustment.get_value() <= 0:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > 0:
+ # Show refresh indicator when pulling down
+ self.refresh_indicator.set_visible(True)
+ if pull_distance >= self.pull_threshold:
+ self.refresh_indicator.set_label("↑ Release to scan")
+ self.refresh_indicator.add_style_class("ready-to-refresh")
+ else:
+ self.refresh_indicator.set_label("↓ Pull to scan for networks")
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ else:
+ self.refresh_indicator.set_visible(False)
+ return False
+
+ def on_destroy(self, widget):
+ """Cleanup when widget is destroyed"""
+ # Mark as destroyed to prevent further updates
+ self._destroyed = True
+ # Stop monitoring
+ self.stop_network_monitoring()
+ # Make sure other networks revealer is collapsed when closing
+ try:
+ self.other_networks_revealer.child_revealed = False
+ except Exception:
+ pass # Widget might already be destroyed
+
+ def close_wifi(self):
+ """Called when WiFi panel is being closed"""
+ # Collapse the other networks section when closing
+ self.other_networks_revealer.child_revealed = False
diff --git a/src/window/desktop/__init__.py b/src/window/desktop/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/window/desktop/constants.py b/src/window/desktop/constants.py
new file mode 100644
index 0000000..38545c2
--- /dev/null
+++ b/src/window/desktop/constants.py
@@ -0,0 +1,159 @@
+import datetime
+
+WEATHER_UPDATE_INTERVAL = 600 # 10 minutes
+WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes
+SYSTEM_UPDATE_INTERVAL = 60000 # 1 minute instead of 1 second
+CALENDAR_UPDATE_INTERVAL = int(
+ (
+ (
+ datetime.datetime.combine(
+ datetime.date.today() + datetime.timedelta(days=1), datetime.time.min
+ )
+ - datetime.datetime.now()
+ ).total_seconds()
+ )
+ * 1000
+) # Calculate time till midnight
+LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h)
+
+
+WEATHER_GRADIENT_MAP = {
+ # Clear/Sunny conditions - bright blue to lighter blue
+ 0: "weather-clear", # Clear sky
+ 1: "weather-mostly-clear", # Mainly clear
+ # Cloudy conditions - grey gradients
+ 2: "weather-partly-cloudy", # Partly cloudy
+ 3: "weather-overcast", # Overcast
+ # Fog conditions - muted grey/blue
+ 45: "weather-fog", # Fog
+ 48: "weather-fog", # Depositing rime fog
+ # Light rain/drizzle - blue-grey gradients
+ 51: "weather-light-rain", # Light drizzle
+ 53: "weather-rain", # Moderate drizzle
+ 55: "weather-rain", # Dense drizzle
+ 61: "weather-light-rain", # Slight rain
+ 80: "weather-light-rain", # Slight rain showers
+ # Heavy rain - darker blue-grey
+ 63: "weather-heavy-rain", # Moderate rain
+ 65: "weather-heavy-rain", # Heavy rain
+ 81: "weather-heavy-rain", # Moderate rain showers
+ 82: "weather-storm", # Violent rain showers
+ # Snow conditions - blue-white gradients
+ 56: "weather-snow", # Light freezing drizzle
+ 57: "weather-snow", # Dense freezing drizzle
+ 66: "weather-snow", # Light freezing rain
+ 67: "weather-snow", # Heavy freezing rain
+ 71: "weather-snow", # Slight snow fall
+ 73: "weather-heavy-snow", # Moderate snow fall
+ 75: "weather-heavy-snow", # Heavy snow fall
+ 77: "weather-snow", # Snow grains
+ 85: "weather-snow", # Slight snow showers
+ 86: "weather-heavy-snow", # Heavy snow showers
+ # Storm conditions - dark dramatic gradients
+ 95: "weather-storm", # Thunderstorm
+ 96: "weather-storm", # Thunderstorm with slight hail
+ 99: "weather-storm", # Thunderstorm with heavy hail
+}
+
+# Weather condition to emoji mapping
+WEATHER_EMOJI_MAP = {
+ 0: "☀️", # Clear sky
+ 1: "🌤️", # Mainly clear
+ 2: "⛅", # Partly cloudy
+ 3: "☁️", # Overcast
+ 45: "🌫️", # Fog
+ 48: "🌫️", # Depositing rime fog
+ 51: "🌦️", # Light drizzle
+ 53: "🌧️", # Moderate drizzle
+ 55: "🌧️", # Dense drizzle
+ 56: "🌨️", # Light freezing drizzle
+ 57: "🌨️", # Dense freezing drizzle
+ 61: "🌦️", # Slight rain
+ 63: "🌧️", # Moderate rain
+ 65: "🌧️", # Heavy rain
+ 66: "🌨️", # Light freezing rain
+ 67: "🌨️", # Heavy freezing rain
+ 71: "🌨️", # Slight snow fall
+ 73: "❄️", # Moderate snow fall
+ 75: "❄️", # Heavy snow fall
+ 77: "🌨️", # Snow grains
+ 80: "🌦️", # Slight rain showers
+ 81: "🌧️", # Moderate rain showers
+ 82: "⛈️", # Violent rain showers
+ 85: "🌨️", # Slight snow showers
+ 86: "❄️", # Heavy snow showers
+ 95: "⛈️", # Thunderstorm
+ 96: "⛈️", # Thunderstorm with slight hail
+ 99: "⛈️", # Thunderstorm with heavy hail
+}
+
+# Weather condition to SVG icon mapping
+WEATHER_ICON_MAP = {
+ 0: "weather-clear",
+ 1: "weather-few-clouds",
+ 2: "weather-clouds",
+ 3: "weather-overcast",
+ 45: "weather-fog",
+ 48: "weather-fog",
+ 51: "weather-showers-scattered",
+ 53: "weather-showers",
+ 55: "weather-showers",
+ 61: "weather-showers-scattered",
+ 80: "weather-showers-scattered",
+ 63: "weather-showers",
+ 65: "weather-showers",
+ 81: "weather-showers",
+ 82: "weather-storm",
+ 56: "weather-freezing-rain",
+ 57: "weather-freezing-rain",
+ 66: "weather-freezing-rain",
+ 67: "weather-freezing-rain",
+ 71: "weather-snow",
+ 73: "weather-snow",
+ 75: "weather-snow",
+ 77: "weather-hail",
+ 85: "weather-snow-scattered",
+ 86: "weather-snow-scattered",
+ 95: "weather-storm",
+ 96: "weather-storm",
+ 99: "weather-storm",
+}
+
+# Weather condition descriptions
+WEATHER_DESC_MAP = {
+ 0: "Clear sky",
+ 1: "Mainly clear",
+ 2: "Partly cloudy",
+ 3: "Overcast",
+ 45: "Fog",
+ 48: "Depositing rime fog",
+ 51: "Light drizzle",
+ 53: "Moderate drizzle",
+ 55: "Dense drizzle",
+ 56: "Light freezing drizzle",
+ 57: "Dense freezing drizzle",
+ 61: "Slight rain",
+ 63: "Moderate rain",
+ 65: "Heavy rain",
+ 66: "Light freezing rain",
+ 67: "Heavy freezing rain",
+ 71: "Slight snow",
+ 73: "Moderate snow",
+ 75: "Heavy snow",
+ 77: "Snow grains",
+ 80: "Light rain showers",
+ 81: "Moderate rain showers",
+ 82: "Violent rain showers",
+ 85: "Slight snow showers",
+ 86: "Heavy snow showers",
+ 95: "Thunderstorm",
+ 96: "Thunderstorm with hail",
+ 99: "Thunderstorm with heavy hail",
+}
+
+# Location APIs in order of preference (fastest first)
+LOCATION_APIS = [
+ "https://ipapi.co/json/", # Fastest, 200ms average
+ "http://ip-api.com/json/", # Fast fallback, 150ms average
+ "https://ipinfo.io/json", # Original fallback
+]
diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py
new file mode 100644
index 0000000..2b665dd
--- /dev/null
+++ b/src/window/desktop/widget.py
@@ -0,0 +1,653 @@
+import calendar
+import datetime
+import json
+import urllib.parse
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any, Dict, List, Optional, Tuple
+
+import psutil
+from fabric.utils import GLib, invoke_repeater, time
+from fabric.widgets.box import Box
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from shared.data import load_config
+from utils.debounce import sync_debounce
+from utils.utils import svg_file
+from window.desktop.constants import (
+ CALENDAR_UPDATE_INTERVAL,
+ LOCATION_APIS,
+ LOCATION_CACHE_TIMEOUT,
+ SYSTEM_UPDATE_INTERVAL,
+ WEATHER_CACHE_TIMEOUT,
+ WEATHER_DESC_MAP,
+ WEATHER_GRADIENT_MAP,
+ WEATHER_ICON_MAP,
+ WEATHER_UPDATE_INTERVAL,
+)
+
+# Thread pool for async operations
+executor = ThreadPoolExecutor(max_workers=4)
+
+# Global cache for weather data
+_weather_cache: Dict[str, Tuple[Any, float]] = {}
+_location_cache: Dict[str, Tuple[float, float, float]] = {}
+
+
+def http_get_json(
+ url: str, timeout: int = 3, headers: Optional[Dict] = None
+) -> Optional[Dict]:
+ """Helper to perform GET requests and return JSON using built-in urllib."""
+ try:
+ req = urllib.request.Request(url, headers=headers or {})
+ with urllib.request.urlopen(req, timeout=timeout) as response:
+ if response.status == 200:
+ return json.loads(response.read().decode("utf-8"))
+ except Exception as e:
+ print(f"HTTP Request to {url} failed: {e}")
+ return None
+
+
+def get_location() -> str:
+ """Get current location using multiple IP geolocation APIs with fallback."""
+ for api_url in LOCATION_APIS:
+ data = http_get_json(api_url, timeout=2)
+ if data:
+ city = data.get("city", "")
+ if city:
+ return city.replace(" ", "")
+
+ print("All location APIs failed")
+ return ""
+
+
+def get_coordinates(city: str) -> Optional[Tuple[float, float]]:
+ """Get coordinates for a city using Nominatim geocoding API."""
+ cache_key = city.lower()
+ current_time = time.time()
+
+ if cache_key in _location_cache:
+ lat, lon, timestamp = _location_cache[cache_key]
+ if current_time - timestamp < LOCATION_CACHE_TIMEOUT:
+ return lat, lon
+
+ encoded_city = urllib.parse.quote(city)
+ url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1"
+
+ data = http_get_json(url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"})
+
+ if data and isinstance(data, list) and len(data) > 0:
+ try:
+ lat = float(data[0]["lat"])
+ lon = float(data[0]["lon"])
+ _location_cache[cache_key] = (lat, lon, current_time)
+ return lat, lon
+ except (ValueError, KeyError):
+ pass
+
+ return None
+
+
+def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]:
+ """Fetch weather data from Open-Meteo API."""
+ url = (
+ f"https://api.open-meteo.com/v1/forecast?"
+ f"latitude={lat}&longitude={lon}"
+ f"¤t_weather=true"
+ f"&daily=temperature_2m_max,temperature_2m_min"
+ f"&timezone=auto"
+ f"&forecast_days=1"
+ )
+ return http_get_json(url, timeout=3)
+
+
+def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]:
+ """Format weather data into the expected format."""
+ try:
+ current = weather_data["current_weather"]
+ daily = weather_data["daily"]
+
+ weather_code = current["weathercode"]
+ is_day = current.get("is_day", 1) # Default to day if missing
+
+ base_icon = WEATHER_ICON_MAP.get(weather_code, "weather-none-available")
+
+ # Determine day/night variant if applicable
+ icon_name = base_icon
+ if not is_day:
+ night_variant = f"{base_icon}-night"
+
+ # Fast check if night variant exists using predefined list or checking the path
+ # Since we know the variants from earlier, let's just optimistically build it
+ # then logic in svg_file will handle resolution seamlessly (fallbacks can be tricky, but we assume it's correct)
+ if base_icon in [
+ "weather-clear",
+ "weather-clouds",
+ "weather-few-clouds",
+ "weather-overcast",
+ "weather-showers",
+ "weather-showers-scattered",
+ "weather-snow",
+ "weather-snow-scattered",
+ "weather-storm",
+ ]:
+ icon_name = night_variant
+
+ condition = WEATHER_DESC_MAP.get(weather_code, "Unknown")
+ gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear")
+
+ temp = f"{round(current['temperature'])}°"
+ max_temp = f"{round(daily['temperature_2m_max'][0])}°"
+ min_temp = f"{round(daily['temperature_2m_min'][0])}°"
+
+ return [icon_name, temp, condition, city, max_temp, min_temp, gradient_class]
+ except (KeyError, IndexError, TypeError) as e:
+ print(f"Error formatting weather data: {e}")
+ return None
+
+
+def get_weather(callback):
+ """Fetch weather data asynchronously."""
+
+ def fetch_weather():
+ location = get_location()
+ if not location:
+ return GLib.idle_add(callback, None)
+
+ cache_key = location.lower()
+ current_time = time.time()
+
+ if cache_key in _weather_cache:
+ cached_data, timestamp = _weather_cache[cache_key]
+ if current_time - timestamp < WEATHER_CACHE_TIMEOUT:
+ return GLib.idle_add(callback, cached_data)
+
+ coords = get_coordinates(location)
+ if not coords:
+ return GLib.idle_add(callback, None)
+
+ lat, lon = coords
+ weather_data = get_weather_data(lat, lon)
+ if not weather_data:
+ return GLib.idle_add(callback, None)
+
+ formatted_data = format_weather_data(weather_data, location)
+ if formatted_data:
+ _weather_cache[cache_key] = (formatted_data, current_time)
+ GLib.idle_add(callback, formatted_data)
+ else:
+ GLib.idle_add(callback, None)
+
+ executor.submit(fetch_weather)
+
+
+def update_weather(widget):
+ """Update weather widget with new data."""
+
+ def perform_fetch():
+ get_weather(lambda weather_info: update_widget(widget, weather_info))
+
+ debounced_perform_fetch = sync_debounce(1000, immediate=True)(perform_fetch)
+
+ def fetch_and_update():
+ debounced_perform_fetch()
+ return True
+
+ GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update)
+ fetch_and_update()
+
+
+def update_widget(widget, weather_info):
+ """Update widget labels with weather information."""
+ if weather_info:
+ widget.weatherinfo = weather_info
+ widget.update_labels(weather_info)
+
+
+class Weather(Box):
+ def __init__(self, parent, **kwargs):
+ super().__init__(
+ name="weather-widget",
+ h_expand=True,
+ v_expand=True,
+ justification="right",
+ orientation="v",
+ all_visible=False,
+ **kwargs,
+ )
+ self.parent = parent
+ self.weatherinfo = None
+ self._create_labels()
+ self._layout_labels()
+ update_weather(self)
+
+ def _create_labels(self):
+ self.header = Box(orientation="h", h_expand=True)
+ self.body = Box(orientation="v", v_expand=True, valign="end")
+
+ self.city = Label(
+ name="city",
+ label="Loading...",
+ justification="left",
+ h_align="start",
+ max_chars_width=15,
+ ellipsization="end",
+ )
+ self.temperature = Label(name="temperature", label="--°", h_align="start")
+ self.condition_em = svg_file(
+ "weather/weather-none-available.svg", size=(35, 35), name="condition-emoji"
+ )
+ self.condition = Label(
+ name="condition",
+ label="Loading...",
+ max_chars_width=18,
+ ellipsization="end",
+ h_align="start",
+ )
+ self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start")
+
+ def _layout_labels(self):
+ # Header: City (left) and Icon (right)
+ self.header.add(self.city)
+ self.header.pack_end(self.condition_em, False, False, 0)
+
+ # Body: Temp, Condition, High/Low
+ self.body.add(self.temperature)
+ self.body.add(self.condition)
+ self.body.add(self.feels_like)
+
+ self.add(self.header)
+ self.add(self.body)
+
+ def update_labels(self, weather_info: List[str]):
+ if not weather_info or len(weather_info) != 7:
+ return
+ icon_name, temp, condition, location, maxtemp, mintemp, gradient_class = (
+ weather_info
+ )
+ maxmin = f"L:{mintemp} H:{maxtemp}"
+
+ self.city.set_label(location)
+ self.temperature.set_label(temp)
+
+ # update SVG File
+ self.condition_em.dynamic_file(f"weather/{icon_name}.svg")
+
+ self.condition.set_label(condition)
+ self.feels_like.set_label(maxmin)
+
+ # Apply gradient class to container
+ if hasattr(self, "parent") and self.parent:
+ # Remove old weather classes
+ for cls in self.parent.get_style_context().list_classes():
+ if cls.startswith("weather-"):
+ self.parent.remove_style_class(cls)
+ # Add new one
+ self.parent.add_style_class(gradient_class)
+
+ self.parent.set_visible(True)
+
+
+class WeatherContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v",
+ name="weather-container",
+ v_expand=True,
+ v_align="center",
+ size=(170, 170),
+ visible=True,
+ h_align="center",
+ children=[Weather(self)],
+ **kwargs,
+ )
+
+
+class Date(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="date-widget",
+ h_expand=True,
+ v_expand=True,
+ justification="center",
+ h_align="center",
+ v_align="start",
+ orientation="v",
+ **kwargs,
+ )
+ self.top = Box(orientation="h", name="date-top", h_expand=True)
+ date_interval = 10000
+ self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day")
+ self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month")
+ self.datethree = DateTime(
+ formatters=["%-d"], interval=date_interval, name="date"
+ )
+ self.top.add(self.dateone)
+ self.top.add(self.datetwo)
+ self.add(self.top)
+ self.add(self.datethree)
+
+
+class DateContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v",
+ name="date-container",
+ v_expand=True,
+ size=(170, 170),
+ v_align="center",
+ h_align="center",
+ children=[Date()],
+ **kwargs,
+ )
+
+
+class Calendar(Box):
+ def __init__(self, **kwargs):
+ calendar.setfirstweekday(6)
+ super().__init__(
+ name="calendar-widget",
+ h_expand=True,
+ v_expand=True,
+ orientation="v",
+ **kwargs,
+ )
+ self._update_current_date()
+ self._create_header()
+ self._create_days_header()
+ self._create_calendar_grid()
+ self.add(self.month_label)
+ self.add(self.days_header)
+ self.add(self.calendar_grid)
+ invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed)
+
+ def _update_current_date(self):
+ now = datetime.datetime.now()
+ self.current_month, self.current_year, self.current_day = (
+ now.month,
+ now.year,
+ now.day,
+ )
+
+ def _create_header(self):
+ self.month_label = Label(
+ name="calendar-month",
+ label=calendar.month_name[self.current_month],
+ h_align="start",
+ justification="left",
+ )
+
+ def _create_days_header(self):
+ self.days_header = Box(
+ name="calendar-days-header", orientation="h", h_expand=True, spacing=2
+ )
+ for i, day_name in enumerate(["S", "M", "T", "W", "T", "F", "S"]):
+ self.days_header.add(
+ Label(
+ name="calendar-day-header-weekend"
+ if i in (0, 6)
+ else "calendar-day-header",
+ label=day_name,
+ h_align="center",
+ h_expand=True,
+ )
+ )
+
+ def _create_calendar_grid(self):
+ self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1)
+ self.update_calendar()
+
+ def update_calendar_if_needed(self) -> bool:
+ now = datetime.datetime.now()
+ if (now.month, now.year, now.day) != (
+ self.current_month,
+ self.current_year,
+ self.current_day,
+ ):
+ self._update_current_date()
+ self.update_calendar()
+ return True
+
+ def update_calendar(self):
+ for child in self.calendar_grid.get_children():
+ self.calendar_grid.remove(child)
+ self.month_label.set_label(calendar.month_name[self.current_month])
+ cal = calendar.monthcalendar(self.current_year, self.current_month)
+ for week in cal:
+ week_box = Box(orientation="h", spacing=2, h_expand=True)
+ for i, day in enumerate(week):
+ if day == 0:
+ label = Label(
+ name="calendar-day-empty",
+ label="",
+ h_align="center",
+ h_expand=True,
+ )
+ else:
+ is_today = (
+ day == self.current_day
+ and self.current_month == datetime.datetime.now().month
+ )
+ name = (
+ "calendar-day-today"
+ if is_today
+ else ("calendar-day-weekend" if i in (0, 6) else "calendar-day")
+ )
+ label = Label(
+ name=name, label=str(day), h_align="center", h_expand=True
+ )
+ week_box.add(label)
+ self.calendar_grid.add(week_box)
+
+
+class CalendarContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v",
+ name="calendar-box-widget",
+ v_expand=True,
+ size=(170, 170),
+ v_align="center",
+ h_align="center",
+ children=[Calendar()],
+ **kwargs,
+ )
+
+
+class SystemInfoBase(Box):
+ @staticmethod
+ def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs):
+ return CircularProgressBar(
+ name=name,
+ start_angle=270,
+ end_angle=630,
+ min_value=0,
+ max_value=100,
+ size=size,
+ **kwargs,
+ )
+
+ def __init__(self, name: str, **kwargs):
+ super().__init__(
+ layer="bottom",
+ title="sysinfo",
+ name=name,
+ visible=True,
+ size=(170, 170),
+ h_expand=True,
+ v_expand=True,
+ all_visible=True,
+ **kwargs,
+ )
+ self.progress = self.create_progress_bar(name="progress")
+ self.main_label = Label(
+ label="0%\nLoading", justification="center", name="progress-label"
+ )
+ self.info_container = Box(
+ name="info-container", orientation="v", spacing=2, h_align="center"
+ )
+ self.add(
+ Box(
+ name="progress-bar-container",
+ h_expand=True,
+ v_expand=True,
+ orientation="v",
+ spacing=12,
+ h_align="center",
+ v_align="center",
+ children=[
+ Box(
+ children=[
+ Overlay(
+ child=self.progress,
+ tooltip_text="",
+ overlays=self.main_label,
+ )
+ ]
+ ),
+ Box(
+ h_align="center",
+ justification="centre",
+ orientation="v",
+ children=[self.info_container],
+ ),
+ ],
+ )
+ )
+
+ def start_updates(self):
+ invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)
+
+ def create_info_line(
+ self, indicator_name: str, info_text: str, value_text: str
+ ) -> Box:
+ line = Box(
+ orientation="h",
+ spacing=4,
+ h_align="start",
+ children=[
+ Label(label="■", name=indicator_name),
+ Label(label=info_text, name="info-text"),
+ Label(label=value_text, name="info-value"),
+ ],
+ )
+ line.value_label = line.get_children()[2]
+ return line
+
+ def update(self) -> bool:
+ raise NotImplementedError
+
+
+class RamInfo(SystemInfoBase):
+ def __init__(self, **kwargs):
+ super().__init__("info-box-widget", **kwargs)
+ self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB")
+ self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB")
+ self.info_container.add(self.used_line)
+ self.info_container.add(self.free_line)
+ self.start_updates()
+
+ def update(self) -> bool:
+ try:
+ mem = psutil.virtual_memory()
+ self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM")
+ self.used_line.value_label.set_label(f"{round(mem.used / (1024**3), 1)}GB")
+ self.free_line.value_label.set_label(
+ f"{round(mem.available / (1024**3), 1)}GB"
+ )
+ GLib.idle_add(self.progress.set_value, mem.percent)
+ except Exception as e:
+ print(f"Error: {e}")
+ return True
+
+
+class CpuInfo(SystemInfoBase):
+ def __init__(self, **kwargs):
+ super().__init__("info-box-widget", **kwargs)
+ self.temp_value = Label(label="0°C", name="info-value")
+ self.info_container.add(
+ Box(
+ orientation="h",
+ spacing=4,
+ h_align="start",
+ children=[Label(label="Temp", name="info-text"), self.temp_value],
+ )
+ )
+ self.start_updates()
+
+ def get_cpu_temp(self) -> Optional[float]:
+ try:
+ temps = psutil.sensors_temperatures()
+ for name, entries in temps.items():
+ if any(s in name.lower() for s in ["coretemp", "k10temp", "cpu"]):
+ for entry in entries:
+ if any(
+ p in (entry.label or "").lower()
+ for p in ["package id 0", "core 0", ""]
+ ):
+ return round(entry.current, 1)
+ except Exception:
+ pass
+ return None
+
+ def update(self) -> bool:
+ try:
+ cpu = psutil.cpu_percent()
+ self.main_label.set_label(f" {round(cpu):<2} %\nCPU")
+ temp = self.get_cpu_temp()
+ self.temp_value.set_label(f"{temp}°C" if temp else "N/A")
+ GLib.idle_add(self.progress.set_value, cpu)
+ except Exception as e:
+ print(f"Error: {e}")
+ return True
+
+
+class Deskwidgets:
+ """Desktop widgets manager - handles all desktop widgets."""
+
+ config = load_config()
+
+ def __init__(self):
+ # This class now just manages other windows instead of being one itself
+
+ # Create separate independent windows as attributes
+ self.top_left = Window(
+ anchor="top left",
+ title="modus-widgets-topleft",
+ exclusivity="none",
+ orientation="h",
+ layer="bottom",
+ visible=False, # Start hidden until content ready
+ child=Box(
+ name="desktop-widgets-container",
+ children=[
+ DateContainer(),
+ WeatherContainer(),
+ CalendarContainer(),
+ ],
+ ),
+ )
+
+ self.bottom_left = Window(
+ anchor="bottom right",
+ title="modus-widgets-bottomright",
+ orientation="h",
+ layer="bottom",
+ exclusivity="none",
+ visible=False, # Start hidden until content ready
+ child=Box(
+ name="desktop-widgets-container",
+ children=[
+ CpuInfo(),
+ RamInfo(),
+ ],
+ ),
+ )
+
+ # Show widgets after initialization is complete
+ self.top_left.set_visible(True)
+ self.bottom_left.set_visible(True)
diff --git a/src/window/dock.py b/src/window/dock.py
new file mode 100644
index 0000000..1c63f42
--- /dev/null
+++ b/src/window/dock.py
@@ -0,0 +1,854 @@
+import json
+import subprocess
+
+from fabric.utils import (
+ GLib,
+ Gtk,
+ get_desktop_applications,
+ get_relative_path,
+ logger,
+ os,
+ random,
+ re,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
+from services.modus import modus_service
+from utils.functions import (
+ clear_children,
+ is_special_workspace_id,
+ read_json_file,
+ write_json_file,
+)
+from utils.icon_resolver import IconResolver
+from utils.occlusion import check_occlusion
+
+# Pinned apps file
+PINNED_APPS_FILE = get_relative_path("../../config/dock.json")
+
+
+class AppBar(Box):
+ def __init__(self, parent: Window):
+ self.client_buttons = {} # For running app instances
+ self.pinned_buttons = {} # For pinned apps
+ # Position tracking for hover effects
+ self.running_items_pos = []
+ self.pinned_items_pos = []
+ self._parent = parent
+
+ # Set orientation based on dock position
+ orientation = (
+ "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal"
+ )
+
+ super().__init__(
+ spacing=0,
+ name="dock",
+ orientation=orientation,
+ children=[],
+ )
+ self.icon_resolver = IconResolver()
+ self._hyprland_connection = modus_service._hyprland_connection
+
+ # Initialize GTK menu
+ self.menu = Gtk.Menu()
+
+ self.pinned_apps = read_json_file(PINNED_APPS_FILE) or []
+ self.pinned_apps_container = Box()
+ self.add(self.pinned_apps_container)
+
+ self.separator = Box(
+ v_align="center", style_classes=["hidden", "dock_separator"]
+ )
+ self.add(self.separator)
+
+ self.running_apps_container = Box(name="dock_container")
+ self.add(self.running_apps_container)
+
+ self._populate_pinned_apps()
+ self.setup_app_monitoring()
+
+ def setup_app_monitoring(self):
+ def update_running_apps():
+ try:
+ self.update_dock_apps()
+ except Exception as e:
+ logger.error(f"[AppBar] Error updating apps: {e}")
+ return True
+
+ GLib.timeout_add(250, update_running_apps)
+ GLib.idle_add(self.update_dock_apps)
+
+ def _populate_pinned_apps(self):
+ clear_children(self.pinned_apps_container)
+
+ self.pinned_buttons = {}
+ self.pinned_items_pos = []
+
+ try:
+ desktop_apps = get_desktop_applications(include_hidden=False)
+ except Exception:
+ desktop_apps = []
+
+ for app_data in self.pinned_apps:
+ self._create_pinned_button(app_data, desktop_apps)
+
+ # Add trash icon at the end of pinned apps
+ self._create_trash_button()
+
+ def _create_pinned_button(self, app_data, desktop_apps):
+ app = self._find_desktop_app(app_data, desktop_apps)
+ app_identifier = self._get_app_identifier(app_data)
+ display_name = (
+ app.display_name
+ if app
+ else (
+ app_data.get("display_name", app_identifier)
+ if isinstance(app_data, dict)
+ else app_identifier
+ )
+ )
+
+ icon_pixbuf = self._get_app_icon(app_data, app)
+
+ main_container = Box(
+ name="dock_item_main_container",
+ orientation="v",
+ children=[Image(name="dock_item_icon", pixbuf=icon_pixbuf)],
+ )
+
+ pinned_button = Button(
+ name="dock_item",
+ child=main_container,
+ tooltip_text=display_name,
+ on_button_press_event=lambda _, event: self._handle_pinned_app_click(
+ event, app_data
+ ),
+ on_enter_notify_event=lambda *_: self._set_item_hover_state(
+ pinned_button, True, pinned=True
+ ),
+ on_leave_notify_event=lambda *_: self._set_item_hover_state(
+ pinned_button, False, pinned=True
+ ),
+ )
+
+ pinned_button.add_style_class("shown")
+
+ self.pinned_buttons[app_identifier] = pinned_button
+ self.pinned_apps_container.add(pinned_button)
+ self.pinned_items_pos.append(pinned_button)
+
+ def _get_app_icon(self, app_data, app=None):
+ if app:
+ return app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
+
+ icon_name = ""
+ if isinstance(app_data, dict):
+ icon_name = app_data.get("window_class") or app_data.get("name")
+ elif isinstance(app_data, str):
+ icon_name = app_data
+
+ return self.icon_resolver.get_icon_pixbuf(
+ icon_name or "application-x-executable", data.DOCK_ICON_SIZE
+ )
+
+ def _create_trash_button(self):
+ """Create a trash button that opens the trash in file manager"""
+ trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
+ "user-trash", data.DOCK_ICON_SIZE
+ )
+
+ main_container = Box(
+ name="dock_item_main_container",
+ orientation="v",
+ children=[Image(name="dock_item_icon", pixbuf=trash_icon_pixbuf)],
+ )
+
+ trash_button = Button(
+ name="dock_item",
+ child=main_container,
+ tooltip_text="Trash",
+ on_button_press_event=lambda _, event: self._handle_trash_click(event),
+ on_enter_notify_event=lambda *_: self._set_item_hover_state(
+ trash_button, True, pinned=True
+ ),
+ on_leave_notify_event=lambda *_: self._set_item_hover_state(
+ trash_button, False, pinned=True
+ ),
+ )
+
+ trash_button.add_style_class("shown")
+ trash_button.is_trash = True
+
+ self.pinned_buttons["trash"] = trash_button
+ self.pinned_apps_container.add(trash_button)
+ self.pinned_items_pos.append(trash_button)
+
+ def _find_desktop_app(self, app_info, desktop_apps):
+ """Find a desktop app from either a string ID or a data dictionary"""
+ if not app_info:
+ return None
+
+ # Extract normalized search terms
+ search_terms = []
+ if isinstance(app_info, dict):
+ search_terms = [
+ app_info.get("window_class"),
+ app_info.get("name"),
+ app_info.get("executable"),
+ ]
+ else:
+ search_terms = [app_info]
+
+ search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)]
+ if not search_terms:
+ return None
+
+ # Handle Reverse DNS or decorated names (e.g., org.gnome.Nautilus, float_kitty)
+ expanded_terms = list(search_terms)
+ for term in search_terms:
+ # Handle dots (Reverse DNS)
+ if "." in term:
+ parts = term.split(".")
+ if parts[-1] and parts[-1] not in expanded_terms:
+ expanded_terms.append(parts[-1])
+
+ # Handle underscores and hyphens (decorated names)
+ for sep in ["_", "-"]:
+ if sep in term:
+ for part in term.split(sep):
+ if len(part) > 2 and part not in expanded_terms:
+ expanded_terms.append(part)
+
+ # Priority 1: Exact matches for window_class
+ for app in desktop_apps:
+ app_class = getattr(app, "window_class", None)
+ if app_class and app_class.lower() in search_terms:
+ return app
+
+ # Priority 2: Exact matches for app name or display name
+ for app in desktop_apps:
+ app_names = [
+ getattr(app, "name", None),
+ getattr(app, "display_name", None),
+ ]
+ app_names = [n.lower() for n in app_names if n]
+ if any(name in expanded_terms for name in app_names):
+ return app
+
+ # Priority 3: Exact matches for executable basename
+ for app in desktop_apps:
+ if app.executable:
+ exe_base = os.path.basename(app.executable).lower()
+ if exe_base in expanded_terms:
+ # If this is a common terminal or shell, only match if the name also matches
+ # This prevents Neovim (which uses 'kitty' as its executable wrapper) from overriding Kitty
+ common_wrappers = [
+ "kitty",
+ "bash",
+ "sh",
+ "zsh",
+ "python",
+ "python3",
+ ]
+ if exe_base in common_wrappers:
+ app_names = [
+ getattr(app, "name", ""),
+ getattr(app, "display_name", ""),
+ ]
+ if not any(exe_base in n.lower() for n in app_names if n):
+ continue
+ return app
+
+ # Priority 4: Fuzzy name matching (containment)
+ for app in desktop_apps:
+ app_terms = [
+ getattr(app, "name", ""),
+ getattr(app, "display_name", ""),
+ getattr(app, "window_class", ""),
+ ]
+ app_terms = [t.lower() for t in app_terms if t]
+
+ for term in expanded_terms:
+ if len(term) < 3:
+ continue
+ if any(term in app_term for app_term in app_terms):
+ return app
+
+ return None
+
+ def show_menu(self, app_id: str, client=None, instance_address=None):
+ for item in self.menu.get_children():
+ self.menu.remove(item)
+ item.destroy()
+
+ if client or instance_address:
+ close_item = Gtk.MenuItem(label="Close")
+ if instance_address:
+ close_item.connect(
+ "activate", lambda *_: self._close_running_app(instance_address)
+ )
+ self.menu.add(close_item)
+
+ if app_id:
+ separator = Gtk.SeparatorMenuItem()
+ self.menu.add(separator)
+
+ if app_id:
+ is_pinned = self._is_app_pinned(app_id)
+ pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin")
+
+ if is_pinned:
+ pin_item.connect("activate", lambda *_: self._unpin_app(app_id))
+ else:
+ pin_item.connect("activate", lambda *_: self._pin_app(app_id))
+
+ self.menu.add(pin_item)
+
+ self.menu.show_all()
+
+ def _close_running_app(self, instance_address):
+ try:
+ self._hyprland_connection.send_command(
+ f"dispatch closewindow address:{instance_address}"
+ )
+ except Exception as e:
+ logger.error(f"[AppBar] Error closing window: {e}")
+
+ def _handle_pinned_app_click(self, event, app_data):
+ app_identifier = self._get_app_identifier(app_data)
+ if event.button == 1: # Left click - launch app
+ self._launch_app(app_data)
+ elif event.button == 2: # Middle click - unpin app
+ self._unpin_app(app_identifier)
+ elif event.button == 3: # Right click - show context menu
+ self.show_menu(app_identifier)
+ self.menu.popup_at_pointer(event)
+
+ def _handle_trash_click(self, event):
+ """Handle trash button click to open trash in file manager"""
+ if event.button == 1: # Left click
+ try:
+ trash_path = os.path.expanduser("~/.local/share/Trash/files")
+ file_managers = [
+ "nautilus",
+ "dolphin",
+ "thunar",
+ "nemo",
+ "caja",
+ "pcmanfm",
+ ]
+
+ for fm in file_managers:
+ try:
+ result = subprocess.run(
+ ["which", fm], capture_output=True, text=True
+ )
+ if result.returncode == 0:
+ subprocess.Popen([fm, trash_path])
+ return
+ except Exception:
+ continue
+ except Exception as e:
+ logger.error(f"[AppBar] Error opening trash: {e}")
+
+ def _set_item_hover_state(self, item, is_hovered, pinned=False):
+ if is_hovered:
+ self._parent.on_hover_enter()
+
+ items_list = self.pinned_items_pos if pinned else self.running_items_pos
+ try:
+ index = items_list.index(item)
+ style_class = "semi_hovered"
+ action = "add" if is_hovered else "remove"
+
+ if index > 0:
+ getattr(items_list[index - 1], f"{action}_style_class")(style_class)
+ if index < len(items_list) - 1:
+ getattr(items_list[index + 1], f"{action}_style_class")(style_class)
+ except (ValueError, IndexError):
+ pass
+
+ def _launch_app(self, app_info):
+ """Launch an app using its desktop app instance or a data dictionary"""
+ try:
+ command_line = ""
+ if hasattr(app_info, "command_line"):
+ command_line = app_info.command_line
+ elif isinstance(app_info, dict):
+ command_line = app_info.get("command_line") or app_info.get(
+ "executable"
+ )
+ elif isinstance(app_info, str):
+ command_line = app_info
+
+ if not command_line:
+ # Fallback to desktop app search if it's just a string class
+ desktop_apps = get_desktop_applications(include_hidden=False)
+ app = self._find_desktop_app(app_info, desktop_apps)
+ if app:
+ command_line = app.command_line
+
+ if command_line:
+ cleaned_command = re.sub(r"%\w+", "", command_line).strip()
+ final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
+ subprocess.Popen(final_command, shell=True)
+ elif hasattr(app_info, "launch"):
+ app_info.launch()
+ else:
+ logger.error(
+ f"[AppBar] Could not determine launch command for: {app_info}"
+ )
+ except Exception as e:
+ logger.error(f"[AppBar] Failed to launch app: {e}")
+
+ def _get_app_identifier(self, app_data):
+ if isinstance(app_data, dict):
+ return app_data.get("name", "") or app_data.get("window_class", "")
+ return app_data
+
+ def _pin_app(self, app_class: str):
+ if self._is_app_pinned(app_class):
+ return False
+
+ try:
+ desktop_apps = get_desktop_applications(include_hidden=False)
+ app = self._find_desktop_app(app_class, desktop_apps)
+
+ app_data = {
+ "name": app.name if app else app_class,
+ "display_name": (app.display_name or app.name) if app else app_class,
+ "window_class": (getattr(app, "window_class", None) or app_class)
+ if app
+ else app_class,
+ "executable": app.executable if app else app_class,
+ "command_line": app.command_line if app else app_class,
+ }
+ self.pinned_apps.append(app_data)
+ except Exception:
+ self.pinned_apps.append(app_class)
+
+ write_json_file(self.pinned_apps, PINNED_APPS_FILE)
+ self._populate_pinned_apps()
+ return True
+
+ def _unpin_app(self, app_identifier: str):
+ apps_to_remove = []
+
+ for i, pinned_app in enumerate(self.pinned_apps):
+ if self._matches_app_identifier(pinned_app, app_identifier):
+ apps_to_remove.append(i)
+
+ for i in reversed(apps_to_remove):
+ self.pinned_apps.pop(i)
+
+ if apps_to_remove:
+ write_json_file(self.pinned_apps, PINNED_APPS_FILE)
+ self._populate_pinned_apps()
+ return True
+ return False
+
+ def _matches_app_identifier(self, pinned_app, app_identifier):
+ if not app_identifier:
+ return False
+
+ if isinstance(pinned_app, dict):
+ window_class = pinned_app.get("window_class") or ""
+ name = pinned_app.get("name") or ""
+ return (
+ window_class.lower() == app_identifier.lower()
+ or name.lower() == app_identifier.lower()
+ )
+ return (
+ isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower()
+ )
+
+ def get_clients(self):
+ try:
+ clients_data = self._hyprland_connection.send_command("j/clients").reply
+ if not clients_data:
+ return []
+ return json.loads(clients_data.decode("utf-8"))
+ except Exception as e:
+ logger.error(f"[AppBar] Error getting clients: {e}")
+ return []
+
+ def get_focused_window(self):
+ try:
+ active_data = self._hyprland_connection.send_command("j/activewindow").reply
+ if not active_data:
+ return None
+ return json.loads(active_data.decode("utf-8"))
+ except Exception as e:
+ logger.error(f"[AppBar] Error getting focused window: {e}")
+ return None
+
+ def update_dock_apps(self):
+ try:
+ clients = self.get_clients()
+ focused_window = self.get_focused_window()
+ focused_address = (
+ focused_window.get("address", "") if focused_window else ""
+ )
+
+ current_instance_ids = set()
+
+ for client in clients:
+ if client.get("hidden", False) or not self._should_show_app_instance(
+ client
+ ):
+ continue
+
+ instance_address = client.get("address", "")
+ app_class = client.get("class", "") or client.get("title", "")
+ if not instance_address or not app_class:
+ continue
+
+ current_instance_ids.add(instance_address)
+
+ if instance_address not in self.client_buttons:
+ self.create_instance_button(instance_address, client, app_class)
+ else:
+ self.update_instance_button(instance_address, client, app_class)
+
+ button = self.client_buttons[instance_address]
+ if instance_address == focused_address:
+ button.add_style_class("activated")
+ else:
+ button.remove_style_class("activated")
+
+ self._update_pinned_apps_state(clients)
+ self._update_separator_visibility()
+
+ self._cleanup_removed_instances(current_instance_ids)
+
+ except Exception as e:
+ logger.error(f"[AppBar] Error in update_dock_apps: {e}")
+
+ def _update_pinned_apps_state(self, clients):
+ running_app_classes = {
+ client.get("class", "").lower() or client.get("title", "").lower()
+ for client in clients
+ if not client.get("hidden", False)
+ and (client.get("class") or client.get("title"))
+ and self._should_show_app_instance(client)
+ }
+
+ for app_identifier, button in self.pinned_buttons.items():
+ # Skip trash button as it's not a regular app
+ if app_identifier == "trash" or hasattr(button, "is_trash"):
+ continue
+
+ if app_identifier.lower() in running_app_classes:
+ button.add_style_class("instance")
+ else:
+ button.remove_style_class("instance")
+
+ def _cleanup_removed_instances(self, current_instance_ids):
+ buttons_to_remove = [
+ instance_id
+ for instance_id in self.client_buttons.keys()
+ if instance_id not in current_instance_ids
+ ]
+
+ # Clean up removed and orphaned buttons
+ for instance_id in buttons_to_remove + [
+ k
+ for k, v in self.client_buttons.items()
+ if not hasattr(v, "instance_address") or not v.get_parent()
+ ]:
+ if instance_id in self.client_buttons:
+ button = self.client_buttons.pop(instance_id)
+ try:
+ if button in self.running_items_pos:
+ self.running_items_pos.remove(button)
+ button.remove_style_class("shown")
+ button.remove_style_class("activated")
+ if button.get_parent():
+ button.get_parent().remove(button)
+ button.destroy()
+ except Exception as e:
+ logger.warning(f"[AppBar] Error during cleanup: {e}")
+
+ def create_instance_button(self, instance_address, client, app_class):
+ try:
+ client_image = Image(name="dock_item_icon")
+
+ try:
+ desktop_apps = get_desktop_applications(include_hidden=False)
+ app = self._find_desktop_app(app_class, desktop_apps)
+ pixbuf = self._get_app_icon(app_class, app)
+ client_image.set_from_pixbuf(pixbuf)
+ except Exception as e:
+ logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}")
+
+ workspace_id = self._get_workspace_id(client)
+ workspace_label = None
+ if workspace_id is not None:
+ workspace_label = Label(
+ label=str(workspace_id),
+ name="workspace-indicator",
+ h_align="end",
+ v_align="end",
+ )
+
+ image_overlay = Overlay(name="dock-image-overlay", child=client_image)
+ if workspace_label:
+ image_overlay.add_overlay(workspace_label)
+
+ indicator = Box(name="dock_item_indicator", h_align="center")
+ main_container = Box(
+ name="dock_item_main_container",
+ orientation="v",
+ children=[image_overlay, indicator],
+ )
+
+ tooltip_text = client.get("title", app_class)
+ if tooltip_text != app_class:
+ tooltip_text = f"{app_class}: {tooltip_text}"
+
+ client_button = Button(
+ name="dock_item",
+ child=main_container,
+ tooltip_text=tooltip_text,
+ on_button_press_event=lambda widget, event: self.handle_instance_click(
+ widget, event
+ ),
+ on_enter_notify_event=lambda *_: self._set_item_hover_state(
+ client_button, True, pinned=False
+ ),
+ on_leave_notify_event=lambda *_: self._set_item_hover_state(
+ client_button, False, pinned=False
+ ),
+ )
+
+ client_button.instance_address = instance_address
+ client_button.client_data = client
+ client_button.app_class = app_class
+ client_button.workspace_label = workspace_label
+ client_button.add_style_class("shown")
+
+ self.client_buttons[instance_address] = client_button
+ self.running_apps_container.add(client_button)
+ self.running_items_pos.append(client_button)
+
+ except Exception as e:
+ logger.error(
+ f"[AppBar] Error creating instance button for {app_class}: {e}"
+ )
+
+ def _get_workspace_id(self, client):
+ workspace_data = client.get("workspace", {})
+ if isinstance(workspace_data, dict):
+ return workspace_data.get("id")
+ elif isinstance(workspace_data, (int, str)):
+ return workspace_data
+ return None
+
+ def _should_show_app_instance(self, client):
+ if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS:
+ return True
+
+ workspace_id = self._get_workspace_id(client)
+ return (
+ False if workspace_id is None else not is_special_workspace_id(workspace_id)
+ )
+
+ def update_instance_button(self, instance_address, client, app_class):
+ if instance_address not in self.client_buttons:
+ return
+
+ button = self.client_buttons[instance_address]
+ button.client_data = client
+ button.app_class = app_class
+
+ tooltip_text = client.get("title", app_class)
+ if tooltip_text != app_class:
+ tooltip_text = f"{app_class}: {tooltip_text}"
+ button.set_tooltip_text(tooltip_text)
+
+ workspace_id = self._get_workspace_id(client)
+ existing_label = getattr(button, "workspace_label", None)
+
+ container = button.get_child()
+ if not hasattr(container, "get_children"):
+ return
+
+ children = container.get_children()
+ if not children or not isinstance(children[0], Overlay):
+ return
+
+ image_overlay = children[0]
+ # Remove existing workspace label
+ if existing_label and existing_label.get_parent():
+ image_overlay.remove_overlay(existing_label)
+
+ # Add new workspace label if needed
+ if workspace_id is not None:
+ new_label = Label(
+ label=str(workspace_id),
+ name="workspace-indicator",
+ h_align="end",
+ v_align="end",
+ )
+ image_overlay.add_overlay(new_label)
+ button.workspace_label = new_label
+ else:
+ button.workspace_label = None
+
+ def handle_instance_click(self, button_widget, event):
+ instance_address = getattr(button_widget, "instance_address", None)
+ app_class = getattr(button_widget, "app_class", None)
+
+ if event.button == 1: # Left click - focus window
+ if instance_address:
+ try:
+ self._hyprland_connection.send_command(
+ f"dispatch focuswindow address:{instance_address}"
+ )
+ except Exception as e:
+ logger.error(f"[AppBar] Error focusing window: {e}")
+
+ elif event.button == 2: # Middle click - pin/unpin app
+ if app_class and not self._is_app_pinned(app_class):
+ self._pin_app(app_class)
+
+ elif event.button == 3: # Right click - context menu
+ if app_class:
+ self.show_menu(app_class, instance_address=instance_address)
+ self.menu.popup_at_pointer(event)
+
+ def _is_app_pinned(self, app_class: str) -> bool:
+ return any(
+ self._matches_app_identifier(pinned_app, app_class)
+ for pinned_app in self.pinned_apps
+ )
+
+ def _update_separator_visibility(self):
+ has_pinned_apps = len(self.pinned_items_pos) > 0
+ has_running_apps = len(self.running_items_pos) > 0
+ if has_pinned_apps and has_running_apps:
+ self.separator.remove_style_class("hidden")
+ else:
+ self.separator.add_style_class("hidden")
+
+
+class Dock(Window):
+ def __init__(self):
+ dock_config = self._get_dock_config()
+ if not data.DOCK_ENABLED:
+ super().__init__(layer="top", title="dock", anchor=dock_config["anchor"])
+ self.children = Box() # Empty dock if disabled
+ return
+
+ super().__init__(layer="top", anchor=dock_config["anchor"])
+
+ self.app_bar = AppBar(self)
+ self.revealer = Revealer(
+ child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"),
+ transition_duration=200,
+ transition_type=dock_config["transition"],
+ )
+
+ self.children = EventBox(
+ events=["enter-notify", "leave-notify"],
+ child=Box(style="min-height: 1px", children=self.revealer),
+ on_enter_notify_event=lambda *_: self.on_hover_enter(),
+ on_leave_notify_event=lambda *_: self.on_hover_leave(),
+ )
+
+ self.revealer.set_reveal_child(True)
+ self.app_bar.add_style_class("shown")
+
+ self.dock_height = 100
+ self.is_hovered = False
+ self.hide_ticket = 0
+
+ # Only setup occlusion monitoring if auto-hide is enabled
+ if data.DOCK_AUTO_HIDE:
+ self.setup_occlusion_monitoring()
+
+ def on_hover_enter(self):
+ self.is_hovered = True
+ self.hide_ticket = random.getrandbits(32)
+ self.revealer.set_reveal_child(True)
+ self.app_bar.add_style_class("shown")
+
+ def on_hover_leave(self):
+ self.is_hovered = False
+ self.hide_ticket = random.getrandbits(32)
+
+ def delayed_hide(ticket):
+ if ticket == self.hide_ticket and not self.is_hovered:
+ # Only hide if we aren't occluded (or if occlusion logic says so)
+ # For now, just trigger the basic hide if auto-hide is on
+ if data.DOCK_AUTO_HIDE:
+ # Check if actually occluded before hiding if DOCK_ALWAYS_OCCLUDED is false
+ occlusion_pos = self._get_dock_config()["occlusion"]
+ is_occluded = data.DOCK_ALWAYS_OCCLUDED or check_occlusion(
+ (occlusion_pos, self.dock_height)
+ )
+ if is_occluded:
+ self.revealer.set_reveal_child(False)
+ self.app_bar.remove_style_class("shown")
+ return False
+
+ GLib.timeout_add(500, delayed_hide, self.hide_ticket)
+
+ def _get_dock_config(self):
+ config_map = {
+ "Left": {
+ "anchor": "left center",
+ "transition": "slide-right",
+ "occlusion": "left",
+ },
+ "Right": {
+ "anchor": "right center",
+ "transition": "slide-left",
+ "occlusion": "right",
+ },
+ "Bottom": {
+ "anchor": "bottom center",
+ "transition": "slide-up",
+ "occlusion": "bottom",
+ },
+ }
+ return config_map.get(data.DOCK_POSITION, config_map["Bottom"])
+
+ def setup_occlusion_monitoring(self):
+ def check_dock_occlusion():
+ try:
+ if data.DOCK_ALWAYS_OCCLUDED:
+ is_occluded = True
+ else:
+ occlusion_pos = self._get_dock_config()["occlusion"]
+ is_occluded = check_occlusion((occlusion_pos, self.dock_height))
+
+ if (
+ is_occluded
+ and not self.is_hovered
+ and self.revealer.get_reveal_child()
+ ):
+ self.revealer.set_reveal_child(False)
+ self.app_bar.remove_style_class("shown")
+ elif not is_occluded and not self.revealer.get_reveal_child():
+ self.revealer.set_reveal_child(True)
+ self.app_bar.add_style_class("shown")
+ elif is_occluded and self.is_hovered:
+ if not self.revealer.get_reveal_child():
+ self.revealer.set_reveal_child(True)
+ self.app_bar.add_style_class("shown")
+ except Exception as e:
+ logger.error(f"[Dock] Occlusion check error: {e}")
+
+ return True
+
+ GLib.timeout_add(300, check_dock_occlusion)
diff --git a/src/window/launcher/__init__.py b/src/window/launcher/__init__.py
new file mode 100644
index 0000000..547fa73
--- /dev/null
+++ b/src/window/launcher/__init__.py
@@ -0,0 +1,8 @@
+"""
+Plugin-based launcher module for Fabric.
+Similar to Albert Launcher with extensible plugin system.
+"""
+
+from .main import Launcher
+
+__all__ = ["Launcher"]
diff --git a/src/window/launcher/main.py b/src/window/launcher/main.py
new file mode 100644
index 0000000..918d0ea
--- /dev/null
+++ b/src/window/launcher/main.py
@@ -0,0 +1,1380 @@
+from typing import List, Optional, Tuple
+
+from fabric.core.service import Property
+from fabric.utils import Gdk, GLib
+from fabric.widgets.box import Box
+from fabric.widgets.entry import Entry
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from window.launcher.plugin_manager import PluginManager
+from window.launcher.result import Result
+from window.launcher.result_item import ResultItem
+from window.launcher.trigger_config import TriggerConfig
+
+# Constants
+SEARCH_DEBOUNCE_MS = 50
+TRIGGER_SEARCH_DEBOUNCE_MS = 50
+CURSOR_POSITION_DELAY_MS = 10
+SCROLL_PADDING = 10
+DEFAULT_ITEM_HEIGHT = 68
+PAGE_NAVIGATION_STEP = 5
+LAUNCHER_WIDTH = 640
+LAUNCHER_HEIGHT = 400
+
+
+class Launcher(Window):
+ """
+ Main launcher window with search functionality and plugin system.
+ Spotlight-style interface.
+ """
+
+ # Properties
+ query = Property(str, flags="read-write", default_value="")
+ visible = Property(bool, flags="read-write", default_value=False)
+ active_trigger = Property(str, flags="read-write", default_value="")
+
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="launcher-window",
+ title="modus-launcher",
+ layer="top",
+ anchor="center",
+ exclusivity="none",
+ keyboard_mode="exclusive",
+ visible=False, # Start hidden until explicitly shown
+ **kwargs,
+ )
+ self._initializing = True
+ self._auto_adding_space = False
+ self._processing_backspace = False
+ self.plugin_manager = PluginManager()
+ self.trigger_config = TriggerConfig()
+
+ self.results: List[Result] = []
+ self.selected_index = 0
+ self.max_results = 5 # Show 4 applications by default instead of list
+
+ # Trigger system
+ self.triggered_plugin = None # Currently active triggered plugin
+ self.active_trigger = "" # Currently active trigger keyword
+ self.query = "" # Current search query
+ self.visible = False # Launcher visibility state
+ self.opened_with_trigger = False # Whether launcher was opened with a trigger
+
+ # Focus management
+ self.focus_mode = "search" # "search", "results"
+
+ # Mouse activity tracking
+ self._mouse_active = False
+ self._last_mouse_activity = 0
+ self._mouse_activity_timeout = 1000 # 1 second timeout
+ self._keyboard_used_recently = False
+ self._last_keyboard_activity = 0
+ self._keyboard_activity_timeout = 2000 # 2 second timeout
+ self._launcher_just_opened = False
+ self._mouse_interaction_delay = 500 # 500ms delay after opening
+
+ # Setup UI
+ main_box = Box(
+ name="launcher",
+ orientation="v",
+ spacing=0,
+ h_align="center",
+ v_align="center",
+ )
+ self.add(main_box)
+
+ # Search entry with Spotlight-style large text
+ self.search_entry = Entry(
+ name="launcher-search",
+ placeholder="Spotlight Search",
+ h_expand=True,
+ h_align="fill",
+ notify_text=lambda entry, *_: self._on_search_changed(entry),
+ )
+ self.search_entry.connect("changed", self._on_search_changed)
+ self.search_entry.connect("activate", self._on_entry_activate)
+
+ self.header_box = Box(
+ name="header_box",
+ spacing=10,
+ orientation="h",
+ children=[
+ self.search_entry,
+ ],
+ )
+
+ main_box.add(self.header_box)
+
+ self.results_scroll = ScrolledWindow(
+ name="launcher-results-scroll",
+ h_scrollbar_policy="never",
+ min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
+ max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
+ propagate_width=False,
+ propagate_height=False,
+ )
+
+ self.results_box = Box(
+ name="launcher-results",
+ orientation="v",
+ spacing=0,
+ )
+ self.results_scroll.add(self.results_box)
+ main_box.add(self.results_scroll)
+
+ # Keep the results container visible initially
+
+ self.connect("key-press-event", self._on_key_press)
+
+ # Connect mouse events to track activity
+ self.results_scroll.connect("button-press-event", self._on_mouse_activity)
+ self.results_scroll.connect("motion-notify-event", self._on_mouse_activity)
+ self.results_scroll.connect("scroll-event", self._on_mouse_activity)
+
+ # Also track mouse activity on the main window for better coverage
+ self.connect("button-press-event", self._on_mouse_activity)
+ self.connect("motion-notify-event", self._on_mouse_activity)
+
+ self.hide()
+
+ # Mark initialization as complete
+ self._initializing = False
+
+ # Hide trigger suggestions at startup
+ self._clear_results()
+
+ def show_launcher(self, trigger_keyword: str = None, external: bool = False):
+ """Show the launcher and focus the search entry, or execute command externally.
+
+ Args:
+ trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app")
+ external: If True, execute the command without showing the launcher UI
+ """
+ # Reset mouse activity tracking when launcher is shown
+ self._mouse_active = False
+ self._last_mouse_activity = 0
+ self._keyboard_used_recently = False
+ self._last_keyboard_activity = 0
+ self._launcher_just_opened = True
+
+ # Schedule to allow mouse interactions after a delay
+ def enable_mouse_interactions():
+ self._launcher_just_opened = False
+ return False
+
+ GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions)
+
+ if external and trigger_keyword:
+ # Execute command externally without showing launcher
+ return self._execute_external_command(trigger_keyword)
+
+ self.show_all()
+
+ if trigger_keyword:
+ # Set flag to track that launcher was opened with a trigger keyword
+ self.opened_with_trigger = True
+
+ # Set the trigger keyword with a space and activate trigger mode
+ trigger_text = f"{trigger_keyword} "
+ self.search_entry.set_text(trigger_text)
+
+ # Detect and activate the trigger
+ triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
+ if triggered_plugin:
+ self.triggered_plugin = triggered_plugin
+ self.active_trigger = detected_trigger
+
+ # Query the plugin with empty string to show default options
+ try:
+ results = triggered_plugin.query("")
+ self.results = results
+ self.selected_index = 0
+ self._update_results_display()
+ except Exception as e:
+ print(
+ f"Error querying triggered plugin {triggered_plugin.name}: {e}"
+ )
+ self._clear_results()
+ else:
+ # Trigger not found, clear and show error or fallback
+ self.search_entry.set_text("")
+ self._clear_results()
+ else:
+ # Normal launcher opening - show applications
+ self.opened_with_trigger = False
+ self.search_entry.set_text("")
+ # Trigger initial search to show applications immediately
+ self._perform_search("")
+
+ # Reset focus mode to search
+ self.focus_mode = "search"
+
+ # Focus search entry without selecting text
+ if trigger_keyword:
+ # For trigger keywords, we want the cursor at the end
+ self.search_entry.grab_focus()
+
+ def position_cursor():
+ if hasattr(self.search_entry, "set_position"):
+ self.search_entry.set_position(-1) # Move caret to end
+ return False # Only run once
+
+ GLib.idle_add(position_cursor)
+ else:
+ # For normal opening, use our method that prevents text selection
+ self._focus_search_entry_without_selection()
+
+ self.visible = True
+
+ def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None:
+ """Position cursor at the end of search entry text."""
+ if text_length is None:
+ text_length = len(self.search_entry.get_text())
+
+ def position_cursor():
+ if hasattr(self.search_entry, "set_position"):
+ self.search_entry.set_position(-1) # Move caret to end
+ if hasattr(self.search_entry, "select_region"):
+ self.search_entry.select_region(
+ text_length, text_length
+ ) # No selection
+ return False # Only run once
+
+ GLib.idle_add(position_cursor)
+
+ def _add_space_to_trigger(self, trigger_word: str) -> None:
+ """Add space after trigger keyword and position cursor."""
+ trigger_text_with_space = f"{trigger_word} "
+
+ # Temporarily disable search change handling to prevent recursion
+ self._auto_adding_space = True
+ self.search_entry.set_text(trigger_text_with_space)
+
+ # Position cursor at the end
+ def position_cursor():
+ if hasattr(self.search_entry, "set_position"):
+ self.search_entry.set_position(-1) # Move caret to end
+ if hasattr(self.search_entry, "select_region"):
+ self.search_entry.select_region(
+ len(trigger_text_with_space), len(trigger_text_with_space)
+ ) # No selection
+ self._auto_adding_space = False
+ return False # Only run once
+
+ GLib.idle_add(position_cursor)
+
+ # Update query
+ self.query = trigger_text_with_space
+ return trigger_text_with_space
+
+ def close_launcher(self):
+ """Hide the launcher and clear search."""
+ self.hide()
+ self.search_entry.set_text("")
+ self._clear_results()
+ self.triggered_plugin = None
+ self.active_trigger = ""
+ self.visible = False
+ self.opened_with_trigger = False
+
+ def _on_search_changed(self, entry):
+ """Handle search text changes."""
+ # Skip if still initializing
+ if getattr(self, "_initializing", True):
+ return
+
+ # Skip if we're automatically adding a space to prevent recursion
+ if getattr(self, "_auto_adding_space", False):
+ return
+
+ # Skip if we're processing a backspace to prevent interference
+ if getattr(self, "_processing_backspace", False):
+ return
+
+ # Reset focus to search when user types
+ if self.focus_mode != "search":
+ self.focus_mode = "search"
+
+ query = entry.get_text().strip()
+ self.query = query
+
+ # If query is exactly ':', show all triggers
+ if query == ":":
+ self._show_available_triggers()
+ # If query matches a trigger exactly (with or without space), handle trigger activation
+ elif any(
+ query == trig or query == f"{trig} "
+ for trig in [
+ t.strip()
+ for p in self.plugin_manager.get_active_plugins()
+ for t in p.get_triggers()
+ ]
+ ):
+ # Check if we need to add space immediately for exact trigger matches
+ if not query.endswith(" "):
+ # This is an exact trigger match without space - add space immediately
+ trigger_text_with_space = self._add_space_to_trigger(query)
+ GLib.timeout_add(
+ TRIGGER_SEARCH_DEBOUNCE_MS,
+ self._perform_search,
+ trigger_text_with_space,
+ )
+ else:
+ # Already has space, proceed with normal search
+ GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
+ elif query:
+ # Debounce search to avoid too many queries
+ GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
+ else:
+ # Show applications when query is empty
+ self._perform_search("")
+
+ def _perform_search(self, query: str) -> bool:
+ """Perform search across all plugins."""
+ # Only search if query hasn't changed
+ if query != self.query:
+ return False
+
+ if not query:
+ # Empty query - show popular applications
+ self.triggered_plugin = None
+ self.active_trigger = ""
+
+ # Get applications plugin and show popular apps
+ applications_plugin = self._get_applications_plugin()
+ if applications_plugin:
+ try:
+ # Query with empty string to get popular/frequently used applications
+ all_results = applications_plugin.query("")
+ # Limit to max_results for empty query
+ self.results = all_results[: self.max_results]
+ self.selected_index = 0
+ self._update_results_display()
+ except Exception as e:
+ print(f"Error getting applications: {e}")
+ self._clear_results()
+ else:
+ self._clear_results()
+ return False
+
+ # Check if we're already in trigger mode
+ if self.triggered_plugin and self.active_trigger:
+ # We're in trigger mode - search within the triggered plugin
+ try:
+ # Extract the search query after the trigger
+ remaining_query = self._extract_query_after_trigger(
+ query, self.active_trigger
+ )
+ all_results = self.triggered_plugin.query(remaining_query)
+ except Exception as e:
+ print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}")
+ all_results = []
+ else:
+ # Check for trigger activation
+ triggered_plugin, trigger = self._detect_trigger(query)
+
+ if triggered_plugin:
+ # New trigger detected - check if we need to add space automatically
+ trigger_word = trigger.strip()
+ current_text = self.search_entry.get_text()
+
+ # If the current text is exactly the trigger word (no space), add space automatically
+ if (
+ current_text.strip() == trigger_word
+ and not current_text.endswith(" ")
+ and not getattr(self, "_auto_adding_space", False)
+ ):
+ query = self._add_space_to_trigger(trigger_word)
+
+ # Enter trigger mode
+ self.triggered_plugin = triggered_plugin
+ self.active_trigger = trigger
+
+ # Extract search query after trigger
+ remaining_query = self._extract_query_after_trigger(query, trigger)
+
+ # Always call the plugin's query method, even with empty remaining query
+ # This allows plugins to show default options when just the trigger is typed
+ try:
+ all_results = triggered_plugin.query(remaining_query)
+ except Exception as e:
+ print(f"Error in triggered plugin {triggered_plugin.name}: {e}")
+ all_results = []
+ else:
+ # No trigger detected - search applications and show trigger suggestions
+ self.triggered_plugin = None
+ self.active_trigger = ""
+
+ all_results = []
+
+ # Search applications directly without trigger
+ applications_plugin = self._get_applications_plugin()
+ if applications_plugin:
+ try:
+ app_results = applications_plugin.query(query)
+ all_results.extend(app_results)
+ except Exception as e:
+ print(f"Error searching applications: {e}")
+
+ # Also show trigger suggestions if query matches trigger prefixes
+ trigger_suggestions = self._get_trigger_suggestions(query)
+ all_results.extend(trigger_suggestions)
+
+ # Sort results by relevance score
+ all_results.sort(key=lambda r: r.relevance, reverse=True)
+
+ # Check if any results have bypass_max_results flag
+ has_bypass = any(
+ hasattr(r, "data") and r.data and r.data.get("bypass_max_results")
+ for r in all_results
+ )
+
+ # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions
+ if self.triggered_plugin and self.active_trigger:
+ # In trigger mode - show all results from the triggered plugin
+ self.results = all_results
+ elif not has_bypass:
+ # Global search - check if it's an application search
+ applications_plugin = self._get_applications_plugin()
+ is_application_search = applications_plugin and any(
+ hasattr(r, "plugin_name") and r.plugin_name == "Applications"
+ for r in all_results
+ )
+
+ if is_application_search:
+ # Don't limit application search results
+ self.results = all_results
+ else:
+ # Apply max_results limit for other global searches and trigger suggestions
+ self.results = all_results[: self.max_results]
+ else:
+ # Has bypass flag - show all results
+ self.results = all_results
+ self.selected_index = 0
+
+ # Update UI
+ self._update_results_display()
+
+ return False # Don't repeat timeout
+
+ def _extract_query_after_trigger(self, query: str, trigger: str) -> str:
+ """
+ Extract the search query after removing the trigger.
+
+ Args:
+ query: The full query string
+ trigger: The trigger keyword
+
+ Returns:
+ The remaining query after the trigger
+ """
+ if not query or not trigger:
+ return ""
+
+ query_lower = query.lower()
+ trigger_lower = trigger.lower()
+
+ # Handle trigger with space (e.g., "app ")
+ if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower):
+ return query[len(trigger) :].strip()
+
+ # Handle trigger without space (e.g., "app")
+ trigger_word = trigger.strip().lower()
+ if query_lower.startswith(trigger_word):
+ # Check if it's followed by space or end of string
+ if len(query) == len(trigger_word):
+ return ""
+ elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ":
+ return query[len(trigger_word) + 1 :].strip()
+ elif len(query) > len(trigger_word):
+ # No space after trigger word, extract rest
+ return query[len(trigger_word) :].strip()
+
+ return ""
+
+ def _show_available_triggers(self):
+ """Show available triggers when launcher is first opened."""
+ trigger_suggestions = self._get_trigger_suggestions("")
+ self.results = trigger_suggestions # Show all available triggers without limit
+ self.selected_index = 0
+ self._update_results_display()
+
+ def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]:
+ """
+ Detect if query starts with a trigger keyword.
+
+ Args:
+ query: The search query
+
+ Returns:
+ Tuple of (plugin, trigger) if triggered, (None, "") otherwise
+ """
+ if not query.strip():
+ return None, ""
+
+ # Check all plugins for triggers
+ for plugin in self.plugin_manager.get_active_plugins():
+ trigger = plugin.get_active_trigger(query)
+ if trigger:
+ return plugin, trigger
+
+ return None, ""
+
+ def _get_applications_plugin(self):
+ """Get the applications plugin instance."""
+ for plugin in self.plugin_manager.get_active_plugins():
+ if (
+ hasattr(plugin, "display_name")
+ and plugin.display_name == "Applications"
+ ):
+ return plugin
+ return None
+
+ def _get_trigger_suggestions(self, query: str) -> List[Result]:
+ """
+ Get trigger suggestions based on the current query.
+
+ Args:
+ query: The search query
+
+ Returns:
+ List of Result objects showing available triggers
+ """
+ suggestions = []
+ query_lower = query.lower().strip()
+
+ # Get all available triggers from plugins
+ all_triggers = {}
+ for plugin in self.plugin_manager.get_active_plugins():
+ triggers = plugin.get_triggers()
+ for trigger in triggers:
+ trigger_clean = trigger.strip()
+ if trigger_clean not in all_triggers:
+ all_triggers[trigger_clean] = {
+ "plugin": plugin,
+ "trigger": trigger,
+ "examples": [],
+ }
+
+ # Get max examples to show from configuration
+ max_examples = self.trigger_config.settings.get("max_examples_shown", 2)
+
+ # Show trigger suggestions based on query
+ if query_lower:
+ # Show triggers that match the query
+ for trigger_clean, _ in all_triggers.items():
+ if trigger_clean.lower().startswith(query_lower):
+ result = self._create_trigger_result(trigger_clean, max_examples)
+ suggestions.append(result)
+ else:
+ # Empty query - show all available triggers
+ for trigger_clean, _ in all_triggers.items():
+ result = self._create_trigger_result(trigger_clean, max_examples)
+ suggestions.append(result)
+
+ return suggestions # Return all trigger suggestions without limit
+
+ def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result:
+ """Create a Result object for a trigger suggestion."""
+ examples = self.trigger_config.get_trigger_examples(trigger_clean)
+ icon_name = self.trigger_config.get_trigger_icon(trigger_clean)
+ description = self.trigger_config.get_trigger_description(trigger_clean)
+
+ return Result(
+ title=f"{trigger_clean}",
+ subtitle=f"{description} - {', '.join(examples[:max_examples])}",
+ icon_name=icon_name,
+ action=lambda t=trigger_clean: self._activate_trigger(t),
+ # Shorter triggers get higher relevance
+ relevance=100 - len(trigger_clean),
+ data={"type": "trigger_suggestion", "trigger": trigger_clean},
+ )
+
+ def _activate_trigger(self, trigger: str):
+ """
+ Activate a trigger by setting it in the search entry.
+
+ Args:
+ trigger: The trigger keyword to activate
+ """
+ # Set the trigger text in the search entry
+ trigger_text = f"{trigger} "
+ self.search_entry.set_text(trigger_text)
+ self.search_entry.grab_focus()
+
+ def clear_selection():
+ if hasattr(self.search_entry, "set_position"):
+ self.search_entry.set_position(-1) # Move caret to end
+ if hasattr(self.search_entry, "select_region"):
+ self.search_entry.select_region(
+ len(trigger_text), len(trigger_text)
+ ) # No selection
+ return False # Only run once
+
+ GLib.idle_add(clear_selection)
+
+ # Manually set the trigger mode to avoid search processing issues
+ triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
+ if triggered_plugin:
+ self.triggered_plugin = triggered_plugin
+ self.active_trigger = detected_trigger
+
+ # Clear results and show trigger ready state
+ self.results = []
+ self.selected_index = 0
+ self._update_results_display()
+
+ # Focus back to the search entry for immediate typing
+ self.search_entry.grab_focus()
+
+ # Don't hide the launcher - user should be able to continue typing
+
+ def _execute_external_command(self, command_string: str):
+ """Execute a command externally without showing the launcher UI.
+
+ Args:
+ command_string: Full command string (e.g., "wall random", "calc 2+2")
+
+ Returns:
+ Result of the command execution or None if failed
+ """
+ try:
+ # Parse the command to extract trigger and query
+ parts = command_string.strip().split(" ", 1)
+ if not parts:
+ return None
+
+ trigger_part = parts[0]
+ query_part = parts[1] if len(parts) > 1 else ""
+
+ # Find the plugin that handles this trigger
+ triggered_plugin = None
+
+ for plugin in self.plugin_manager.get_active_plugins():
+ trigger = plugin.get_active_trigger(f"{trigger_part} ")
+ if trigger:
+ triggered_plugin = plugin
+ break
+
+ if not triggered_plugin:
+ print(f"No plugin found for trigger: {trigger_part}")
+ return None
+
+ # Query the plugin with the remaining query
+ try:
+ results = triggered_plugin.query(query_part)
+ if not results:
+ print(f"No results found for query: {query_part}")
+ return None
+
+ # Find the first result that matches the query exactly or has highest relevance
+ best_result = None
+ for result in results:
+ # For exact matches like "random", execute immediately
+ if (
+ hasattr(result, "data")
+ and result.data
+ and result.data.get("action") == query_part.strip()
+ ):
+ best_result = result
+ break
+ # For partial matches, take the first high-relevance result
+ elif not best_result and result.relevance >= 0.9:
+ best_result = result
+
+ # If no exact match, take the first result
+ if not best_result and results:
+ best_result = results[0]
+
+ if best_result:
+ # Execute the result action
+ try:
+ result_value = best_result.activate()
+ print(f"External command executed: {command_string}")
+ return result_value
+ except Exception as e:
+ print(f"Error executing result action: {e}")
+ return None
+ else:
+ print(f"No suitable result found for: {command_string}")
+ return None
+
+ except Exception as e:
+ print(f"Error querying plugin {triggered_plugin.name}: {e}")
+ return None
+
+ except Exception as e:
+ print(f"Error executing external command '{command_string}': {e}")
+ return None
+
+ def _update_results_display(self):
+ """Update the results display."""
+ # Skip if still initializing or results_box not ready
+ if getattr(self, "_initializing", True) or not hasattr(self, "results_box"):
+ return
+
+ # Update input field with trigger indication (Spotlight-style)
+ self._update_input_action_text()
+
+ # Clear existing results
+ for child in self.results_box.get_children():
+ self.results_box.remove(child)
+
+ # Add new results
+ for i, result in enumerate(self.results):
+ # Check if this result has a custom widget
+ if result.custom_widget:
+ # Ensure the widget is not already parented
+ parent = result.custom_widget.get_parent()
+ if parent:
+ parent.remove(result.custom_widget)
+
+ result.custom_widget.show_all() # Ensure widget is visible
+ self.results_box.add(result.custom_widget)
+ else:
+ # Create normal result item
+ result_item = ResultItem(
+ result=result, selected=(i == self.selected_index), index=i
+ )
+ result_item.clicked.connect(
+ lambda _, idx=i: self._on_result_clicked(result_item, idx)
+ )
+ result_item.hovered.connect(
+ lambda _, idx=i: self._on_result_hovered(idx)
+ )
+ self.results_box.add(result_item)
+
+ self.results_box.show_all()
+
+ self.results_scroll.show()
+
+ def _update_input_action_text(self):
+ """Update the input field with action text (Spotlight-style)."""
+ # Check if search_entry is initialized
+ if not hasattr(self, "search_entry") or self.search_entry is None:
+ return
+
+ # Get the current input text
+ current_text = self.search_entry.get_text()
+
+ # Spotlight-style placeholder text
+ if self.triggered_plugin:
+ if self.results:
+ # Show more minimal placeholder for triggered mode
+ self.search_entry.set_placeholder_text(
+ f"Search {self.active_trigger.strip()}..."
+ )
+ else:
+ # In trigger mode but no results yet
+ if current_text == self.active_trigger.strip():
+ # Just the trigger keyword
+ self.search_entry.set_placeholder_text(
+ f"Search {self.active_trigger.strip()}..."
+ )
+ else:
+ # Searching within trigger
+ self.search_entry.set_placeholder_text(
+ f"Searching {self.active_trigger.strip()}..."
+ )
+ else:
+ # Not in trigger mode - show Spotlight-style help
+ if current_text == ":":
+ self.search_entry.set_placeholder_text("Available search triggers")
+ else:
+ self.search_entry.set_placeholder_text("Spotlight Search")
+
+ def _clear_results(self):
+ """Clear all results."""
+ self.results = []
+ self.selected_index = 0
+ for child in self.results_box.get_children():
+ self.results_box.remove(child)
+ # Keep the results scroll visible even when empty
+
+ def _handle_escape_key(self) -> bool:
+ """Handle escape key press."""
+ # First check if there's a password entry widget that should handle Escape
+ password_entry_widget = self._find_password_entry_widget()
+ if password_entry_widget:
+ # Cancel the password entry
+ password_entry_widget.cancel_password_entry()
+ return True
+ elif self.opened_with_trigger:
+ # If launcher was opened with a trigger keyword, close directly
+ self.close_launcher()
+ return True
+ else:
+ # Hide launcher
+ self.close_launcher()
+ return True
+
+ def _handle_backspace_key(self) -> bool:
+ """Handle backspace key press in trigger mode."""
+ if self.triggered_plugin:
+ trigger_text = self.active_trigger.strip()
+ current_text = self.search_entry.get_text()
+
+ # Set flag to prevent search change handler from interfering
+ self._processing_backspace = True
+
+ # Allow normal backspace behavior first, then check if we need to exit trigger mode
+ # Don't intercept the backspace - let GTK handle it normally
+
+ # Schedule a check after the backspace is processed
+ def check_trigger_after_backspace():
+ try:
+ # Get the text after backspace has been processed
+ new_text = self.search_entry.get_text()
+
+ # Check if we should exit trigger mode
+ # We need to check if the text still matches the trigger pattern
+ should_exit_trigger = False
+
+ # If the active trigger ends with a space (like "calc "),
+ # we should exit if the text doesn't contain that space anymore
+ if self.active_trigger.endswith(" "):
+ # For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc "
+ trigger_with_space = self.active_trigger.lower()
+ if not new_text.lower().startswith(trigger_with_space):
+ should_exit_trigger = True
+ else:
+ # For triggers without space, exit if text doesn't start with trigger
+ if not new_text.lower().startswith(trigger_text.lower()):
+ should_exit_trigger = True
+
+ if should_exit_trigger:
+ self.triggered_plugin = None
+ self.active_trigger = ""
+ self._clear_results()
+ # Don't clear the text - let the user's edit stand
+
+ # If we're still in trigger mode but the text changed, update the search
+ elif self.triggered_plugin and new_text != current_text:
+ # Trigger a search with the new text
+ self.query = new_text
+ GLib.timeout_add(50, self._perform_search, new_text)
+
+ finally:
+ # Clear the backspace processing flag
+ self._processing_backspace = False
+
+ return False # Don't repeat
+
+ # Use idle_add to check after the backspace is processed
+ GLib.idle_add(check_trigger_after_backspace)
+
+ # Allow the normal backspace to proceed
+ return False
+
+ # Let normal backspace behavior continue for other cases
+ return False
+
+ def _on_key_press(self, _widget, event):
+ """Handle key press events."""
+ # Track keyboard activity
+ import time
+
+ current_time = int(time.time() * 1000)
+ self._last_keyboard_activity = current_time
+ self._keyboard_used_recently = True
+
+ keyval = event.keyval
+
+ # Escape - handle password entry, exit trigger mode, or hide launcher
+ if keyval == Gdk.KEY_Escape:
+ return self._handle_escape_key()
+
+ # Backspace - handle trigger mode backspace behavior
+ if keyval == Gdk.KEY_BackSpace:
+ return self._handle_backspace_key()
+
+ # Up/Down - navigate results (alternative to Tab)
+ if keyval == Gdk.KEY_Up:
+ if self.focus_mode == "results" and self.results:
+ if self.selected_index > 0:
+ # Move to previous result
+ self.selected_index -= 1
+ self._update_selection()
+ else:
+ # At first result, go back to search entry
+ self.focus_mode = "search"
+ self._focus_search_entry_without_selection()
+ elif self.results:
+ # If not in results mode but have results, enter results mode at last item
+ self.focus_mode = "results"
+ self.selected_index = len(self.results) - 1
+ self._update_selection()
+ return True
+
+ if keyval == Gdk.KEY_Down:
+ if self.focus_mode == "results" and self.results:
+ if self.selected_index < len(self.results) - 1:
+ # Move to next result
+ self.selected_index += 1
+ self._update_selection()
+ else:
+ # At last result, wrap around to first result
+ self.selected_index = 0
+ self._update_selection()
+ elif self.results:
+ # If not in results mode but have results, enter results mode at first item
+ self.focus_mode = "results"
+ self.selected_index = 0
+ self._update_selection()
+ return True
+
+ # Enter - activate selected result
+ if keyval == Gdk.KEY_Return:
+ # Check for Shift+Enter for alternative actions
+ if event.state & Gdk.ModifierType.SHIFT_MASK:
+ if self.results and 0 <= self.selected_index < len(self.results):
+ result = self.results[self.selected_index]
+ if result.data:
+ # Check for generic alternative action first
+ if result.data.get("alt_action"):
+ result.data["alt_action"]()
+ return True
+ # Fallback to pin_action for backward compatibility
+ elif result.data.get("pin_action"):
+ result.data["pin_action"]()
+ return True
+
+ # Check if the selected result has a custom widget with Entry fields
+ if self.results and 0 <= self.selected_index < len(self.results):
+ result = self.results[self.selected_index]
+ if result.custom_widget:
+ # Check if the custom widget contains Entry widgets that should handle Enter
+ if self._custom_widget_has_entry(result.custom_widget):
+ # Let the custom widget handle the Enter key
+ # Find the focused Entry widget and trigger its activate signal
+ focused_entry = self._find_focused_entry_in_widget(
+ result.custom_widget
+ )
+ if focused_entry:
+ focused_entry.emit("activate")
+ return True
+
+ # Normal Enter behavior
+ self._activate_selected()
+ return True
+
+ # Tab - cycle through focus areas and results
+ if keyval == Gdk.KEY_Tab:
+ if event.state & Gdk.ModifierType.SHIFT_MASK:
+ # Shift+Tab - reverse direction
+ if self.focus_mode == "results":
+ # Navigate through results in reverse
+ self._navigate_results_backward()
+ else:
+ self._cycle_focus_backward()
+ else:
+ # Tab - forward direction
+ if self.focus_mode == "results":
+ # Navigate through results forward
+ self._navigate_results_forward()
+ else:
+ self._cycle_focus_forward()
+ return True
+
+ # Page Up/Page Down - navigate results faster
+ if keyval == Gdk.KEY_Page_Up:
+ if self.results:
+ self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP)
+ self._update_selection()
+ return True
+
+ if keyval == Gdk.KEY_Page_Down:
+ if self.results:
+ self.selected_index = min(
+ len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP
+ )
+ self._update_selection()
+ return True
+
+ # Home/End - go to first/last result
+ if keyval == Gdk.KEY_Home:
+ if self.results:
+ self.selected_index = 0
+ self._update_selection()
+ return True
+
+ if keyval == Gdk.KEY_End:
+ if self.results:
+ self.selected_index = len(self.results) - 1
+ self._update_selection()
+ return True
+
+ # Forward other keys to custom widgets if they can handle them
+ if self.results and 0 <= self.selected_index < len(self.results):
+ result = self.results[self.selected_index]
+ if result.custom_widget and hasattr(result.custom_widget, "on_key_press"):
+ # Try to forward the key event to the custom widget
+ if result.custom_widget.on_key_press(result.custom_widget, event):
+ return True
+
+ return False
+
+ def _custom_widget_has_entry(self, widget):
+ """Check if a custom widget contains Entry widgets."""
+
+ if isinstance(widget, Entry):
+ return True
+
+ # Check children recursively
+ if hasattr(widget, "get_children"):
+ for child in widget.get_children():
+ if self._custom_widget_has_entry(child):
+ return True
+
+ return False
+
+ def _find_focused_entry_in_widget(self, widget):
+ """Find the focused Entry widget within a custom widget."""
+
+ if isinstance(widget, Entry):
+ # Try multiple ways to check if this Entry is focused
+ try:
+ if widget.has_focus() or widget.is_focus():
+ return widget
+ # Also check if this is the only Entry in the widget (likely to be the target)
+ return widget
+ except Exception:
+ # If focus checking fails, assume this Entry should handle the event
+ return widget
+
+ # Check children recursively
+ if hasattr(widget, "get_children"):
+ for child in widget.get_children():
+ focused_entry = self._find_focused_entry_in_widget(child)
+ if focused_entry:
+ return focused_entry
+
+ return None
+
+ def _find_password_entry_widget(self):
+ """Find a NetworkPasswordEntry widget in the current results."""
+ for result in self.results:
+ if result.custom_widget:
+ # Check if this is a NetworkPasswordEntry widget
+ if (
+ hasattr(result.custom_widget, "__class__")
+ and result.custom_widget.__class__.__name__
+ == "NetworkPasswordEntry"
+ ):
+ return result.custom_widget
+ # Also check if it has the cancel_password_entry method (duck typing)
+ elif hasattr(result.custom_widget, "cancel_password_entry"):
+ return result.custom_widget
+ return None
+
+ def _on_entry_activate(self, _entry):
+ """Handle entry activation (Enter key)."""
+ self._activate_selected()
+
+ def _on_result_clicked(self, _result_item, index):
+ """Handle result item click."""
+ # Only allow clicks when mouse is active or keyboard hasn't been used recently
+ if self._should_allow_mouse_interaction():
+ self.selected_index = index
+ self._activate_selected()
+
+ def _on_result_hovered(self, index):
+ """Handle result item hover."""
+ # Only allow hover selection when mouse is active and keyboard hasn't been used recently
+ if self._should_allow_mouse_interaction():
+ if 0 <= index < len(self.results):
+ self.selected_index = index
+ self.focus_mode = "results" # Switch to results focus mode
+ self._update_selection_visual_only()
+
+ def _on_mouse_activity(self, widget, event):
+ """Track mouse activity to determine when mouse interactions should be enabled."""
+ import time
+
+ current_time = int(time.time() * 1000)
+ self._last_mouse_activity = current_time
+ self._mouse_active = True
+
+ # Schedule a check to disable mouse activity after timeout
+ def check_mouse_timeout():
+ if current_time - self._last_mouse_activity >= self._mouse_activity_timeout:
+ self._mouse_active = False
+ return False
+
+ GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout)
+ return False
+
+ def _should_allow_mouse_interaction(self):
+ """Determine if mouse interactions should be allowed."""
+ import time
+
+ current_time = int(time.time() * 1000)
+
+ # Don't allow mouse interactions immediately after launcher opens
+ if self._launcher_just_opened:
+ return False
+
+ # Allow mouse interaction if:
+ # 1. Mouse is currently active (recent activity)
+ # 2. OR keyboard hasn't been used recently (2 seconds)
+ mouse_recently_active = (
+ current_time - self._last_mouse_activity
+ ) < self._mouse_activity_timeout
+ keyboard_not_recently_used = (
+ current_time - self._last_keyboard_activity
+ ) > self._keyboard_activity_timeout
+
+ return mouse_recently_active or keyboard_not_recently_used
+
+ def _is_mouse_over_results(self):
+ """Check if mouse is over the results area."""
+ # Simple check - if we have results visible, assume user might be interacting
+ return len(self.results) > 0 and self.results_scroll.get_visible()
+
+ def _update_selection(self):
+ """Update the visual selection of results with scrolling (for keyboard navigation)."""
+ children = self.results_box.get_children()
+ selected_widget = None
+
+ for i, child in enumerate(children):
+ if isinstance(child, ResultItem):
+ is_selected = i == self.selected_index
+ child.set_selected(is_selected)
+ if is_selected:
+ selected_widget = child
+ # For custom widgets, we don't need to handle selection visually
+ # since they manage their own interaction
+
+ # Focus custom widgets when selected for keyboard interaction
+ if self.results and 0 <= self.selected_index < len(self.results):
+ result = self.results[self.selected_index]
+ if result.custom_widget and result.custom_widget.get_can_focus():
+ # Give focus to the custom widget for keyboard interaction
+ result.custom_widget.grab_focus()
+
+ # Scroll to make the selected item visible
+ if selected_widget and self.results_scroll.get_visible():
+ # Use immediate scrolling for better responsiveness
+ self._scroll_to_widget(selected_widget)
+ # Also schedule a more accurate scroll after layout is complete
+ GLib.idle_add(self._ensure_selected_visible)
+
+ def _update_selection_visual_only(self):
+ """Update the visual selection of results without scrolling (for mouse hover)."""
+ children = self.results_box.get_children()
+
+ for i, child in enumerate(children):
+ if isinstance(child, ResultItem):
+ is_selected = i == self.selected_index
+ child.set_selected(is_selected)
+ # For custom widgets, we don't need to handle selection visually
+ # since they manage their own interaction
+
+ # Focus custom widgets when selected for keyboard interaction
+ if self.results and 0 <= self.selected_index < len(self.results):
+ result = self.results[self.selected_index]
+ if result.custom_widget and result.custom_widget.get_can_focus():
+ # Give focus to the custom widget for keyboard interaction
+ result.custom_widget.grab_focus()
+
+ def _scroll_to_widget(self, widget):
+ """Scroll the results container to make the widget visible."""
+ if not widget or not self.results_scroll.get_visible():
+ return
+
+ # Debug output (can be removed in production)
+ # print(f"Scrolling to selected index: {self.selected_index}")
+
+ # Get the scrolled window's vertical adjustment
+ vadjustment = self.results_scroll.get_vadjustment()
+ if not vadjustment:
+ return
+
+ # Use a simpler approach: scroll to the selected item index
+ if self.results and 0 <= self.selected_index < len(self.results):
+ # Get all children to work with actual widgets
+ children = self.results_box.get_children()
+ if not children or self.selected_index >= len(children):
+ return
+
+ # Get the selected child widget
+ selected_child = children[self.selected_index]
+
+ # Try to get actual allocation, fallback to estimation
+ try:
+ allocation = selected_child.get_allocation()
+ item_height = allocation.height if allocation.height > 0 else 68
+ item_y = allocation.y
+ except Exception:
+ # Fallback to estimation
+ item_height = DEFAULT_ITEM_HEIGHT
+ item_y = self.selected_index * item_height
+
+ # Get current scroll info
+ current_scroll = vadjustment.get_value()
+ page_size = vadjustment.get_page_size()
+ max_scroll = vadjustment.get_upper() - page_size
+
+ # Calculate visible area
+ visible_top = current_scroll
+ visible_bottom = current_scroll + page_size
+
+ # Check if selected item is visible
+ item_top = item_y
+ item_bottom = item_y + item_height
+
+ # Add some padding for better visibility
+ # Scroll if needed
+ if item_top < visible_top + SCROLL_PADDING:
+ # Item is above visible area - scroll up
+ new_scroll = max(0, item_top - SCROLL_PADDING)
+ vadjustment.set_value(new_scroll)
+ elif item_bottom > visible_bottom - SCROLL_PADDING:
+ # Item is below visible area - scroll down
+ new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING)
+ vadjustment.set_value(new_scroll)
+
+ def _ensure_selected_visible(self):
+ """Alternative method to ensure selected item is visible using GTK methods."""
+ if (
+ not self.results
+ or self.selected_index < 0
+ or self.selected_index >= len(self.results)
+ ):
+ return False
+
+ children = self.results_box.get_children()
+ if self.selected_index < len(children):
+ selected_child = children[self.selected_index]
+
+ # Try to use widget's allocation for more accurate scrolling
+ try:
+ allocation = selected_child.get_allocation()
+ if allocation.height > 0:
+ vadjustment = self.results_scroll.get_vadjustment()
+ if vadjustment:
+ # Calculate the position to center the selected item
+ page_size = vadjustment.get_page_size()
+ target_pos = (
+ allocation.y - (page_size / 2) + (allocation.height / 2)
+ )
+ target_pos = max(
+ 0, min(target_pos, vadjustment.get_upper() - page_size)
+ )
+ vadjustment.set_value(target_pos)
+ except Exception as e:
+ print(f"Error in _ensure_selected_visible: {e}")
+
+ return False # Don't repeat the idle callback
+
+ def _cycle_focus_forward(self):
+ """Cycle focus forward: search -> results"""
+ if self.focus_mode == "search":
+ if self.results:
+ self.focus_mode = "results"
+ self._update_selection()
+
+ def _cycle_focus_backward(self):
+ """Cycle focus backward: results -> search"""
+ if self.focus_mode == "results":
+ self.focus_mode = "search"
+ self._focus_search_entry_without_selection()
+
+ def _focus_search_entry_without_selection(self):
+ """Focus search entry and position cursor at end without selecting text."""
+ # First grab focus
+ self.search_entry.grab_focus()
+
+ # Use multiple approaches to prevent text selection
+ def clear_selection():
+ try:
+ text_length = len(self.search_entry.get_text())
+
+ # Method 1: Set position and clear selection
+ if hasattr(self.search_entry, "set_position"):
+ self.search_entry.set_position(text_length)
+ if hasattr(self.search_entry, "select_region"):
+ self.search_entry.select_region(text_length, text_length)
+
+ # Method 2: Try to access underlying GTK widget
+ try:
+ # For fabric Entry widgets, try to get the actual GTK Entry
+ if hasattr(self.search_entry, "_entry"):
+ gtk_entry = self.search_entry._entry
+ elif (
+ hasattr(self.search_entry, "get_children")
+ and self.search_entry.get_children()
+ ):
+ gtk_entry = self.search_entry.get_children()[0]
+ else:
+ gtk_entry = self.search_entry
+
+ if hasattr(gtk_entry, "set_position"):
+ gtk_entry.set_position(text_length)
+ if hasattr(gtk_entry, "select_region"):
+ gtk_entry.select_region(text_length, text_length)
+
+ except Exception:
+ pass
+
+ except Exception as e:
+ print(f"Could not clear selection: {e}")
+ return False
+
+ # Schedule clearing selection after focus is established
+ GLib.idle_add(clear_selection)
+ # Also try with a small delay as backup
+ GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection)
+
+ def _navigate_results_forward(self):
+ """Navigate to next result or wrap around to first result."""
+ if self.results and self.selected_index < len(self.results) - 1:
+ # Move to next result
+ self.selected_index += 1
+ self._update_selection()
+ else:
+ # At last result, wrap around to first result
+ self.selected_index = 0
+ self._update_selection()
+
+ def _navigate_results_backward(self):
+ """Navigate to previous result or exit results mode."""
+ if self.results and self.selected_index > 0:
+ # Move to previous result
+ self.selected_index -= 1
+ self._update_selection()
+ else:
+ # Exit results mode and go to search
+ self.focus_mode = "search"
+ self._focus_search_entry_without_selection()
+
+ def _activate_selected(self):
+ """Activate the currently selected result."""
+ if self.results and 0 <= self.selected_index < len(self.results):
+ result = self.results[self.selected_index]
+ try:
+ # Check if this result has a custom widget
+ if result.custom_widget:
+ # For custom widgets, we don't activate them since they're already displayed
+ # The widget handles its own interactions
+ return
+
+ # Check if this is a trigger suggestion or should keep launcher open
+ is_trigger_suggestion = (
+ result.data and result.data.get("type") == "trigger_suggestion"
+ )
+ keep_launcher_open = result.data and result.data.get(
+ "keep_launcher_open", False
+ )
+
+ # Activate the result
+ result.activate()
+
+ # Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag
+ if not is_trigger_suggestion and not keep_launcher_open:
+ self.close_launcher()
+ # For trigger suggestions and keep_launcher_open actions, the launcher stays open
+ except Exception as e:
+ print(f"Error activating result: {e}")
diff --git a/src/window/launcher/plugin_base.py b/src/window/launcher/plugin_base.py
new file mode 100644
index 0000000..f0898ec
--- /dev/null
+++ b/src/window/launcher/plugin_base.py
@@ -0,0 +1,172 @@
+from abc import ABC, abstractmethod
+from typing import List
+
+from window.launcher.result import Result
+
+
+class PluginBase(ABC):
+ """
+ Abstract base class for launcher plugins.
+ All plugins must inherit from this class.
+ """
+
+ def __init__(self):
+ self.name = self.__class__.__name__.lower()
+ self.display_name = self.__class__.__name__
+ self.description = "A launcher plugin"
+ self.version = "1.0.0"
+ self.enabled = True
+ self._triggers = [] # List of trigger keywords
+
+ @abstractmethod
+ def initialize(self):
+ """
+ Initialize the plugin.
+ Called when the plugin is activated.
+ """
+ pass
+
+ @abstractmethod
+ def cleanup(self):
+ """
+ Cleanup the plugin.
+ Called when the plugin is deactivated.
+ """
+ pass
+
+ @abstractmethod
+ def query(self, query_string: str) -> List[Result]:
+ """
+ Process a search query and return results.
+
+ Args:
+ query_string: The search query from the user
+
+ Returns:
+ List of Result objects
+ """
+ pass
+
+ def get_triggers(self) -> List[str]:
+ """
+ Get list of trigger keywords for this plugin.
+ If the query starts with any of these, this plugin gets priority.
+
+ Returns:
+ List of trigger strings (e.g., ["calc", "=", "math"])
+ """
+ return self._triggers
+
+ def set_triggers(self, triggers: List[str]):
+ """
+ Set the trigger keywords for this plugin.
+
+ Args:
+ triggers: List of trigger keywords
+ """
+ self._triggers = triggers
+
+ def handles_query(self, query_string: str) -> bool:
+ """
+ Check if this plugin should handle the given query.
+
+ Args:
+ query_string: The search query
+
+ Returns:
+ True if this plugin should process the query
+ """
+ if not self.enabled:
+ return False
+
+ # Check triggers
+ triggers = self.get_triggers()
+ if triggers:
+ query_lower = query_string.lower().strip()
+ return any(query_lower.startswith(trigger.lower()) for trigger in triggers)
+
+ # Default: handle all queries
+ return True
+
+ def get_active_trigger(self, query_string: str) -> str:
+ """
+ Get the active trigger for the given query.
+
+ Args:
+ query_string: The search query
+
+ Returns:
+ The trigger keyword if found, empty string otherwise
+ """
+ if not self.enabled:
+ return ""
+
+ triggers = self.get_triggers()
+ if triggers:
+ query_lower = query_string.lower().strip()
+
+ # Sort triggers by length (longest first) to match more specific triggers first
+ sorted_triggers = sorted(triggers, key=len, reverse=True)
+
+ for trigger in sorted_triggers:
+ trigger_lower = trigger.lower()
+
+ # Exact match with trigger (including space if present)
+ if query_lower.startswith(trigger_lower):
+ return trigger
+
+ # Match trigger word followed by space
+ trigger_word = trigger.strip().lower()
+ if (
+ query_lower.startswith(trigger_word + " ")
+ or query_lower == trigger_word
+ ):
+ return trigger
+
+ return ""
+
+ def query_triggered(self, query_string: str, trigger: str) -> List[Result]:
+ """
+ Process a triggered query (when plugin is in sticky mode).
+ Default implementation removes trigger and calls query().
+
+ Args:
+ query_string: The full query string including trigger
+ trigger: The trigger that activated this plugin
+
+ Returns:
+ List of Result objects
+ """
+ # Remove trigger from query and process remaining text
+ remaining_query = query_string[len(trigger) :].strip()
+ return self.query(remaining_query)
+
+ def get_config(self) -> dict:
+ """
+ Get plugin configuration.
+
+ Returns:
+ Dictionary of configuration options
+ """
+ return {
+ "name": self.name,
+ "display_name": self.display_name,
+ "description": self.description,
+ "version": self.version,
+ "enabled": self.enabled,
+ }
+
+ def set_config(self, config: dict):
+ """
+ Set plugin configuration.
+
+ Args:
+ config: Dictionary of configuration options
+ """
+ self.enabled = config.get("enabled", self.enabled)
+
+ def __str__(self):
+ return f"Plugin({self.name})"
+
+ def __repr__(self):
+ return self.__str__()
diff --git a/src/window/launcher/plugin_manager.py b/src/window/launcher/plugin_manager.py
new file mode 100644
index 0000000..e84add7
--- /dev/null
+++ b/src/window/launcher/plugin_manager.py
@@ -0,0 +1,183 @@
+import importlib
+import importlib.util
+import os
+from typing import Dict, List, Type
+
+from window.launcher.plugin_base import PluginBase
+
+
+class PluginManager:
+ """
+ Manages launcher plugins.
+ """
+
+ def __init__(self):
+ self.plugins: Dict[str, PluginBase] = {}
+ self.plugin_classes: Dict[str, Type[PluginBase]] = {}
+ self.active_plugins: List[str] = []
+
+ # Load built-in plugins
+ self._load_builtin_plugins()
+
+ # Load external plugins
+ self._load_external_plugins()
+
+ # Activate default plugins
+ self._activate_default_plugins()
+
+ def _load_builtin_plugins(self):
+ """Load built-in plugins from the plugins directory."""
+ plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
+
+ if not os.path.exists(plugins_dir):
+ return
+
+ for filename in os.listdir(plugins_dir):
+ if filename.endswith(".py") and not filename.startswith("_"):
+ plugin_name = filename[:-3] # Remove .py extension
+ self._load_plugin_from_file(plugins_dir, plugin_name)
+
+ def _load_external_plugins(self):
+ """Load external plugins from user directory."""
+ # Could be implemented to load from ~/.config/launcher/plugins/
+ pass
+
+ def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str):
+ """Load a plugin from a Python file."""
+ try:
+ plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py")
+ spec = importlib.util.spec_from_file_location(
+ f"window.launcher.plugins.{plugin_name}", plugin_path
+ )
+
+ if spec and spec.loader:
+ module = importlib.util.module_from_spec(spec)
+
+ # Add the module to sys.window to support relative imports
+ import sys
+
+ sys.modules[f"window.launcher.plugins.{plugin_name}"] = module
+
+ spec.loader.exec_module(module)
+
+ # Look for plugin class
+ for attr_name in dir(module):
+ attr = getattr(module, attr_name)
+ if (
+ isinstance(attr, type)
+ and issubclass(attr, PluginBase)
+ and attr != PluginBase
+ ):
+ self.plugin_classes[plugin_name] = attr
+ break
+
+ except Exception as e:
+ print(f"Failed to load plugin {plugin_name}: {e}")
+
+ def _activate_default_plugins(self):
+ """Activate default plugins."""
+ default_plugins = [
+ "applications",
+ "calculator",
+ "system",
+ "clipboard",
+ "power",
+ "caffeine",
+ "screencapture",
+ "emoji",
+ "wallpaper",
+ "websearch",
+ "reminders",
+ "otp",
+ "password",
+ "bookmarks",
+ "tmux",
+ "colorpicker",
+ ]
+
+ for plugin_name in default_plugins:
+ self.activate_plugin(plugin_name)
+
+ def activate_plugin(self, plugin_name: str) -> bool:
+ """Activate a plugin by name."""
+ if plugin_name in self.plugins:
+ # Already activated
+ return True
+
+ if plugin_name not in self.plugin_classes:
+ return False
+
+ try:
+ # Instantiate plugin
+ plugin_class = self.plugin_classes[plugin_name]
+ plugin_instance = plugin_class()
+
+ # Initialize plugin
+ plugin_instance.initialize()
+
+ # Store plugin
+ self.plugins[plugin_name] = plugin_instance
+ self.active_plugins.append(plugin_name)
+
+ return True
+
+ except Exception as e:
+ print(f"Failed to activate plugin {plugin_name}: {e}")
+ return False
+
+ def deactivate_plugin(self, plugin_name: str) -> bool:
+ """Deactivate a plugin by name."""
+ if plugin_name not in self.plugins:
+ return False
+
+ try:
+ # Cleanup plugin
+ plugin = self.plugins[plugin_name]
+ plugin.cleanup()
+
+ # Remove from active plugins
+ del self.plugins[plugin_name]
+ if plugin_name in self.active_plugins:
+ self.active_plugins.remove(plugin_name)
+
+ return True
+
+ except Exception as e:
+ print(f"Failed to deactivate plugin {plugin_name}: {e}")
+ return False
+
+ def get_active_plugins(self) -> List[PluginBase]:
+ """Get list of active plugin instances."""
+ return [
+ self.plugins[name] for name in self.active_plugins if name in self.plugins
+ ]
+
+ def get_plugin_names(self) -> List[str]:
+ """Get list of available plugin names."""
+ return list(self.plugin_classes.keys())
+
+ def get_active_plugin_names(self) -> List[str]:
+ """Get list of active plugin names."""
+ return self.active_plugins.copy()
+
+ def reload_plugin(self, plugin_name: str) -> bool:
+ """Reload a plugin."""
+ was_active = plugin_name in self.active_plugins
+
+ # Deactivate if active
+ if was_active:
+ self.deactivate_plugin(plugin_name)
+
+ # Remove from classes
+ if plugin_name in self.plugin_classes:
+ del self.plugin_classes[plugin_name]
+
+ # Reload from file
+ plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
+ self._load_plugin_from_file(plugins_dir, plugin_name)
+
+ # Reactivate if it was active
+ if was_active and plugin_name in self.plugin_classes:
+ return self.activate_plugin(plugin_name)
+
+ return plugin_name in self.plugin_classes
diff --git a/src/window/launcher/plugins/__init__.py b/src/window/launcher/plugins/__init__.py
new file mode 100644
index 0000000..666274b
--- /dev/null
+++ b/src/window/launcher/plugins/__init__.py
@@ -0,0 +1,3 @@
+"""
+Built-in plugins for the launcher.
+"""
diff --git a/src/window/launcher/plugins/applications.py b/src/window/launcher/plugins/applications.py
new file mode 100644
index 0000000..fa272a8
--- /dev/null
+++ b/src/window/launcher/plugins/applications.py
@@ -0,0 +1,184 @@
+import json
+import subprocess
+from typing import List
+
+from fabric.utils import DesktopApp, get_desktop_applications, get_relative_path, re
+
+from utils.roam import modus_service
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class ApplicationsPlugin(PluginBase):
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Applications"
+ self.description = "Search and launch desktop applications"
+
+ def initialize(self):
+ pass
+
+ def cleanup(self):
+ pass
+
+ def _pin_application(self, app):
+ """Pin an application to the dock."""
+ config_path = get_relative_path("../../../../config/dock.json")
+ try:
+ with open(config_path, "r") as file:
+ pinned_apps = json.load(file)
+ except (FileNotFoundError, json.JSONDecodeError):
+ pinned_apps = []
+
+ # Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list)
+ if isinstance(pinned_apps, dict):
+ pinned_apps = pinned_apps.get("pinned_apps", [])
+
+ # Check if app is already pinned (by name/app_id)
+ app_id = app.name # Use app.name as the identifier
+ if app_id not in pinned_apps:
+ pinned_apps.append(app_id)
+
+ # Save the updated list
+ with open(config_path, "w") as file:
+ json.dump(pinned_apps, file, indent=4)
+
+ # Notify dock about the change via modus_service
+ if modus_service:
+ try:
+ dock_apps_json = json.dumps(pinned_apps)
+ modus_service.dock_apps = dock_apps_json
+ except Exception as e:
+ print(f"Failed to notify dock about pinned app change: {e}")
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search applications based on query."""
+ if not query_string.strip():
+ return self._get_all_applications()
+
+ try:
+ applications = get_desktop_applications(include_hidden=False)
+ except Exception as e:
+ print(f"Failed to load applications: {e}")
+ applications = []
+
+ query = query_string.lower().strip()
+ results = []
+
+ for app in applications:
+ relevance = self._calculate_relevance(app, query)
+ if relevance > 0:
+ description = app.description or app.generic_name or ""
+ if len(description) > 80:
+ description = description[:70] + "..."
+
+ result = Result(
+ title=app.display_name or app.name,
+ subtitle=description,
+ icon=app.get_icon_pixbuf(size=48),
+ action=lambda a=app: self._launch_application(a),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={
+ "app": app,
+ "pin_action": lambda a=app: self._pin_application(a),
+ },
+ )
+ results.append(result)
+
+ return results
+
+ def _calculate_relevance(self, app, query: str) -> float:
+ """Calculate relevance score for an application."""
+ if not query:
+ return 0.0
+
+ # Get searchable text
+ name = (app.name or "").lower()
+ display_name = (app.display_name or "").lower()
+ description = (app.description or "").lower()
+ generic_name = (app.generic_name or "").lower()
+ executable = (app.executable or "").lower()
+
+ # Exact matches get highest score
+ if query == name or query == display_name:
+ return 1.0
+
+ # Starts with matches get high score
+ if (
+ name.startswith(query)
+ or display_name.startswith(query)
+ or generic_name.startswith(query)
+ ):
+ return 0.9
+
+ # Contains matches get medium score
+ if (
+ query in name
+ or query in display_name
+ or query in description
+ or query in generic_name
+ ):
+ return 0.7
+
+ # Fuzzy matching for partial matches
+ if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name):
+ return 0.5
+
+ # Executable name matching
+ if executable and query in executable:
+ return 0.4
+
+ return 0.0
+
+ def _fuzzy_match(self, query: str, text: str) -> bool:
+ """Simple fuzzy matching algorithm."""
+ if not query or not text:
+ return False
+
+ # Create regex pattern for fuzzy matching
+ pattern = ".*".join(re.escape(char) for char in query)
+ return bool(re.search(pattern, text, re.IGNORECASE))
+
+ def _launch_application(self, app: DesktopApp):
+
+ # Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.)
+ cleaned_command = re.sub(r"%\w+", "", app.command_line).strip()
+
+ # Final command with hyprctl dispatch
+ final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
+ subprocess.Popen(final_command, shell=True)
+
+ # app.launch()
+
+ def _get_all_applications(self) -> List[Result]:
+ """Get a list of all available applications."""
+ try:
+ applications = get_desktop_applications(include_hidden=False)
+ except Exception as e:
+ print(f"Failed to load applications: {e}")
+ return []
+
+ results = []
+
+ for app in applications:
+ # Truncate description
+ description = app.description or app.generic_name or ""
+ if len(description) > 80:
+ description = description[:70] + "..."
+
+ result = Result(
+ title=app.display_name or app.name,
+ subtitle=description,
+ icon=app.get_icon_pixbuf(size=48),
+ action=lambda a=app: self._launch_application(a),
+ relevance=0.5, # Default relevance for all apps
+ plugin_name=self.display_name,
+ data={
+ "app": app,
+ "pin_action": lambda a=app: self._pin_application(a),
+ },
+ )
+ results.append(result)
+
+ return results
diff --git a/src/window/launcher/plugins/bookmarks.py b/src/window/launcher/plugins/bookmarks.py
new file mode 100644
index 0000000..417b924
--- /dev/null
+++ b/src/window/launcher/plugins/bookmarks.py
@@ -0,0 +1,784 @@
+import json
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import Dict, List, Optional
+from urllib.parse import urlparse
+
+from fabric.utils import GLib, get_relative_path
+from thefuzz import fuzz
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class BookmarkManager:
+ """Manages user's custom bookmarks."""
+
+ def __init__(self, storage_file: Path):
+ self.storage_file = storage_file
+ self.bookmarks: List[Dict] = []
+ self.cache_lock = threading.Lock()
+ self.last_loaded = 0
+ self.cache_ttl = 30 # Cache for 30 seconds
+ self._load_bookmarks()
+
+ def _get_favicon_url(self, url: str) -> str:
+ """Generate favicon URL for a given website URL."""
+ try:
+ parsed = urlparse(url)
+ return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
+ except Exception:
+ return ""
+
+ def _extract_domain(self, url: str) -> str:
+ """Extract domain from URL."""
+ try:
+ parsed = urlparse(url)
+ domain = parsed.netloc
+ # Remove www. prefix
+ if domain.startswith("www."):
+ domain = domain[4:]
+ return domain
+ except Exception:
+ return url
+
+ def _normalize_url(self, url: str) -> str:
+ """Normalize URL by adding protocol if missing."""
+ url = url.strip()
+ if not url.startswith(("http://", "https://")):
+ if url.startswith("www."):
+ url = "https://" + url
+ else:
+ url = "https://" + url
+ return url
+
+ def _load_bookmarks(self):
+ """Load bookmarks from JSON file with caching."""
+ with self.cache_lock:
+ current_time = time.time()
+
+ # Check if cache is still valid
+ if (
+ current_time - self.last_loaded
+ ) < self.cache_ttl and self.last_loaded > 0:
+ return
+
+ try:
+ if self.storage_file.exists():
+ with open(self.storage_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ self.bookmarks = data.get("bookmarks", [])
+ else:
+ # File doesn't exist, start with empty list but don't save yet
+ self.bookmarks = []
+
+ self.last_loaded = current_time
+ except Exception as e:
+ print(f"Error loading bookmarks: {e}")
+ self.bookmarks = []
+
+ def get_bookmarks(self) -> List[Dict]:
+ """Get bookmarks, loading from file if needed."""
+ self._load_bookmarks()
+ return self.bookmarks
+
+ def _save_bookmarks_unlocked(self):
+ """Save bookmarks to JSON file without acquiring lock."""
+ try:
+ self.storage_file.parent.mkdir(parents=True, exist_ok=True)
+ data = {
+ "bookmarks": self.bookmarks,
+ "last_updated": time.time(),
+ }
+ with open(self.storage_file, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+ # Update cache timestamp
+ self.last_loaded = time.time()
+ except Exception as e:
+ print(f"Error saving bookmarks: {e}")
+
+ def _save_bookmarks(self):
+ """Save bookmarks to JSON file."""
+ with self.cache_lock:
+ self._save_bookmarks_unlocked()
+
+ def add_bookmark(
+ self, title: str, url: str, description: str = "", tags: List[str] = None
+ ) -> bool:
+ """Add a new bookmark."""
+ try:
+ url = self._normalize_url(url)
+
+ # Check if bookmark already exists
+ for bookmark in self.bookmarks:
+ if bookmark["url"] == url:
+ return False # Already exists
+
+ new_bookmark = {
+ "title": title.strip(),
+ "url": url,
+ "description": description.strip(),
+ "tags": tags or [],
+ "created": time.time(),
+ "accessed": 0,
+ }
+
+ self.bookmarks.append(new_bookmark)
+ self._save_bookmarks()
+
+ # Clear cache to force reload
+ self.last_loaded = 0
+
+ return True
+
+ except Exception as e:
+ print(f"Error adding bookmark: {e}")
+ return False
+
+ def remove_bookmark(self, identifier: str) -> bool:
+ """Remove a bookmark by title or URL."""
+ try:
+ identifier = identifier.lower().strip()
+
+ for i, bookmark in enumerate(self.bookmarks):
+ if (
+ bookmark["title"].lower() == identifier
+ or bookmark["url"].lower() == identifier
+ or self._extract_domain(bookmark["url"]).lower() == identifier
+ ):
+ self.bookmarks.pop(i)
+ self._save_bookmarks()
+
+ # Clear cache to force reload
+ self.last_loaded = 0
+
+ return True
+
+ return False
+
+ except Exception as e:
+ print(f"Error removing bookmark: {e}")
+ return False
+
+ def update_access_time(self, url: str):
+ """Update the last accessed time for a bookmark."""
+ try:
+ for bookmark in self.bookmarks:
+ if bookmark["url"] == url:
+ bookmark["accessed"] = time.time()
+ self._save_bookmarks()
+ break
+ except Exception as e:
+ print(f"Error updating access time: {e}")
+
+ def get_bookmark_count(self) -> int:
+ """Get total number of bookmarks."""
+ return len(self.get_bookmarks())
+
+
+class BookmarksPlugin(PluginBase):
+ """
+ User bookmarks plugin for the launcher.
+ Allows users to add, remove, and search their own bookmarks.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Bookmarks"
+ self.description = "Manage and search your personal bookmarks"
+
+ # Initialize bookmark manager with storage file
+ self.bookmark_file = Path(
+ get_relative_path("../../../../config/bookmarks.json")
+ )
+ self.bookmark_manager = BookmarkManager(self.bookmark_file)
+ self.max_results = 15
+
+ # Cache for results
+ self._results_cache = {}
+ self._cache_timestamps = {}
+ self._cache_ttl = 30 # 30 seconds
+
+ # Launcher instance for refreshing
+ self._launcher_instance = None
+ self._original_close_launcher = None
+
+ def initialize(self):
+ """Initialize the bookmarks plugin."""
+ self.set_triggers(["bm"])
+ self._setup_launcher_hooks()
+
+ def cleanup(self):
+ """Cleanup the bookmarks plugin."""
+ self._results_cache.clear()
+ self._cache_timestamps.clear()
+ self._cleanup_launcher_hooks()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process bookmark queries with caching."""
+ query_key = query_string.strip()
+ current_time = time.time()
+
+ # Check cache first (except for add/remove commands which should always execute)
+ if (
+ not query_key.startswith(("add ", "remove ", "delete ", "rm "))
+ and query_key in self._results_cache
+ and (current_time - self._cache_timestamps.get(query_key, 0))
+ < self._cache_ttl
+ ):
+ return self._results_cache[query_key]
+
+ query = query_key.lower()
+ results = []
+
+ if not query:
+ # Show recent/popular bookmarks when no query
+ results = self._get_recent_bookmarks()
+ elif query.startswith("add "):
+ # Add new bookmark (don't cache)
+ results = self._handle_add_command(query[4:].strip())
+ elif query.startswith(("remove ", "delete ", "rm ")):
+ # Remove bookmark (don't cache)
+ command_parts = query_key.split(" ", 1)
+ if len(command_parts) > 1:
+ results = self._handle_remove_command(command_parts[1].strip())
+ else:
+ results = self._show_remove_help()
+ else:
+ # Search bookmarks
+ results = self._search_bookmarks(query)
+
+ # Cache results (except for add/remove commands)
+ if not query.startswith(("add ", "remove ", "delete ", "rm ")):
+ self._results_cache[query_key] = results
+ self._cache_timestamps[query_key] = current_time
+
+ return results
+
+ def _search_bookmarks(self, query: str) -> List[Result]:
+ """Search through bookmarks."""
+ bookmarks = self.bookmark_manager.get_bookmarks()
+
+ if not bookmarks:
+ return [
+ Result(
+ title="No Bookmarks Found",
+ subtitle="Use 'add ' to add first bookmark",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "info", "keep_launcher_open": True},
+ )
+ ]
+
+ # Search bookmarks
+ results = []
+ for bookmark in bookmarks:
+ relevance = self._calculate_relevance(bookmark, query)
+ if relevance > 0.3: # Only show relevant results
+ result = self._create_bookmark_result(bookmark, relevance)
+ if result:
+ results.append(result)
+
+ # Sort by relevance and limit results
+ results.sort(key=lambda r: r.relevance, reverse=True)
+ return results[: self.max_results]
+
+ def _get_recent_bookmarks(self) -> List[Result]:
+ """Get recent/popular bookmarks when no query is provided."""
+ bookmarks = self.bookmark_manager.get_bookmarks()
+
+ if not bookmarks:
+ return [
+ Result(
+ title="No Bookmarks Yet",
+ subtitle="Use 'add ' to add first bookmark",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ ),
+ Result(
+ title="Example: Add Google",
+ subtitle="add Google https://google.com",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "example", "keep_launcher_open": True},
+ ),
+ ]
+
+ # Sort by access time (most recent first) and show top 10
+ sorted_bookmarks = sorted(
+ bookmarks, key=lambda b: b.get("accessed", 0), reverse=True
+ )
+ results = []
+ for bookmark in sorted_bookmarks[:10]:
+ result = self._create_bookmark_result(bookmark, 0.8)
+ if result:
+ results.append(result)
+
+ return results
+
+ def _handle_add_command(self, args: str) -> List[Result]:
+ """Handle add bookmark command."""
+ if not args:
+ return [
+ Result(
+ title="Add Bookmark",
+ subtitle="Usage: add [description]",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ ),
+ Result(
+ title="Example",
+ subtitle="add Google https://google.com Search engine",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "example", "keep_launcher_open": True},
+ ),
+ ]
+
+ # Parse arguments: title url [description]
+ parts = args.split()
+ if len(parts) < 2:
+ return [
+ Result(
+ title="Invalid Format",
+ subtitle="Usage: add [description]",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ title = parts[0]
+ url = parts[1]
+ description = " ".join(parts[2:]) if len(parts) > 2 else ""
+
+ # Check if bookmark already exists
+ normalized_url = self.bookmark_manager._normalize_url(url)
+ existing_bookmarks = self.bookmark_manager.get_bookmarks()
+ already_exists = any(
+ bookmark["url"] == normalized_url for bookmark in existing_bookmarks
+ )
+
+ if already_exists:
+ # Truncate URL for display to prevent launcher resize
+ display_url = normalized_url
+ if len(display_url) > 35:
+ display_url = display_url[:32] + "..."
+
+ return [
+ Result(
+ title="Bookmark Already Exists",
+ subtitle=f"URL '{display_url}' already exists",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ # Show add action - will execute on Enter
+ domain = self.bookmark_manager._extract_domain(normalized_url)
+
+ # Truncate domain if too long
+ if len(domain) > 25:
+ domain = domain[:22] + "..."
+
+ subtitle = f"Click to add: {domain}"
+ if description:
+ # Truncate description to prevent launcher resize
+ max_desc_len = 35 - len(domain) # Account for domain + separator
+ if len(description) > max_desc_len:
+ description = description[: max_desc_len - 3] + "..."
+ subtitle += f" • {description}"
+
+ # Truncate title for display
+ display_title = title
+ if len(display_title) > 25:
+ display_title = display_title[:22] + "..."
+
+ return [
+ Result(
+ title=f"Add bookmark '{display_title}'",
+ subtitle=subtitle,
+ icon_name="plus",
+ action=lambda: self._add_bookmark_action(title, url, description),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "add", "name": title, "keep_launcher_open": True},
+ )
+ ]
+
+ def _handle_remove_command(self, identifier: str) -> List[Result]:
+ """Handle remove bookmark command."""
+ if not identifier:
+ return self._show_remove_help()
+
+ # Find matching bookmarks
+ bookmarks = self.bookmark_manager.get_bookmarks()
+ identifier_lower = identifier.lower().strip()
+
+ matching_bookmarks = []
+ for bookmark in bookmarks:
+ if (
+ bookmark["title"].lower() == identifier_lower
+ or bookmark["url"].lower() == identifier_lower
+ or self.bookmark_manager._extract_domain(bookmark["url"]).lower()
+ == identifier_lower
+ ):
+ matching_bookmarks.append(bookmark)
+
+ if not matching_bookmarks:
+ return [
+ Result(
+ title="Bookmark Not Found",
+ subtitle=f"No bookmark found matching '{identifier}'",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ # Show remove action - will execute on Enter
+ bookmark = matching_bookmarks[0] # Take first match
+ title = bookmark.get("title", "Untitled")
+ domain = self.bookmark_manager._extract_domain(bookmark.get("url", ""))
+
+ # Truncate title and domain for display
+ display_title = title
+ if len(display_title) > 25:
+ display_title = display_title[:22] + "..."
+
+ display_domain = domain
+ if len(display_domain) > 30:
+ display_domain = display_domain[:27] + "..."
+
+ return [
+ Result(
+ title=f"Remove '{display_title}'?",
+ subtitle=f"Click to confirm: {display_domain}",
+ icon_name="trash",
+ action=lambda: self._remove_bookmark_action(identifier),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "remove", "name": title, "keep_launcher_open": True},
+ )
+ ]
+
+ def _show_remove_help(self) -> List[Result]:
+ """Show help for remove command."""
+ bookmarks = self.bookmark_manager.get_bookmarks()
+ results = [
+ Result(
+ title="Remove Bookmark",
+ subtitle="Usage: remove ",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ ]
+
+ # Show available bookmarks to remove
+ if bookmarks:
+ results.append(
+ Result(
+ title="Available Bookmarks:",
+ subtitle=f"{len(bookmarks)} bookmarks available to remove",
+ icon_name="bookmarks-organize",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "info", "keep_launcher_open": True},
+ )
+ )
+
+ # Show first few bookmarks as examples
+ for bookmark in bookmarks[:3]:
+ title = bookmark.get("title", "Untitled")
+ domain = self._extract_domain(bookmark.get("url", ""))
+
+ # Truncate title and domain for consistent display
+ display_title = title
+ if len(display_title) > 20:
+ display_title = display_title[:17] + "..."
+
+ display_domain = domain
+ if len(display_domain) > 20:
+ display_domain = display_domain[:17] + "..."
+
+ results.append(
+ Result(
+ title=f"remove {display_title}",
+ subtitle=f"Click to remove: {display_title} ({display_domain})",
+ icon_name="trash",
+ action=lambda t=title: self._remove_bookmark_action(t),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={
+ "type": "remove_option",
+ "bookmark": bookmark,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ return results
+
+ def _add_bookmark_action(self, title: str, url: str, description: str = ""):
+ """Execute the add bookmark action."""
+ success = self.bookmark_manager.add_bookmark(title, url, description)
+ if success:
+ print(f"✓ Added bookmark '{title}' - {url}")
+ # Clear cache to force refresh
+ self._results_cache.clear()
+ self._cache_timestamps.clear()
+ # Reset to trigger word and refresh
+ self._reset_to_trigger()
+ else:
+ print(f"✗ Failed to add bookmark '{title}' - already exists")
+
+ def _remove_bookmark_action(self, identifier: str):
+ """Execute the remove bookmark action."""
+ success = self.bookmark_manager.remove_bookmark(identifier)
+ if success:
+ print(f"✓ Removed bookmark '{identifier}'")
+ # Clear cache to force refresh
+ self._results_cache.clear()
+ self._cache_timestamps.clear()
+ # Reset to trigger word and refresh
+ self._reset_to_trigger()
+ else:
+ print(f"✗ Failed to remove bookmark '{identifier}' - not found")
+
+ def _remove_bookmark_with_reset(self, identifier: str):
+ """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger."""
+ success = self.bookmark_manager.remove_bookmark(identifier)
+ if success:
+ print(f"✓ Removed bookmark '{identifier}'")
+ # Clear cache to force refresh
+ self._results_cache.clear()
+ self._cache_timestamps.clear()
+ # Reset to trigger word and refresh
+ self._reset_to_trigger()
+ else:
+ print(f"✗ Failed to remove bookmark '{identifier}' - not found")
+
+ def _calculate_relevance(self, bookmark: Dict, query: str) -> float:
+ """Calculate relevance score for a bookmark."""
+ title = bookmark.get("title", "").lower()
+ url = bookmark.get("url", "").lower()
+ description = bookmark.get("description", "").lower()
+
+ # Exact title match
+ if query == title:
+ return 1.0
+
+ # Title starts with query
+ if title.startswith(query):
+ return 0.95
+
+ # Query in title
+ if query in title:
+ position = title.index(query)
+ position_score = 1.0 - (position / len(title))
+ return 0.8 + (position_score * 0.1)
+
+ # Query in URL
+ if query in url:
+ return 0.7
+
+ # Query in description
+ if query in description:
+ return 0.6
+
+ # Fuzzy matching for title
+ if len(query) >= 3:
+ fuzzy_score = fuzz.partial_ratio(query, title) / 100.0
+ if fuzzy_score >= 0.7:
+ return fuzzy_score * 0.6
+
+ return 0.0
+
+ def _create_bookmark_result(
+ self, bookmark: Dict, relevance: float
+ ) -> Optional[Result]:
+ """Create a Result object for a bookmark."""
+ try:
+ title = bookmark.get("title", "Untitled")
+ url = bookmark.get("url", "")
+ description = bookmark.get("description", "")
+
+ # Truncate long titles to prevent launcher resize
+ if len(title) > 45:
+ title = title[:42] + "..."
+
+ # Create subtitle with domain and description
+ domain = self._extract_domain(url)
+
+ # Truncate domain if too long
+ if len(domain) > 30:
+ domain = domain[:27] + "..."
+
+ if description:
+ # Truncate description to prevent launcher resize
+ # Account for domain + separator
+ max_desc_len = 50 - len(domain)
+ if len(description) > max_desc_len:
+ description = description[: max_desc_len - 3] + "..."
+ subtitle = f"{domain} • {description}"
+ else:
+ subtitle = domain
+
+ # Final subtitle length check to ensure consistent launcher size
+ if len(subtitle) > 60:
+ subtitle = subtitle[:57] + "..."
+
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name="bookmark-organize",
+ action=lambda u=url: self._open_bookmark(u),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={
+ "type": "bookmark",
+ "url": url,
+ "domain": domain,
+ "description": description,
+ "keep_launcher_open": False,
+ "alt_action": lambda t=title: self._remove_bookmark_with_reset(t),
+ },
+ )
+ except Exception as e:
+ print(f"Error creating bookmark result: {e}")
+ return None
+
+ def _extract_domain(self, url: str) -> str:
+ """Extract domain from URL."""
+ try:
+ parsed = urlparse(url)
+ domain = parsed.netloc
+ # Remove www. prefix
+ if domain.startswith("www."):
+ domain = domain[4:]
+ return domain
+ except Exception:
+ return url
+
+ def _open_bookmark(self, url: str):
+ """Open bookmark URL in default browser and update access time."""
+ try:
+ # Update access time
+ self.bookmark_manager.update_access_time(url)
+
+ # Open URL
+ subprocess.Popen(
+ ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ )
+ except Exception as e:
+ print(f"Failed to open bookmark: {e}")
+
+ def _setup_launcher_hooks(self):
+ """Setup hooks to monitor launcher state."""
+ try:
+ # Try to find the launcher instance
+ import gc
+
+ for obj in gc.get_objects():
+ if (
+ hasattr(obj, "__class__")
+ and obj.__class__.__name__ == "Launcher"
+ and hasattr(obj, "close_launcher")
+ ):
+ self._launcher_instance = obj
+ break
+ except Exception as e:
+ print(f"Warning: Could not setup launcher hooks: {e}")
+
+ def _cleanup_launcher_hooks(self):
+ """Cleanup launcher hooks."""
+ try:
+ self._launcher_instance = None
+ except Exception as e:
+ print(f"Warning: Could not cleanup launcher hooks: {e}")
+
+ def _reset_to_trigger(self):
+ """Reset launcher to trigger word and refresh."""
+ try:
+ if self._launcher_instance and hasattr(
+ self._launcher_instance, "search_entry"
+ ):
+ # Get the current trigger (bookmark or bm)
+ current_text = self._launcher_instance.search_entry.get_text()
+ trigger = "bookmark "
+
+ # Determine which trigger was used
+ if current_text.lower().startswith("bm "):
+ trigger = "bm "
+
+ # Reset to trigger word with space
+ try:
+
+ def reset_and_refresh():
+ # Set text to trigger word
+ self._launcher_instance.search_entry.set_text(trigger)
+ # Position cursor at end
+ self._launcher_instance.search_entry.set_position(-1)
+ # Trigger search to show default bookmarks
+ self._launcher_instance._perform_search(trigger)
+ return False
+
+ GLib.timeout_add(50, reset_and_refresh)
+ except ImportError:
+ # Fallback: direct call if GLib not available
+ self._launcher_instance.search_entry.set_text(trigger)
+ self._launcher_instance.search_entry.set_position(-1)
+ self._launcher_instance._perform_search(trigger)
+ except Exception as e:
+ print(f"Could not reset to trigger: {e}")
+
+ def _force_launcher_refresh(self):
+ """Force the launcher to refresh and show updated results."""
+ try:
+ if self._launcher_instance and hasattr(
+ self._launcher_instance, "_perform_search"
+ ):
+ # Get current search text
+ current_text = ""
+ if hasattr(self._launcher_instance, "search_entry"):
+ current_text = self._launcher_instance.search_entry.get_text()
+
+ # Trigger a search to refresh results
+ try:
+
+ def refresh():
+ self._launcher_instance._perform_search(current_text)
+ return False
+
+ GLib.timeout_add(50, refresh)
+ except ImportError:
+ # Fallback: direct call if GLib not available
+ self._launcher_instance._perform_search(current_text)
+ except Exception as e:
+ print(f"Could not force launcher refresh: {e}")
diff --git a/src/window/launcher/plugins/caffeine.py b/src/window/launcher/plugins/caffeine.py
new file mode 100644
index 0000000..0df855a
--- /dev/null
+++ b/src/window/launcher/plugins/caffeine.py
@@ -0,0 +1,204 @@
+import subprocess
+from threading import Timer
+from typing import List
+
+from fabric.utils import exec_shell_command_async, get_relative_path
+
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class CaffeinePlugin(PluginBase):
+ """
+ Plugin for preventing system idle using the inhibit script.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Caffeine"
+ self.description = "Prevent system from going idle"
+ self.inhibit_script = get_relative_path("../../../utils/inhibit.py")
+
+ # Predefined durations
+ self.durations = {
+ "30m": "30 minutes",
+ "1h": "1 hour",
+ "2h": "2 hours",
+ "4h": "4 hours",
+ "8h": "8 hours",
+ "on": "On",
+ "off": "Off",
+ }
+
+ def initialize(self):
+ """Initialize the caffeine plugin."""
+ self.set_triggers(["caffeine"])
+
+ def cleanup(self):
+ """Cleanup the caffeine plugin."""
+ pass
+
+ def _create_inhibit_action(self, duration: str):
+ """Create an inhibit action that properly captures the duration."""
+
+ def action():
+ try:
+ # Run the script in the background without waiting
+ subprocess.Popen(
+ ["python3", self.inhibit_script, duration],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True, # Run in a new session to prevent hanging
+ )
+
+ # Send notification based on duration
+ if duration.lower() == "off":
+ # Deactivation notification
+ exec_shell_command_async(
+ f"notify-send '☕ Caffeine' 'Deactivated' -a '{
+ data.APP_NAME_CAP
+ }' -e"
+ )
+ else:
+ # Activation notification with duration
+ duration_text = self.durations.get(duration, duration)
+ exec_shell_command_async(
+ f"notify-send '☕ Caffeine' 'Activated for {
+ duration_text
+ }' -a '{data.APP_NAME_CAP}' -e"
+ )
+
+ # Schedule expiration notification for timed durations
+ if duration.lower() not in ["on", "off"]:
+ self._schedule_expiration_notification(duration, duration_text)
+
+ except Exception as e:
+ print(f"Error starting inhibit script: {e}")
+
+ return action
+
+ def _parse_duration_to_seconds(self, duration_str: str) -> int:
+ """Parse duration string into seconds. Same logic as inhibit.py"""
+ try:
+ if duration_str.lower() in ["on", "off"]:
+ return 0
+ elif duration_str.endswith("h"):
+ return int(float(duration_str[:-1]) * 3600)
+ elif duration_str.endswith("m"):
+ return int(float(duration_str[:-1]) * 60)
+ elif duration_str.endswith("s"):
+ return int(float(duration_str[:-1]))
+ else:
+ return int(duration_str)
+ except ValueError:
+ return 0
+
+ def _schedule_expiration_notification(self, duration: str, duration_text: str):
+ """Schedule a notification for when the caffeine effect expires."""
+ seconds = self._parse_duration_to_seconds(duration)
+ if seconds > 0:
+
+ def send_expiration_notification():
+ exec_shell_command_async(
+ f"notify-send '☕ Caffeine' 'Expired after {duration_text}' -a '{
+ data.APP_NAME_CAP
+ }' -e"
+ )
+
+ # Schedule the notification
+ timer = Timer(seconds, send_expiration_notification)
+ timer.daemon = True # Don't prevent program exit
+ timer.start()
+
+ def _is_valid_duration(self, query: str) -> bool:
+ """Check if the query is a valid duration format."""
+ if query in self.durations:
+ return True
+ if query.isdigit():
+ return True
+ if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit():
+ return True
+ return False
+
+ def _get_default_action(self, query: str):
+ """Get the default action for a direct duration input."""
+ if self._is_valid_duration(query):
+ return self._create_inhibit_action(query)
+ return None
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search caffeine durations based on query."""
+ # For empty queries, show all available durations
+ if not query_string.strip():
+ query_string = "" # Will match all durations in the loop below
+
+ query = query_string.lower().strip()
+ results = []
+
+ # Handle direct search entry (e.g., "caffeine 30m" or just "30m")
+ if query.startswith("caffeine "):
+ query = query[9:].strip() # Remove "caffeine " prefix
+ # If query becomes empty after removing prefix, show all durations
+ if not query:
+ query = "" # Will match all durations in the loop below
+ elif query == "caffeine":
+ # Handle just "caffeine" without space - show all durations
+ query = ""
+ elif not query:
+ # Handle empty query - show all durations
+ pass
+ elif (
+ query in self.durations
+ or query.isdigit()
+ or (
+ query.endswith(("h", "m", "s"))
+ and query[:-1].replace(".", "").isdigit()
+ )
+ ):
+ # If query is a valid duration without prefix, use it directly
+ pass
+ else:
+ return []
+
+ # Add custom duration option
+ if query.isdigit() or (
+ query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit()
+ ):
+ result = Result(
+ title=f"Custom: {query}",
+ subtitle="Set custom duration",
+ icon_name="caffeine",
+ action=self._create_inhibit_action(query),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"duration": query},
+ )
+ results.append(result)
+
+ # Add predefined durations
+ for duration, description in self.durations.items():
+ # If query is empty, show all durations; otherwise filter by query
+ if not query or query in duration or query in description.lower():
+ # Special handling for "off" - it should stop inhibition
+ if duration.lower() == "off":
+ subtitle = "Stop idle inhibition"
+ else:
+ subtitle = f"Prevent idle for {description.lower()}"
+
+ result = Result(
+ title=description,
+ subtitle=subtitle,
+ icon_name="caffeine",
+ action=self._create_inhibit_action(duration),
+ relevance=0.9 if query == duration else 0.7,
+ plugin_name=self.display_name,
+ data={"duration": duration},
+ )
+ results.append(result)
+
+ # Set default action for direct duration input
+ if results:
+ results[0].default_action = self._get_default_action(query)
+
+ return results
diff --git a/src/window/launcher/plugins/calculator.py b/src/window/launcher/plugins/calculator.py
new file mode 100644
index 0000000..566e0c7
--- /dev/null
+++ b/src/window/launcher/plugins/calculator.py
@@ -0,0 +1,163 @@
+import math
+import re
+import subprocess
+import time
+from typing import List
+
+from utils.conversion import Conversion
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class CalculatorPlugin(PluginBase):
+ """
+ Plugin for calculating mathematical expressions and converting units.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Calculator"
+ self.description = "Evaluate mathematical expressions and convert units"
+
+ # Safe functions for evaluation
+ self.safe_functions = {
+ "abs": abs,
+ "round": round,
+ "min": min,
+ "max": max,
+ "sum": sum,
+ "pow": pow,
+ "sqrt": math.sqrt,
+ "sin": math.sin,
+ "cos": math.cos,
+ "tan": math.tan,
+ "asin": math.asin,
+ "acos": math.acos,
+ "atan": math.atan,
+ "log": math.log,
+ "log10": math.log10,
+ "exp": math.exp,
+ "pi": math.pi,
+ "e": math.e,
+ }
+
+ # Initialize conversion utility
+ self.converter = Conversion()
+
+ # Pre-compiled regex patterns
+ self.expression_pattern = re.compile(r"[\d+\-*/^()=]")
+ self.number_pattern = re.compile(r"\d")
+ self.conversion_pattern = re.compile(
+ r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)"
+ )
+
+ # Cache for conversion results
+ self._conversion_cache = {}
+ self._last_cache_cleanup = time.time()
+ self._cache_cleanup_interval = 300 # 5 minutes
+
+ def initialize(self):
+ """Initialize the files plugin."""
+ self.set_triggers(["="])
+
+ def _cleanup_cache(self):
+ """Clean up old cache entries."""
+ current_time = time.time()
+ if current_time - self._last_cache_cleanup > self._cache_cleanup_interval:
+ self._conversion_cache.clear()
+ self._last_cache_cleanup = current_time
+
+ def query(self, query: str) -> List[Result]:
+ """Process a query and return results."""
+ if not query:
+ return []
+
+ # Clean up cache periodically
+ self._cleanup_cache()
+
+ # Check if it's a conversion query
+ conversion_match = self.conversion_pattern.match(query)
+ if conversion_match:
+ try:
+ value, from_unit, to_unit = conversion_match.groups()
+ value = float(value)
+
+ # Check cache first
+ cache_key = f"{value}_{from_unit}_{to_unit}"
+ if cache_key in self._conversion_cache:
+ result = self._conversion_cache[cache_key]
+ subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
+ else:
+ # Use the conversion utility
+ result = self.converter.convert(value, from_unit, to_unit)
+ # Cache the result
+ self._conversion_cache[cache_key] = result
+ subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
+
+ return [
+ Result(
+ title=f"{result:.6g} {to_unit}",
+ subtitle=subtitle,
+ icon_name="calculator-symbolic",
+ action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"from": (value, from_unit), "to": (result, to_unit)},
+ )
+ ]
+ except ValueError as e:
+ return [
+ Result(
+ title="Invalid conversion",
+ subtitle=str(e),
+ icon_name="calculator-symbolic",
+ relevance=0.0,
+ plugin_name=self.display_name,
+ )
+ ]
+
+ # Check if it's a math expression
+ if self.expression_pattern.search(query):
+ try:
+ # Evaluate the expression
+ result = eval(query, {"__builtins__": {}}, self.safe_functions)
+ if isinstance(result, (int, float)):
+ return [
+ Result(
+ title=f"{result:.6g}",
+ subtitle=f"{query} = {result:.6g}",
+ icon_name="calculator-symbolic",
+ action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ ]
+ except Exception:
+ pass
+
+ return []
+
+ def _format_cache_age(self, age_seconds: float) -> str:
+ """Format cache age for display."""
+ if age_seconds < 60:
+ return f"{int(age_seconds)}s ago"
+ elif age_seconds < 3600:
+ return f"{int(age_seconds // 60)}m ago"
+ else:
+ return f"{int(age_seconds // 3600)}h ago"
+
+ def _copy_to_clipboard(self, text: str):
+ """Copy text to clipboard using cliphist."""
+ try:
+ # First copy to clipboard
+ subprocess.run(["wl-copy"], input=text.encode(), check=True)
+ # Then store in cliphist
+ subprocess.run(["cliphist", "store"], input=text.encode(), check=True)
+ except subprocess.CalledProcessError:
+ # If cliphist fails, at least we have the text in clipboard
+ pass
+
+ def cleanup(self):
+ """Clean up resources."""
+ self._conversion_cache.clear()
+ self.converter.cleanup()
diff --git a/src/window/launcher/plugins/clipboard.py b/src/window/launcher/plugins/clipboard.py
new file mode 100644
index 0000000..7db592f
--- /dev/null
+++ b/src/window/launcher/plugins/clipboard.py
@@ -0,0 +1,435 @@
+import subprocess
+import sys
+import tempfile
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from typing import Dict, List, Optional
+
+from fabric.utils import GdkPixbuf, GLib, os, time
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class ClipboardPlugin(PluginBase):
+ def __init__(self):
+ super().__init__()
+ self.name = "clipboard"
+ self.display_name = "Clipboard History"
+ self.description = "Search and manage clipboard history using cliphist"
+
+ # Performance settings - show more history like example_cliphist.py
+ self.max_results = 50
+ # Cache clipboard items for 5 seconds (more responsive)
+ self.cache_ttl = 5
+
+ # Initialize cache and temp directory
+ self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
+ # Cache images forever like example_cliphist.py
+ self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {}
+ self.clipboard_items_cache: List[str] = []
+ self.cache_timestamp = 0
+
+ # Threading
+ self.executor = ThreadPoolExecutor(
+ max_workers=2, thread_name_prefix="clipboard"
+ )
+ self.cache_lock = threading.Lock()
+
+ # State tracking
+ self._loading = False
+ self._pending_updates = False
+
+ def initialize(self):
+ """Initialize the plugin."""
+ self.set_triggers(["clip"])
+ try:
+ subprocess.run(
+ ["cliphist", "list"], capture_output=True, check=True, timeout=5
+ )
+ except (subprocess.SubprocessError, FileNotFoundError):
+ raise RuntimeError("cliphist is not installed or not working properly")
+
+ # Pre-warm cache in background
+ self.executor.submit(self._load_clipboard_items_cached)
+
+ def cleanup(self):
+ """Cleanup the plugin."""
+ try:
+ # Shutdown executor
+ if hasattr(self, "executor"):
+ self.executor.shutdown(wait=False)
+
+ # Clean up temp files
+ if os.path.exists(self.tmp_dir):
+ import shutil
+
+ shutil.rmtree(self.tmp_dir)
+
+ # Clear caches
+ with self.cache_lock:
+ self.image_cache.clear()
+ self.clipboard_items_cache.clear()
+ except Exception as e:
+ print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
+
+ def invalidate_cache(self):
+ """Force invalidation of the clipboard cache."""
+ with self.cache_lock:
+ self.clipboard_items_cache.clear()
+ self.cache_timestamp = 0
+ # Keep image cache forever like example_cliphist.py
+
+ def _force_launcher_refresh(self):
+ """Force the launcher to refresh its results."""
+ try:
+
+ def trigger_refresh():
+ try:
+ # Try to access the launcher through the fabric Application
+ from fabric import Application
+
+ app = Application.get_default()
+
+ if app and hasattr(app, "launcher"):
+ launcher = app.launcher
+ if (
+ launcher
+ and hasattr(launcher, "search_entry")
+ and hasattr(launcher, "_perform_search")
+ ):
+ # Get current search text to preserve the query
+ current_text = launcher.search_entry.get_text()
+ # Trigger the search to refresh results
+ launcher._perform_search(current_text)
+ return False
+
+ # Fallback: try to find launcher instance through other means
+ import gc
+
+ for obj in gc.get_objects():
+ if (
+ hasattr(obj, "__class__")
+ and obj.__class__.__name__ == "Launcher"
+ ):
+ if hasattr(obj, "search_entry") and hasattr(
+ obj, "_perform_search"
+ ):
+ current_text = obj.search_entry.get_text()
+ obj._perform_search(current_text)
+ return False
+
+ except Exception as e:
+ print(f"Error forcing launcher refresh: {e}")
+
+ return False # Don't repeat
+
+ # Use immediate refresh
+ GLib.timeout_add(10, trigger_refresh)
+
+ except Exception as e:
+ print(f"Could not trigger refresh: {e}")
+
+ def _load_clipboard_items_cached(self) -> List[str]:
+ """Load clipboard items from cliphist with caching and change detection."""
+ current_time = time.time()
+
+ # Always load fresh data to check for changes
+ try:
+ result = subprocess.run(
+ ["cliphist", "list"], capture_output=True, check=True, timeout=5
+ )
+ stdout_str = result.stdout.decode("utf-8", errors="replace")
+ if stdout_str.strip():
+ lines = stdout_str.strip().split("\n")
+ items = [
+ line for line in lines if line and " List[str]:
+ """Load clipboard items (legacy method for compatibility)."""
+ return self._load_clipboard_items_cached()
+
+ def _create_pixbuf_from_bytes(
+ self, image_data: bytes, max_size: int = 100
+ ) -> GdkPixbuf.Pixbuf:
+ """Create a GdkPixbuf from image bytes with size limit."""
+ try:
+ loader = GdkPixbuf.PixbufLoader()
+ loader.write(image_data)
+ loader.close()
+ pixbuf = loader.get_pixbuf()
+
+ # Scale image if needed
+ width, height = pixbuf.get_width(), pixbuf.get_height()
+ if width > height:
+ new_width = max_size
+ new_height = int(height * (max_size / width))
+ else:
+ new_height = max_size
+ new_width = int(width * (max_size / height))
+
+ return pixbuf.scale_simple(
+ new_width, new_height, GdkPixbuf.InterpType.BILINEAR
+ )
+ except GLib.Error:
+ return None
+
+ def _is_image_data(self, content: str) -> bool:
+ """Determine if clipboard content is likely an image (like example_cliphist.py)."""
+ return (
+ content.startswith("data:image/")
+ or content.startswith("\x89PNG")
+ or content.startswith("GIF8")
+ or content.startswith("\xff\xd8\xff")
+ or "binary" in content.lower()
+ and any(
+ ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"]
+ )
+ )
+
+ def _get_text_preview(self, content: str) -> str:
+ """Get a text preview of the content."""
+ if len(content) > 50:
+ return content[:37] + "..."
+ return content
+
+ def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
+ """Load image preview with forever caching (like example_cliphist.py)."""
+ # Check cache first - cache forever like example_cliphist.py
+ with self.cache_lock:
+ if item_id in self.image_cache:
+ return self.image_cache[item_id]
+
+ try:
+ result = subprocess.run(
+ ["cliphist", "decode", item_id],
+ capture_output=True,
+ check=True,
+ timeout=3,
+ )
+ pixbuf = self._create_pixbuf_from_bytes(result.stdout)
+ if pixbuf:
+ with self.cache_lock:
+ self.image_cache[item_id] = pixbuf
+
+ return pixbuf
+ except Exception as e:
+ print(f"Error loading image preview: {e}", file=sys.stderr)
+ return None
+
+ def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
+ """Load image preview (legacy method for compatibility)."""
+ return self._load_image_preview_cached(item_id)
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search clipboard history using cliphist with optimized performance."""
+ results = []
+
+ # Handle query string
+ if query_string.lower() == "clip":
+ query_string = "" # Show all items
+
+ try:
+ # Load clipboard items from cache
+ clipboard_items = self._load_clipboard_items_cached()
+
+ # Early exit if no items
+ if not clipboard_items:
+ return results
+
+ # Filter items based on query - search through ALL items first
+ filtered_items = []
+ query_lower = query_string.lower() if query_string else ""
+
+ for item in clipboard_items:
+ parts = item.split("\t", 1)
+ content = parts[1] if len(parts) > 1 else item
+ content_lower = content.lower()
+
+ # Fast filtering - search through all items, don't limit here
+ if not query_lower or query_lower in content_lower:
+ # Calculate relevance score for better sorting
+ relevance = 1.0
+ if query_lower:
+ if content_lower.startswith(query_lower):
+ relevance = 1.0 # Exact start match
+ elif query_lower in content_lower:
+ # Position-based scoring: earlier matches get higher scores
+ position = content_lower.find(query_lower)
+ relevance = max(
+ 0.5, 1.0 - (position / len(content_lower)) * 0.4
+ )
+
+ filtered_items.append((item, relevance))
+
+ # Sort by relevance (highest first) and then limit results
+ filtered_items.sort(key=lambda x: x[1], reverse=True)
+ filtered_items = filtered_items[: self.max_results]
+
+ # Process items with lazy image loading
+ for i, (item, relevance) in enumerate(filtered_items):
+ parts = item.split("\t", 1)
+ item_id = parts[0] if len(parts) > 1 else str(i)
+ content = parts[1] if len(parts) > 1 else item
+
+ # Handle image content like example_cliphist.py
+ if self._is_image_data(content):
+ # Check if image is already cached (forever cache like example_cliphist.py)
+ cached_pixbuf = None
+ with self.cache_lock:
+ cached_pixbuf = self.image_cache.get(item_id)
+
+ if cached_pixbuf:
+ # Use cached image immediately
+ result = Result(
+ title="Image from clipboard",
+ subtitle="Click to copy image to clipboard",
+ description="Image content",
+ icon=cached_pixbuf,
+ relevance=relevance,
+ plugin_name=self.name,
+ action=lambda id=item_id: self._copy_to_clipboard(id),
+ data={"bypass_max_results": True},
+ )
+ else:
+ # Try to load image immediately like example_cliphist.py
+ try:
+ immediate_pixbuf = self._load_image_preview_cached(item_id)
+ if immediate_pixbuf:
+ # Successfully loaded immediately
+ result = Result(
+ title="Image from clipboard",
+ subtitle="Click to copy image to clipboard",
+ description="Image content",
+ icon=immediate_pixbuf,
+ relevance=relevance,
+ plugin_name=self.name,
+ action=lambda id=item_id: self._copy_to_clipboard(
+ id
+ ),
+ data={"bypass_max_results": True},
+ )
+ else:
+ # Show placeholder if loading failed
+ result = Result(
+ title="Image from clipboard",
+ subtitle="Click to copy image to clipboard",
+ description="Image content",
+ icon_name="image-x-generic",
+ relevance=relevance,
+ plugin_name=self.name,
+ action=lambda id=item_id: self._copy_to_clipboard(
+ id
+ ),
+ data={"bypass_max_results": True},
+ )
+ except Exception:
+ # Fallback to placeholder
+ result = Result(
+ title="Image from clipboard",
+ subtitle="Click to copy image to clipboard",
+ description="Image content",
+ icon_name="image-x-generic",
+ relevance=relevance,
+ plugin_name=self.name,
+ action=lambda id=item_id: self._copy_to_clipboard(id),
+ data={"bypass_max_results": True},
+ )
+
+ results.append(result)
+ continue
+
+ # Handle text content
+ display_text = self._get_text_preview(content)
+ result = Result(
+ title=display_text,
+ subtitle="Text from clipboard",
+ description=(
+ content if len(content) <= 100 else content[:97] + "..."
+ ),
+ icon_name="edit-paste",
+ relevance=relevance,
+ plugin_name=self.name,
+ action=lambda id=item_id: self._copy_to_clipboard(id),
+ data={"bypass_max_results": True},
+ )
+ results.append(result)
+
+ except Exception as e:
+ # Handle errors gracefully
+ results.append(
+ Result(
+ title="Error accessing clipboard history",
+ subtitle=str(e),
+ icon_name="dialog-error",
+ relevance=0.0,
+ plugin_name=self.name,
+ data={"bypass_max_results": True},
+ )
+ )
+
+ return results
+
+ def _copy_to_clipboard(self, entry_id: str):
+ """Copy entry to clipboard using cliphist with timeout."""
+ try:
+ result = subprocess.run(
+ ["cliphist", "decode", entry_id],
+ capture_output=True,
+ check=True,
+ timeout=5,
+ )
+ # Use wl-copy for Wayland or xclip for X11
+ try:
+ subprocess.run(["wl-copy"], input=result.stdout, check=True, timeout=3)
+ except subprocess.SubprocessError:
+ subprocess.run(
+ ["xclip", "-selection", "clipboard"],
+ input=result.stdout,
+ check=True,
+ timeout=3,
+ )
+
+ # Don't invalidate image cache - keep images cached forever like example_cliphist.py
+ # Only invalidate clipboard items cache
+ with self.cache_lock:
+ self.clipboard_items_cache.clear()
+ self.cache_timestamp = 0
+
+ except subprocess.SubprocessError as e:
+ print(f"Error copying to clipboard: {e}", file=sys.stderr)
+ except subprocess.TimeoutExpired as e:
+ print(f"Timeout copying to clipboard: {e}", file=sys.stderr)
diff --git a/src/window/launcher/plugins/colorpicker.py b/src/window/launcher/plugins/colorpicker.py
new file mode 100644
index 0000000..424ad9c
--- /dev/null
+++ b/src/window/launcher/plugins/colorpicker.py
@@ -0,0 +1,146 @@
+import os
+import re
+import subprocess
+from typing import List
+
+from fabric.utils import exec_shell_command, exec_shell_command_async, logger
+from shared.data import APP_NAME
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class ColorPickerPlugin(PluginBase):
+ """
+ Plugin for picking colors from the screen using hyprpicker.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Color Picker"
+ self.description = "Pick colors from the screen (HEX, RGB, HSV)"
+ self.tmp_icon_path = "/tmp/color.png"
+
+ self.options = [
+ {"name": "Pick HEX", "arg": "hex", "icon": "color-select-symbolic"},
+ {"name": "Pick RGB", "arg": "rgb", "icon": "color-select-symbolic"},
+ {"name": "Pick HSV", "arg": "hsv", "icon": "color-select-symbolic"},
+ ]
+
+ def initialize(self):
+ """Initialize the color picker plugin."""
+ self.set_triggers(["color", "cp"])
+
+ def cleanup(self):
+ """Cleanup resources."""
+ if os.path.exists(self.tmp_icon_path):
+ try:
+ os.remove(self.tmp_icon_path)
+ except OSError:
+ pass
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process a search query and return results."""
+ q = query_string.strip().lower()
+
+ results = []
+
+ # Filter options based on query
+ filtered = (
+ self.options
+ if not q
+ else [o for o in self.options if q in o["name"].lower() or q == o["arg"]]
+ )
+
+ for option in filtered:
+ mode = option["arg"]
+ results.append(
+ Result(
+ title=f"Color Picker: {option['name']}",
+ subtitle=f"Pick and copy {mode.upper()} color to clipboard",
+ icon_name=option["icon"],
+ action=lambda m=mode: self._execute_pick(m),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ )
+
+ return results
+
+ def _execute_pick(self, mode: str):
+ """Run hyprpicker, process output, copy to clipboard and notify."""
+ try:
+ # Run hyprpicker
+ # -n: do not print a newline
+ # -f: format
+ raw = exec_shell_command(f"hyprpicker -n -f {mode}")
+ text = raw if isinstance(raw, str) else ""
+
+ color = self._sanitize_color_output(mode, text)
+ if not color:
+ logger.warning(
+ f"[ColorPicker] Failed to parse color from output: {text}"
+ )
+ return
+
+ # Copy to clipboard
+ self._copy_to_clipboard(color)
+
+ # Generate preview and notify
+ self._send_notification(mode, color)
+
+ except Exception as e:
+ logger.error(f"[ColorPicker] Error during pick: {e}")
+
+ def _sanitize_color_output(self, mode: str, text: str) -> str:
+ """Extract color value from hyprpicker output."""
+ joined = " ".join(ln.strip() for ln in text.splitlines() if ln.strip())
+
+ if mode == "hex":
+ matches = re.findall(r"#?[0-9A-Fa-f]{6,8}", joined)
+ if not matches:
+ return ""
+ val = matches[-1]
+ return val if val.startswith("#") else f"#{val}"
+
+ if mode in ("rgb", "hsv"):
+ # Matches "r, g, b" or "h, s, v"
+ matches = re.findall(
+ r"\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?", joined
+ )
+ return matches[-1] if matches else ""
+
+ return ""
+
+ def _copy_to_clipboard(self, text: str):
+ """Copy text to clipboard using wl-copy and cliphist."""
+ try:
+ subprocess.run(["wl-copy"], input=text.encode(), check=True)
+ subprocess.run(["cliphist", "store"], input=text.encode(), check=True)
+ except subprocess.CalledProcessError:
+ pass
+
+ def _send_notification(self, mode: str, color: str):
+ """Create a color preview icon and send a notification."""
+ # CSS-like color specification for ImageMagick
+ color_spec = (
+ color
+ if mode == "hex"
+ else (f"rgb({color})" if mode == "rgb" else f"hsv({color})")
+ )
+
+ # Cleanup existing tmp icon
+ if os.path.exists(self.tmp_icon_path):
+ try:
+ os.remove(self.tmp_icon_path)
+ except OSError:
+ pass
+
+ # Use magick to create a 64x64 color block
+ # Then send notification and cleanup
+ cmd = (
+ f"magick -size 64x64 xc:'{color_spec}' {self.tmp_icon_path} >/dev/null 2>&1; "
+ f"notify-send 'Color Picked' '{mode.upper()}: {color}' -i {self.tmp_icon_path} -a '{APP_NAME}' -e; "
+ f"rm -f {self.tmp_icon_path}"
+ )
+
+ exec_shell_command_async(["bash", "-lc", cmd])
diff --git a/src/window/launcher/plugins/emoji.py b/src/window/launcher/plugins/emoji.py
new file mode 100644
index 0000000..9b0fa8b
--- /dev/null
+++ b/src/window/launcher/plugins/emoji.py
@@ -0,0 +1,291 @@
+import json
+import subprocess
+from collections import OrderedDict
+from typing import Dict, List
+
+from fabric.utils import os, time, GLib, logger
+
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class EmojiPlugin(PluginBase):
+ """
+ Plugin for searching and copying emojis.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.name = "emoji"
+ self.display_name = "Emoji"
+ self.description = "Search and copy emojis"
+ self.emoji_data = {}
+
+ # Language detection (default to en)
+ self.lang = os.getenv("LANG", "en").split("_")[0].split(".")[0]
+ if not self.lang:
+ self.lang = "en"
+
+ # Paths
+ self.state_dir = os.path.join(GLib.get_user_state_dir(), data.APP_NAME)
+ self.emoji_path = os.path.join(self.state_dir, f"emojis_{self.lang}.json")
+
+ # Use cache directory for recent emojis
+ self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json")
+ self.recent_emojis = OrderedDict()
+ self.max_recent_emojis = 20
+ self._is_downloading = False
+
+ def initialize(self):
+ """Initialize the emoji plugin."""
+ self.set_triggers(["em"])
+ self._load_recent_emojis()
+
+ # Check if local data exists, if not trigger download
+ if not os.path.exists(self.emoji_path):
+ self._download_emoji_data()
+ else:
+ self._load_emoji_data()
+
+ def cleanup(self):
+ """Cleanup the emoji plugin."""
+ pass
+
+ def _download_emoji_data(self, force=False):
+ """Download emoji annotations from CLDR repository."""
+ if self._is_downloading and not force:
+ return
+
+ os.makedirs(self.state_dir, exist_ok=True)
+ url = (
+ "https://raw.githubusercontent.com/unicode-org/cldr-json/main/"
+ f"cldr-json/cldr-annotations-full/annotations/{self.lang}/annotations.json"
+ )
+
+ self._is_downloading = True
+
+ def on_done(*args):
+ self._is_downloading = False
+ if os.path.exists(self.emoji_path):
+ self._load_emoji_data()
+ logger.info(f"[Emoji] Data updated for language: {self.lang}")
+ else:
+ logger.error(f"[Emoji] Download failed for URL: {url}")
+
+ logger.info(f"[Emoji] Starting download: {url}")
+ from fabric.utils import exec_shell_command_async
+
+ exec_shell_command_async(f"curl -L '{url}' -o '{self.emoji_path}'", on_done)
+
+ def _load_emoji_data(self):
+ """Load emoji data from JSON file and parse CLDR structure."""
+ try:
+ if os.path.exists(self.emoji_path):
+ with open(self.emoji_path, "r", encoding="utf-8") as f:
+ raw_data = json.load(f)
+ self.emoji_data = self._parse_cldr_json(raw_data)
+ else:
+ logger.warning(f"[Emoji] Local data missing: {self.emoji_path}")
+ except Exception as e:
+ logger.error(f"[Emoji] Error loading emoji data: {e}")
+
+ def _parse_cldr_json(self, raw_data: Dict) -> Dict:
+ """Parse CLDR annotations JSON into a flatter format for searching."""
+ try:
+ # Structure matches individual file: annotations -> annotations
+ annotations_container = raw_data.get("annotations", {})
+ raw_annotations = annotations_container.get("annotations", {})
+
+ if not raw_annotations:
+ # Fallback to the nested structure just in case it's a full bundle
+ main = raw_data.get("main", {})
+ lang_data = main.get(self.lang, main.get("en", {}))
+ annotations_container = lang_data.get("annotations", {})
+ raw_annotations = annotations_container.get("annotations", {})
+
+ parsed = {}
+ for emoji, info in raw_annotations.items():
+ # Extract TTL (name)
+ tts = info.get("tts", [""])
+ name = tts[0] if isinstance(tts, list) else tts
+
+ # Extract keywords
+ keywords = info.get("default", [])
+ if isinstance(keywords, str):
+ keywords = [keywords]
+
+ parsed[emoji] = {
+ "name": name,
+ "keywords": keywords,
+ }
+ return parsed
+ except Exception as e:
+ logger.error(f"[Emoji] CLDR parsing error: {e}")
+ return {}
+
+ def _load_recent_emojis(self):
+ """Load recently used emojis from JSON file."""
+ try:
+ if os.path.exists(self.recent_emoji_path):
+ with open(self.recent_emoji_path, "r", encoding="utf-8") as f:
+ recent_data = json.load(f)
+ # Convert to OrderedDict to maintain order
+ self.recent_emojis = OrderedDict(recent_data)
+ else:
+ # Create empty recent emojis file
+ self.recent_emojis = OrderedDict()
+ self._save_recent_emojis()
+ except Exception as e:
+ print(f"Error loading recent emoji data: {e}")
+ self.recent_emojis = OrderedDict()
+
+ def _save_recent_emojis(self):
+ """Save recently used emojis to JSON file."""
+ try:
+ # Ensure the cache directory exists
+ os.makedirs(data.CACHE_DIR, exist_ok=True)
+
+ with open(self.recent_emoji_path, "w", encoding="utf-8") as f:
+ json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2)
+ except Exception as e:
+ print(f"Error saving recent emoji data: {e}")
+
+ def _add_to_recent(self, emoji: str):
+ """Add an emoji to the recent list."""
+ # Remove if already exists (to move it to front)
+ if emoji in self.recent_emojis:
+ del self.recent_emojis[emoji]
+
+ # Add to front with current timestamp
+ self.recent_emojis[emoji] = time.time()
+
+ # Keep only the most recent emojis
+ while len(self.recent_emojis) > self.max_recent_emojis:
+ # Remove the oldest item
+ self.recent_emojis.popitem(last=False)
+
+ # Save to file
+ self._save_recent_emojis()
+
+ def _copy_to_clipboard(self, emoji: str):
+ """Copy emoji to clipboard and track usage."""
+ try:
+ # Try Wayland first
+ try:
+ subprocess.run(["wl-copy"], input=emoji.encode(), check=True)
+ except subprocess.SubprocessError:
+ # Fall back to X11
+ subprocess.run(
+ ["xclip", "-selection", "clipboard"],
+ input=emoji.encode(),
+ check=True,
+ )
+
+ # Track this emoji as recently used
+ self._add_to_recent(emoji)
+
+ except Exception as e:
+ print(f"Failed to copy to clipboard: {e}")
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search emojis based on query."""
+ results = []
+ query = query_string.lower().strip()
+
+ # Check if we need to download/reload data
+ if not self.emoji_data and not self._is_downloading:
+ if not os.path.exists(self.emoji_path):
+ self._download_emoji_data()
+ else:
+ self._load_emoji_data()
+
+ # Handle update command
+ if query == "updatejson":
+ self._download_emoji_data(force=True)
+ return [
+ Result(
+ title="Updating Emoji Database...",
+ subtitle=f"Downloading latest annotations for {self.lang}",
+ icon_name="view-refresh-symbolic",
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ ]
+
+ # If data is not yet loaded and not downloading, try loading it
+ if not self.emoji_data and not self._is_downloading:
+ self._load_emoji_data()
+
+ # Show status if downloading
+ if self._is_downloading:
+ results.append(
+ Result(
+ title="Downloading Emoji Data...",
+ subtitle="Results will appear once finished",
+ icon_name="view-refresh-symbolic",
+ relevance=0.1,
+ plugin_name=self.display_name,
+ )
+ )
+
+ # If no query, show recently used emojis
+ if not query:
+ if self.recent_emojis:
+ for emoji in reversed(list(self.recent_emojis.keys())):
+ if emoji in self.emoji_data:
+ info = self.emoji_data[emoji]
+ results.append(self._create_emoji_result(emoji, info, 1.0))
+ return results
+
+ # Search by name, keywords, or the emoji itself
+ for emoji, info in self.emoji_data.items():
+ relevance = 0
+ name = info.get("name", "").lower()
+ keywords = info.get("keywords", [])
+
+ # Exact match with emoji
+ if query == emoji:
+ relevance = 1.0
+ # Exact match with name
+ elif query == name:
+ relevance = 0.95
+ # Starts with name
+ elif name.startswith(query):
+ relevance = 0.9
+ # Contains name
+ elif query in name:
+ relevance = 0.8
+ # In keywords
+ elif any(query == k.lower() for k in keywords):
+ relevance = 0.7
+ elif any(query in k.lower() for k in keywords):
+ relevance = 0.6
+
+ if relevance > 0:
+ results.append(self._create_emoji_result(emoji, info, relevance))
+
+ # Sort by relevance and limit
+ results.sort(key=lambda x: x.relevance, reverse=True)
+ return results[:20]
+
+ def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result:
+ """Create a Result object for an emoji."""
+ name = info.get("name", "").capitalize()
+ keywords = ", ".join(info.get("keywords", [])[:3])
+
+ is_recent = emoji in self.recent_emojis
+ subtitle = f"{keywords}" + (" • Recent" if is_recent else "")
+
+ # Use larger font size for emojis to make them visible
+ icon_markup = f"{emoji}"
+
+ return Result(
+ title=name,
+ subtitle=subtitle,
+ icon_markup=icon_markup,
+ action=lambda e=emoji: self._copy_to_clipboard(e),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"emoji": emoji, "name": name, "recent": is_recent},
+ )
diff --git a/src/window/launcher/plugins/otp.py b/src/window/launcher/plugins/otp.py
new file mode 100644
index 0000000..bf859a4
--- /dev/null
+++ b/src/window/launcher/plugins/otp.py
@@ -0,0 +1,844 @@
+import json
+import subprocess
+import threading
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from fabric.utils import GLib, get_relative_path, time
+
+from services.auth import (
+ generate_totp,
+ get_time_remaining_with_blink,
+ parse_otpauth_uri,
+ scan_qr_and_add_account,
+ validate_base32_secret,
+)
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class OTPPlugin(PluginBase):
+ """Plugin for managing TOTP (Time-based One-Time Password) codes."""
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "OTP Manager"
+ self.description = "Manage TOTP codes and 2FA authentication"
+
+ self.secrets_file = Path(get_relative_path("../../../../config/accounts.json"))
+ self.secrets: Dict[str, Dict] = {}
+ self.last_update = 0
+
+ # Threading for auto-refresh
+ self.refresh_thread = None
+ self.stop_refresh = threading.Event()
+
+ def initialize(self):
+ """Initialize the OTP plugin."""
+ self.set_triggers(["otp"])
+ self._load_secrets()
+ self._ensure_config_file()
+ self._start_refresh_thread()
+
+ def cleanup(self):
+ """Cleanup the OTP plugin."""
+ if self.refresh_thread and self.refresh_thread.is_alive():
+ self.stop_refresh.set()
+ self.refresh_thread.join(timeout=1)
+
+ def _load_secrets(self):
+ """Load secrets from JSON file."""
+ try:
+ if self.secrets_file.exists():
+ with open(self.secrets_file, "r", encoding="utf-8") as f:
+ self.secrets = json.load(f)
+ else:
+ self.secrets = {}
+ except Exception as e:
+ print(f"Error loading OTP secrets: {e}")
+ self.secrets = {}
+
+ def _save_secrets(self):
+ """Save secrets to JSON file."""
+ try:
+ self.secrets_file.parent.mkdir(parents=True, exist_ok=True)
+ with open(self.secrets_file, "w", encoding="utf-8") as f:
+ json.dump(self.secrets, f, indent=2)
+ except Exception as e:
+ print(f"Error saving OTP secrets: {e}")
+
+ def _ensure_config_file(self):
+ """Ensure the config file exists."""
+ if not self.secrets_file.exists():
+ self.secrets_file.parent.mkdir(parents=True, exist_ok=True)
+ with open(self.secrets_file, "w", encoding="utf-8") as f:
+ json.dump({}, f, indent=2)
+
+ def _start_refresh_thread(self):
+ """Start background thread for auto-refreshing tokens."""
+
+ def refresh_loop():
+ while not self.stop_refresh.wait(5):
+ current_time = time.time()
+ if current_time - self.last_update >= 5:
+ self.last_update = current_time
+ # Only refresh if we have secrets and launcher is likely active
+ if self.secrets:
+ try:
+ self._selective_force_refresh()
+ except Exception:
+ pass
+
+ self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True)
+ self.refresh_thread.start()
+
+ def _selective_force_refresh(self):
+ """Update time display in existing OTP result items."""
+ try:
+ import gc
+
+ def do_update():
+ try:
+ for obj in gc.get_objects():
+ if (
+ hasattr(obj, "__class__")
+ and obj.__class__.__name__ == "Launcher"
+ and hasattr(obj, "results_box")
+ and hasattr(obj, "visible")
+ and obj.visible
+ and hasattr(obj, "results")
+ and obj.results
+ ):
+ has_otp_results = any(
+ result.data and result.data.get("type") == "totp"
+ for result in obj.results
+ if hasattr(result, "data") and result.data
+ )
+
+ if has_otp_results:
+ self._update_existing_result_labels(obj.results_box)
+ return False
+ except Exception:
+ pass
+ return False
+
+ GLib.idle_add(do_update)
+ except Exception:
+ pass
+
+ def _update_existing_result_labels(self, results_box):
+ """Update subtitle labels in existing ResultItem widgets."""
+ try:
+ time_display = self._get_time_remaining_with_blink()
+ for child in results_box.get_children():
+ if (
+ hasattr(child, "__class__")
+ and child.__class__.__name__ == "ResultItem"
+ and hasattr(child, "result")
+ and hasattr(child.result, "data")
+ and child.result.data
+ and child.result.data.get("type") == "totp"
+ ):
+ self._update_result_item_content(child, time_display)
+ except Exception as e:
+ print(f"Error updating result labels: {e}")
+
+ def _update_result_item_content(self, result_item, time_display):
+ """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem."""
+ try:
+ account_name = result_item.result.data.get("account", "")
+ if not account_name or account_name not in self.secrets:
+ return
+
+ account_data = self.secrets[account_name]
+ secret = account_data.get("secret", "")
+ issuer = account_data.get("issuer", "")
+ display_name = f"{issuer} - {account_name}" if issuer else account_name
+
+ current_totp_code = self._generate_totp(secret)
+ if not current_totp_code:
+ return
+
+ old_code = result_item.result.data.get("code", "")
+ if current_totp_code != old_code:
+ result_item.result.data["code"] = current_totp_code
+ self._find_and_update_title_label(result_item, current_totp_code)
+ result_item.result.action = lambda code=current_totp_code: (
+ self._copy_to_clipboard(code)
+ )
+
+ new_subtitle_markup = f"{display_name} • {time_display} remaining"
+ self._find_and_update_subtitle_label(result_item, new_subtitle_markup)
+ except Exception as e:
+ print(f"Error updating result item: {e}")
+
+ def _find_and_update_title_label(self, result_item, new_title):
+ """Find the title label widget and update its text."""
+
+ def find_title_label(widget):
+ if hasattr(widget, "get_name") and widget.get_name() == "result-item-title":
+ return widget
+ if hasattr(widget, "get_children"):
+ for child in widget.get_children():
+ found = find_title_label(child)
+ if found:
+ return found
+ return None
+
+ title_label = find_title_label(result_item)
+ if title_label and hasattr(title_label, "set_label"):
+ title_label.set_label(new_title)
+
+ def _find_and_update_subtitle_label(self, result_item, new_markup):
+ """Find the subtitle label widget and update its markup."""
+
+ def find_subtitle_label(widget):
+ if (
+ hasattr(widget, "get_name")
+ and widget.get_name() == "result-item-subtitle"
+ ):
+ return widget
+ if hasattr(widget, "get_children"):
+ for child in widget.get_children():
+ found = find_subtitle_label(child)
+ if found:
+ return found
+ return None
+
+ subtitle_label = find_subtitle_label(result_item)
+ if subtitle_label and hasattr(subtitle_label, "set_markup"):
+ subtitle_label.set_markup(new_markup)
+
+ def _copy_to_clipboard(self, text: str):
+ """Copy text to clipboard."""
+ try:
+ try:
+ subprocess.run(["wl-copy"], input=text.encode(), check=True)
+ except subprocess.SubprocessError:
+ subprocess.run(
+ ["xclip", "-selection", "clipboard"],
+ input=text.encode(),
+ check=True,
+ )
+ except Exception as e:
+ print(f"Failed to copy to clipboard: {e}")
+
+ def _trigger_refresh(self):
+ """Trigger launcher refresh to return to default OTP view."""
+ try:
+ # Use a small delay to ensure the action completes first
+ def trigger_refresh():
+ try:
+ # Try to access the launcher through the fabric Application
+ from fabric import Application
+
+ app = Application.get_default()
+
+ if app and hasattr(app, "launcher"):
+ launcher = app.launcher
+ if launcher and hasattr(launcher, "search_entry"):
+ # Clear the search entry and set it to just "otp "
+ launcher.search_entry.set_text("otp ")
+ # Position cursor at the end
+ launcher.search_entry.set_position(-1)
+ # Trigger the search to show default OTP view
+ if hasattr(launcher, "_perform_search"):
+ launcher._perform_search("otp ")
+ return False
+
+ # Fallback: try to find launcher instance through other means
+ import gc
+
+ for obj in gc.get_objects():
+ if (
+ hasattr(obj, "__class__")
+ and obj.__class__.__name__ == "Launcher"
+ ):
+ if hasattr(obj, "search_entry") and hasattr(
+ obj, "_perform_search"
+ ):
+ obj.search_entry.set_text("otp ")
+ obj.search_entry.set_position(-1)
+ obj._perform_search("otp ")
+ return False
+
+ except Exception as e:
+ print(f"Error forcing launcher refresh: {e}")
+
+ return False # Don't repeat
+
+ # Use a small delay to ensure the action completes first
+ GLib.timeout_add(50, trigger_refresh)
+
+ except Exception as e:
+ print(f"Could not trigger refresh: {e}")
+
+ def _remove_account_and_refresh(self, account_name: str):
+ """Remove an account and trigger refresh to return to default OTP view."""
+ try:
+ if account_name in self.secrets:
+ # Remove the account
+ del self.secrets[account_name]
+ self._save_secrets()
+
+ # Trigger refresh to return to default OTP view
+ self._trigger_refresh()
+ except Exception as e:
+ print(f"Error removing account {account_name}: {e}")
+
+ def _generate_totp(self, secret: str) -> Optional[str]:
+ """Generate TOTP code from secret."""
+ return generate_totp(secret)
+
+ def _get_time_remaining_with_blink(self) -> str:
+ """Get time remaining with blinking effect."""
+ return get_time_remaining_with_blink()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process OTP queries."""
+ query = query_string.strip()
+
+ if not query:
+ return self._list_otp_codes()
+
+ query_lower = query.lower()
+ if query_lower.startswith("add "):
+ add_content = query[4:].strip()
+
+ # Handle both formats: with ``` and without ```
+ if "```" in add_content:
+ # Old format: add account```secret```
+ parts = add_content.split("```", 1)
+ if len(parts) == 2:
+ account_name = parts[0].strip()
+ secret_or_uri = parts[1].strip()
+ return self._handle_direct_add(account_name, secret_or_uri)
+ elif " " in add_content:
+ # New format: add account secret
+ parts = add_content.split(" ", 1)
+ if len(parts) == 2:
+ account_name = parts[0].strip()
+ secret_or_uri = parts[1].strip()
+ return self._handle_direct_add(account_name, secret_or_uri)
+
+ return self._handle_add_command(add_content)
+ elif query_lower == "remove" or query_lower.startswith("remove "):
+ # Handle both "remove" and "remove accountname"
+ if query_lower == "remove":
+ remove_content = ""
+ else:
+ remove_content = query[7:].strip()
+ return self._handle_remove_command(remove_content)
+ elif query_lower == "qr" or query_lower.startswith("qr "):
+ # Handle QR scanning command
+ if query_lower == "qr":
+ qr_content = ""
+ else:
+ qr_content = query[3:].strip()
+ return self._handle_qr_command(qr_content)
+ else:
+ return self._search_accounts(query)
+
+ def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]:
+ """Handle direct addition of OTP account."""
+ if not account_name or not secret_or_uri:
+ return [
+ Result(
+ title="Invalid format",
+ subtitle="Usage: add or add ``````",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ ]
+
+ try:
+ if secret_or_uri.startswith("otpauth://"):
+ return self._handle_otpauth_uri(account_name, secret_or_uri)
+ else:
+ return self._handle_base32_secret(account_name, secret_or_uri)
+ except Exception as e:
+ print(f"OTP Debug: Error in _handle_direct_add: {e}")
+ return [
+ Result(
+ title="Error adding account",
+ subtitle=f"Debug: {str(e)}",
+ icon_name="cancel",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ def _list_otp_codes(self) -> List[Result]:
+ """List all OTP codes with current tokens."""
+ results = []
+
+ if not self.secrets:
+ results.append(
+ Result(
+ title="No OTP accounts configured",
+ subtitle="Use 'add ' to add your first account",
+ icon_name="gtk-authentication-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "empty", "keep_launcher_open": True},
+ )
+ )
+ results.append(
+ Result(
+ title="Available commands:",
+ subtitle="add | remove | qr ",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ )
+ return results
+
+ time_display = self._get_time_remaining_with_blink()
+
+ for account_name, account_data in self.secrets.items():
+ secret = account_data.get("secret", "")
+ issuer = account_data.get("issuer", "")
+ totp_code = self._generate_totp(secret)
+
+ if totp_code:
+ display_name = f"{issuer} - {account_name}" if issuer else account_name
+ results.append(
+ Result(
+ title=f"{totp_code}",
+ subtitle_markup=f"{display_name} • {
+ time_display
+ } remaining • Shift+Enter: remove",
+ icon_name="gtk-authentication-symbolic",
+ action=lambda code=totp_code: self._copy_to_clipboard(code),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "type": "totp",
+ "account": account_name,
+ "code": totp_code,
+ "alt_action": lambda acc=account_name: (
+ self._remove_account_and_refresh(acc)
+ ),
+ },
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title=f"Error: {account_name}",
+ subtitle="Invalid secret or configuration",
+ icon_name="dialog-cancel-symbolic",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={
+ "type": "error",
+ "account": account_name,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ return results
+
+ def _search_accounts(self, query: str) -> List[Result]:
+ """Search accounts by name or issuer."""
+ results = []
+ query_lower = query.lower()
+
+ for account_name, account_data in self.secrets.items():
+ issuer = account_data.get("issuer", "").lower()
+ account_lower = account_name.lower()
+
+ if query_lower in account_lower or query_lower in issuer:
+ secret = account_data.get("secret", "")
+ totp_code = self._generate_totp(secret)
+
+ if totp_code:
+ display_name = (
+ f"{account_data.get('issuer', '')} - {account_name}"
+ if account_data.get("issuer")
+ else account_name
+ )
+ time_display = self._get_time_remaining_with_blink()
+
+ results.append(
+ Result(
+ title=f"{totp_code}",
+ subtitle_markup=f"{display_name} • {
+ time_display
+ } remaining • Shift+Enter: remove",
+ icon_name="gtk-authentication-symbolic",
+ action=lambda code=totp_code: self._copy_to_clipboard(code),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "type": "totp",
+ "account": account_name,
+ "code": totp_code,
+ "alt_action": lambda acc=account_name: (
+ self._remove_account_and_refresh(acc)
+ ),
+ },
+ )
+ )
+
+ if not results:
+ results.append(
+ Result(
+ title=f"No accounts found for '{query}'",
+ subtitle="Use 'add ' to create new account",
+ icon_name="edit-find-symbolic",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "no_results", "keep_launcher_open": True},
+ )
+ )
+ results.append(
+ Result(
+ title="Available commands:",
+ subtitle="add | remove | qr ",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.4,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ )
+
+ return results
+
+ def _handle_add_command(self, account_name: str) -> List[Result]:
+ """Handle manual addition of OTP secret."""
+ if not account_name:
+ return [
+ Result(
+ title="Enter account name",
+ subtitle="Usage: add ",
+ icon_name="dialog-question-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ ]
+
+ return [
+ Result(
+ title=f"To add '{account_name}':",
+ subtitle=f"Type: add {account_name} ",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "type": "instruction",
+ "account": account_name,
+ "keep_launcher_open": True,
+ },
+ ),
+ Result(
+ title="Base32 Secret Format:",
+ subtitle="Example: add gmail JBSWY3DPEHPK3PXP",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ ),
+ Result(
+ title="otpauth URI Format:",
+ subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ ),
+ Result(
+ title="Remove Account:",
+ subtitle="Example: remove gmail",
+ icon_name="trash",
+ action=lambda: None,
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ ),
+ ]
+
+ def _handle_qr_command(self, account_name: str) -> List[Result]:
+ """Handle QR scanning command."""
+ if not account_name:
+ return [
+ Result(
+ title="Scan QR Code",
+ subtitle="Click to scan QR code from screen",
+ icon_name="view-barcode-qr-symbolic",
+ action=lambda: self._scan_qr_and_add_account(""),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "qr_scan"},
+ ),
+ Result(
+ title="QR Scan Instructions:",
+ subtitle="Use 'qr ' to specify account name",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ ),
+ ]
+ else:
+ return [
+ Result(
+ title=f"Scan QR Code for '{account_name}'",
+ subtitle="Click to scan QR code from screen",
+ icon_name="view-barcode-qr-symbolic",
+ action=lambda name=account_name: self._scan_qr_and_add_account(
+ name
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "qr_scan"},
+ ),
+ ]
+
+ def _scan_qr_and_add_account(self, account_name: str):
+ """Scan QR code and add OTP account."""
+ print(f"QR scan action called for account: '{account_name}'")
+ print("Starting QR scan process asynchronously...")
+
+ # Run QR scanning asynchronously so launcher closes immediately
+ import threading
+
+ thread = threading.Thread(target=self._scan_qr_async, args=(account_name,))
+ thread.daemon = True
+ thread.start()
+
+ def _scan_qr_async(self, account_name: str):
+ """Async QR scanning process."""
+ result = scan_qr_and_add_account(account_name, str(self.secrets_file))
+
+ if result["success"]:
+ print(result["message"])
+ # Reload secrets from file
+ self._load_secrets()
+ # Trigger refresh to show the new account
+ self._trigger_refresh()
+ else:
+ print(f"QR scan failed: {result['error']}")
+
+ def _handle_remove_command(self, account_name: str) -> List[Result]:
+ """Handle removal of OTP account."""
+ if not account_name:
+ # Show all available accounts for removal
+ results = []
+
+ if not self.secrets:
+ results.append(
+ Result(
+ title="No OTP accounts to remove",
+ subtitle="Use 'add ' to add accounts first",
+ icon_name="info",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "empty", "keep_launcher_open": True},
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title="Select account to remove:",
+ subtitle="Type: remove to remove an account",
+ icon_name="user-trash-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ )
+
+ # Get time display for consistency with main OTP view
+ time_display = self._get_time_remaining_with_blink()
+
+ # Show all accounts with their current OTP codes and remove actions
+ for acc_name, account_data in self.secrets.items():
+ secret = account_data.get("secret", "")
+ issuer = account_data.get("issuer", "")
+ display_name = f"{issuer} - {acc_name}" if issuer else acc_name
+
+ # Generate current TOTP code
+ totp_code = self._generate_totp(secret)
+
+ if totp_code:
+ results.append(
+ Result(
+ title=f"{totp_code}",
+ subtitle_markup=f"Press Enter to remove • {
+ time_display
+ } remaining",
+ icon_name="user-trash-symbolic",
+ action=lambda acc=acc_name: (
+ self._remove_account_and_refresh(acc)
+ ),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "type": "remove_instruction",
+ "account": acc_name,
+ "code": totp_code,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title=f"Error: {acc_name}",
+ subtitle="Press Enter to remove (Invalid secret)",
+ icon_name="user-trash-symbolic",
+ action=lambda acc=acc_name: (
+ self._remove_account_and_refresh(acc)
+ ),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={
+ "type": "remove_instruction",
+ "account": acc_name,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ return results
+
+ # Check if account exists
+ if account_name not in self.secrets:
+ return [
+ Result(
+ title=f"Account '{account_name}' not found",
+ subtitle="Use 'remove' to see all available accounts",
+ icon_name="dialog-cancel-symbolic",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ # Get account info for confirmation
+ account_data = self.secrets[account_name]
+ issuer = account_data.get("issuer", "")
+ display_name = f"{issuer} - {account_name}" if issuer else account_name
+
+ # Show confirmation for removal
+ return [
+ Result(
+ title=f"Remove '{display_name}'?",
+ subtitle="Press Enter to confirm removal",
+ icon_name="user-trash-symbolic",
+ action=lambda acc=account_name: self._remove_account_and_refresh(acc),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "type": "remove_confirm",
+ "account": account_name,
+ "keep_launcher_open": True,
+ },
+ )
+ ]
+
+ def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]:
+ """Handle raw Base32 secret."""
+ result = validate_base32_secret(secret)
+
+ if not result["success"]:
+ return [
+ Result(
+ title="Invalid Base32 secret",
+ subtitle=result["error"],
+ icon_name="dialog-cancel-symbolic",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ self.secrets[account_name] = {
+ "secret": result["secret"],
+ "issuer": "",
+ "algorithm": "SHA1",
+ "digits": 6,
+ "period": 30,
+ }
+ self._save_secrets()
+
+ return [
+ Result(
+ title=f"✓ Added '{account_name}'",
+ subtitle=f"OTP account added successfully (secret: {
+ result['secret'][:4]
+ }...)",
+ icon_name="emblem-ok-symbolic",
+ action=lambda: self._trigger_refresh(),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "success", "keep_launcher_open": True},
+ )
+ ]
+
+ def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]:
+ """Handle otpauth:// URI."""
+ result = parse_otpauth_uri(uri, account_name)
+
+ if not result["success"]:
+ return [
+ Result(
+ title="Error parsing otpauth URI",
+ subtitle=result["error"],
+ icon_name="dialog-cancel-symbolic",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ ]
+
+ self.secrets[result["account_name"]] = {
+ "secret": result["secret"],
+ "issuer": result["issuer"],
+ "algorithm": result["algorithm"],
+ "digits": result["digits"],
+ "period": result["period"],
+ }
+ self._save_secrets()
+
+ display_name = (
+ f"{result['issuer']} - {result['account_name']}"
+ if result["issuer"]
+ else result["account_name"]
+ )
+ return [
+ Result(
+ title=f"✓ Added '{display_name}'",
+ subtitle="OTP account added from URI",
+ icon_name="emblem-ok-symbolic",
+ action=lambda: self._trigger_refresh(),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "success", "keep_launcher_open": True},
+ )
+ ]
diff --git a/src/window/launcher/plugins/password.py b/src/window/launcher/plugins/password.py
new file mode 100644
index 0000000..1019fac
--- /dev/null
+++ b/src/window/launcher/plugins/password.py
@@ -0,0 +1,689 @@
+import base64
+import json
+import subprocess
+import threading
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from fabric.utils import GLib, get_relative_path, time
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class PasswordManager:
+ """Simple password manager with basic encryption and caching."""
+
+ def __init__(self, storage_file: Path):
+ self.storage_file = storage_file
+ self.passwords: Dict[str, Dict] = {}
+ self._cache_lock = threading.Lock()
+ self._last_loaded = 0
+ self._cache_ttl = 30 # Cache for 30 seconds
+ self._load_passwords()
+
+ def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str:
+ """Simple encryption using XOR with base64 encoding."""
+ key_bytes = key.encode("utf-8")
+ text_bytes = text.encode("utf-8")
+
+ # XOR encryption
+ encrypted = bytearray()
+ for i, byte in enumerate(text_bytes):
+ encrypted.append(byte ^ key_bytes[i % len(key_bytes)])
+
+ # Base64 encode
+ return base64.b64encode(encrypted).decode("utf-8")
+
+ def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str:
+ """Simple decryption using XOR with base64 decoding."""
+ try:
+ key_bytes = key.encode("utf-8")
+
+ # Base64 decode
+ encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8"))
+
+ # XOR decryption
+ decrypted = bytearray()
+ for i, byte in enumerate(encrypted_bytes):
+ decrypted.append(byte ^ key_bytes[i % len(key_bytes)])
+
+ return decrypted.decode("utf-8")
+ except Exception:
+ return encrypted_text # Return as-is if decryption fails
+
+ def _load_passwords(self):
+ """Load passwords from JSON file with caching."""
+ with self._cache_lock:
+ current_time = time.time()
+
+ # Check if cache is still valid
+ if (current_time - self._last_loaded) < self._cache_ttl and self.passwords:
+ return
+
+ try:
+ if self.storage_file.exists():
+ with open(self.storage_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ self.passwords = data.get("passwords", {})
+ else:
+ self.passwords = {}
+
+ self._last_loaded = current_time
+ except Exception as e:
+ print(f"Error loading passwords: {e}")
+ self.passwords = {}
+
+ def _save_passwords(self):
+ """Save passwords to JSON file."""
+ with self._cache_lock:
+ try:
+ self.storage_file.parent.mkdir(parents=True, exist_ok=True)
+ data = {
+ "passwords": self.passwords,
+ "last_modified": datetime.now().isoformat(),
+ }
+ with open(self.storage_file, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+
+ # Update cache timestamp
+ self._last_loaded = time.time()
+ except Exception as e:
+ print(f"Error saving passwords: {e}")
+
+ def add_password(self, name: str, password: str, description: str = "") -> bool:
+ """Add a new password entry."""
+ try:
+ encrypted_password = self._simple_encrypt(password)
+ self.passwords[name] = {
+ "password": encrypted_password,
+ "description": description,
+ "created": datetime.now().isoformat(),
+ "last_accessed": None,
+ }
+ self._save_passwords()
+ return True
+ except Exception as e:
+ print(f"Error adding password: {e}")
+ return False
+
+ def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]:
+ """Get decrypted password by name."""
+ # Ensure we have fresh data
+ self._load_passwords()
+
+ if name in self.passwords:
+ try:
+ encrypted = self.passwords[name]["password"]
+ decrypted = self._simple_decrypt(encrypted)
+
+ # Update last accessed time only if requested (to avoid frequent saves)
+ if update_access_time:
+ self.passwords[name]["last_accessed"] = datetime.now().isoformat()
+ # Don't save immediately - batch saves for better performance
+
+ return decrypted
+ except Exception as e:
+ print(f"Error decrypting password: {e}")
+ return None
+ return None
+
+ def remove_password(self, name: str) -> bool:
+ """Remove a password entry."""
+ if name in self.passwords:
+ del self.passwords[name]
+ self._save_passwords()
+ return True
+ return False
+
+ def list_passwords(self) -> List[str]:
+ """Get list of all password names."""
+ # Ensure we have fresh data
+ self._load_passwords()
+ return list(self.passwords.keys())
+
+ def get_password_info(self, name: str) -> Optional[Dict]:
+ """Get password metadata without decrypting."""
+ if name in self.passwords:
+ info = self.passwords[name].copy()
+ info.pop("password", None) # Remove encrypted password
+ return info
+ return None
+
+
+class PasswordPlugin(PluginBase):
+ """
+ Password manager plugin for the launcher.
+ Stores passwords securely and allows easy access.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Password Manager"
+ self.description = "Secure password storage and management"
+
+ # Initialize password manager
+ self.password_file = Path(
+ get_relative_path("../../../../config/passwords.json")
+ )
+ self.password_manager = PasswordManager(self.password_file)
+
+ # State for password visibility
+ self.revealed_passwords: Dict[str, str] = {}
+
+ # Cache for results to avoid repeated queries
+ self._results_cache: Dict[str, List[Result]] = {}
+ self._cache_timestamps: Dict[str, float] = {}
+ self._cache_ttl = 5 # Cache results for 5 seconds
+
+ # Track launcher state for auto-hiding passwords
+ self._launcher_instance = None
+
+ def initialize(self):
+ """Initialize the password plugin."""
+ self.set_triggers(["pass"])
+ self._setup_launcher_hooks()
+
+ def cleanup(self):
+ """Cleanup the password plugin."""
+ self.revealed_passwords.clear()
+ self._results_cache.clear()
+ self._cache_timestamps.clear()
+ self._cleanup_launcher_hooks()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process password manager queries with caching."""
+ query_key = query_string.strip()
+ current_time = time.time()
+
+ # Check cache first (except for add/remove commands which should always execute)
+ if (
+ not query_key.startswith(("add ", "remove ", "delete "))
+ and query_key in self._results_cache
+ and (current_time - self._cache_timestamps.get(query_key, 0))
+ < self._cache_ttl
+ ):
+ return self._results_cache[query_key]
+
+ results = []
+ query = query_key.lower()
+
+ # Handle different commands
+ if not query:
+ # Show all passwords
+ results.extend(self._list_all_passwords())
+ elif query.startswith("add "):
+ # Add new password (don't cache)
+ results.extend(self._handle_add_command(query_string))
+ elif query.startswith("remove ") or query.startswith("delete "):
+ # Remove password (don't cache)
+ results.extend(self._handle_remove_command(query_string))
+ else:
+ # Search for specific password
+ results.extend(self._search_passwords(query))
+
+ # Cache results (except for add/remove commands)
+ if not query.startswith(("add ", "remove ", "delete ")):
+ self._results_cache[query_key] = results
+ self._cache_timestamps[query_key] = current_time
+
+ return results
+
+ def _list_all_passwords(self) -> List[Result]:
+ """List all stored passwords."""
+ results = []
+ password_names = self.password_manager.list_passwords()
+
+ if not password_names:
+ results.append(
+ Result(
+ title="No passwords stored",
+ subtitle="Use 'pass add ' to add your first password",
+ icon_name="password-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "empty", "keep_launcher_open": True},
+ )
+ )
+ results.append(
+ Result(
+ title="Available commands:",
+ subtitle="add | remove | (to search)",
+ icon_name="info",
+ action=lambda: None,
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={"type": "help", "keep_launcher_open": True},
+ )
+ )
+ return results
+
+ # Sort passwords alphabetically
+ password_names.sort()
+
+ for name in password_names:
+ info = self.password_manager.get_password_info(name)
+ description = info.get("description", "") if info else ""
+
+ # Check if password is revealed
+ if name in self.revealed_passwords:
+ title = f"{name}: {self.revealed_passwords[name]}"
+ subtitle = "Password revealed - Enter: copy | Shift+Enter: hide"
+ else:
+ title = f"{name}: {'*' * 8}"
+ subtitle = "Enter: copy | Shift+Enter: reveal password"
+
+ if description:
+ subtitle += f" | {description}"
+
+ results.append(
+ Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name="key",
+ action=lambda n=name: self._copy_password_to_clipboard(n),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "type": "password",
+ "name": name,
+ "keep_launcher_open": False,
+ "alt_action": lambda n=name: self._toggle_password_visibility(
+ n
+ ),
+ },
+ )
+ )
+
+ return results
+
+ def _handle_add_command(self, query_string: str) -> List[Result]:
+ """Handle add password command."""
+ results = []
+ parts = query_string.strip().split(" ", 3)
+
+ if len(parts) < 3:
+ results.append(
+ Result(
+ title="Add Password - Invalid format",
+ subtitle="Usage: add [description]",
+ icon_name="cancel",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ )
+ return results
+
+ name = parts[1]
+ password = parts[2]
+ description = parts[3] if len(parts) > 3 else ""
+
+ # Check if password already exists
+ if name in self.password_manager.list_passwords():
+ results.append(
+ Result(
+ title=f"Update password for '{name}'?",
+ subtitle="Password already exists. Click to update it.",
+ icon_name="key",
+ action=lambda: self._add_password_action(
+ name, password, description, update=True
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "update", "name": name, "keep_launcher_open": False},
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title=f"Add password for '{name}'",
+ subtitle="Click to save password"
+ + (f" | {description}" if description else ""),
+ icon_name="plus",
+ action=lambda: self._add_password_action(
+ name, password, description
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "add", "name": name, "keep_launcher_open": False},
+ )
+ )
+
+ return results
+
+ def _handle_remove_command(self, query_string: str) -> List[Result]:
+ """Handle remove password command."""
+ results = []
+ parts = query_string.strip().split(" ", 1)
+
+ if len(parts) < 2:
+ results.append(
+ Result(
+ title="Remove Password - Invalid format",
+ subtitle="Usage: remove ",
+ icon_name="cancel",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ )
+ return results
+
+ name = parts[1]
+
+ if name not in self.password_manager.list_passwords():
+ results.append(
+ Result(
+ title=f"Password '{name}' not found",
+ subtitle="Check the name and try again",
+ icon_name="cancel",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "error", "keep_launcher_open": True},
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title=f"Remove password '{name}'?",
+ subtitle="Click to confirm deletion (this cannot be undone)",
+ icon_name="trash",
+ action=lambda: self._remove_password_action(name),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "remove", "name": name, "keep_launcher_open": False},
+ )
+ )
+
+ return results
+
+ def _search_passwords(self, query: str) -> List[Result]:
+ """Search for passwords by name."""
+ results = []
+ password_names = self.password_manager.list_passwords()
+
+ # Filter passwords that match the query
+ matching_passwords = [
+ name for name in password_names if query.lower() in name.lower()
+ ]
+
+ if not matching_passwords:
+ results.append(
+ Result(
+ title=f"No passwords found matching '{query}'",
+ subtitle="Try a different search term or use 'pass' to see all passwords",
+ icon_name="magnifier",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "no_results", "keep_launcher_open": True},
+ )
+ )
+ return results
+
+ # Sort by relevance (exact match first, then starts with, then contains)
+ def get_relevance(name: str) -> float:
+ name_lower = name.lower()
+ query_lower = query.lower()
+
+ if name_lower == query_lower:
+ return 1.0
+ elif name_lower.startswith(query_lower):
+ return 0.9
+ else:
+ return 0.7
+
+ matching_passwords.sort(key=get_relevance, reverse=True)
+
+ for name in matching_passwords:
+ info = self.password_manager.get_password_info(name)
+ description = info.get("description", "") if info else ""
+
+ # Check if password is revealed
+ if name in self.revealed_passwords:
+ title = f"{name}: {self.revealed_passwords[name]}"
+ subtitle = "Password revealed - Enter: copy | Shift+Enter: hide"
+ else:
+ title = f"{name}: {'*' * 8}"
+ subtitle = "Enter: copy | Shift+Enter: reveal password"
+
+ if description:
+ subtitle += f" | {description}"
+
+ results.append(
+ Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name="key",
+ action=lambda n=name: self._copy_password_to_clipboard(n),
+ relevance=get_relevance(name),
+ plugin_name=self.display_name,
+ data={
+ "type": "password",
+ "name": name,
+ "keep_launcher_open": False,
+ "alt_action": lambda n=name: self._toggle_password_visibility(
+ n
+ ),
+ },
+ )
+ )
+
+ return results
+
+ def _add_password_action(
+ self, name: str, password: str, description: str = "", update: bool = False
+ ):
+ """Action to add/update a password."""
+ try:
+ success = self.password_manager.add_password(name, password, description)
+ if success:
+ action_word = "updated" if update else "added"
+ # Clear cache to force refresh
+ self._results_cache.clear()
+
+ # Send notification if available (non-blocking)
+ try:
+ subprocess.Popen(
+ [
+ "notify-send",
+ "Password Manager",
+ f"Password '{name}' {action_word} successfully",
+ ]
+ )
+ except Exception:
+ pass
+ else:
+ print(f"Failed to add password '{name}'")
+ except Exception as e:
+ print(f"Error adding password: {e}")
+
+ def _remove_password_action(self, name: str):
+ """Action to remove a password."""
+ try:
+ success = self.password_manager.remove_password(name)
+ if success:
+ # Remove from revealed passwords if present
+ self.revealed_passwords.pop(name, None)
+
+ # Clear cache to force refresh
+ self._results_cache.clear()
+
+ # Send notification if available (non-blocking)
+ try:
+ subprocess.Popen(
+ [
+ "notify-send",
+ "Password Manager",
+ f"Password '{name}' removed successfully",
+ ]
+ )
+ except Exception:
+ pass
+ else:
+ print(f"Failed to remove password '{name}'")
+ except Exception as e:
+ print(f"Error removing password: {e}")
+
+ def _copy_password_to_clipboard(self, name: str):
+ """Copy password to clipboard and reveal it temporarily."""
+ try:
+ password = self.password_manager.get_password(
+ name, update_access_time=False
+ )
+ if password:
+ # Copy to clipboard (use timeout to avoid hanging)
+ try:
+ subprocess.run(
+ ["wl-copy"], input=password.encode(), check=True, timeout=2
+ )
+ except subprocess.SubprocessError:
+ # Fall back to X11
+ subprocess.run(
+ ["xclip", "-selection", "clipboard"],
+ input=password.encode(),
+ check=True,
+ timeout=2,
+ )
+
+ # Reveal password temporarily
+ self.revealed_passwords[name] = password
+
+ # Send notification if available (non-blocking)
+ try:
+ subprocess.Popen(
+ [
+ "notify-send",
+ "Password Manager",
+ f"Password for '{name}' copied to clipboard",
+ ]
+ )
+ except Exception:
+ pass
+
+ # Clear cache to force refresh
+ self._results_cache.clear()
+
+ else:
+ print(f"Failed to retrieve password for '{name}'")
+ except Exception as e:
+ print(f"Error copying password: {e}")
+
+ def _reveal_password(self, name: str):
+ """Reveal password without copying to clipboard."""
+ try:
+ password = self.password_manager.get_password(
+ name, update_access_time=False
+ )
+ if password:
+ self.revealed_passwords[name] = password
+ # Clear cache to force refresh with revealed password
+ self._results_cache.clear()
+ else:
+ print(f"Failed to retrieve password for '{name}'")
+ except Exception as e:
+ print(f"Error revealing password: {e}")
+
+ def _hide_password(self, name: str):
+ """Hide revealed password."""
+ self.revealed_passwords.pop(name, None)
+
+ def _hide_all_passwords(self):
+ """Hide all revealed passwords."""
+ if self.revealed_passwords:
+ self.revealed_passwords.clear()
+ # Clear cache to force refresh with hidden passwords
+ self._results_cache.clear()
+
+ def _setup_launcher_hooks(self):
+ """Setup hooks to monitor launcher state."""
+ try:
+ # Try to find the launcher instance
+ import gc
+
+ for obj in gc.get_objects():
+ if (
+ hasattr(obj, "__class__")
+ and obj.__class__.__name__ == "Launcher"
+ and hasattr(obj, "close_launcher")
+ ):
+ self._launcher_instance = obj
+ # Store original close_launcher method
+ self._original_close_launcher = obj.close_launcher
+ # Replace with our wrapper
+ obj.close_launcher = self._wrapped_close_launcher
+ break
+ except Exception as e:
+ print(f"Warning: Could not setup launcher hooks: {e}")
+
+ def _cleanup_launcher_hooks(self):
+ """Cleanup launcher hooks."""
+ try:
+ if self._launcher_instance and hasattr(self, "_original_close_launcher"):
+ # Restore original close_launcher method
+ self._launcher_instance.close_launcher = self._original_close_launcher
+ self._launcher_instance = None
+ except Exception as e:
+ print(f"Warning: Could not cleanup launcher hooks: {e}")
+
+ def _wrapped_close_launcher(self):
+ """Wrapper for launcher close that hides passwords."""
+ # Hide all passwords when launcher closes
+ self._hide_all_passwords()
+ # Call original close_launcher method
+ if hasattr(self, "_original_close_launcher"):
+ self._original_close_launcher()
+
+ def _toggle_password_visibility(self, name: str):
+ """Toggle password visibility when Shift+Enter is pressed."""
+ if name in self.revealed_passwords:
+ # Hide password
+ self.revealed_passwords.pop(name, None)
+ self._results_cache.clear()
+ else:
+ # Reveal password
+ try:
+ password = self.password_manager.get_password(
+ name, update_access_time=False
+ )
+ if password:
+ self.revealed_passwords[name] = password
+ self._results_cache.clear()
+ else:
+ print(f"Failed to retrieve password for '{name}'")
+ except Exception as e:
+ print(f"Error revealing password: {e}")
+
+ # Force refresh of the launcher to show updated state
+ self._force_launcher_refresh()
+
+ def _force_launcher_refresh(self):
+ """Force the launcher to refresh and show updated results."""
+ try:
+ if self._launcher_instance and hasattr(
+ self._launcher_instance, "_perform_search"
+ ):
+ # Get current search text
+ current_text = ""
+ if hasattr(self._launcher_instance, "search_entry"):
+ current_text = self._launcher_instance.search_entry.get_text()
+
+ # Trigger a search to refresh results
+ try:
+
+ def refresh():
+ self._launcher_instance._perform_search(current_text)
+ return False
+
+ GLib.timeout_add(50, refresh)
+ except ImportError:
+ # Fallback: direct call if GLib not available
+ self._launcher_instance._perform_search(current_text)
+ except Exception as e:
+ print(f"Could not force launcher refresh: {e}")
diff --git a/src/window/launcher/plugins/power.py b/src/window/launcher/plugins/power.py
new file mode 100644
index 0000000..d6c2dd5
--- /dev/null
+++ b/src/window/launcher/plugins/power.py
@@ -0,0 +1,102 @@
+from typing import List
+
+from fabric.utils import exec_shell_command_async
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class PowerPlugin(PluginBase):
+ """
+ Plugin for system power management operations.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Power"
+ self.description = "System power management and control"
+ self.commands = {
+ "shutdown": {
+ "description": "Shutdown the system",
+ "icon": "system-shutdown-symbolic",
+ "action": self.shutdown,
+ },
+ "restart": {
+ "description": "Restart the system",
+ "icon": "system-reboot-symbolic",
+ "action": self.restart,
+ },
+ "lock": {
+ "description": "Lock the screen",
+ "icon": "system-lock-screen-symbolic",
+ "action": self.lock,
+ },
+ "suspend": {
+ "description": "Suspend the system",
+ "icon": "system-suspend-symbolic",
+ "action": self.suspend,
+ },
+ "logout": {
+ "description": "Logout from current session",
+ "icon": "system-log-out-symbolic",
+ "action": self.logout,
+ },
+ }
+
+ def initialize(self):
+ """Initialize the power plugin."""
+ self.set_triggers(["power"])
+
+ def cleanup(self):
+ """Cleanup the power plugin."""
+ pass
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search power commands based on query."""
+ query = query_string.lower().strip()
+ results = []
+
+ # If no query, show all power commands
+ if not query:
+ for cmd, info in self.commands.items():
+ result = Result(
+ title=cmd.capitalize(),
+ subtitle=info["description"],
+ icon_name=info["icon"],
+ action=info["action"],
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"command": cmd},
+ )
+ results.append(result)
+ else:
+ # Filter commands based on query
+ for cmd, info in self.commands.items():
+ if query in cmd.lower() or query in info["description"].lower():
+ result = Result(
+ title=cmd.capitalize(),
+ subtitle=info["description"],
+ icon_name=info["icon"],
+ action=info["action"],
+ relevance=1.0 if query == cmd else 0.7,
+ plugin_name=self.display_name,
+ data={"command": cmd},
+ )
+ results.append(result)
+
+ return results
+
+ def shutdown(self, *args) -> None:
+ exec_shell_command_async("systemctl poweroff")
+
+ def restart(self, *args) -> None:
+ exec_shell_command_async("systemctl reboot")
+
+ def lock(self, *args) -> None:
+ exec_shell_command_async("loginctl lock-session")
+
+ def suspend(self, *args) -> None:
+ exec_shell_command_async("systemctl suspend")
+
+ def logout(self, *args) -> None:
+ exec_shell_command_async("hyprctl dispatch exit")
diff --git a/src/window/launcher/plugins/reminders.py b/src/window/launcher/plugins/reminders.py
new file mode 100644
index 0000000..4cd1357
--- /dev/null
+++ b/src/window/launcher/plugins/reminders.py
@@ -0,0 +1,448 @@
+import re
+import subprocess
+import threading
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class Reminder:
+ """
+ Represents a single reminder with its timer and metadata.
+ """
+
+ def __init__(
+ self,
+ reminder_id: int,
+ message: str,
+ target_time: datetime,
+ timer: threading.Timer,
+ ):
+ self.id = reminder_id
+ self.message = message
+ self.target_time = target_time
+ self.timer = timer
+ self.created_time = datetime.now()
+
+ def cancel(self):
+ """Cancel this reminder."""
+ if self.timer:
+ self.timer.cancel()
+
+ def get_time_remaining(self) -> str:
+ """Get formatted time remaining until reminder."""
+ now = datetime.now()
+ if self.target_time <= now:
+ return "Overdue"
+
+ delta = self.target_time - now
+ total_seconds = int(delta.total_seconds())
+
+ if total_seconds < 60:
+ return f"{total_seconds}s"
+ elif total_seconds < 3600:
+ minutes = total_seconds // 60
+ seconds = total_seconds % 60
+ return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m"
+ else:
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
+
+ def get_target_time_str(self) -> str:
+ """Get formatted target time."""
+ return self.target_time.strftime("%H:%M")
+
+
+class RemindersPlugin(PluginBase):
+ """
+ Time-based reminders plugin for the launcher.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Reminders"
+ self.description = "Set time-based reminders with notifications"
+ self.reminders: Dict[int, Reminder] = {}
+ self.next_id = 1
+
+ # Regex patterns for time parsing
+ self.time_patterns = {
+ "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d
+ "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15
+ "relative_with_unit": re.compile(
+ r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$",
+ re.IGNORECASE,
+ ),
+ }
+
+ def initialize(self):
+ """Initialize the reminders plugin."""
+ self.set_triggers(["remind"])
+
+ def cleanup(self):
+ """Cleanup the reminders plugin."""
+ # Cancel all active reminders
+ for reminder in self.reminders.values():
+ reminder.cancel()
+ self.reminders.clear()
+
+ def _send_notification(self, title: str, message: str):
+ """Send a desktop notification using notify-send."""
+ try:
+ subprocess.run(
+ ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message],
+ check=False,
+ )
+ except Exception as e:
+ print(f"Failed to send notification: {e}")
+
+ def _parse_time_input(self, time_str: str) -> Optional[datetime]:
+ """
+ Parse various time input formats and return target datetime.
+
+ Supported formats:
+ - 5m, 30s, 2h, 1d (relative time)
+ - 14:30, 9:15 (absolute time today)
+ - 5 minutes, 2 hours (relative with full unit names)
+ """
+ time_str = time_str.strip().lower()
+
+ # Try relative time format (5m, 30s, 2h, 1d)
+ match = self.time_patterns["relative_time"].match(time_str)
+ if match:
+ value, unit = match.groups()
+ value = int(value)
+
+ if unit == "s":
+ delta = timedelta(seconds=value)
+ elif unit == "m":
+ delta = timedelta(minutes=value)
+ elif unit == "h":
+ delta = timedelta(hours=value)
+ elif unit == "d":
+ delta = timedelta(days=value)
+ else:
+ return None
+
+ return datetime.now() + delta
+
+ # Try absolute time format (14:30, 9:15)
+ match = self.time_patterns["absolute_time"].match(time_str)
+ if match:
+ hour, minute = map(int, match.groups())
+ if 0 <= hour <= 23 and 0 <= minute <= 59:
+ target = datetime.now().replace(
+ hour=hour, minute=minute, second=0, microsecond=0
+ )
+ # If the time has already passed today, schedule for tomorrow
+ if target <= datetime.now():
+ target += timedelta(days=1)
+ return target
+
+ # Try relative time with full unit names
+ match = self.time_patterns["relative_with_unit"].match(time_str)
+ if match:
+ value, unit = match.groups()
+ value = int(value)
+ unit = unit.lower()
+
+ if unit in ["sec", "seconds"]:
+ delta = timedelta(seconds=value)
+ elif unit in ["min", "mins", "minute", "minutes"]:
+ delta = timedelta(minutes=value)
+ elif unit in ["hour", "hours"]:
+ delta = timedelta(hours=value)
+ elif unit in ["day", "days"]:
+ delta = timedelta(days=value)
+ else:
+ return None
+
+ return datetime.now() + delta
+
+ return None
+
+ def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]:
+ """Create a new reminder with the given time and message."""
+ target_time = self._parse_time_input(time_str)
+ if not target_time:
+ return None
+
+ # Calculate delay in seconds
+ delay = (target_time - datetime.now()).total_seconds()
+ if delay <= 0:
+ return None
+
+ # Create timer that will trigger the notification
+ timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message])
+
+ # Create reminder object
+ reminder = Reminder(self.next_id, message, target_time, timer)
+
+ # Store reminder and start timer
+ self.reminders[self.next_id] = reminder
+ timer.start()
+
+ # Increment ID for next reminder
+ self.next_id += 1
+
+ return reminder
+
+ def _trigger_reminder(self, reminder_id: int, message: str):
+ """Trigger a reminder notification and remove it from active reminders."""
+ # Send notification
+ self._send_notification("⏰ Reminder", message)
+
+ # Remove from active reminders
+ if reminder_id in self.reminders:
+ del self.reminders[reminder_id]
+
+ def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int:
+ """Cancel a specific reminder or all reminders. Returns number of cancelled reminders."""
+ if reminder_id is not None:
+ if reminder_id in self.reminders:
+ self.reminders[reminder_id].cancel()
+ del self.reminders[reminder_id]
+ return 1
+ return 0
+ else:
+ # Cancel all reminders
+ count = len(self.reminders)
+ for reminder in self.reminders.values():
+ reminder.cancel()
+ self.reminders.clear()
+ return count
+
+ def _format_time_remaining(self, total_seconds: float) -> str:
+ """Format time remaining in a human-readable way."""
+ total_seconds = int(total_seconds)
+
+ if total_seconds < 60:
+ return f"{total_seconds}s"
+ elif total_seconds < 3600:
+ minutes = total_seconds // 60
+ seconds = total_seconds % 60
+ return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m"
+ else:
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
+
+ def _create_and_confirm_reminder(self, time_str: str, message: str):
+ """Actually create the reminder when the user presses Enter."""
+ reminder = self._create_reminder(time_str, message)
+ if reminder:
+ time_remaining = reminder.get_time_remaining()
+ self._send_notification(
+ "✅ Reminder Created", f"Reminder set for {time_remaining}: {message}"
+ )
+ else:
+ self._send_notification(
+ "❌ Failed to Create Reminder", "The specified time may be in the past"
+ )
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process reminder queries."""
+ results = []
+ query = query_string.strip()
+
+ if not query:
+ # Show help and active reminders count
+ active_count = len(self.reminders)
+ results.append(
+ Result(
+ title="Reminders Help",
+ subtitle=f"Active reminders: {
+ active_count
+ } | Usage: remind 5m Take a break",
+ icon_name="alarm-timer",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "help"},
+ )
+ )
+
+ # Show quick examples
+ examples = [
+ ("remind 5m Take a break", "Set 5 minute reminder"),
+ ("remind 14:30 Meeting", "Set reminder for 2:30 PM"),
+ ("remind list", "List active reminders"),
+ ("remind cancel", "Cancel all reminders"),
+ ]
+
+ for example, desc in examples:
+ results.append(
+ Result(
+ title=example,
+ subtitle=desc,
+ icon_name="alarm-timer",
+ action=lambda: None,
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={"type": "example"},
+ )
+ )
+
+ return results
+
+ # Handle list command
+ if query.lower() in ["list", "ls", "show"]:
+ if not self.reminders:
+ results.append(
+ Result(
+ title="No Active Reminders",
+ subtitle="Use 'remind 5m message' to set a reminder",
+ icon_name="timer-off",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "empty_list"},
+ )
+ )
+ else:
+ for reminder in sorted(
+ self.reminders.values(), key=lambda r: r.target_time
+ ):
+ time_remaining = reminder.get_time_remaining()
+ target_time = reminder.get_target_time_str()
+
+ results.append(
+ Result(
+ title=f"#{reminder.id}: {reminder.message}",
+ subtitle=f"In {time_remaining} (at {target_time})",
+ icon_name="alarm-timer",
+ action=lambda rid=reminder.id: self._cancel_reminder(rid),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "active_reminder", "id": reminder.id},
+ )
+ )
+
+ return results
+
+ # Handle cancel command
+ if query.lower().startswith("cancel") or query.lower().startswith("stop"):
+ parts = query.split()
+ if len(parts) == 1:
+ # Cancel all reminders
+ count = self._cancel_reminder()
+ results.append(
+ Result(
+ title=f"Cancelled {count} Reminders",
+ subtitle="All active reminders have been cancelled",
+ icon_name="timer-off",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "cancel_all"},
+ )
+ )
+ else:
+ # Try to cancel specific reminder by ID
+ try:
+ reminder_id = int(parts[1])
+ count = self._cancel_reminder(reminder_id)
+ if count > 0:
+ results.append(
+ Result(
+ title=f"Cancelled Reminder #{reminder_id}",
+ subtitle="Reminder has been cancelled",
+ icon_name="timer-off",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "cancel_specific"},
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title="Reminder Not Found",
+ subtitle=f"No reminder with ID #{reminder_id}",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error"},
+ )
+ )
+ except ValueError:
+ results.append(
+ Result(
+ title="Invalid Reminder ID",
+ subtitle="Please provide a valid reminder ID number",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error"},
+ )
+ )
+
+ return results
+
+ # Handle setting new reminders
+ parts = query.split(None, 1)
+ if len(parts) >= 1:
+ time_str = parts[0]
+ message = parts[1] if len(parts) > 1 else "Reminder"
+
+ # Try to parse the time (but don't create the reminder yet!)
+ target_time = self._parse_time_input(time_str)
+ if target_time:
+ # Calculate delay and check if it's valid
+ delay = (target_time - datetime.now()).total_seconds()
+ if delay > 0:
+ # Show what would happen, but don't create the reminder yet
+ time_remaining = self._format_time_remaining(delay)
+ target_time_str = target_time.strftime("%H:%M")
+
+ results.append(
+ Result(
+ title=f"Set Reminder: {message}",
+ subtitle=f"Will remind in {time_remaining} (at {
+ target_time_str
+ })",
+ icon_name="alarm-timer",
+ action=lambda ts=time_str, msg=message: (
+ self._create_and_confirm_reminder(ts, msg)
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "type": "preview",
+ "time_str": time_str,
+ "message": message,
+ },
+ )
+ )
+ else:
+ results.append(
+ Result(
+ title="Time is in the Past",
+ subtitle="Please specify a future time",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error"},
+ )
+ )
+ else:
+ # Invalid time format
+ results.append(
+ Result(
+ title="Invalid Time Format",
+ subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'",
+ icon_name="alert",
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error"},
+ )
+ )
+
+ return results
diff --git a/src/window/launcher/plugins/screencapture.py b/src/window/launcher/plugins/screencapture.py
new file mode 100644
index 0000000..d746813
--- /dev/null
+++ b/src/window/launcher/plugins/screencapture.py
@@ -0,0 +1,442 @@
+from typing import List
+from services.screencapture import screen_capture_service
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class ScreencapturePlugin(PluginBase):
+ """
+ Plugin for taking screenshots and screen recordings using screen-capture.sh script.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Screencapture"
+ self.description = "Take screenshots and screen recordings"
+
+ def initialize(self):
+ """Initialize the screencapture plugin."""
+ self.set_triggers(["sc"])
+
+ def cleanup(self):
+ """Cleanup the screencapture plugin."""
+ pass
+
+ def get_commands(self):
+ """Return available commands for this plugin."""
+ return {
+ # Screenshot commands
+ "screenshot": "Take a screenshot of the main display",
+ "screenshot-region": "Take a screenshot of selected region",
+ "screenshot-both": "Take a screenshot of both displays",
+ "screenshot-active": "Take a screenshot of the active display",
+ # Recording commands (with audio)
+ "record": "Start recording main display with audio",
+ "record-region": "Start recording selected region with audio",
+ "record-active": "Start recording active display with audio",
+ # Recording overrides
+ "record-noaudio": "Start recording main display without audio",
+ "record-hq": "Start high-quality recording (eDP-1)",
+ "record-gif": "Start GIF recording (eDP-1)",
+ # Control commands
+ "stop": "Stop current recording",
+ # Conversion commands
+ "convert-webm": "Convert latest MKV recording to WebM format",
+ "convert-iphone": "Convert latest recordings for iPhone",
+ "convert-youtube": "Convert latest recordings for YouTube",
+ "convert-gif": "Convert latest recordings to GIF",
+ # File conversion
+ "convert-file": "Convert specific file (Usage: convert-file [type] [path])",
+ }
+
+ def _activate_default_plugins(self):
+ """No action needed for script cleanup."""
+ pass
+
+ def _is_recording(self):
+ """Check if recording is currently active."""
+ return screen_capture_service.is_recording
+
+ def _get_command_result(self, command: str) -> Result:
+ """Get a Result object for a specific command."""
+ # Map aliases manually if needed
+ if command == "ss":
+ command = "screenshot"
+ if command == "rec":
+ command = "record"
+ if command == "conv":
+ command = "convert"
+
+ command_info = {
+ # Screenshot commands
+ "screenshot": (
+ "Take Screenshot (eDP-1)",
+ "Capture the main display",
+ "camera-photo-symbolic",
+ lambda: screen_capture_service.screenshot("eDP-1"),
+ ),
+ "screenshot-region": (
+ "Take Region Screenshot",
+ "Capture a selected region",
+ "camera-photo-symbolic",
+ lambda: screen_capture_service.screenshot("region"),
+ ),
+ "screenshot-both": (
+ "Take Screenshot (Both Displays)",
+ "Capture both displays combined",
+ "video-joined-displays-symbolic",
+ lambda: screen_capture_service.screenshot("both"),
+ ),
+ "screenshot-active": (
+ "Take Screenshot (Active)",
+ "Capture the active display",
+ "camera-photo-symbolic",
+ lambda: screen_capture_service.screenshot("active"),
+ ),
+ # Recording commands (with audio)
+ "record": (
+ "Start Recording (eDP-1)",
+ "Record the main display with audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1"),
+ ),
+ "record-region": (
+ "Start Region Recording",
+ "Record a selected region with audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("selection"),
+ ),
+ "record-active": (
+ "Start Recording (Active)",
+ "Record the active display with audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("active"),
+ ),
+ # Recording overrides
+ "record-noaudio": (
+ "Start Recording No Audio (eDP-1)",
+ "Record without audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1", no_audio=True),
+ ),
+ "record-hq": (
+ "Start HQ Recording (eDP-1)",
+ "High-quality recording for YouTube",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1", mode="hq"),
+ ),
+ "record-gif": (
+ "Start GIF Recording (eDP-1)",
+ "Record as optimized GIF",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1", mode="gif"),
+ ),
+ # Control commands
+ "stop": (
+ "Stop Recording",
+ "Stop the current screen recording",
+ "media-playback-stop-symbolic",
+ lambda: screen_capture_service.stop_recording(),
+ ),
+ # Conversion commands
+ "convert-webm": (
+ "Convert Latest to WebM",
+ "Convert latest MKV recording to WebM format",
+ "video-x-generic-symbolic",
+ lambda: screen_capture_service.convert("webm"),
+ ),
+ "convert-iphone": (
+ "Convert Latest for iPhone",
+ "Convert latest MKV recording for iPhone",
+ "video-x-generic-symbolic",
+ lambda: screen_capture_service.convert("iphone"),
+ ),
+ "convert-youtube": (
+ "Convert Latest for YouTube",
+ "Convert latest recording for YouTube",
+ "video-x-generic-symbolic",
+ lambda: screen_capture_service.convert("youtube"),
+ ),
+ "convert-gif": (
+ "Convert Latest to GIF",
+ "Convert latest recording to GIF",
+ "image-x-generic-symbolic",
+ lambda: screen_capture_service.convert("gif"),
+ ),
+ }
+
+ if command in command_info:
+ title, subtitle, icon, action = command_info[command]
+ if action is not None: # Regular command
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name=icon,
+ action=action,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ else: # File-based command, show instruction
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name=icon,
+ action=lambda: None, # No action for instruction
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+
+ return None
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search for screencapture actions based on query."""
+ # Import here to avoid circular imports
+
+ # Clean the query string
+ query = query_string.strip().lower()
+
+ results = []
+
+ # Parse query for file-based commands
+ query_parts = query.split()
+ if len(query_parts) >= 3 and query_parts[0] == "convert-file":
+ format_type = query_parts[1]
+ file_param = " ".join(query_parts[2:])
+ if format_type in ["webm", "iphone", "youtube", "gif"]:
+ return [
+ Result(
+ title=f"Convert {file_param} to {format_type.upper()}",
+ subtitle=f"Convert specified file to {format_type} format",
+ icon_name=(
+ "video-x-generic-symbolic"
+ if format_type != "gif"
+ else "image-x-generic-symbolic"
+ ),
+ action=lambda fp=file_param, ft=format_type: (
+ screen_capture_service.convert(ft, fp)
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ ]
+
+ # Check if query matches a command and return it as a result
+ command_result = self._get_command_result(query)
+ if command_result:
+ return [command_result]
+
+ # Check recording status
+ is_recording = self._is_recording()
+
+ # If recording is active, show stop button first with highest relevance
+ if is_recording:
+ results.append(
+ Result(
+ title="Stop Recording",
+ subtitle="Stop the current screen recording",
+ icon_name="media-playback-stop-symbolic",
+ action=lambda: screen_capture_service.stop_recording(),
+ relevance=2.0, # Highest relevance to appear at top
+ plugin_name=self.display_name,
+ )
+ )
+
+ # Screenshot actions
+ results.extend(
+ [
+ Result(
+ title="Take Screenshot",
+ subtitle="Capture the entire screen (eDP-1)",
+ icon_name="camera-photo-symbolic",
+ action=lambda: screen_capture_service.screenshot("eDP-1"),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Region Screenshot",
+ subtitle="Capture a selected region",
+ icon_name="camera-photo-symbolic",
+ action=lambda: screen_capture_service.screenshot("region"),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Screenshot (Both Displays)",
+ subtitle="Capture both displays combined",
+ icon_name="video-joined-displays-symbolic",
+ action=lambda: screen_capture_service.screenshot("both"),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Screenshot (HDMI-A-1)",
+ subtitle="Capture HDMI display",
+ icon_name="video-display-symbolic",
+ action=lambda: screen_capture_service.screenshot("HDMI-A-1"),
+ relevance=0.7,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # Standard recording actions
+ results.extend(
+ [
+ Result(
+ title="Start Recording (eDP-1)",
+ subtitle="Record the main display with audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("eDP-1"),
+ relevance=0.7,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Region Recording",
+ subtitle="Record a selected region",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("selection"),
+ relevance=0.6,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Recording (HDMI-A-1)",
+ subtitle="Record HDMI display with audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("HDMI-A-1"),
+ relevance=0.5,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # No-audio recording actions
+ results.extend(
+ [
+ Result(
+ title="Start Recording No Audio (eDP-1)",
+ subtitle="Record the main display without audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "eDP-1", no_audio=True
+ ),
+ relevance=0.65,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Region Recording No Audio",
+ subtitle="Record a selected region without audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "selection", no_audio=True
+ ),
+ relevance=0.55,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Recording No Audio (HDMI-A-1)",
+ subtitle="Record HDMI display without audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "HDMI-A-1", no_audio=True
+ ),
+ relevance=0.45,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # High-quality recording actions
+ results.extend(
+ [
+ Result(
+ title="Start HQ Recording (eDP-1)",
+ subtitle="High-quality recording for YouTube",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("eDP-1", mode="hq"),
+ relevance=0.4,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start HQ Region Recording",
+ subtitle="High-quality region recording",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "selection", mode="hq"
+ ),
+ relevance=0.3,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start HQ Recording (HDMI-A-1)",
+ subtitle="High-quality HDMI recording",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("HDMI-A-1", mode="hq"),
+ relevance=0.2,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # GIF recording actions
+ results.extend(
+ [
+ Result(
+ title="Start GIF Recording (eDP-1)",
+ subtitle="Record as optimized GIF",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("eDP-1", mode="gif"),
+ relevance=0.1,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start GIF Region Recording",
+ subtitle="Record selected region as GIF",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "selection", mode="gif"
+ ),
+ relevance=0.05,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # Conversion actions
+ results.extend(
+ [
+ Result(
+ title="Convert Latest to WebM",
+ subtitle="Convert latest MKV recording to WebM format",
+ icon_name="video-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("webm"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest for iPhone",
+ subtitle="Convert latest MKV recording for iPhone compatibility",
+ icon_name="video-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("iphone"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest for YouTube",
+ subtitle="Convert latest recording for YouTube upload",
+ icon_name="video-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("youtube"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest to GIF",
+ subtitle="Convert latest recording to GIF format",
+ icon_name="image-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("gif"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ return results
diff --git a/src/window/launcher/plugins/system.py b/src/window/launcher/plugins/system.py
new file mode 100644
index 0000000..e850921
--- /dev/null
+++ b/src/window/launcher/plugins/system.py
@@ -0,0 +1,240 @@
+import json
+import threading
+from typing import List, Set, Union
+
+from fabric.utils import exec_shell_command_async, os, shlex, time
+
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class SystemPlugin(PluginBase):
+ """
+ Plugin for system commands and actions.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "System"
+ self.description = "System commands and actions"
+
+ # JSON cache file for system binaries
+ self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json")
+
+ # In-memory cache for system binaries
+ self._bin_cache: Set[str] = set()
+ self._last_bin_update = 0
+ self._bin_update_interval = 300 # 5 minutes
+
+ # Background cache building
+ self._cache_building = False
+ self._cache_thread = None
+
+ def initialize(self):
+ """Initialize the system plugin."""
+ self.set_triggers(["bin"])
+ self._load_bin_cache()
+ self._start_background_cache_update()
+
+ def cleanup(self):
+ """Cleanup the system plugin."""
+ self._bin_cache.clear()
+ if self._cache_thread and self._cache_thread.is_alive():
+ # Note: We don't join the thread to avoid blocking cleanup
+ pass
+
+ def _load_bin_cache(self):
+ """Load binary cache from JSON file."""
+ try:
+ if os.path.exists(self.bin_cache_file):
+ with open(self.bin_cache_file, "r", encoding="utf-8") as f:
+ cache_data = json.load(f)
+ self._bin_cache = set(cache_data.get("binaries", []))
+ self._last_bin_update = cache_data.get("last_update", 0)
+ else:
+ print(
+ "SystemPlugin: No cache file found, will build cache in background"
+ )
+ except Exception as e:
+ print(f"SystemPlugin: Error loading binary cache: {e}")
+ self._bin_cache = set()
+ self._last_bin_update = 0
+
+ def _save_bin_cache(self):
+ """Save binary cache to JSON file."""
+ try:
+ # Ensure the cache directory exists
+ os.makedirs(data.CACHE_DIR, exist_ok=True)
+
+ cache_data = {
+ "binaries": sorted(list(self._bin_cache)),
+ "last_update": self._last_bin_update,
+ "cache_version": "1.0",
+ }
+
+ with open(self.bin_cache_file, "w", encoding="utf-8") as f:
+ json.dump(cache_data, f, indent=2)
+ except Exception as e:
+ print(f"SystemPlugin: Error saving binary cache: {e}")
+
+ def _start_background_cache_update(self):
+ """Start background thread to update binary cache."""
+ current_time = time.time()
+
+ # Check if cache needs updating
+ if (
+ current_time - self._last_bin_update > self._bin_update_interval
+ or not self._bin_cache
+ ):
+ if not self._cache_building:
+ self._cache_building = True
+ self._cache_thread = threading.Thread(
+ target=self._build_bin_cache_background, daemon=True
+ )
+ self._cache_thread.start()
+
+ def _build_bin_cache_background(self):
+ """Build binary cache in background thread."""
+ try:
+ new_cache = set()
+ processed_paths = set() # Avoid duplicate paths
+
+ for path in os.environ["PATH"].split(":"):
+ # Skip empty paths and duplicates
+ if not path or path in processed_paths:
+ continue
+ processed_paths.add(path)
+
+ if os.path.exists(path) and os.path.isdir(path):
+ try:
+ # Use os.scandir for better performance than os.listdir
+ with os.scandir(path) as entries:
+ for entry in entries:
+ if entry.is_file(follow_symlinks=False) and os.access(
+ entry.path, os.X_OK
+ ):
+ new_cache.add(entry.name)
+ except (PermissionError, FileNotFoundError, OSError):
+ continue
+
+ # Update cache atomically
+ self._bin_cache = new_cache
+ self._last_bin_update = time.time()
+
+ # Save to disk
+ self._save_bin_cache()
+
+ except Exception as e:
+ print(f"SystemPlugin: Error building binary cache: {e}")
+ finally:
+ self._cache_building = False
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search for system commands matching the query."""
+ query = query_string.strip()
+
+ if not query:
+ return []
+
+ results = []
+
+ # Parse the query to extract binary name and arguments
+ query_parts = query.split()
+ if not query_parts:
+ return []
+
+ binary_query = query_parts[0].lower()
+ full_command = query # Keep the original case and spacing
+
+ # Check system binaries
+ # Start background update if needed (non-blocking) - but only if cache is empty or very old
+ if not self._bin_cache or (
+ time.time() - self._last_bin_update > self._bin_update_interval
+ ):
+ self._start_background_cache_update()
+
+ # Optimize search with early termination and result limiting
+ exact_matches = []
+ prefix_matches = []
+ partial_matches = []
+ max_results = 20 # Limit total results for performance
+
+ for binary in self._bin_cache:
+ # Pre-compute lowercase once
+ binary_lower = binary.lower()
+
+ # Skip if no match at all
+ if binary_query not in binary_lower:
+ continue
+
+ # Categorize matches for better sorting
+ if binary_lower == binary_query:
+ # Exact match - highest priority
+ display_command = full_command
+ command_to_execute = full_command
+ relevance = 1.0
+ exact_matches.append(
+ (binary, display_command, command_to_execute, relevance)
+ )
+ elif binary_lower.startswith(binary_query):
+ # Prefix match - high priority
+ display_command = binary
+ command_to_execute = binary
+ relevance = 0.9
+ prefix_matches.append(
+ (binary, display_command, command_to_execute, relevance)
+ )
+ else:
+ # Partial match - lower priority
+ display_command = binary
+ command_to_execute = binary
+ relevance = 0.7
+ partial_matches.append(
+ (binary, display_command, command_to_execute, relevance)
+ )
+
+ # Early termination if we have enough good matches
+ if len(exact_matches) + len(prefix_matches) >= max_results:
+ break
+
+ # Combine results in priority order
+ all_matches = exact_matches + prefix_matches + partial_matches
+
+ # Convert to Result objects (limit to max_results)
+ for binary, display_command, command_to_execute, relevance in all_matches[
+ :max_results
+ ]:
+ result = Result(
+ title=display_command,
+ subtitle=f"Execute: {display_command}",
+ icon_name="terminal",
+ action=self._create_action(command_to_execute),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"command": command_to_execute, "id": binary},
+ )
+ results.append(result)
+
+ return results # Already sorted by priority
+
+ def _create_action(self, command: Union[str, List[str]]):
+ """Create an action function for the given command."""
+
+ def action():
+ self._execute_command(command)
+
+ return action
+
+ def _execute_command(self, command: Union[str, List[str]]):
+ """Execute a system command."""
+ try:
+ if isinstance(command, str):
+ # Handle string commands with arguments - split into list for proper execution
+ command_list = shlex.split(command)
+ exec_shell_command_async(command_list)
+ else:
+ # Handle list commands (backward compatibility)
+ exec_shell_command_async(command)
+ except Exception as e:
+ print(f"SystemPlugin: Error executing command '{command}': {e}")
diff --git a/src/window/launcher/plugins/tmux.py b/src/window/launcher/plugins/tmux.py
new file mode 100644
index 0000000..00d96b6
--- /dev/null
+++ b/src/window/launcher/plugins/tmux.py
@@ -0,0 +1,334 @@
+import subprocess
+import threading
+from typing import List
+
+from fabric.utils import exec_shell_command_async, time
+
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class TmuxPlugin(PluginBase):
+ """
+ Plugin for managing tmux sessions through the launcher.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Tmux Manager"
+ self.description = "Manage tmux sessions - create, attach, rename, and kill"
+
+ # Cache for sessions to avoid repeated subprocess calls
+ self._sessions_cache = []
+ self._last_cache_update = 0
+ # Cache sessions for 10 seconds (increased from 2)
+ self._cache_ttl = 10
+
+ # Threading for auto-refresh - only when actively used
+ self.refresh_thread = None
+ self.stop_refresh = threading.Event()
+ self._last_query_time = 0
+ # Stop refreshing after 30 seconds of inactivity
+ self._active_refresh_timeout = 30
+
+ def initialize(self):
+ """Initialize the tmux plugin."""
+ self.set_triggers(["tmux"])
+ # Don't start refresh thread immediately - start it when first used
+
+ def cleanup(self):
+ """Cleanup the tmux plugin."""
+ self.stop_refresh.set()
+ if self.refresh_thread:
+ self.refresh_thread.join(timeout=1)
+ self._sessions_cache.clear()
+
+ def _start_refresh_thread(self):
+ """Start background thread to refresh session cache."""
+ if not self.refresh_thread or not self.refresh_thread.is_alive():
+ self.refresh_thread = threading.Thread(
+ target=self._refresh_sessions_background, daemon=True
+ )
+ self.refresh_thread.start()
+
+ def _refresh_sessions_background(self):
+ """Background thread to refresh sessions cache only when actively used."""
+ while not self.stop_refresh.is_set():
+ try:
+ current_time = time.time()
+
+ # Stop refreshing if plugin hasn't been used recently
+ if current_time - self._last_query_time > self._active_refresh_timeout:
+ print("TmuxPlugin: Stopping background refresh due to inactivity")
+ break
+
+ if current_time - self._last_cache_update > self._cache_ttl:
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = current_time
+
+ self.stop_refresh.wait(5)
+ except Exception as e:
+ print(f"TmuxPlugin: Error in refresh thread: {e}")
+ self.stop_refresh.wait(10) # Wait longer on error
+
+ def _get_tmux_sessions(self):
+ """Get list of tmux sessions."""
+ try:
+ result = subprocess.run(
+ ["tmux", "list-sessions", "-F", "#{session_name}"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ if result.returncode == 0:
+ return [
+ s.strip() for s in result.stdout.strip().split("\n") if s.strip()
+ ]
+ return []
+ except (
+ subprocess.TimeoutExpired,
+ subprocess.CalledProcessError,
+ FileNotFoundError,
+ ) as e:
+ print(f"TmuxPlugin: Error getting tmux sessions: {e}")
+ return []
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process tmux queries."""
+ query = query_string.strip().lower()
+ results = []
+
+ # Track usage and start refresh thread if needed
+ current_time = time.time()
+ self._last_query_time = current_time
+
+ # Start refresh thread if not running and plugin is being used
+ if not self.refresh_thread or not self.refresh_thread.is_alive():
+ self._start_refresh_thread()
+
+ # Get current sessions (use cache if recent)
+ if current_time - self._last_cache_update > self._cache_ttl:
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = current_time
+
+ sessions = self._sessions_cache
+
+ # Handle specific commands
+ if query.startswith("new ") or query.startswith("create "):
+ session_name = query.split(" ", 1)[1].strip() if " " in query else ""
+ results.append(self._create_new_session_result(session_name))
+
+ elif query.startswith("kill ") or query.startswith("delete "):
+ session_name = query.split(" ", 1)[1].strip() if " " in query else ""
+ if session_name:
+ matching_sessions = [
+ s for s in sessions if session_name.lower() in s.lower()
+ ]
+ for session in matching_sessions:
+ results.append(self._create_kill_session_result(session))
+
+ elif query.startswith("rename "):
+ parts = query.split(" ", 2)
+ if len(parts) >= 3:
+ old_name, new_name = parts[1], parts[2]
+ if old_name in sessions:
+ results.append(
+ self._create_rename_session_result(old_name, new_name)
+ )
+
+ else:
+ # Show existing sessions for attachment
+ if sessions:
+ # Filter sessions based on query
+ if query:
+ filtered_sessions = [s for s in sessions if query in s.lower()]
+ else:
+ filtered_sessions = sessions
+
+ for session in filtered_sessions:
+ results.append(self._create_attach_session_result(session))
+
+ # Always show option to create new session
+ if not query or "new" in query or "create" in query:
+ results.append(
+ self._create_new_session_result(
+ query
+ if query and not any(cmd in query for cmd in ["new", "create"])
+ else ""
+ )
+ )
+
+ return results
+
+ def _create_attach_session_result(self, session_name: str) -> Result:
+ """Create result for attaching to a session."""
+ return Result(
+ title=f"Attach to '{session_name}'",
+ subtitle=f"Connect to tmux session: {session_name}",
+ icon_name="terminal",
+ action=lambda: self._attach_to_session(session_name),
+ relevance=0.9,
+ data={"type": "attach", "session": session_name},
+ )
+
+ def _create_new_session_result(self, session_name: str = "") -> Result:
+ """Create result for creating a new session."""
+ display_name = session_name if session_name else "new session"
+ return Result(
+ title=f"Create '{display_name}'",
+ subtitle=f"Create new tmux session{
+ f': {session_name}' if session_name else ''
+ }",
+ icon_name="plus",
+ action=lambda: self._create_session(session_name),
+ relevance=0.8,
+ data={"type": "create", "session": session_name},
+ )
+
+ def _create_kill_session_result(self, session_name: str) -> Result:
+ """Create result for killing a session."""
+ return Result(
+ title=f"Kill '{session_name}'",
+ subtitle=f"Terminate tmux session: {session_name}",
+ icon_name="trash",
+ action=lambda: self._kill_session(session_name),
+ relevance=0.7,
+ data={"type": "kill", "session": session_name},
+ )
+
+ def _create_rename_session_result(self, old_name: str, new_name: str) -> Result:
+ """Create result for renaming a session."""
+ return Result(
+ title=f"Rename '{old_name}' to '{new_name}'",
+ subtitle=f"Rename tmux session from {old_name} to {new_name}",
+ icon_name="config",
+ action=lambda: self._rename_session(old_name, new_name),
+ relevance=0.6,
+ data={"type": "rename", "old_session": old_name, "new_session": new_name},
+ )
+
+ def _attach_to_session(self, session_name: str):
+ """Attach to an existing tmux session."""
+ try:
+ terminal_cmd = self._get_terminal_command(
+ f"tmux attach-session -t '{session_name}'"
+ )
+ exec_shell_command_async(terminal_cmd)
+ print(f"TmuxPlugin: Attaching to session '{session_name}'")
+ except Exception as e:
+ print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}")
+
+ def _create_session(self, session_name: str = ""):
+ """Create a new tmux session."""
+ try:
+ if not session_name:
+ # Generate a default name
+ existing_sessions = self._get_tmux_sessions()
+ counter = 0
+ while str(counter) in existing_sessions:
+ counter += 1
+ session_name = str(counter)
+
+ # Clean the session name
+ clean_name = session_name.strip().replace(" ", "_")
+
+ # Create session
+ subprocess.run(
+ ["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10
+ )
+
+ # Launch terminal and attach
+ terminal_cmd = self._get_terminal_command(
+ f"tmux attach-session -t '{clean_name}'"
+ )
+ exec_shell_command_async(terminal_cmd)
+
+ # Refresh cache
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = time.time()
+
+ print(f"TmuxPlugin: Created and attached to session '{clean_name}'")
+ except Exception as e:
+ print(f"TmuxPlugin: Error creating session '{session_name}': {e}")
+
+ def _kill_session(self, session_name: str):
+ """Kill a tmux session."""
+ try:
+ subprocess.run(
+ ["tmux", "kill-session", "-t", session_name], check=True, timeout=10
+ )
+
+ # Refresh cache
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = time.time()
+
+ print(f"TmuxPlugin: Killed session '{session_name}'")
+ except Exception as e:
+ print(f"TmuxPlugin: Error killing session '{session_name}': {e}")
+
+ def _rename_session(self, old_name: str, new_name: str):
+ """Rename a tmux session."""
+ try:
+ clean_name = new_name.strip().replace(" ", "_")
+ subprocess.run(
+ ["tmux", "rename-session", "-t", old_name, clean_name],
+ check=True,
+ timeout=10,
+ )
+
+ # Refresh cache
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = time.time()
+
+ print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'")
+ except Exception as e:
+ print(
+ f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}"
+ )
+
+ def _get_terminal_command(self, cmd: str) -> str:
+ """Get terminal command based on configured terminal or available terminals."""
+ # First try to use the configured terminal command
+ if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND:
+ parts = data.TERMINAL_COMMAND.split()
+ terminal = parts[0]
+
+ try:
+ # Check if the configured terminal is available
+ subprocess.run(
+ ["which", terminal],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ return f"{data.TERMINAL_COMMAND} {cmd}"
+ except subprocess.CalledProcessError:
+ # If configured terminal is not available, fall back to defaults
+ pass
+
+ # Fallback to checking available terminals
+ terminals = [
+ ("kitty", f"kitty -e {cmd}"),
+ ("alacritty", f"alacritty -e {cmd}"),
+ ("foot", f"foot {cmd}"),
+ ("gnome-terminal", f"gnome-terminal -- {cmd}"),
+ ("konsole", f"konsole -e {cmd}"),
+ ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"),
+ ]
+
+ for term, term_cmd in terminals:
+ try:
+ # Check if terminal is available
+ subprocess.run(
+ ["which", term],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ return term_cmd
+ except subprocess.CalledProcessError:
+ continue
+
+ # Default fallback
+ return f"kitty -e {cmd}"
diff --git a/src/window/launcher/plugins/wallpaper.py b/src/window/launcher/plugins/wallpaper.py
new file mode 100644
index 0000000..ce482dd
--- /dev/null
+++ b/src/window/launcher/plugins/wallpaper.py
@@ -0,0 +1,983 @@
+import colorsys
+import hashlib
+import json
+import threading
+from typing import Dict, List, Optional
+
+from fabric.utils import (
+ GdkPixbuf,
+ GLib,
+ exec_shell_command_async,
+ logger,
+ os,
+ random,
+ re,
+ time,
+)
+
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class WallpaperPlugin(PluginBase):
+ """
+ Plugin for wallpaper management with search, random selection, and matugen integration.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Wallpaper"
+ self.wallpapers = []
+ self.cache_dir = f"{data.CACHE_DIR}/thumbs"
+ self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {}
+ self.thumbnail_loading = set() # Track which thumbnails are being loaded
+ self.schemes = {
+ "scheme-tonal-spot": "Tonal Spot",
+ "scheme-content": "Content",
+ "scheme-expressive": "Expressive",
+ "scheme-fidelity": "Fidelity",
+ "scheme-fruit-salad": "Fruit Salad",
+ "scheme-monochrome": "Monochrome",
+ "scheme-neutral": "Neutral",
+ "scheme-rainbow": "Rainbow",
+ }
+
+ def initialize(self):
+ """Initialize the wallpaper plugin."""
+ self.set_triggers(["wall"])
+ self._load_wallpapers()
+ os.makedirs(self.cache_dir, exist_ok=True)
+ # Start background thumbnail creation
+ self._start_background_thumbnail_creation()
+
+ def cleanup(self):
+ """Cleanup the wallpaper plugin."""
+ pass
+
+ def _load_wallpapers(self):
+ """Load available wallpapers from the wallpapers directory."""
+ try:
+ if os.path.exists(data.WALLPAPERS_DIR):
+ self.wallpapers = sorted(
+ [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]
+ )
+ else:
+ logger.error(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}")
+ except Exception as e:
+ logger.error(f"Error loading wallpapers: {e}")
+
+ def _is_image(self, filename: str) -> bool:
+ """Check if file is a supported image format."""
+ return filename.lower().endswith(
+ (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
+ )
+
+ def _get_matugen_state(self) -> bool:
+ """Get current matugen state from config.json."""
+ self.matugen_enabled = True # Default to True
+ try:
+ with open(data.CONFIG_FILE, "r") as f:
+ config = json.load(f)
+ self.matugen_enabled = config.get("matugen_enabled", True)
+ except FileNotFoundError:
+ # File doesn't exist, keep default True
+ pass
+ except Exception as e:
+ logger.error(f"Error reading config file: {e}")
+ # Keep default True on error
+
+ return self.matugen_enabled
+
+ def _set_matugen_state(self, enabled: bool):
+ """Set matugen state and save to config.json."""
+ self.matugen_enabled = enabled
+
+ # Save the state to config.json
+ try:
+ # Read current config
+ config = {}
+ if os.path.exists(data.CONFIG_FILE):
+ with open(data.CONFIG_FILE, "r") as f:
+ config = json.load(f)
+
+ # Update matugen state
+ config["matugen_enabled"] = enabled
+
+ # Write back to config file
+ with open(data.CONFIG_FILE, "w") as f:
+ json.dump(config, f, indent=4)
+
+ except Exception as e:
+ logger.error(f"Error writing matugen state to config: {e}")
+
+ # Clear the search query after toggling matugen
+ self._clear_launcher_query()
+
+ # # Send notification
+ # status = "enabled" if enabled else "disabled"
+ # exec_shell_command_async(
+ # f"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{
+ # data.APP_NAME_CAP
+ # }' -e"
+ # )
+
+ def _clear_launcher_query(self):
+ """Clear the launcher search query and reset to trigger."""
+ try:
+ # Try to access the launcher through the fabric Application
+
+ from fabric import Application
+
+ app = Application.get_default()
+
+ if app and hasattr(app, "launcher"):
+ launcher = app.launcher
+ if launcher and hasattr(launcher, "search_entry"):
+
+ def clear_and_refresh():
+ # Clear the search entry to just the trigger
+ launcher.search_entry.set_text("wall ")
+ # Position cursor at the end
+ launcher.search_entry.set_position(-1)
+ # Trigger the search to show default wallpaper view
+ if hasattr(launcher, "_perform_search"):
+ launcher._perform_search("wall ")
+ return False
+
+ # Use a small delay to ensure the action completes first
+ GLib.timeout_add(50, clear_and_refresh)
+ return
+
+ # Fallback: try to find launcher instance through other means
+ import gc
+
+ for obj in gc.get_objects():
+ if hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher":
+ if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"):
+
+ def clear_and_refresh():
+ obj.search_entry.set_text("wall ")
+ obj.search_entry.set_position(-1)
+ obj._perform_search("wall ")
+ return False
+
+ GLib.timeout_add(50, clear_and_refresh)
+ return
+
+ except Exception as e:
+ logger.error(f"Could not clear launcher query: {e}")
+
+ def _get_cache_path(self, filename: str) -> str:
+ """Get cache path for wallpaper thumbnail."""
+ file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest()
+ return os.path.join(self.cache_dir, f"{file_hash}.png")
+
+ def _create_thumbnail(self, filename: str) -> str:
+ """Create thumbnail for wallpaper if it doesn't exist."""
+ full_path = os.path.join(data.WALLPAPERS_DIR, filename)
+ cache_path = self._get_cache_path(filename)
+
+ if not os.path.exists(cache_path):
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ full_path, 32, 32, True
+ )
+ pixbuf.savev(cache_path, "png", [], [])
+ except Exception as e:
+ logger.error(f"Error creating thumbnail for {filename}: {e}")
+ return None
+
+ return cache_path
+
+ def _start_background_thumbnail_creation(self):
+ """Start background thread to create thumbnails for all wallpapers."""
+
+ def create_thumbnails():
+ for wallpaper in self.wallpapers:
+ if wallpaper not in self.thumbnail_loading:
+ self.thumbnail_loading.add(wallpaper)
+ try:
+ cache_path = self._create_thumbnail(wallpaper)
+ if cache_path and os.path.exists(cache_path):
+ # Load thumbnail into memory cache
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ cache_path, 32, 32, True
+ )
+ self.thumbnail_cache[wallpaper] = pixbuf
+ except Exception as e:
+ logger.error(
+ f"Error loading thumbnail for {wallpaper}: {e}"
+ )
+ self.thumbnail_cache[wallpaper] = None
+ else:
+ self.thumbnail_cache[wallpaper] = None
+ except Exception as e:
+ logger.error(f"Error processing thumbnail for {wallpaper}: {e}")
+ self.thumbnail_cache[wallpaper] = None
+ finally:
+ self.thumbnail_loading.discard(wallpaper)
+
+ # Small delay to prevent overwhelming the system
+ time.sleep(0.01)
+
+ # Start background thread
+ thread = threading.Thread(target=create_thumbnails, daemon=True)
+ thread.start()
+
+ def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]:
+ """Get thumbnail quickly from cache or return None if not ready."""
+ # Return cached thumbnail if available
+ if filename in self.thumbnail_cache:
+ return self.thumbnail_cache[filename]
+
+ # Check if thumbnail file exists and load it immediately
+ cache_path = self._get_cache_path(filename)
+ if os.path.exists(cache_path):
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ cache_path, 32, 32, True
+ )
+ self.thumbnail_cache[filename] = pixbuf
+ return pixbuf
+ except Exception as e:
+ logger.error(f"Error loading thumbnail for {filename}: {e}")
+ self.thumbnail_cache[filename] = None
+ return None
+
+ # If not in cache and file doesn't exist, trigger background creation
+ if filename not in self.thumbnail_loading:
+ self.thumbnail_loading.add(filename)
+
+ def create_async():
+ try:
+ cache_path = self._create_thumbnail(filename)
+ if cache_path and os.path.exists(cache_path):
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ cache_path, 32, 32, True
+ )
+ self.thumbnail_cache[filename] = pixbuf
+ except Exception as e:
+ print(f"Error loading thumbnail for {filename}: {e}")
+ self.thumbnail_cache[filename] = None
+ else:
+ self.thumbnail_cache[filename] = None
+ except Exception as e:
+ print(f"Error creating thumbnail for {filename}: {e}")
+ self.thumbnail_cache[filename] = None
+ finally:
+ self.thumbnail_loading.discard(filename)
+
+ thread = threading.Thread(target=create_async, daemon=True)
+ thread.start()
+
+ return None
+
+ def _set_wallpaper(self, filename: str, scheme: str = None):
+ """Set wallpaper and apply matugen color scheme if enabled."""
+ full_path = os.path.join(data.WALLPAPERS_DIR, filename)
+ current_wall = os.path.expanduser("~/.current.wall")
+
+ if scheme is None:
+ scheme = self._get_current_scheme()
+
+ # Update current wallpaper symlink
+ if os.path.isfile(current_wall) or os.path.islink(current_wall):
+ os.remove(current_wall)
+ os.symlink(full_path, current_wall)
+
+ # Always set the wallpaper image
+ exec_shell_command_async(
+ f'awww img "{
+ full_path
+ }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
+ )
+
+ # If Matugen is enabled, also apply the color scheme
+ matugen_enabled = self._get_matugen_state()
+ if matugen_enabled:
+ exec_shell_command_async(
+ f'matugen image "{full_path}" -t {scheme} --source-color-index 0'
+ )
+
+ def _set_random_wallpaper(self):
+ """Set a random wallpaper."""
+ if not self.wallpapers:
+ return
+
+ filename = random.choice(self.wallpapers)
+ self._set_wallpaper(filename)
+ # Show notification for immediate feedback
+ exec_shell_command_async(
+ f"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{
+ data.APP_NAME_CAP
+ }' -e"
+ )
+ return filename
+
+ def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l_val: float = 0.5) -> str:
+ """Convert HSL color value to RGB HEX string."""
+ # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0
+ hue = h / 360.0
+ r, g, b = colorsys.hls_to_rgb(hue, l_val, s) # Note the order: H, L, S
+ r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)
+ return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
+
+ def _is_valid_hex_color(self, hex_color: str) -> bool:
+ """Check if string is a valid hex color."""
+ if not hex_color.startswith("#"):
+ hex_color = "#" + hex_color
+ return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color))
+
+ def _get_current_scheme(self) -> str:
+ """Get current color scheme from config (default to tonal-spot)."""
+ try:
+ with open(data.CONFIG_FILE, "r") as f:
+ config = json.load(f)
+ return config.get("current_scheme", "scheme-tonal-spot")
+ except FileNotFoundError:
+ # File doesn't exist, return default
+ return "scheme-tonal-spot"
+ except Exception as e:
+ print(f"Error reading current scheme from config: {e}")
+ return "scheme-tonal-spot"
+
+ def _set_current_scheme(self, scheme: str):
+ """Set current color scheme and apply it."""
+ scheme_name = self.schemes.get(scheme, scheme)
+ matugen_enabled = self._get_matugen_state()
+
+ # Save the scheme to config
+ try:
+ # Read current config
+ config = {}
+ if os.path.exists(data.CONFIG_FILE):
+ with open(data.CONFIG_FILE, "r") as f:
+ config = json.load(f)
+
+ # Update current scheme
+ config["current_scheme"] = scheme
+
+ # Write back to config file
+ with open(data.CONFIG_FILE, "w") as f:
+ json.dump(config, f, indent=4)
+
+ except Exception as e:
+ print(f"Error saving current scheme to config: {e}")
+ return
+
+ # Apply the scheme to current wallpaper if matugen is enabled
+ if matugen_enabled:
+ current_wall = os.path.expanduser("~/.current.wall")
+ if os.path.exists(current_wall) and os.path.islink(current_wall):
+ # Get the current wallpaper path
+ wallpaper_path = os.readlink(current_wall)
+ if os.path.exists(wallpaper_path):
+ # Apply the new scheme to current wallpaper
+ exec_shell_command_async(
+ f'matugen image "{wallpaper_path}" -t {scheme} --source-color-index 0'
+ )
+
+ # Send notification
+ exec_shell_command_async(
+ f"notify-send '🎨 Color Scheme' 'Applied {
+ scheme_name
+ } scheme' -a '{data.APP_NAME_CAP}' -e"
+ )
+ else:
+ # No current wallpaper, just show scheme change notification
+ exec_shell_command_async(
+ f"notify-send '🎨 Color Scheme' 'Set to {
+ scheme_name
+ } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e"
+ )
+ else:
+ # No current wallpaper, just show scheme change notification
+ exec_shell_command_async(
+ f"notify-send '🎨 Color Scheme' 'Set to {
+ scheme_name
+ } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e"
+ )
+ else:
+ # Matugen is disabled, just save the setting
+ exec_shell_command_async(
+ f"notify-send '🎨 Color Scheme' 'Set to {
+ scheme_name
+ } (matugen disabled)' -a '{data.APP_NAME_CAP}' -e"
+ )
+
+ def _apply_hex_color(self, hex_color: str, scheme: str = None):
+ """Apply hex color using matugen. Assumes matugen is enabled."""
+ if not hex_color.startswith("#"):
+ hex_color = "#" + hex_color
+
+ if scheme is None:
+ scheme = self._get_current_scheme()
+
+ exec_shell_command_async(
+ f'matugen color hex "{hex_color}" -t {scheme} --source-color-index 0'
+ )
+
+ def _apply_hex_color_direct(self, hex_color: str, scheme: str = None):
+ """Apply hex color using matugen (following example_wallpapers.py pattern)."""
+ if not hex_color.startswith("#"):
+ hex_color = "#" + hex_color
+
+ if scheme is None:
+ scheme = self._get_current_scheme()
+
+ exec_shell_command_async(
+ f'matugen color hex "{hex_color}" -t {scheme} --source-color-index 0'
+ )
+
+ # Send notification
+ exec_shell_command_async(
+ f"notify-send '🎨 Hex Color Applied' 'Applied color: {hex_color}' -a '{
+ data.APP_NAME_CAP
+ }' -e"
+ )
+
+ def _generate_random_hex_color(self) -> str:
+ """Generate a random hex color."""
+ hue = random.randint(0, 360)
+ return self._hsl_to_rgb_hex(hue)
+
+ def _get_status_indicators(self) -> tuple:
+ """Get current status indicators for display."""
+ current_scheme = self._get_current_scheme()
+ matugen_enabled = self._get_matugen_state()
+ scheme_name = self.schemes.get(current_scheme, current_scheme)
+
+ indicators = []
+ if not matugen_enabled:
+ indicators.append("Matugen Off")
+
+ indicator_text = " • " + " • ".join(indicators) if indicators else ""
+ status_text = f"Matugen: {
+ 'Enabled' if matugen_enabled else 'Disabled'
+ } • Scheme: {scheme_name}"
+
+ return indicator_text, status_text, current_scheme, matugen_enabled
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search wallpapers and provide management options."""
+ results = []
+ query = query_string.lower().strip()
+
+ # Get status indicators for consistent display
+ indicator_text, status_text, current_scheme, matugen_enabled = (
+ self._get_status_indicators()
+ )
+
+ # Special commands
+ if query.strip() == "random":
+ # Show result for random wallpaper (execute on Enter)
+ results.append(
+ Result(
+ title=f"Random Wallpaper{indicator_text}",
+ subtitle=f"Set a random wallpaper • {status_text}",
+ icon_name="media-playlist-shuffle-symbolic",
+ action=lambda: self._set_random_wallpaper(),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "action": "random",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ elif query.startswith("random") and query.strip() != "random":
+ # Show suggestion for random wallpaper (partial match)
+ results.append(
+ Result(
+ title=f"Random Wallpaper{indicator_text}",
+ subtitle=f"Set a random wallpaper • {status_text}",
+ icon_name="media-playlist-shuffle-symbolic",
+ action=lambda: self._set_random_wallpaper(),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "action": "random_suggestion",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ # Hex color commands
+ if "color" in query or "hex" in query or query.startswith("#"):
+ # Check for scheme specification in the query
+ scheme = self._get_current_scheme()
+ for scheme_id, scheme_name in self.schemes.items():
+ if (
+ scheme_name.lower() in query.lower()
+ or scheme_id.lower() in query.lower()
+ ):
+ scheme = scheme_id
+ break
+
+ # Check for hex color patterns
+ hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query)
+ if hex_match:
+ hex_color = "#" + hex_match.group(1)
+ scheme_name = self.schemes.get(scheme, scheme)
+
+ # Check if this is a complete hex color input (6 digits)
+ # Execute immediately when we have a complete 6-digit hex color
+ if len(hex_match.group(1)) == 6:
+ # Check if there's additional text after the hex color
+ hex_end_pos = hex_match.end()
+ remaining_text = query[hex_end_pos:].strip()
+
+ # Only show result if no additional text after hex color (exact match)
+ if not remaining_text:
+ # Hex colors work when matugen is OFF (following example_wallpapers.py pattern)
+ if not matugen_enabled:
+ # Show result for hex color (execute on Enter)
+ results.append(
+ Result(
+ title=f"Apply Hex Color: {hex_color}{
+ indicator_text
+ }",
+ subtitle=f"Apply with {scheme_name} scheme • {
+ status_text
+ }",
+ icon_name="color-picker-symbolic",
+ action=lambda c=hex_color, s=scheme: (
+ self._apply_hex_color_direct(c, s)
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "action": "hex_color",
+ "color": hex_color,
+ "scheme": scheme,
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ else:
+ # Show error result when matugen is enabled
+ results.append(
+ Result(
+ title=f"Cannot Apply Hex Color: {hex_color}{
+ indicator_text
+ }",
+ subtitle="Matugen is enabled • Disable matugen to use hex colors",
+ icon_name="color-picker-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "action": "hex_color_failed",
+ "color": hex_color,
+ "bypass_max_results": True,
+ },
+ )
+ )
+ else:
+ # Show suggestion for hex color with additional text (partial match)
+ results.append(
+ Result(
+ title=f"Apply Hex Color: {hex_color}{indicator_text}",
+ subtitle=f"Apply with {scheme_name} scheme • {
+ status_text
+ }",
+ icon_name="color-picker-symbolic",
+ action=lambda c=hex_color, s=scheme: (
+ self._apply_hex_color_direct(c, s)
+ if not matugen_enabled
+ else None
+ ),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "action": "hex_color_suggestion",
+ "color": hex_color,
+ "scheme": scheme,
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ else:
+ # Incomplete hex color, show as suggestion
+ results.append(
+ Result(
+ title=f"Hex Color (incomplete): {hex_color}",
+ subtitle="Complete the 6-digit hex color to apply",
+ icon_name="color-picker-symbolic",
+ action=lambda: None,
+ relevance=0.7,
+ plugin_name=self.display_name,
+ data={
+ "action": "hex_color_incomplete",
+ "color": hex_color,
+ "bypass_max_results": True,
+ },
+ )
+ )
+ elif "random" in query and ("color" in query or "hex" in query):
+ # Check for exact matches for random color commands
+ if (
+ query.strip() == "color random"
+ or query.strip() == "hex random"
+ or query.strip() == "random color"
+ or query.strip() == "random hex"
+ ):
+ # Random hex color - show result (execute on Enter)
+ scheme_name = self.schemes.get(scheme, scheme)
+
+ # Check if matugen is disabled (hex colors work when matugen is OFF)
+ if not matugen_enabled:
+ results.append(
+ Result(
+ title=f"Random Hex Color{indicator_text}",
+ subtitle=f"Generate and apply random color with {
+ scheme_name
+ } scheme • {status_text}",
+ icon_name="color-picker-symbolic",
+ action=lambda s=scheme: self._apply_hex_color_direct(
+ self._generate_random_hex_color(), s
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "action": "random_hex",
+ "scheme": scheme,
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ else:
+ # Show error result when matugen is enabled
+ results.append(
+ Result(
+ title=f"Cannot Apply Random Color{indicator_text}",
+ subtitle="Matugen is enabled • Disable matugen to use hex colors",
+ icon_name="color-picker-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={
+ "action": "random_hex_failed",
+ "bypass_max_results": True,
+ },
+ )
+ )
+ else:
+ # Show suggestion for random hex color (partial match)
+ results.append(
+ Result(
+ title=f"Random Hex Color{indicator_text}",
+ subtitle=f"Generate and apply random color • {status_text}",
+ icon_name="color-picker-symbolic",
+ action=lambda s=scheme: (
+ self._apply_hex_color_direct(
+ self._generate_random_hex_color(), s
+ )
+ if not matugen_enabled
+ else None
+ ),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={
+ "action": "random_hex_suggestion",
+ "scheme": scheme,
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ else:
+ # Show hex color help
+ results.append(
+ Result(
+ title="Hex Color Commands",
+ subtitle="Use: color #FF5733, hex #00FF00, color random",
+ icon_name="color-picker-symbolic",
+ action=lambda: None,
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={"action": "hex_help", "bypass_max_results": True},
+ )
+ )
+
+ # Color scheme commands
+ if "scheme" in query:
+ current_scheme = self._get_current_scheme()
+ matugen_enabled = self._get_matugen_state()
+
+ # Show all available schemes
+ for scheme_id, scheme_name in self.schemes.items():
+ # Check if this scheme matches the query (for filtering)
+ if query.strip() == "scheme":
+ # Show all schemes when just "scheme" is typed
+ scheme_matches = True
+ else:
+ # Extract search terms from query (remove "scheme" keyword)
+ search_terms = query.replace("scheme", "").strip().split()
+ scheme_matches = False
+
+ # Check if any search term matches the scheme name or ID
+ for term in search_terms:
+ if (
+ term.lower() in scheme_name.lower()
+ or term.lower() in scheme_id.lower()
+ ):
+ scheme_matches = True
+ break
+
+ if scheme_matches:
+ # Create indicators
+ indicators = []
+ if scheme_id == current_scheme:
+ indicators.append("Current")
+ if not matugen_enabled:
+ indicators.append("Matugen Off")
+
+ indicator_text = (
+ " • " + " • ".join(indicators) if indicators else ""
+ )
+
+ results.append(
+ Result(
+ title=f"{scheme_name}{indicator_text}",
+ subtitle=f"Set color scheme to {scheme_name}"
+ + (
+ " (requires matugen enabled)"
+ if not matugen_enabled
+ else ""
+ ),
+ icon_name="color-management-symbolic",
+ action=lambda s=scheme_id: self._set_current_scheme(s),
+ relevance=1.0 if scheme_id == current_scheme else 0.8,
+ plugin_name=self.display_name,
+ data={
+ "action": "scheme_select",
+ "scheme": scheme_id,
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ # Matugen controls
+ if "matugen" in query:
+ current_state = self._get_matugen_state()
+
+ # Check for exact command matches
+ if query.strip() == "matugen on" or query.strip() == "matugen enable":
+ # Show result for enabling matugen (execute on Enter)
+ results.append(
+ Result(
+ title=f"Enable Matugen{indicator_text}",
+ subtitle=f"Turn on dynamic color generation • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: self._set_matugen_state(True),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "action": "matugen_on",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ elif query.strip() == "matugen off" or query.strip() == "matugen disable":
+ # Show result for disabling matugen (execute on Enter)
+ results.append(
+ Result(
+ title=f"Disable Matugen{indicator_text}",
+ subtitle=f"Turn off dynamic color generation • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: self._set_matugen_state(False),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "action": "matugen_off",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ elif query.strip() == "matugen toggle":
+ # Show result for toggling matugen (execute on Enter)
+ new_state = not current_state
+ results.append(
+ Result(
+ title=f"Toggle Matugen to {
+ 'Enabled' if new_state else 'Disabled'
+ }{indicator_text}",
+ subtitle=f"Switch matugen to {
+ 'enabled' if new_state else 'disabled'
+ } • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: self._set_matugen_state(new_state),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "action": "matugen_toggle",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ elif ("on" in query or "enable" in query) and not query.strip().endswith(
+ ("on", "enable")
+ ):
+ # Show suggestion for enabling (partial match)
+ results.append(
+ Result(
+ title=f"Enable Matugen{indicator_text}",
+ subtitle=f"Turn on dynamic color generation • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: self._set_matugen_state(True),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={
+ "action": "matugen_on_suggestion",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ elif ("off" in query or "disable" in query) and not query.strip().endswith(
+ ("off", "disable")
+ ):
+ # Show suggestion for disabling (partial match)
+ results.append(
+ Result(
+ title=f"Disable Matugen{indicator_text}",
+ subtitle=f"Turn off dynamic color generation • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: self._set_matugen_state(False),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={
+ "action": "matugen_off_suggestion",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ elif "toggle" in query and not query.strip().endswith("toggle"):
+ # Show suggestion for toggling (partial match)
+ results.append(
+ Result(
+ title=f"Toggle Matugen{indicator_text}",
+ subtitle=f"Switch matugen state • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: self._set_matugen_state(not current_state),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={
+ "action": "matugen_toggle_suggestion",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+ else:
+ # Show current state with scheme info
+ results.append(
+ Result(
+ title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{
+ indicator_text
+ }",
+ subtitle=f"Dynamic colors • {status_text}",
+ icon_name="color-management-symbolic",
+ action=lambda: None,
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={"action": "matugen_status", "bypass_max_results": True},
+ )
+ )
+
+ # Status command
+ if query == "status" or query == "info":
+ results.append(
+ Result(
+ title=f"Wallpaper System Status{indicator_text}",
+ subtitle=status_text,
+ icon_name="color-management-symbolic",
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"action": "status", "bypass_max_results": True},
+ )
+ )
+
+ # Show quick actions
+ results.append(
+ Result(
+ title=f"Random Wallpaper{indicator_text}",
+ subtitle=f"Set a random wallpaper • {status_text}",
+ icon_name="media-playlist-shuffle-symbolic",
+ action=lambda: self._set_random_wallpaper(),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ data={
+ "action": "random_quick",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py
+ if not query or (
+ query
+ and "matugen" not in query
+ and "random" not in query
+ and "scheme" not in query
+ and "status" not in query
+ and "info" not in query
+ and "color" not in query
+ and "hex" not in query
+ ):
+ matching_wallpapers = []
+ for wallpaper in self.wallpapers:
+ if not query or query in wallpaper.lower():
+ # Calculate relevance
+ relevance = 1.0 if query == wallpaper.lower() else 0.7
+ if query and query in wallpaper.lower():
+ relevance = 0.8
+ matching_wallpapers.append((wallpaper, relevance))
+
+ # Sort by relevance and show ALL wallpapers (like example_wallpapers.py)
+ matching_wallpapers.sort(key=lambda x: x[1], reverse=True)
+
+ # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern)
+ for wallpaper, relevance in matching_wallpapers:
+ # Use fast thumbnail loading
+ icon = self._get_thumbnail_fast(wallpaper)
+
+ results.append(
+ Result(
+ title=f"{wallpaper}{indicator_text if not query else ''}",
+ subtitle=f"Set as wallpaper{
+ ' • ' + status_text if not query else ''
+ }",
+ icon=icon,
+ icon_name="image-x-generic-symbolic" if not icon else None,
+ action=lambda w=wallpaper: self._set_wallpaper(w),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={
+ "wallpaper": wallpaper,
+ "action": "set",
+ "bypass_max_results": True,
+ "keep_launcher_open": True,
+ },
+ )
+ )
+
+ # Sort by relevance - no limit since we use bypass_max_results
+ results.sort(key=lambda x: x.relevance, reverse=True)
+ return results
diff --git a/src/window/launcher/plugins/websearch.py b/src/window/launcher/plugins/websearch.py
new file mode 100644
index 0000000..d48b358
--- /dev/null
+++ b/src/window/launcher/plugins/websearch.py
@@ -0,0 +1,259 @@
+import subprocess
+import urllib.parse
+from typing import List
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class WebSearchPlugin(PluginBase):
+ """
+ Web search plugin that supports multiple search engines.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Web Search"
+ self.description = "Search the web using various search engines"
+ self.current_trigger = "" # Track the current active trigger
+
+ # Define search engines with their URLs and icons
+ self.search_engines = {
+ "google": {
+ "name": "Google",
+ "url": "https://www.google.com/search?q={}",
+ "icon": "google-symbolic",
+ "description": "Search with Google",
+ },
+ "duckduckgo": {
+ "name": "DuckDuckGo",
+ "url": "https://duckduckgo.com/?q={}",
+ "icon": "preferences-desktop-search",
+ "description": "Search with DuckDuckGo (privacy-focused)",
+ },
+ "youtube": {
+ "name": "YouTube",
+ "url": "https://www.youtube.com/results?search_query={}",
+ "icon": "youtube-symbolic",
+ "description": "Search videos on YouTube",
+ },
+ "github": {
+ "name": "GitHub",
+ "url": "https://github.com/search?q={}",
+ "icon": "github-desktop-symbolic",
+ "description": "Search repositories on GitHub",
+ },
+ "stackoverflow": {
+ "name": "Stack Overflow",
+ "url": "https://stackoverflow.com/search?q={}",
+ "icon": "stack",
+ "description": "Search programming questions on Stack Overflow",
+ },
+ "wikipedia": {
+ "name": "Wikipedia",
+ "url": "https://en.wikipedia.org/wiki/Special:Search?search={}",
+ "icon": "search-menus-symbolic",
+ "description": "Search articles on Wikipedia",
+ },
+ "reddit": {
+ "name": "Reddit",
+ "url": "https://www.reddit.com/search/?q={}",
+ "icon": "reddit-symbolic",
+ "description": "Search discussions on Reddit",
+ },
+ "linkedin": {
+ "name": "LinkedIn",
+ "url": "https://www.linkedin.com/search/results/all/?keywords={}",
+ "icon": "link",
+ "description": "Search professionals and jobs on LinkedIn",
+ },
+ }
+
+ # Default search engine
+ self.default_engine = "google"
+
+ def initialize(self):
+ """Initialize the web search plugin."""
+ # Set up triggers for web search - only main triggers, not individual search engines
+ triggers = ["?"]
+
+ # Don't add individual search engine triggers to avoid cluttering trigger keywords
+ # Search engines can still be used within web search context (e.g., "web google cats")
+
+ self.set_triggers(triggers)
+
+ def get_active_trigger(self, query_string: str) -> str:
+ """Override to track which trigger was activated."""
+ trigger = super().get_active_trigger(query_string)
+ if trigger:
+ self.current_trigger = trigger.strip()
+ return trigger
+
+ def cleanup(self):
+ """Cleanup the web search plugin."""
+ pass
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process web search queries."""
+ results = []
+
+ if not query_string.strip():
+ # Show available search engines when no query
+ return self._get_search_engine_list()
+
+ # Check if the query is a URL
+ if self._is_url(query_string):
+ results.append(self._create_url_result(query_string))
+ return results
+
+ # Parse query to check if it starts with a search engine name
+ engine_name, search_query = self._parse_engine_query(query_string)
+
+ if engine_name and engine_name in self.search_engines:
+ # Specific search engine specified in query (e.g., "google cats")
+ if search_query:
+ # Search with specific engine
+ results.append(self._create_search_result(engine_name, search_query))
+ else:
+ # Show specific engine info
+ results.append(self._create_engine_info_result(engine_name))
+ else:
+ # General search - offer multiple engines
+ search_query = query_string.strip()
+
+ # Add default search engine first
+ results.append(
+ self._create_search_result(self.default_engine, search_query)
+ )
+
+ # Add other popular search engines
+ popular_engines = ["duckduckgo", "youtube", "github"]
+ for engine in popular_engines:
+ if engine != self.default_engine:
+ results.append(self._create_search_result(engine, search_query))
+
+ return results
+
+ def _parse_engine_query(self, query: str) -> tuple[str, str]:
+ """Parse query to extract search engine and search terms."""
+ query = query.strip().lower()
+
+ for engine in self.search_engines.keys():
+ if query.startswith(f"{engine} "):
+ return engine, query[len(engine) :].strip()
+ elif query == engine:
+ return engine, ""
+
+ return "", query
+
+ def _is_url(self, text: str) -> bool:
+ """Check if the text is a URL."""
+ text = text.strip().lower()
+ return text.startswith(("http://", "https://", "www.")) or (
+ "." in text and " " not in text and len(text) > 3
+ )
+
+ def _create_url_result(self, url: str) -> Result:
+ """Create a result for opening a URL directly."""
+ # Add protocol if missing
+ if not url.startswith(("http://", "https://")):
+ if url.startswith("www."):
+ url = "https://" + url
+ else:
+ url = "https://" + url
+
+ return Result(
+ title=f"Open {url}",
+ subtitle="Open this URL in your default browser",
+ icon_name="link",
+ action=lambda u=url: self._open_url(u),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "url", "url": url},
+ )
+
+ def _open_url(self, url: str):
+ """Open a URL directly in the default browser."""
+ try:
+ subprocess.Popen(
+ ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ )
+ except Exception as e:
+ print(f"Failed to open URL: {e}")
+
+ def _get_search_engine_list(self) -> List[Result]:
+ """Get list of available search engines."""
+ results = []
+
+ for engine_id, engine_info in self.search_engines.items():
+ result = Result(
+ title=engine_info["name"],
+ subtitle=engine_info["description"],
+ icon_name=engine_info["icon"],
+ action=lambda e=engine_id: self._show_engine_help(e),
+ relevance=1.0 if engine_id == self.default_engine else 0.8,
+ plugin_name=self.display_name,
+ data={"type": "engine_info", "engine": engine_id},
+ )
+ results.append(result)
+
+ return results
+
+ def _create_search_result(self, engine_id: str, query: str) -> Result:
+ """Create a search result for a specific engine and query."""
+ engine_info = self.search_engines[engine_id]
+
+ return Result(
+ title=f"Search '{query}' on {engine_info['name']}",
+ subtitle=f"{engine_info['description']} - {query}",
+ icon_name=engine_info["icon"],
+ action=lambda e=engine_id, q=query: self._perform_search(e, q),
+ relevance=1.0 if engine_id == self.default_engine else 0.9,
+ plugin_name=self.display_name,
+ data={"type": "search", "engine": engine_id, "query": query},
+ )
+
+ def _create_engine_info_result(self, engine_id: str) -> Result:
+ """Create an info result for a specific search engine."""
+ engine_info = self.search_engines[engine_id]
+
+ return Result(
+ title=f"{engine_info['name']} Search",
+ subtitle=f"{engine_info['description']} - Type your search query",
+ icon_name=engine_info["icon"],
+ action=lambda: None, # No action for info result
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "engine_ready", "engine": engine_id},
+ )
+
+ def _perform_search(self, engine_id: str, query: str):
+ """Perform a web search using the specified engine."""
+ if not query.strip():
+ return
+
+ engine_info = self.search_engines.get(engine_id)
+ if not engine_info:
+ return
+
+ # URL encode the search query
+ encoded_query = urllib.parse.quote_plus(query)
+ search_url = engine_info["url"].format(encoded_query)
+
+ try:
+ # Open the search URL in the default browser
+ subprocess.Popen(
+ ["xdg-open", search_url],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except Exception as e:
+ print(f"Failed to open search URL: {e}")
+
+ def _show_engine_help(self, engine_id: str):
+ """Show help for a specific search engine."""
+ engine_info = self.search_engines.get(engine_id)
+ if engine_info:
+ print(
+ f"Search engine: {engine_info['name']} - {engine_info['description']}"
+ )
diff --git a/src/window/launcher/result.py b/src/window/launcher/result.py
new file mode 100644
index 0000000..860225b
--- /dev/null
+++ b/src/window/launcher/result.py
@@ -0,0 +1,52 @@
+from dataclasses import dataclass
+from typing import Any, Callable, Optional
+
+from fabric.utils import GdkPixbuf, Gtk
+
+
+@dataclass
+class Result:
+ """
+ Represents a search result that can be displayed and activated.
+ """
+
+ # Display information
+ title: str
+ subtitle: str = ""
+ subtitle_markup: Optional[str] = None # Pango markup for subtitle
+ description: str = ""
+ icon: Optional[GdkPixbuf.Pixbuf] = None
+ icon_name: Optional[str] = None
+ icon_markup: Optional[str] = None
+ # Behavior
+ action: Optional[Callable[[], Any]] = None
+ relevance: float = 0.0
+
+ # Custom widget support
+ custom_widget: Optional[Gtk.Widget] = None
+
+ # Metadata
+ plugin_name: str = ""
+ data: Optional[dict] = None
+
+ def activate(self):
+ """Activate this result (execute its action)."""
+ if self.action:
+ return self.action()
+ else:
+ raise NotImplementedError("No action defined for this result")
+
+ def __post_init__(self):
+ """Post-initialization processing."""
+ # Ensure relevance is within valid range
+ self.relevance = max(0.0, min(1.0, self.relevance))
+
+ # Set default data if None
+ if self.data is None:
+ self.data = {}
+
+ def __str__(self):
+ return f"Result(title='{self.title}', relevance={self.relevance})"
+
+ def __repr__(self):
+ return self.__str__()
diff --git a/src/window/launcher/result_item.py b/src/window/launcher/result_item.py
new file mode 100644
index 0000000..8576f24
--- /dev/null
+++ b/src/window/launcher/result_item.py
@@ -0,0 +1,161 @@
+from fabric.core.service import Signal
+from fabric.widgets.box import Box
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+
+from window.launcher.result import Result
+
+
+class ResultItem(EventBox):
+ """
+ Widget for displaying a single search result.
+ """
+
+ # Signals
+ @Signal
+ def clicked(self, index: int) -> None:
+ """Emitted when result is clicked."""
+ pass
+
+ @Signal
+ def hovered(self, index: int) -> None:
+ """Emitted when result is hovered."""
+ pass
+
+ def __init__(
+ self, result: Result, selected: bool = False, index: int = 0, **kwargs
+ ):
+ super().__init__(name="launcher-result-item", **kwargs)
+
+ self.result = result
+ self._selected = selected
+ self.index = index
+
+ # Setup UI
+ self._setup_ui()
+
+ # Connect signals
+ self.connect("button-press-event", self._on_button_press)
+ self.connect("enter-notify-event", self._on_enter)
+ self.connect("leave-notify-event", self._on_leave)
+
+ # Set initial selection state
+ self.set_selected(selected)
+
+ def _setup_ui(self):
+ """Setup the result item UI."""
+ # Main container
+ main_box = Box(
+ name="result-item-main",
+ orientation="h",
+ spacing=12,
+ h_align="fill",
+ v_align="center",
+ )
+ self.add(main_box)
+
+ # Icon
+ if self.result.icon:
+ icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon")
+ elif self.result.icon_name:
+ icon_widget = Image(
+ icon_name=self.result.icon_name, icon_size=48, name="result-item-icon"
+ )
+ elif self.result.icon_markup:
+ icon_widget = Label(
+ markup=self.result.icon_markup, name="launcher-icon-label"
+ )
+ else:
+ # Default icon
+ icon_widget = Image(
+ icon_name="application-default-icon",
+ icon_size=48,
+ name="result-item-icon",
+ )
+
+ main_box.add(icon_widget)
+
+ # Text container
+ text_box = Box(
+ name="result-item-text",
+ orientation="v",
+ spacing=2,
+ h_expand=True,
+ v_align="center",
+ )
+ main_box.add(text_box)
+
+ # Title
+ title_label = Label(
+ label=self.result.title,
+ name="result-item-title",
+ h_align="start",
+ v_align="center",
+ ellipsize="end",
+ )
+ text_box.add(title_label)
+
+ # Subtitle (if present)
+ if self.result.subtitle or self.result.subtitle_markup:
+ if self.result.subtitle_markup:
+ # Use markup for subtitle (supports Pango markup)
+ subtitle_label = Label(
+ markup=self.result.subtitle_markup,
+ name="result-item-subtitle",
+ h_align="start",
+ v_align="center",
+ ellipsize="end",
+ )
+ else:
+ # Use plain text for subtitle
+ subtitle_label = Label(
+ label=self.result.subtitle,
+ name="result-item-subtitle",
+ h_align="start",
+ v_align="center",
+ ellipsize="end",
+ )
+ text_box.add(subtitle_label)
+
+ # Plugin name (small text)
+ if self.result.plugin_name:
+ plugin_label = Label(
+ label=f"via {self.result.plugin_name}",
+ name="result-item-plugin",
+ h_align="start",
+ v_align="center",
+ ellipsize="end",
+ )
+ text_box.add(plugin_label)
+
+ def set_selected(self, selected: bool):
+ """Set the selection state of this result item."""
+ self._selected = selected
+
+ if selected:
+ self.add_style_class("selected")
+ else:
+ self.remove_style_class("selected")
+
+ def get_selected(self) -> bool:
+ """Get the selection state of this result item."""
+ return self._selected
+
+ def _on_button_press(self, widget, event):
+ """Handle button press events."""
+ if event.button == 1: # Left click
+ self.clicked.emit(self.index)
+ return True
+ return False
+
+ def _on_enter(self, widget, event):
+ """Handle mouse enter events."""
+ # Emit hover signal to update selection
+ self.hovered.emit(self.index)
+ return False
+
+ def _on_leave(self, widget, event):
+ """Handle mouse leave events."""
+ # Could be used for hover effects cleanup
+ return False
diff --git a/src/window/launcher/trigger_config.py b/src/window/launcher/trigger_config.py
new file mode 100644
index 0000000..bbf77e2
--- /dev/null
+++ b/src/window/launcher/trigger_config.py
@@ -0,0 +1,57 @@
+import json
+import os
+from typing import Any, Dict, List
+
+from fabric.utils import get_relative_path
+
+
+class TriggerConfig:
+ def __init__(self, config_path: str = None):
+ if config_path is None:
+ config_path = get_relative_path("../../../config/launcher.json")
+
+ self.config_path = config_path
+
+ # Load configuration from JSON file
+ config = {"launcher_config": {}, "settings": {}}
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ except Exception as e:
+ print(f"Error loading trigger config: {e}")
+
+ self.config = config
+ self.launcher_config = self.config.get("launcher_config", {})
+
+ # Initialize settings with defaults
+ default_settings = {
+ "max_examples_shown": 2,
+ "default_icon": "application-default-icon",
+ "fallback_example_template": "{trigger} ",
+ "config_version": "1.0",
+ }
+ self.settings = {**default_settings, **self.config.get("settings", {})}
+
+ def get_trigger_examples(self, trigger: str) -> List[str]:
+ examples = self.launcher_config.get(trigger, {}).get("examples", [])
+ if not examples:
+ template = self.settings.get(
+ "fallback_example_template", "{trigger} "
+ )
+ examples = [template.format(trigger=trigger)]
+ return examples
+
+ def get_trigger_icon(self, trigger: str) -> str:
+ icon = self.launcher_config.get(trigger, {}).get(
+ "icon", self.settings.get("default_icon", "application-default-icon")
+ )
+ return icon
+
+ def get_trigger_description(self, trigger: str) -> str:
+ return self.launcher_config.get(trigger, {}).get(
+ "description", f"{trigger} - No description available"
+ )
+
+ def get_all_triggers(self) -> Dict[str, Dict[str, Any]]:
+ return self.launcher_config.copy()
diff --git a/src/window/notification/__init__.py b/src/window/notification/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/window/notification/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py
new file mode 100644
index 0000000..d80bfaf
--- /dev/null
+++ b/src/window/notification/notification.py
@@ -0,0 +1,1264 @@
+import hashlib
+import time
+
+from fabric.notifications import (
+ Notification,
+ NotificationAction,
+ NotificationCloseReason,
+)
+from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger, os
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
+from services.config import get_config, on_config_change
+from services.modus import notification_service
+from shared.widgets.clipping_box import ClippingBox
+from shared.widgets.custom_image import CustomImage
+from shared.widgets.customrevealer import SlideRevealer
+from utils.functions import escape_markup_text, parse_timeout_string
+from utils.roam import modus_service
+
+# ruff: noqa: I001
+from window.notification.unified_cache import (
+ UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR,
+ cleanup_cache as _unified_cleanup_cache,
+ cleanup_old_cache_files as _unified_cleanup_old_cache_files,
+ ensure_cache_dir as _ensure_unified_cache_dir,
+ get_fallback_icon as _shared_fallback_icon,
+ get_from_cache as _unified_get_from_cache,
+ get_unified_cache_key,
+ save_to_cache as _unified_save_to_cache,
+)
+
+NOTIFICATION_WIDTH = 360
+NOTIFICATION_IMAGE_SIZE = 48
+
+# Use shared unified cache directory from unified_cache
+NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR
+NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR
+
+
+def ensure_notification_cache_dirs():
+ """Ensure unified notification cache directory exists"""
+ _ensure_unified_cache_dir()
+
+
+def cleanup_old_cache_files():
+ """Clean up old notification cache files (older than 7 days)"""
+ try:
+ _unified_cleanup_old_cache_files()
+ except Exception as e:
+ logger.warning(f"Failed to cleanup notification cache: {e}")
+
+
+get_cache_key = get_unified_cache_key
+
+
+def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir):
+ """Save a pixbuf to the specified cache directory"""
+ try:
+ ensure_notification_cache_dirs()
+ # Delegate to unified cache; ignore returned key for compatibility
+ result = _unified_save_to_cache(pixbuf, cache_key)
+ if result and result[0]:
+ return result[0]
+ return None
+ except Exception as e:
+ logger.warning(f"Failed to cache notification icon: {e}")
+ return None
+
+
+def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None):
+ """Get a cached pixbuf or return None if not found"""
+ if cache_dir is None:
+ cache_dir = NOTIFICATION_ICON_CACHE_DIR
+
+ try:
+ pixbuf = _unified_get_from_cache(cache_key, fallback_size)
+ if pixbuf:
+ return pixbuf
+ except Exception as e:
+ logger.warning(f"Failed to load cached notification icon: {e}")
+ return None
+
+
+def cache_notification_icon(source, size=(48, 48), app_name=None):
+ """Optimized notification icon caching with immediate pixbuf generation and caching"""
+ try:
+ ensure_notification_cache_dirs()
+
+ # Handle different source types with optimized caching
+ if isinstance(source, str):
+ cache_key = get_unified_cache_key(source, size, app_name)
+
+ # Check cache first for immediate return
+ cached_pixbuf = get_cached_pixbuf(
+ cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
+ )
+ if cached_pixbuf:
+ return cached_pixbuf
+
+ # Load, cache, and return icon in one optimized flow
+ if source.startswith("file://"):
+ # Local file URL - process and cache immediately
+ file_path = source[7:]
+ pixbuf = load_and_cache_local_icon(file_path, cache_key, size)
+ elif os.path.exists(source):
+ # Direct file path - process and cache immediately
+ pixbuf = load_and_cache_local_icon(source, cache_key, size)
+ else:
+ # Icon name - resolve from theme and cache immediately
+ pixbuf = load_and_cache_theme_icon(source, cache_key, size)
+
+ return pixbuf
+
+ elif hasattr(source, "scale_simple"):
+ # Already a pixbuf - cache it directly with optimized flow
+ cache_key = get_unified_cache_key(source, size, app_name)
+
+ # Check cache first
+ cached_pixbuf = get_cached_pixbuf(
+ cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
+ )
+ if cached_pixbuf:
+ return cached_pixbuf
+
+ # Scale once and cache immediately
+ scaled_pixbuf = source.scale_simple(
+ size[0], size[1], GdkPixbuf.InterpType.BILINEAR
+ )
+ save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
+ return scaled_pixbuf
+
+ except Exception as e:
+ logger.warning(f"Failed to cache notification icon: {e}")
+
+ # Return fallback with caching
+ return get_fallback_notification_icon(size)
+
+
+def load_and_cache_local_icon(file_path, cache_key, size):
+ """Load a local icon file and cache it"""
+ try:
+ if os.path.exists(file_path):
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ file_path, size[0], size[1], True
+ )
+ save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
+ return pixbuf
+ except Exception as e:
+ logger.warning(f"Failed to load local notification icon {file_path}: {e}")
+
+ return get_fallback_notification_icon(size)
+
+
+def load_and_cache_theme_icon(icon_name, cache_key, size):
+ """Load an icon from the current theme and cache it"""
+ # For simplicity, just use fallback since theme icon loading is complex in GTK4
+ return get_fallback_notification_icon(size)
+
+
+def get_fallback_notification_icon(size=(48, 48)):
+ return _shared_fallback_icon(size)
+
+
+def get_notification_image_cache_key(notification_id, image_pixbuf):
+ """Generate a deterministic cache key based on image content to prevent duplicate caching"""
+ try:
+ # Use image content hash for deterministic caching
+ if image_pixbuf and hasattr(image_pixbuf, "get_pixels"):
+ try:
+ pixel_data = image_pixbuf.get_pixels()
+ image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
+ return image_hash
+ except Exception:
+ # If pixel data fails, use image dimensions + timestamp
+ try:
+ width = image_pixbuf.get_width()
+ height = image_pixbuf.get_height()
+ dimension_hash = hashlib.md5(
+ f"{width}x{height}".encode()
+ ).hexdigest()[:8]
+ return dimension_hash
+ except Exception:
+ pass
+
+ # Fallback to timestamp for invalid pixbufs
+ return str(int(time.time()))[:8]
+ except Exception:
+ # Ultimate fallback
+ return str(int(time.time()))[:8]
+
+
+def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):
+ """Smart notification image caching that avoids duplicate caching"""
+ try:
+ ensure_notification_cache_dirs()
+
+ # Generate deterministic cache key based on image content
+ cache_key = get_notification_image_cache_key(notification_id, image_pixbuf)
+ try:
+ # Let unified cache handle scaling and saving
+ saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size)
+ if saved_path:
+ return saved_path, cache_key
+ return None, None
+ except Exception as scale_error:
+ logger.debug(
+ f"Failed to cache image (temp file likely gone): {scale_error}"
+ )
+ return None, None
+
+ except Exception as e:
+ logger.warning(f"Failed to cache notification image: {e}")
+ return None, None
+
+
+def get_cached_notification_image(cache_key):
+ """Get a cached notification image or return None if not found"""
+ try:
+ return _unified_get_from_cache(cache_key)
+ except Exception as e:
+ logger.warning(f"Failed to load cached notification image: {e}")
+ return None
+
+
+def cleanup_notification_image_cache(cache_key=None):
+ """Clean up notification image cache - specific key or all"""
+ try:
+ ensure_notification_cache_dirs()
+ _unified_cleanup_cache(cache_key)
+ except Exception as e:
+ logger.warning(f"Failed to cleanup notification image cache: {e}")
+
+
+def cleanup_notification_specific_caches(
+ app_icon_source=None, notification_image_cache_key=None
+):
+ """Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE"""
+ try:
+ # Clean up notification image cache
+ if notification_image_cache_key:
+ _unified_cleanup_cache(notification_image_cache_key)
+
+ # Clean up app icon cache for this specific source (only 35x35 version)
+ if app_icon_source:
+ # Only clean 35x35 version since we only cache this size now
+ cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35))
+ _unified_cleanup_cache(cache_key_35)
+
+ except Exception as e:
+ logger.warning(f"Failed to cleanup notification specific caches: {e}")
+
+
+def cleanup_all_notification_caches():
+ """Clean up ALL notification caches (icons and images)"""
+ try:
+ _unified_cleanup_cache()
+ except Exception as e:
+ logger.warning(f"Failed to cleanup all notification caches: {e}")
+
+
+def verify_cache_persistence():
+ """Verify that cached icons persist and can be loaded after restart"""
+ try:
+ icon_cache_files = []
+ image_cache_files = []
+
+ if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):
+ icon_cache_files = [
+ f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(".png")
+ ]
+
+ if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR):
+ image_cache_files = [
+ f
+ for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR)
+ if f.endswith(".png")
+ ]
+
+ # Test loading a few cached items to verify they work
+ for cache_file in icon_cache_files[:2]: # Test first 2 icon files
+ try:
+ cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file)
+ GdkPixbuf.Pixbuf.new_from_file(cache_path)
+ except Exception as e:
+ logger.warning(f"Failed to load cached icon {cache_file}: {e}")
+
+ for cache_file in image_cache_files[:2]: # Test first 2 image files
+ try:
+ cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file)
+ GdkPixbuf.Pixbuf.new_from_file(cache_path)
+ except Exception as e:
+ logger.warning(f"Failed to load cached image {cache_file}: {e}")
+
+ return len(icon_cache_files) + len(image_cache_files) > 0
+
+ except Exception as e:
+ logger.error(f"Failed to verify cache persistence: {e}")
+ return False
+
+
+def migrate_persistent_notifications():
+ """Migrate persistent notifications to use cached assets when temp files are gone"""
+ try:
+ from services.modus import notification_service
+
+ migrated_count = 0
+ for cached_notification in notification_service.cached_notifications:
+ notification = cached_notification._notification
+
+ # Check if notification has image_pixbuf but temp file might be gone
+ if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
+ try:
+ # Try to access pixel data to test if temp file still exists
+ notification.image_pixbuf.get_pixels()
+ except Exception:
+ # Temp file is gone, ensure app icon is cached as fallback
+ try:
+ if hasattr(notification, "app_icon") and notification.app_icon:
+ cache_notification_icon(notification.app_icon, (35, 35))
+ migrated_count += 1
+
+ except Exception as cache_error:
+ logger.debug(
+ f"Failed to cache app icon for {notification.app_name}: {
+ cache_error
+ }"
+ )
+
+ except Exception as e:
+ logger.warning(f"Failed to migrate persistent notifications: {e}")
+
+
+# Initialize cache and verify persistence on module load
+ensure_notification_cache_dirs()
+cleanup_old_cache_files()
+verify_cache_persistence()
+
+# Run migration for persistent notifications on startup
+try:
+ migrate_persistent_notifications()
+except Exception as e:
+ logger.debug(f"Migration skipped (service not ready): {e}")
+
+
+def preload_notification_assets(notification):
+ """Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE"""
+ try:
+ # Cache app icon only at content size (35x35) - scale down for headers at runtime
+ if hasattr(notification, "app_icon") and notification.app_icon:
+ try:
+ # Only cache at 35x35 to reduce disk usage - headers will scale this down
+ cache_notification_icon(notification.app_icon, (35, 35))
+ except Exception as icon_error:
+ logger.debug(
+ f"Failed to preload app icon for {notification.app_name}: {
+ icon_error
+ }"
+ )
+
+ # Cache notification image if available
+ if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
+ cache_notification_image(
+ notification.id, notification.image_pixbuf, (35, 35)
+ )
+
+ except Exception as e:
+ logger.warning(f"Failed to preload notification assets: {e}")
+
+
+def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280):
+ """Configure revealer for ultra-smooth animation"""
+ revealer.duration = duration
+
+
+class ActionButton(Button):
+ def __init__(
+ self, action: NotificationAction, index: int, total: int, notification_box
+ ):
+ super().__init__(
+ name="action-button",
+ h_expand=True,
+ on_clicked=self.on_clicked,
+ child=Label(name="button-label", label=action.label),
+ )
+ self.action = action
+ self.notification_box = notification_box
+ style_class = (
+ "start-action"
+ if index == 0
+ else "end-action"
+ if index == total - 1
+ else "middle-action"
+ )
+ self.add_style_class(style_class)
+ self.connect(
+ "enter-notify-event", lambda *_: notification_box.hover_button(self)
+ )
+ self.connect(
+ "leave-notify-event", lambda *_: notification_box.unhover_button(self)
+ )
+
+ def on_clicked(self, *_):
+ # Mark for cache cleanup when action button is clicked
+ self.notification_box._should_cleanup_cache = True
+ self.action.invoke()
+ self.action.parent.close("dismissed-by-user")
+
+
+class NotificationWidget(Box):
+ def __init__(
+ self,
+ notification: Notification,
+ timeout_ms=None,
+ show_close_button=True,
+ name="notification",
+ **kwargs,
+ ):
+ # Get current timeout from config manager if not provided
+ if timeout_ms is None:
+ timeout_ms = self._get_current_notification_timeout()
+
+ self.show_close_button = show_close_button
+ self.close_button = None
+ self._is_hovered = False
+ self.notification_image_cache_key = None # Track cached image for cleanup
+ self.app_icon_source = (
+ notification.app_icon
+ ) # Track app icon source for cleanup
+ self._should_cleanup_cache = False # Only cleanup cache on manual dismissal
+
+ super().__init__(
+ size=(NOTIFICATION_WIDTH, -1),
+ name=name,
+ orientation="v",
+ h_align="fill",
+ h_expand=True,
+ children=[
+ self.create_content(notification),
+ self.create_action_buttons(notification),
+ ],
+ )
+
+ self.notification = notification
+ self.timeout_ms = timeout_ms
+ self._timeout_id = None
+
+ # Add hover events to the main notification widget
+ self.connect("enter-notify-event", self._on_enter_notify)
+ self.connect("leave-notify-event", self._on_leave_notify)
+
+ self.start_timeout()
+
+ def create_header(self, notification):
+ """Create notification header with optimized cached app icon - SINGLE CACHE SIZE"""
+ try:
+ # Get 35x35 cached icon and scale down to 24x24 for header
+ cached_app_icon_pixbuf = cache_notification_icon(
+ notification.app_icon, (35, 35)
+ )
+
+ if cached_app_icon_pixbuf:
+ # Scale down the 35x35 cached icon to 24x24 for header display
+ header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple(
+ 24, 24, GdkPixbuf.InterpType.BILINEAR
+ )
+ app_icon = ClippingBox(
+ name="notification-icon",
+ children=Image(pixbuf=header_icon_pixbuf),
+ )
+ else:
+ # Fallback to theme icon if caching fails completely
+ app_icon = ClippingBox(
+ name="notification-icon",
+ children=Image(
+ icon_name="notifications",
+ icon_size=24,
+ ),
+ )
+ except Exception as e:
+ logger.warning(f"Failed to load cached header icon: {e}")
+ # Ultimate fallback
+ app_icon = ClippingBox(
+ name="notification-icon",
+ children=Image(
+ icon_name="notifications",
+ icon_size=24,
+ ),
+ )
+
+ return CenterBox(
+ name="notification-title",
+ start_children=[
+ Box(
+ spacing=4,
+ children=[
+ app_icon,
+ Label(
+ notification.app_name,
+ name="notification-app-name",
+ h_align="start",
+ ),
+ ],
+ )
+ ],
+ end_children=[
+ self.create_close_button() if self.show_close_button else Box()
+ ],
+ )
+
+ def create_content(self, notification):
+ return Box(
+ name="notification-content",
+ spacing=8,
+ children=[
+ ClippingBox(
+ name="notification-image",
+ children=Image(pixbuf=self._get_notification_pixbuf(notification)),
+ ),
+ Box(
+ name="notification-text",
+ orientation="v",
+ v_align="center",
+ children=[
+ Box(
+ name="notification-summary-box",
+ orientation="h",
+ children=[
+ Label(
+ name="notification-summary",
+ markup=escape_markup_text(
+ notification.summary.replace("\n", " ")
+ ),
+ h_align="start",
+ max_chars_width=40,
+ ellipsization="end",
+ ),
+ # Label(
+ # name="notification-app-name",
+ # markup=" | " + notification.app_name,
+ # h_align="start",
+ # ellipsization="end",
+ # ),
+ ],
+ ),
+ (
+ Label(
+ markup=escape_markup_text(
+ notification.body.replace("\n", " ")
+ ),
+ h_align="start",
+ max_chars_width=45,
+ ellipsization="end",
+ )
+ if notification.body
+ else Label(
+ markup="",
+ h_align="start",
+ ellipsization="end",
+ )
+ ),
+ ],
+ ),
+ Box(h_expand=True),
+ Box(
+ orientation="v",
+ children=[
+ Button(
+ name="notification-close-button",
+ image=CustomImage(icon_name="close-symbolic", icon_size=18),
+ visible=True, # Initially hidden
+ on_clicked=lambda *_: self._manual_close(),
+ ),
+ Box(v_expand=True),
+ ],
+ ),
+ ],
+ )
+
+ def _on_enter_notify(self, widget, event):
+ self._is_hovered = True
+ if self.close_button:
+ self.close_button.set_visible(True)
+ self.pause_timeout()
+ return False
+
+ def _on_leave_notify(self, widget, event):
+ self._is_hovered = False
+ if self.close_button:
+ self.close_button.set_visible(False)
+ self.resume_timeout()
+ return False
+
+ def get_pixbuf(self, icon_path, width, height):
+ """Get pixbuf with caching support"""
+ try:
+ # Use the icon caching system
+ cached_pixbuf = cache_notification_icon(icon_path, (width, height))
+ if cached_pixbuf:
+ return cached_pixbuf
+ except Exception as e:
+ logger.warning(f"Failed to get cached pixbuf for {icon_path}: {e}")
+
+ # Fallback to original method if caching fails
+ if icon_path.startswith("file://"):
+ icon_path = icon_path[7:]
+
+ if not os.path.exists(icon_path):
+ return get_fallback_notification_icon((width, height))
+
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
+ if pixbuf:
+ return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
+ else:
+ return get_fallback_notification_icon((width, height))
+ except Exception as e:
+ logger.error(f"Failed to load or scale icon: {e}")
+ return get_fallback_notification_icon((width, height))
+
+ def _get_notification_pixbuf(self, notification):
+ """Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat"""
+ notification_id = getattr(notification, "id", int(time.time()))
+
+ try:
+ # Try to get cached notification image first
+ if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
+ try:
+ cache_key = get_notification_image_cache_key(
+ notification_id, notification.image_pixbuf
+ )
+ cached_image = get_cached_notification_image(cache_key)
+ if cached_image:
+ return cached_image
+ except Exception as image_error:
+ logger.debug(f"Notification image processing failed: {image_error}")
+ # Continue to app icon fallback
+
+ except Exception as e:
+ logger.debug(f"Failed to process notification image: {e}")
+
+ # Use cached app icon as fallback
+ try:
+ app_icon_source = getattr(notification, "app_icon", None)
+ if app_icon_source:
+ cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
+ if cached_app_icon:
+ return cached_app_icon
+ except Exception as e:
+ logger.debug(f"Failed to get cached app icon: {e}")
+
+ # Ultimate fallback
+ return get_fallback_notification_icon((35, 35))
+
+ def create_action_buttons(self, notification):
+ return Box(
+ name="notification-action-buttons",
+ spacing=4,
+ h_expand=True,
+ children=[
+ ActionButton(action, i, len(notification.actions), self)
+ for i, action in enumerate(notification.actions)
+ ],
+ )
+
+ def start_timeout(self):
+ self.stop_timeout()
+ self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification)
+
+ def stop_timeout(self):
+ if self._timeout_id is not None:
+ GLib.source_remove(self._timeout_id)
+ self._timeout_id = None
+
+ def close_notification(self):
+ self.notification.close("expired")
+ self.stop_timeout()
+ return False
+
+ def pause_timeout(self):
+ self.stop_timeout()
+
+ def resume_timeout(self):
+ if not self._is_hovered: # Only resume if not hovered
+ self.start_timeout()
+
+ def _manual_close(self):
+ """Handle manual close button click - just close notification"""
+ self.notification.close("dismissed-by-user")
+
+ def destroy(self):
+ self.stop_timeout()
+ # Only clean up caches if this was a manual dismissal
+ if self._should_cleanup_cache:
+ cleanup_notification_specific_caches(
+ app_icon_source=getattr(self, "app_icon_source", None),
+ notification_image_cache_key=getattr(
+ self, "notification_image_cache_key", None
+ ),
+ )
+ else:
+ logger.debug("Preserved caches for timeout/auto-dismissed notification")
+ super().destroy()
+
+ # @staticmethod
+ def set_pointer_cursor(self, widget, cursor_name):
+ window = widget.get_window()
+ if window:
+ cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name)
+ window.set_cursor(cursor)
+
+ def hover_button(self, button):
+ self.pause_timeout()
+ self.set_pointer_cursor(button, "hand2")
+
+ def unhover_button(self, button):
+ # Don't resume timeout here since the notification itself might still be hovered
+ self.set_pointer_cursor(button, "arrow")
+
+ @staticmethod
+ def _get_current_notification_timeout():
+ """Get the current notification timeout from config manager."""
+ try:
+ timeout_str = get_config(
+ "notification_timeout", data.NOTIFICATION_TIMEOUT_STR
+ )
+ from shared.data import parse_timeout_string
+
+ return parse_timeout_string(timeout_str)
+ except Exception as e:
+ logger.warning(f"Failed to get notification timeout from config: {e}")
+ return data.NOTIFICATION_TIMEOUT
+
+
+class NotificationRevealer(SlideRevealer):
+ def __init__(
+ self,
+ notification: Notification,
+ on_transition_end=None,
+ parent_window=None,
+ **kwargs,
+ ):
+ self.notif_box = NotificationWidget(notification, show_close_button=False)
+ self.notification = notification
+ self.on_transition_end = on_transition_end
+ # Reference to NotificationCenter window for queue clearing
+ self.parent_window = parent_window
+ self._is_closing = False
+
+ # Enhanced swipe detection variables for Android-style animation
+ self._drag_start_y = 0
+ self._drag_start_x = 0
+ self._is_dragging = False
+ self._swipe_threshold = 80 # Distance to trigger auto-dismiss
+ self._swipe_velocity_threshold = (
+ 150 # Velocity to trigger dismiss even on shorter swipes
+ )
+ self._swipe_in_progress = False
+ self._current_offset = 0
+ self._last_drag_time = 0
+ self._drag_velocity = 0
+ self._spring_back_duration = 200 # Duration for spring-back animation
+ self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width
+
+ # Animation state
+ self._animation_in_progress = False
+ self._spring_timer_id = None
+ self._css_provider = None
+
+ # Wrap notification in EventBox for swipe detection
+ self.event_box = EventBox(
+ events=[
+ "button-press-event",
+ "button-release-event",
+ "motion-notify-event",
+ ],
+ child=self.notif_box,
+ )
+
+ super().__init__(
+ child=self.event_box,
+ direction="right",
+ duration=280, # Faster, smoother duration
+ )
+
+ smooth_revealer_animation(self)
+
+ # Connect our own handler that manages the slide animation
+ self.notification.connect("closed", self.on_resolved)
+
+ self._animation_in_progress = True
+
+ def _ease_out_cubic(self, t):
+ """Smoother easing function for better animation quality"""
+ return 1 - pow(1 - t, 3)
+
+ def _ease_out_quart(self, t):
+ """Even smoother easing for ultra-smooth animations"""
+ return 1 - pow(1 - t, 4)
+
+ def _apply_transform(self, offset_x, opacity, scale):
+ """Apply smooth CSS transforms for animation"""
+ try:
+ # Create CSS transformation
+ transform_css = f"""
+ opacity: {opacity};
+ transform: translateX({offset_x}px) scale({scale});
+ transition: none;
+ """
+
+ # Apply to the notification box
+ if hasattr(self.notif_box, "get_style_context"):
+ style_context = self.notif_box.get_style_context()
+ if style_context:
+ # Use CSS provider for smooth transforms
+ if not hasattr(self, "_css_provider") or not self._css_provider:
+ self._css_provider = Gtk.CssProvider()
+ style_context.add_provider(
+ self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ )
+
+ css_data = f"* {{ {transform_css} }}"
+ self._css_provider.load_from_data(css_data.encode())
+
+ except Exception as e:
+ logger.debug(f"Transform apply failed (non-critical): {e}")
+
+ def _animate_dismiss(self, start_offset):
+ """Animate the notification sliding out with smooth 60fps animation"""
+ target_offset = NOTIFICATION_WIDTH + 50
+ duration = 200 # Slightly longer for smoother feel
+
+ if self._spring_timer_id:
+ GLib.source_remove(self._spring_timer_id)
+
+ start_time = GLib.get_monotonic_time() / 1000
+ offset_diff = target_offset - start_offset
+
+ def animate_step():
+ current_time = GLib.get_monotonic_time() / 1000
+ elapsed = current_time - start_time
+ progress = min(1.0, elapsed / duration)
+
+ # Use smoother easing for premium feel
+ eased_progress = self._ease_out_quart(progress)
+ current_offset = start_offset + (offset_diff * eased_progress)
+
+ # Smoother fade and scale transitions
+ opacity = max(0.0, 1.0 - (progress * 0.9)) # Gentler fade
+ scale = max(0.9, 1.0 - (progress * 0.1)) # Subtle scale
+
+ self._apply_transform(current_offset, opacity, scale)
+
+ if progress >= 1.0:
+ # Mark notification for cache cleanup on swipe dismissal
+ self.notif_box._should_cleanup_cache = True
+ try:
+ self.notification.close("dismissed-by-user")
+ except Exception:
+ pass
+ return False
+
+ return True
+
+ # Use consistent 60fps timing
+ self._animation_in_progress = True
+ self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS
+
+ def _calculate_drag_velocity(self, current_x):
+ """Calculate the velocity of the drag gesture"""
+ current_time = GLib.get_monotonic_time() / 1000
+
+ if self._last_drag_time > 0:
+ time_diff = current_time - self._last_drag_time
+ if time_diff > 0:
+ distance_diff = current_x - self._drag_start_x - self._current_offset
+ self._drag_velocity = abs(distance_diff / time_diff)
+
+ self._last_drag_time = current_time
+
+ def _on_animation_complete(self, is_hiding=False):
+ if is_hiding:
+ # Manually destroy the notification widget since we disconnected its handler
+ self.notif_box.destroy()
+
+ if self.on_transition_end:
+ self.on_transition_end()
+ self.destroy()
+
+ def on_resolved(
+ self,
+ _notification: Notification,
+ reason: NotificationCloseReason,
+ ):
+ if self._is_closing:
+ return
+
+ self._is_closing = True
+
+ # Clean up any ongoing animations
+ if self._spring_timer_id:
+ GLib.source_remove(self._spring_timer_id)
+
+ # Use different slide directions based on dismiss reason
+ if reason == "expired":
+ # Gentle fade-out for auto-dismiss
+ self.set_slide_direction("left")
+ self.duration = 250 # Slightly slower for natural feel
+ elif self._swipe_in_progress:
+ # Quick slide for swipe dismissals
+ self.duration = 150
+ self.set_slide_direction("right")
+ else:
+ # Smooth slide for manual close
+ self.set_slide_direction("right")
+ self.duration = 200
+
+ self.hide()
+ # Consistent timing for smooth transitions
+ timeout_duration = self.duration + 50
+ GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True))
+
+ def destroy(self):
+ # Clean up CSS provider and timers
+ if self._spring_timer_id:
+ GLib.source_remove(self._spring_timer_id)
+ super().destroy()
+
+
+class NotificationState:
+ IDLE = 0
+ SHOWING = 1
+ HIDING = 2
+ TRANSITIONING = 3 # New state for smooth transitions
+
+
+class ModusNoti(Window):
+ def __init__(self):
+ # Local config state mirroring services.config usage
+ self._current_config = {
+ "notification_timeout": data.NOTIFICATION_TIMEOUT_STR,
+ "notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY,
+ "notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY,
+ }
+
+ # Subscribe to config changes and apply initial config
+ on_config_change(self._on_config_changed)
+ self._apply_initial_config()
+
+ self._server = notification_service
+
+ self.notifications = Box(
+ v_expand=True,
+ h_expand=True,
+ style="margin: 1px 0px 1px 1px;",
+ orientation="v",
+ spacing=5,
+ )
+
+ # Enhanced queue system for ultra-smooth transitions
+ self.notification_queue = []
+ self.current_notification = None
+ self.notification_state = NotificationState.IDLE
+ self._transition_timer_id = None
+ self._debounce_timer_id = None
+ self._last_notification_time = 0
+
+ # Queue management settings for smooth behavior
+ self.MAX_QUEUE_SIZE = 3 # Limit queue to prevent overwhelming
+ self.TRANSITION_DELAY = 100 # Smoother transition timing
+ self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications
+
+ self._server.connect("notification-added", self.on_new_notification)
+ Window.__init__(
+ self,
+ anchor="top right",
+ child=self.notifications,
+ layer="overlay",
+ title="modus-notifications", # More specific title for debugging
+ all_visible=True,
+ visible=False, # Start hidden, show only when we have content
+ exclusive=False,
+ )
+
+ def on_new_notification(self, fabric_notif, id):
+ notification: Notification = fabric_notif.get_notification_from_id(id)
+
+ # Check if notification still exists (might have been removed already)
+ if not notification:
+ return
+
+ if self._server.dont_disturb or modus_service.dont_disturb:
+ # Notification is already cached by the service, just don't show popup
+ return
+
+ # Check if notification should be ignored based on current config
+ if self._should_ignore_notification(notification):
+ return
+
+ # Preload assets immediately for optimal caching and display performance
+ preload_notification_assets(notification)
+
+ # Implement smart queue management for smooth transitions
+ GLib.get_monotonic_time() / 1000
+
+ # If queue is getting full, remove oldest notifications smoothly
+ if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
+ # Remove oldest notification from queue (not current showing one)
+ if self.notification_queue:
+ oldest = self.notification_queue.pop(0)
+ try:
+ oldest.close("dismissed-by-user")
+ except Exception:
+ pass
+
+ # Add new notification to queue
+ self.notification_queue.append(notification)
+
+ # Debounce rapid notifications for smoother experience
+ if self._debounce_timer_id:
+ GLib.source_remove(self._debounce_timer_id)
+
+ self._debounce_timer_id = GLib.timeout_add(
+ self.DEBOUNCE_DELAY,
+ lambda: self._process_notification_queue_debounced() or False,
+ )
+
+ def _process_notification_queue_debounced(self):
+ """Process queue after debounce delay for smooth transitions"""
+ self._debounce_timer_id = None
+ self._process_notification_queue()
+ return False
+
+ def _process_notification_queue(self):
+ # If we're currently showing a notification and there's a new one in queue
+ if (
+ self.notification_state == NotificationState.SHOWING
+ and self.current_notification
+ and self.notification_queue
+ ):
+ # Smooth transition: start hiding current notification
+ self.notification_state = NotificationState.TRANSITIONING
+ self._start_smooth_transition()
+
+ elif (
+ self.notification_state == NotificationState.IDLE
+ and self.notification_queue
+ ):
+ # If we're idle and have notifications in queue, show the next one
+ self._show_next_notification()
+
+ def _start_smooth_transition(self):
+ """Start smooth transition between notifications"""
+ if self.current_notification and not self.current_notification._is_closing:
+ # Don't mark for cache cleanup during smooth transitions
+ # to maintain performance
+
+ # Use shorter timeout for smooth transitions
+ self.current_notification.notif_box.timeout_ms = 100
+
+ # Force close current notification with smooth animation
+ try:
+ self.current_notification.notification.close("expired")
+ except Exception:
+ pass
+
+ def _show_next_notification(self):
+ if (
+ not self.notification_queue
+ or self.notification_state != NotificationState.IDLE
+ ):
+ return
+
+ notification = self.notification_queue.pop(0)
+
+ # Check if notification is still valid (might have been removed)
+ if not notification or not hasattr(notification, "app_icon"):
+ # Skip invalid notifications and try next one
+ if self.notification_queue:
+ self._show_next_notification()
+ return
+
+ self.notification_state = NotificationState.SHOWING
+
+ new_box = NotificationRevealer(
+ notification,
+ on_transition_end=lambda: self._on_notification_finished(new_box),
+ parent_window=self,
+ )
+
+ self.current_notification = new_box
+
+ # Clear any existing children
+ for child in list(self.notifications.children):
+ try:
+ self.notifications.remove(child)
+ except Exception:
+ pass
+
+ self.notifications.children = [new_box]
+
+ # Show the window now that we have content to display
+ self.set_visible(True)
+
+ new_box.show()
+ self.notifications.queue_resize()
+
+ def start_animation():
+ if new_box.get_parent() and new_box.get_realized():
+ new_box.reveal()
+ return False
+ return True
+
+ GLib.idle_add(start_animation)
+
+ def _on_notification_finished(self, notification_box):
+ if notification_box != self.current_notification:
+ return
+
+ # Cancel any pending transition timer
+ if self._transition_timer_id:
+ GLib.source_remove(self._transition_timer_id)
+ self._transition_timer_id = None
+
+ # Safely remove notification box
+ try:
+ if notification_box in self.notifications.children:
+ self.notifications.remove(notification_box)
+ except Exception:
+ pass
+
+ # Reset state
+ self.current_notification = None
+ self.notification_state = NotificationState.IDLE
+
+ # Process next notification with optimized delay for ultra-smooth transitions
+ if self.notification_queue:
+ self._transition_timer_id = GLib.timeout_add(
+ self.TRANSITION_DELAY, # Consistent smooth timing
+ lambda: self._show_next_notification() or False,
+ )
+ else:
+ # Hide window when no more notifications to show
+ self.set_visible(False)
+
+ def show_next_notification(self):
+ # Legacy method for compatibility - redirect to new implementation
+ self._show_next_notification()
+
+ def on_notification_finished(self, notification_box):
+ # Legacy method for compatibility - redirect to new implementation
+ self._on_notification_finished(notification_box)
+
+ def clear_notification_queue(self):
+ """Clear queue with smooth cleanup"""
+ queue_length = len(self.notification_queue)
+ if queue_length > 0:
+ # Smooth dismissal of queued notifications
+ for notification in list(self.notification_queue):
+ try:
+ notification.close("dismissed-by-user")
+ except Exception:
+ pass
+ self.notification_queue.clear()
+
+ # Also clean current notification if showing
+ if self.current_notification:
+ # Mark for cache cleanup when clearing queue
+ self.current_notification.notif_box._should_cleanup_cache = True
+
+ # Clear animation timers
+ if self._transition_timer_id:
+ GLib.source_remove(self._transition_timer_id)
+ self._transition_timer_id = None
+
+ if self._debounce_timer_id:
+ GLib.source_remove(self._debounce_timer_id)
+ self._debounce_timer_id = None
+
+ def get_queue_length(self):
+ return len(self.notification_queue)
+
+ def _should_ignore_notification(self, notification):
+ """Check if notification should be ignored based on current config."""
+ try:
+ ignored_apps = self._current_config.get("notification_ignored_apps", [])
+ app_name = getattr(notification, "app_name", "")
+
+ # Check if app name is in ignored list
+ if app_name in ignored_apps:
+ return True
+
+ return False
+ except Exception as e:
+ logger.warning(f"Failed to check if notification should be ignored: {e}")
+ return False
+
+ def update_config(self, new_config):
+ """Update notification configuration dynamically."""
+ try:
+ # Handle notification timeout changes
+ if "notification_timeout" in new_config:
+ timeout_str = new_config["notification_timeout"]
+ # Parse the timeout string to milliseconds
+
+ parse_timeout_string(timeout_str)
+
+ # Handle ignored apps changes
+ if "notification_ignored_apps" in new_config:
+ new_config["notification_ignored_apps"]
+
+ # Handle limited apps history changes
+ if "notification_limited_apps_history" in new_config:
+ new_config["notification_limited_apps_history"]
+
+ # Update the current config
+ self._current_config.update(new_config)
+
+ except Exception as e:
+ logger.error(f"[ModusNoti] Failed to update config: {e}")
+
+ def _apply_initial_config(self):
+ try:
+ timeout_val = get_config(
+ "notification_timeout", data.NOTIFICATION_TIMEOUT_STR
+ )
+ ignored_apps = get_config(
+ "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY
+ )
+ limited_apps = get_config(
+ "notification_limited_apps_history",
+ data.NOTIFICATION_LIMITED_APPS_HISTORY,
+ )
+ initial = {
+ "notification_timeout": timeout_val,
+ "notification_ignored_apps": ignored_apps,
+ "notification_limited_apps_history": limited_apps,
+ }
+ # Only apply if different
+ self.update_config(initial)
+ except Exception as e:
+ logger.error(f"[ModusNoti] Failed to apply initial config: {e}")
+
+ def _on_config_changed(self, new_config: dict, old_config: dict):
+ try:
+ 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)
+
+ if changes:
+ self.update_config(changes)
+ except Exception as e:
+ logger.error(f"[ModusNoti] Error handling config change: {e}")
diff --git a/src/window/notification/notification_center.py b/src/window/notification/notification_center.py
new file mode 100644
index 0000000..e519389
--- /dev/null
+++ b/src/window/notification/notification_center.py
@@ -0,0 +1,869 @@
+from collections import defaultdict
+
+from fabric.utils import GLib, logger
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
+from services.modus import notification_service
+from shared.widgets.clipping_box import ClippingBox
+from shared.widgets.custom_image import CustomImage
+from utils.functions import escape_markup_text
+from window.notification.notification import (
+ NotificationWidget,
+ cache_notification_icon,
+ cleanup_all_notification_caches,
+ cleanup_notification_specific_caches,
+ get_cached_notification_image,
+ preload_notification_assets,
+)
+from window.notification.unified_cache import (
+ get_fallback_icon as get_fallback_notification_icon,
+)
+from window.notification.unified_cache import (
+ get_from_cache,
+)
+
+
+class ExpandableNotificationGroup(Box):
+ def __init__(self, app_name, notifications, **kwargs):
+ super().__init__(
+ name="notification-group", orientation="v", spacing=0, **kwargs
+ )
+
+ self.app_name = app_name
+ self.notifications = notifications
+ self.is_expanded = False # Always start collapsed
+
+ # Create collapsed state first (shows only latest notification)
+ self.create_collapsed_state()
+
+ # Create expanded state (hidden initially)
+ self.create_expanded_state()
+
+ # Ensure we start in collapsed state
+ self.collapsed_eventbox.set_visible(True)
+ self.expanded_container.set_visible(False)
+
+ def create_collapsed_state(self):
+ latest_notification = self.notifications[0] # Most recent notification
+
+ # Create clickable event box
+ self.collapsed_eventbox = EventBox(
+ events=["button-press-event"],
+ )
+ self.collapsed_eventbox.connect("button-press-event", self.on_clicked)
+
+ # Only create stacked effect if we have multiple notifications
+ num_notifications = len(self.notifications)
+
+ if num_notifications == 1:
+ # Single notification - no stacking needed
+ notification_widget = NotificationCenterWidget(
+ notification=latest_notification
+ )
+ self.collapsed_eventbox.add(notification_widget)
+ else:
+ # Multiple notifications - create stacked effect
+ # Create container for the entire stack
+ stack_container = Box(
+ name="notification-stack-container",
+ orientation="v",
+ spacing=0,
+ )
+
+ # Add bottom shadow layer first (deepest)
+ if num_notifications >= 3:
+ bottom_shadow = Box(
+ name="stack-shadow-bottom",
+ )
+ stack_container.add(bottom_shadow)
+
+ # Add middle shadow layer
+ if num_notifications >= 2:
+ middle_shadow = Box(
+ name="stack-shadow-middle",
+ )
+ stack_container.add(middle_shadow)
+
+ # Add the main notification content on top
+ main_notification = Box(
+ name="stack-main-notification",
+ spacing=8,
+ children=[
+ ClippingBox(
+ name="notification-image",
+ children=Image(
+ pixbuf=self._get_notification_pixbuf_for_group(
+ latest_notification
+ )
+ ),
+ ),
+ Box(
+ name="notification-text",
+ orientation="v",
+ v_align="center",
+ h_expand=True,
+ children=[
+ Box(
+ name="notification-summary-box",
+ orientation="h",
+ children=[
+ Label(
+ name="notification-summary",
+ markup=f"{self.app_name}",
+ h_align="start",
+ ellipsization="end",
+ ),
+ ],
+ ),
+ Label(
+ name="notification-body",
+ markup=escape_markup_text(
+ latest_notification._notification.summary.replace(
+ "\n", " "
+ )
+ ),
+ max_chars_width=25,
+ h_align="start",
+ ellipsization="end",
+ ),
+ ],
+ ),
+ Box(
+ name="notification-count",
+ orientation="v",
+ children=[
+ Button(
+ name="notification-close",
+ image=CustomImage(
+ icon_name="close-symbolic", icon_size=18
+ ),
+ visible=True,
+ on_clicked=lambda *_: (
+ self._close_single_notification_and_stop_propagation(
+ latest_notification
+ )
+ ),
+ ),
+ Label(
+ name="notification-count-label",
+ label=f"{len(self.notifications)}",
+ h_align="end",
+ ),
+ ],
+ ),
+ ],
+ )
+ stack_container.add(main_notification)
+ self.collapsed_eventbox.add(stack_container)
+
+ self.add(self.collapsed_eventbox)
+
+ # Create expanded state (hidden initially)
+ self.create_expanded_state()
+
+ def _get_notification_pixbuf_for_group(self, cached_notification):
+ """Get notification pixbuf using cached image key - fallback to app icon"""
+ notification = cached_notification._notification
+ getattr(notification, "id", None)
+
+ # First try to get cached notification image using stored cache key
+ if (
+ hasattr(cached_notification, "cache_metadata")
+ and cached_notification.cache_metadata
+ ):
+ notification_image_cache_key = cached_notification.cache_metadata.get(
+ "notification_image_cache_key"
+ )
+ if notification_image_cache_key:
+ try:
+ cached_image = get_cached_notification_image(
+ notification_image_cache_key
+ )
+ if cached_image:
+ return cached_image
+ except Exception as e:
+ logger.debug(f"Failed to load cached notification image: {e}")
+
+ # Fallback to app icon using cached key
+ if (
+ hasattr(cached_notification, "cache_metadata")
+ and cached_notification.cache_metadata
+ ):
+ app_icon_cache_key = cached_notification.cache_metadata.get(
+ "app_icon_cache_key"
+ )
+ if app_icon_cache_key:
+ try:
+ cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))
+ if cached_app_icon:
+ return cached_app_icon
+ except Exception as e:
+ logger.debug(f"Failed to load cached app icon: {e}")
+
+ # Final fallback - try to cache app icon directly if available
+ try:
+ app_icon_source = getattr(notification, "app_icon", None)
+ if app_icon_source:
+ cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
+ if cached_app_icon:
+ return cached_app_icon
+ except Exception as e:
+ logger.debug(f"Failed to get directly cached app icon: {e}")
+
+ # Ultimate fallback
+ return get_fallback_notification_icon((35, 35))
+
+ def create_expanded_state(self):
+ # Create main expanded container
+ self.expanded_container = Box(
+ name="notification-group-expanded-container",
+ orientation="v",
+ spacing=0,
+ )
+
+ # Header with app name and controls
+ self.header_content = Box(
+ orientation="h",
+ h_expand=True,
+ children=[
+ Label(
+ name="notification-group-title",
+ markup=f"{self.app_name}",
+ h_align="start",
+ h_expand=True,
+ ),
+ Button(
+ name="notification-show-less",
+ label="Show less",
+ on_clicked=self.collapse,
+ h_align="end",
+ ),
+ Button(
+ name="notification-close-summery",
+ h_expand=False,
+ v_expand=False,
+ on_clicked=self.close_all,
+ image=CustomImage(
+ icon_name="close-symbolic",
+ name="notification-close-header",
+ icon_size=18,
+ h_align="end",
+ ),
+ visible=True,
+ ),
+ ],
+ )
+
+ # Wrap header in revealer for slide-up animation during collapse
+ self.header_revealer = Revealer(
+ child=self.header_content,
+ transition_type="slide-up",
+ transition_duration=300,
+ child_revealed=False,
+ )
+
+ # Box for individual notifications
+ self.notifications_list = Box(
+ name="notification-group-notifications",
+ orientation="v",
+ spacing=5,
+ )
+
+ # Add individual notifications to the list
+ for notification in self.notifications:
+ notification_widget = NotificationCenterWidget(notification=notification)
+ self.notifications_list.add(notification_widget)
+
+ # Wrap notifications list in revealer for slide-down animation
+ self.notifications_revealer = Revealer(
+ child=self.notifications_list,
+ transition_type="slide-down",
+ transition_duration=300,
+ child_revealed=False,
+ )
+
+ # Wrap notifications revealer in crossfade revealer for closing animation
+ self.notifications_crossfade = Revealer(
+ child=self.notifications_revealer,
+ transition_type="crossfade",
+ transition_duration=250,
+ child_revealed=True, # Start revealed so crossfade works on close
+ )
+
+ # Add header revealer and notifications crossfade to container
+ self.expanded_container.add(self.header_revealer)
+ self.expanded_container.add(self.notifications_crossfade)
+
+ # Add the container to the main group
+ self.add(self.expanded_container)
+
+ # Hide the entire expanded container initially
+ self.expanded_container.set_visible(False)
+
+ def on_clicked(self, widget, event):
+ if event.button == 1: # Left click
+ # Always allow expansion, even for single notifications
+ # This ensures single notifications can show their expanded entry view
+ self.expand()
+ return True
+
+ def expand(self, *args):
+ """Expand to show all notifications in this group with slide-down animation"""
+ self.is_expanded = True
+ self.collapsed_eventbox.set_visible(False)
+ self.expanded_container.set_visible(True)
+
+ # Show header immediately (no animation on expand)
+ self.header_revealer.set_reveal_child(True)
+
+ # Ensure crossfade is revealed for expand
+ self.notifications_crossfade.set_reveal_child(True)
+
+ # Small delay then animate notifications sliding down
+ GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True))
+
+ def collapse(self, *args):
+ """Collapse with header sliding up, notifications crossfading, then sliding up"""
+ self.is_expanded = False
+
+ # Start header slide-up and notifications crossfade simultaneously
+ self.header_revealer.set_reveal_child(False)
+ self.notifications_crossfade.set_reveal_child(False)
+
+ # Show collapsed state and hide expanded container halfway through crossfade
+ GLib.timeout_add(125, self._show_collapsed_midway)
+
+ # After crossfade completes, start slide-up animation (just for cleanup)
+ GLib.timeout_add(
+ 260, lambda: self.notifications_revealer.set_reveal_child(False)
+ )
+
+ def _show_collapsed_midway(self):
+ """Show collapsed state and hide expanded container to prevent deformation"""
+ self.collapsed_eventbox.set_visible(True)
+ self.expanded_container.set_visible(False)
+ return False # Don't repeat timeout
+
+ def _complete_collapse(self):
+ """Complete the collapse animation - no longer needed but kept for compatibility"""
+ return False # Don't repeat timeout
+
+ def close_all(self, *args):
+ """Close all notifications in this group with proper cache cleanup"""
+ # Close all notifications in this group
+ for notification in self.notifications:
+ try:
+ # Get notification cache metadata for cleanup
+ cache_metadata = getattr(notification, "cache_metadata", {})
+
+ # Clean up caches using stored metadata
+ cleanup_notification_specific_caches(
+ app_icon_source=notification._notification.app_icon,
+ notification_image_cache_key=cache_metadata.get(
+ "notification_image_cache_key"
+ ),
+ )
+
+ notification_service.remove_cached_notification(notification.cache_id)
+ except Exception as e:
+ logger.error(
+ f"Error removing notification {notification.cache_id}: {e}"
+ )
+
+ def _close_single_notification(self, notification):
+ """Close a single notification from this group with proper cache cleanup"""
+ try:
+ # Get notification cache metadata for cleanup
+ cache_metadata = getattr(notification, "cache_metadata", {})
+
+ # Clean up caches using stored metadata
+ cleanup_notification_specific_caches(
+ app_icon_source=notification._notification.app_icon,
+ notification_image_cache_key=cache_metadata.get(
+ "notification_image_cache_key"
+ ),
+ )
+
+ notification_service.remove_cached_notification(notification.cache_id)
+ except Exception as e:
+ logger.error(
+ f"Error removing single notification {notification.cache_id}: {e}"
+ )
+
+ def _close_single_notification_and_stop_propagation(self, notification):
+ """Close notification and prevent click from expanding the group"""
+ self._close_single_notification(notification)
+
+ # If this was the last notification in the group, the group will be removed
+ # by the notification_removed signal handler. If there are still notifications,
+ # we need to check if this group should be removed from view.
+ remaining_notifications = [
+ n for n in self.notifications if n.cache_id != notification.cache_id
+ ]
+
+ if not remaining_notifications:
+ # This was the last notification, the group will be destroyed by signal handler
+ pass
+ else:
+ # Update the notifications list and refresh the view immediately
+ self.notifications = remaining_notifications
+ # Force immediate UI update by destroying and recreating collapsed state
+ if hasattr(self, "collapsed_eventbox"):
+ self.collapsed_eventbox.destroy()
+ self.create_collapsed_state()
+ self.show_all()
+
+ return True # Stop event propagation
+
+
+class NotificationCenterWidget(NotificationWidget):
+ def __init__(self, notification, **kwargs):
+ self.notification_id = notification.cache_id
+ self.cache_metadata = getattr(notification, "cache_metadata", {})
+
+ super().__init__(
+ notification._notification,
+ timeout_ms=0,
+ show_close_button=True,
+ name="notification-centre-notifs",
+ **kwargs,
+ )
+
+ def _get_notification_pixbuf(self, notification):
+ """Get notification pixbuf using cached image key - fallback to app icon"""
+ getattr(notification, "id", None)
+
+ # First try to get cached notification image using stored cache key
+ if self.cache_metadata:
+ notification_image_cache_key = self.cache_metadata.get(
+ "notification_image_cache_key"
+ )
+ if notification_image_cache_key:
+ try:
+ cached_image = get_cached_notification_image(
+ notification_image_cache_key
+ )
+ if cached_image:
+ return cached_image
+ except Exception as e:
+ logger.debug(f"Failed to load cached notification image: {e}")
+
+ # Fallback to app icon using cached key
+ if self.cache_metadata:
+ app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key")
+ if app_icon_cache_key:
+ try:
+ cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))
+ if cached_app_icon:
+ return cached_app_icon
+ except Exception as e:
+ logger.debug(f"Failed to load cached app icon: {e}")
+
+ # Final fallback - try to cache app icon directly if available
+ try:
+ app_icon_source = getattr(notification, "app_icon", None)
+ if app_icon_source:
+ cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
+ if cached_app_icon:
+ return cached_app_icon
+ except Exception as e:
+ logger.debug(f"Failed to get directly cached app icon: {e}")
+
+ # Ultimate fallback
+ return get_fallback_notification_icon((35, 35))
+
+ def create_content(self, notification):
+ # Create our custom close button for notification center
+
+ self.close_button = Button(
+ name="notif-close-button",
+ image=CustomImage(
+ icon_name="close-symbolic", name="notification-close", icon_size=18
+ ),
+ visible=True, # Always visible in notification center
+ on_clicked=self._on_close_clicked,
+ )
+ self.close_button.connect(
+ "enter-notify-event", lambda *_: self.hover_button(self.close_button)
+ )
+ self.close_button.connect(
+ "leave-notify-event", lambda *_: self.unhover_button(self.close_button)
+ )
+
+ # Create the content box manually with our custom close button
+ return Box(
+ name="notification-content",
+ spacing=8,
+ children=[
+ ClippingBox(
+ name="notification-image",
+ children=Image(pixbuf=self._get_notification_pixbuf(notification)),
+ ),
+ Box(
+ name="notification-text",
+ orientation="v",
+ v_align="center",
+ children=[
+ Box(
+ name="notification-summary-box",
+ orientation="h",
+ children=[
+ Label(
+ name="notification-summary",
+ markup=escape_markup_text(
+ notification.summary.replace("\n", " ")
+ ),
+ h_align="start",
+ max_chars_width=25,
+ ellipsization="end",
+ ),
+ ],
+ ),
+ (
+ Label(
+ markup=escape_markup_text(
+ notification.body.replace("\n", " ")
+ ),
+ h_align="start",
+ max_chars_width=35,
+ ellipsization="end",
+ )
+ if notification.body
+ else Label(
+ markup="",
+ h_align="start",
+ ellipsization="end",
+ )
+ ),
+ ],
+ ),
+ Box(h_expand=True),
+ Box(
+ orientation="v",
+ children=[
+ self.close_button, # Use our custom close button
+ Box(v_expand=True),
+ ],
+ ),
+ ],
+ )
+
+ # Override to disable the action buttons
+ def create_action_buttons(self, notification):
+ return Box(name="notification-action-buttons")
+
+ def _on_close_clicked(self, *args):
+ try:
+ # Use instance cache metadata instead of notification attribute
+ cache_metadata = self.cache_metadata
+
+ # Clean up caches using stored metadata
+ cleanup_notification_specific_caches(
+ app_icon_source=self.notification.app_icon,
+ notification_image_cache_key=cache_metadata.get(
+ "notification_image_cache_key"
+ ),
+ )
+
+ notification_service.remove_cached_notification(self.notification_id)
+ except Exception as e:
+ logger.error(f"Error removing notification {self.notification_id}: {e}")
+
+ # Override to disable timeout functionality
+ def start_timeout(self):
+ pass
+
+ # Override to disable timeout functionality
+ def stop_timeout(self):
+ pass
+
+ # Override to disable auto-close functionality
+ def close_notification(self):
+ return False
+
+
+class NotificationCenter(Window):
+ def __init__(self):
+ super().__init__(
+ layer="overlay",
+ anchor="top right",
+ visible=False,
+ keyboard_mode="on-demand",
+ title="modus",
+ )
+
+ NOTIFICATION_CENTER_WIDTH = 410
+ self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600)
+
+ # Group notifications by app name
+ self.notification_groups = defaultdict(list)
+ self.group_widgets = {}
+
+ notification_service.connect(
+ "cached-notification-added", self.on_notification_added
+ )
+ notification_service.connect(
+ "cached-notification-removed", self.on_notification_removed
+ )
+ notification_service.connect("clear-all", self.on_clear_all)
+ notification_service.connect("notify::count", self.on_count_changed)
+
+ main_box = Box(
+ orientation="v",
+ spacing=5,
+ name="noti-center-box",
+ )
+
+ self.scrolled = ScrolledWindow(h_expand=False, v_expand=False)
+ self.notifications_box = Box(
+ v_expand=False,
+ h_expand=False,
+ style="margin: 1px 0px 1px 1px;",
+ orientation="v",
+ spacing=5,
+ )
+ self.scrolled.add(self.notifications_box)
+ main_box.add(self.scrolled)
+
+ # No notifications label - REMOVED
+
+ self.clear_all_button = Button(
+ name="noti-clear-button",
+ label="Clear",
+ on_clicked=self.clear_all_notifications,
+ visible=(notification_service.count > 0),
+ )
+ self.button_centre_box = CenterBox(
+ center_children=[self.clear_all_button],
+ )
+ main_box.add(self.button_centre_box)
+
+ # Wrap main content in revealer for slide-left animation
+ self.main_revealer = Revealer(
+ child=main_box,
+ transition_type="slide-left",
+ transition_duration=400,
+ child_revealed=False,
+ )
+
+ self.children = self.main_revealer
+
+ # Load existing notifications and group them
+ self._rebuild_notification_groups()
+
+ self.add_keybinding("Escape", self._on_escape_pressed)
+
+ def _rebuild_notification_groups(self):
+ """Rebuild notification groups from scratch with enhanced asset preloading and debugging"""
+ # Clear existing groups
+ self.notification_groups.clear()
+ self.group_widgets.clear()
+
+ # Clear notifications box
+ for child in self.notifications_box.get_children():
+ child.destroy()
+
+ # Group notifications by app name and preload assets with debugging
+ rebuild_count = 0
+ for cached_notification in notification_service.cached_notifications:
+ app_name = cached_notification._notification.app_name
+ getattr(cached_notification._notification, "id", None)
+
+ # Skip ignored apps during rebuild
+ if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
+ continue
+
+ # Preload assets for each cached notification to ensure display consistency
+ preload_notification_assets(cached_notification._notification)
+
+ self.notification_groups[app_name].append(cached_notification)
+ rebuild_count += 1
+
+ # Create group widgets and handle limited apps
+ for app_name, notifications in self.notification_groups.items():
+ # Sort notifications by ID (highest ID first - newest notifications)
+ # This ensures the latest notifications appear at the top of each group
+ notifications.sort(
+ key=lambda n: getattr(n._notification, "id", 0), reverse=True
+ )
+
+ # Handle limited apps history - only keep 5 notifications during rebuild
+ if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
+ if len(notifications) > 5:
+ # Keep only the 5 most recent notifications
+ self.notification_groups[app_name] = notifications[:5]
+
+ group_widget = ExpandableNotificationGroup(
+ app_name, self.notification_groups[app_name]
+ )
+ self.group_widgets[app_name] = group_widget
+ self.notifications_box.add(group_widget)
+
+ def on_notification_added(self, service, cached_notification):
+ try:
+ app_name = cached_notification._notification.app_name
+
+ # Check if this app should be ignored for history (don't add to notification center)
+ if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
+ return
+
+ # Preload assets for notification center display (ensure caching consistency)
+ preload_notification_assets(cached_notification._notification)
+
+ # Add to groups in sorted order (highest ID first - maintains newest-first ordering)
+ notifications_list = self.notification_groups[app_name]
+ new_notification_id = getattr(cached_notification._notification, "id", 0)
+
+ # Find the correct position to insert based on ID (highest first)
+ insert_position = 0
+ for i, existing_notification in enumerate(notifications_list):
+ existing_id = getattr(existing_notification._notification, "id", 0)
+ if new_notification_id > existing_id:
+ insert_position = i
+ break
+ insert_position = i + 1
+
+ notifications_list.insert(insert_position, cached_notification)
+
+ # Handle limited apps history - only keep 5 notifications
+ if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
+ notifications_for_app = self.notification_groups[app_name]
+ if len(notifications_for_app) > 5:
+ # Remove oldest notifications beyond the limit
+ excess_notifications = notifications_for_app[5:]
+ self.notification_groups[app_name] = notifications_for_app[:5]
+
+ # Remove excess notifications from the service cache
+ for excess_notification in excess_notifications:
+ try:
+ notification_service.remove_cached_notification(
+ excess_notification.cache_id
+ )
+ except Exception as e:
+ logger.error(f"Error removing excess notification: {e}")
+
+ # Update or create group widget
+ if app_name in self.group_widgets:
+ # Update existing group
+ group_widget = self.group_widgets[app_name]
+ group_widget.notifications = self.notification_groups[app_name]
+ # Refresh the group widget
+ self._refresh_group_widget(group_widget)
+ else:
+ # Create new group widget
+ group_widget = ExpandableNotificationGroup(
+ app_name, self.notification_groups[app_name]
+ )
+ self.group_widgets[app_name] = group_widget
+ self.notifications_box.pack_start(group_widget, False, False, 0)
+ group_widget.show_all()
+
+ except Exception as e:
+ logger.error(f"Error adding notification to group: {e}")
+
+ def _refresh_group_widget(self, group_widget):
+ """Refresh a group widget's content"""
+ try:
+ # Remove existing children
+ for child in group_widget.get_children():
+ group_widget.remove(child)
+
+ # Recreate content
+ group_widget.create_collapsed_state()
+ group_widget.show_all()
+
+ except Exception as e:
+ logger.error(f"Error refreshing group widget: {e}")
+
+ def on_notification_removed(self, service, cached_notification):
+ try:
+ app_name = cached_notification._notification.app_name
+
+ # Remove from groups
+ if app_name in self.notification_groups:
+ self.notification_groups[app_name] = [
+ n
+ for n in self.notification_groups[app_name]
+ if n.cache_id != cached_notification.cache_id
+ ]
+
+ # If no more notifications for this app, remove group widget
+ if not self.notification_groups[app_name]:
+ if app_name in self.group_widgets:
+ group_widget = self.group_widgets[app_name]
+ group_widget.destroy()
+ del self.group_widgets[app_name]
+ del self.notification_groups[app_name]
+ else:
+ # Update existing group widget
+ group_widget = self.group_widgets[app_name]
+ group_widget.notifications = self.notification_groups[app_name]
+ self._refresh_group_widget(group_widget)
+
+ # Clean up caches
+ cleanup_notification_specific_caches(
+ app_icon_source=getattr(cached_notification, "app_icon_source", None),
+ notification_image_cache_key=getattr(
+ cached_notification, "notification_image_cache_key", None
+ ),
+ )
+
+ except Exception as e:
+ logger.error(f"Error removing notification from group: {e}")
+
+ def on_clear_all(self, service):
+ try:
+ # Clear all groups
+ self.notification_groups.clear()
+ self.group_widgets.clear()
+
+ # Clear all remaining cached notification images AND icons
+ cleanup_all_notification_caches()
+ for child in self.notifications_box.get_children():
+ child.destroy()
+ except Exception as e:
+ logger.error(f"Error clearing notification groups: {e}")
+
+ def on_count_changed(self, service, count=None):
+ current_count = notification_service.count
+ # No notifications label removed - only update clear button and scrolled visibility
+ self.clear_all_button.set_visible(current_count > 0)
+ self.scrolled.set_visible(current_count > 0)
+
+ # Auto-close notification center when no notifications remain
+ if current_count == 0 and hasattr(self, "mousecapture"):
+ self.mousecapture.hide_child_window()
+
+ def clear_all_notifications(self, *_):
+ # Clear all groups
+ self.notification_groups.clear()
+ self.group_widgets.clear()
+
+ # Clear all remaining cached notification images AND icons when clear all is clicked
+ cleanup_all_notification_caches() # Clear ALL caches (icons + images)
+ notification_service.clear_all_cached_notifications()
+ if hasattr(self, "mousecapture"):
+ self.mousecapture.hide_child_window()
+
+ def _on_escape_pressed(self, *_):
+ if hasattr(self, "mousecapture"):
+ self.mousecapture.hide_child_window()
+
+ def _init_mousecapture(self, mousecapture):
+ self.mousecapture = mousecapture
+
+ def _set_mousecapture(self, visible):
+ """Control notification center visibility with slide-left animation"""
+ if visible:
+ self.main_revealer.set_reveal_child(True)
+ else:
+ self.main_revealer.set_reveal_child(False)
diff --git a/src/window/notification/unified_cache.py b/src/window/notification/unified_cache.py
new file mode 100644
index 0000000..d690d89
--- /dev/null
+++ b/src/window/notification/unified_cache.py
@@ -0,0 +1,185 @@
+import hashlib
+import time
+import uuid
+
+from fabric.utils import GdkPixbuf, get_relative_path, logger, os
+
+import shared.data as data
+
+# Unified notification cache directory (for both app icons and notification images)
+UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications")
+
+
+def ensure_cache_dir():
+ """Ensure unified notification cache directory exists"""
+ os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)
+
+
+def get_unified_cache_key(source_data, size=None, app_name=None):
+ """Generate a unified cache key that works for both app icons and notification images"""
+ try:
+ if hasattr(source_data, "get_pixels"):
+ # For pixbuf data - use hash of pixel data for deterministic caching
+ try:
+ pixel_data = source_data.get_pixels()
+ image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
+ return image_hash
+ except Exception:
+ # Fallback to random UUID if pixel data fails
+ return str(uuid.uuid4())[:8]
+ elif isinstance(source_data, str):
+ # For file paths - create hash-based name
+ if source_data.startswith("file://"):
+ source_data = source_data[7:]
+
+ # Create hash from file path and size
+ hash_input = source_data
+ if size:
+ hash_input += f"_{size[0]}x{size[1]}"
+
+ return hashlib.md5(hash_input.encode()).hexdigest()[:8]
+ else:
+ # Fallback to random UUID
+ return str(uuid.uuid4())[:8]
+ except Exception:
+ # Ultimate fallback
+ return str(uuid.uuid4())[:8]
+
+
+def save_to_cache(pixbuf, cache_key, size=None):
+ """Save a pixbuf to the unified cache directory"""
+ try:
+ ensure_cache_dir()
+ cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
+
+ # Don't overwrite existing cache
+ if os.path.exists(cache_path):
+ logger.debug(f"Cache hit - already exists: {cache_key}")
+ return cache_path, cache_key
+
+ # Scale if size is specified
+ if size and (pixbuf.get_width() != size[0] or pixbuf.get_height() != size[1]):
+ pixbuf = pixbuf.scale_simple(
+ size[0], size[1], GdkPixbuf.InterpType.BILINEAR
+ )
+
+ pixbuf.savev(cache_path, "png", [], [])
+ return cache_path, cache_key
+ except Exception as e:
+ logger.warning(f"Failed to cache notification asset: {e}")
+ return None, None
+
+
+def get_from_cache(cache_key, size=None):
+ """Get a cached asset or return None if not found"""
+ try:
+ cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
+ if os.path.exists(cache_path):
+ # logger.debug(f"Using cached asset: {cache_key}")
+ if size:
+ return GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ cache_path, size[0], size[1], True
+ )
+ else:
+ return GdkPixbuf.Pixbuf.new_from_file(cache_path)
+ except Exception as e:
+ logger.warning(f"Failed to load cached asset: {e}")
+ return None
+
+
+def cleanup_cache(cache_key=None):
+ """Clean up unified cache - specific key or all"""
+ try:
+ ensure_cache_dir()
+
+ if cache_key:
+ # Remove specific cached asset
+ cache_path = os.path.join(
+ UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png"
+ )
+ if os.path.exists(cache_path):
+ os.unlink(cache_path)
+ else:
+ # Remove all cached assets
+ for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
+ if filename.endswith(".png"):
+ filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
+ try:
+ os.unlink(filepath)
+ except Exception as e:
+ logger.warning(f"Failed to cleanup cache file {filename}: {e}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup cache: {e}")
+
+
+def cleanup_old_cache_files():
+ """Clean up old cache files (older than 7 days)"""
+ try:
+ if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
+ return
+
+ current_time = time.time()
+ week_ago = current_time - (7 * 24 * 60 * 60) # 7 days
+
+ for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
+ filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
+ try:
+ if os.path.isfile(filepath):
+ file_mtime = os.path.getmtime(filepath)
+ if file_mtime < week_ago:
+ os.unlink(filepath)
+ except Exception as e:
+ logger.warning(f"Failed to cleanup cache file {filename}: {e}")
+ except Exception as e:
+ logger.warning(f"Failed to cleanup cache: {e}")
+
+
+def verify_cache_persistence():
+ """Verify that cached assets persist and can be loaded after restart"""
+ try:
+ cache_files = []
+
+ if os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
+ cache_files = [
+ f
+ for f in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR)
+ if f.endswith(".png")
+ ]
+
+ # Test loading a few cached items to verify they work
+ for cache_file in cache_files[:2]: # Test first 2 files
+ try:
+ cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file)
+ GdkPixbuf.Pixbuf.new_from_file(cache_path)
+ except Exception as e:
+ logger.warning(f"Failed to load cached asset {cache_file}: {e}")
+
+ return len(cache_files) > 0
+
+ except Exception as e:
+ logger.error(f"Failed to verify cache persistence: {e}")
+ return False
+
+
+def get_fallback_icon(size=(48, 48)):
+ """Get the fallback notification icon"""
+ try:
+ fallback_path = get_relative_path("../../assets/icons/notification.png")
+ return GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ fallback_path, size[0], size[1], True
+ )
+ except Exception as e:
+ logger.warning(f"Failed to load fallback icon: {e}")
+ # Create a simple colored rectangle as ultimate fallback
+ try:
+ return GdkPixbuf.Pixbuf.new(
+ GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]
+ )
+ except Exception:
+ return None
+
+
+# Initialize cache on module load
+ensure_cache_dir()
+cleanup_old_cache_files()
+verify_cache_persistence()
diff --git a/src/window/osd/__init__.py b/src/window/osd/__init__.py
new file mode 100644
index 0000000..d65876c
--- /dev/null
+++ b/src/window/osd/__init__.py
@@ -0,0 +1,8 @@
+from .main import OSDWindow # noqa: F401
+from .components import ( # noqa: F401
+ AudioOSDContainer,
+ BrightnessOSDContainer,
+ MicrophoneOSDContainer,
+ CapsLockOSDContainer,
+ KeyboardLayoutOSDContainer,
+)
diff --git a/src/window/osd/components/__init__.py b/src/window/osd/components/__init__.py
new file mode 100644
index 0000000..0a7ca48
--- /dev/null
+++ b/src/window/osd/components/__init__.py
@@ -0,0 +1,5 @@
+from .audio import AudioOSDContainer # noqa: F401
+from .brightness import BrightnessOSDContainer # noqa: F401
+from .microphone import MicrophoneOSDContainer # noqa: F401
+from .capslock import CapsLockOSDContainer # noqa: F401
+from .keyboard_layout import KeyboardLayoutOSDContainer # noqa: F401
diff --git a/src/window/osd/components/animated_scale.py b/src/window/osd/components/animated_scale.py
new file mode 100644
index 0000000..46b8868
--- /dev/null
+++ b/src/window/osd/components/animated_scale.py
@@ -0,0 +1,30 @@
+from functools import partial
+
+from fabric.widgets.scale import Scale
+
+from shared.widgets.animator import Animator, cubic_bezier
+
+
+class AnimatedScale(Scale):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.animator = (
+ Animator(
+ duration=0.8,
+ timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0),
+ min_value=self.min_value,
+ max_value=self.value,
+ tick_widget=self,
+ notify_value=lambda anim: self.set_value(anim.value),
+ )
+ .build()
+ .play()
+ .unwrap()
+ )
+
+ def animate_value(self, value: float):
+ if self.animator:
+ self.animator.pause()
+ self.animator.min_value = self.value
+ self.animator.max_value = value
+ self.animator.play()
diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py
new file mode 100644
index 0000000..c33d4c9
--- /dev/null
+++ b/src/window/osd/components/audio.py
@@ -0,0 +1,100 @@
+import math
+from fabric.audio import Audio
+from utils.utils import svg_file
+from fabric.widgets.scale import ScaleMark
+from .animated_scale import AnimatedScale
+from .base import BaseOSDContainer
+
+
+class AudioOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.audio = Audio()
+ self._last_volume = None
+ self._last_muted = None
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_window_image = svg_file(
+ "volume/audio-volume.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ orientation="h",
+ )
+ self.add(self.osd_window_image)
+ self.add(self.scale)
+
+ def _connect_specific_signals(self):
+ self.audio.connect("notify::speaker", self._on_speaker_changed)
+ if self.audio.speaker:
+ self._connect_speaker_signals()
+
+ def _connect_speaker_signals(self):
+ if self.audio.speaker:
+ self.audio.speaker.connect("changed", self._on_speaker_stream_changed)
+ self._sync_with_audio()
+
+ def _on_speaker_changed(self, *_):
+ self._connect_speaker_signals()
+ self.update()
+
+ def _on_speaker_stream_changed(self, *_):
+ if self.audio.speaker:
+ current_volume = round(self.audio.speaker.volume)
+ current_muted = self.audio.speaker.muted
+ if self._last_volume != current_volume or self._last_muted != current_muted:
+ self._last_volume = current_volume
+ self._last_muted = current_muted
+ self.update()
+
+ def _sync_with_audio(self):
+ if self.audio.speaker:
+ self._last_volume = round(self.audio.speaker.volume)
+ self._last_muted = self.audio.speaker.muted
+ self.scale.set_value(self._last_volume)
+ self._update_display()
+
+ def _update_display(self):
+ if not self.audio.speaker:
+ return
+
+ volume = (
+ self._last_volume
+ if self._last_volume is not None
+ else round(self.audio.speaker.volume)
+ )
+ muted = (
+ self._last_muted
+ if self._last_muted is not None
+ else self.audio.speaker.muted
+ )
+
+ display_volume = 0 if (volume == 0 or muted) else volume
+ level = (
+ 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3)
+ )
+
+ self.osd_window_image.set_from_file(f"volume/audio-volume-{level}.svg")
+
+ if muted or volume == 0:
+ self.scale.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+
+ self.scale.animate_value(volume)
+
+ def update(self, *_):
+ self._update_display()
+ super().update()
diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py
new file mode 100644
index 0000000..30e2197
--- /dev/null
+++ b/src/window/osd/components/base.py
@@ -0,0 +1,145 @@
+from abc import abstractmethod
+from fabric.utils import Gdk, GLib, invoke_repeater, remove_handler, time
+from fabric.widgets.box import Box
+from fabric.widgets.wayland import WaylandWindow as Window
+
+
+class BaseOSDContainer(Box):
+ MAX_VISIBLE_TIME = 6.0
+ COOLDOWN_MS = 200
+
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(**kwargs, orientation="v", spacing=12, name="osd-container")
+ self.last_handler: int = 0
+ self.window = window
+ self.osd = None
+ self.show_timestamp: float = 0.0
+ self.watchdog_handler: int = 0
+ self._update_in_progress = False
+ self._is_hovered = False
+ self._last_trigger_time: float = 0.0
+
+ GLib.idle_add(self._setup_window_signals)
+
+ def _setup_window_signals(self):
+ self.window.add_events(
+ Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
+ )
+ self.window.connect("enter-notify-event", self._on_enter_notify)
+ self.window.connect("leave-notify-event", self._on_leave_notify)
+ return False
+
+ def _on_enter_notify(self, widget, event):
+ if event.detail != Gdk.NotifyType.INFERIOR:
+ self._is_hovered = True
+
+ def _on_leave_notify(self, widget, event):
+ if event.detail != Gdk.NotifyType.INFERIOR:
+ self._is_hovered = False
+
+ def is_hovered(self):
+ return self._is_hovered
+
+ def remove_last_handler(self):
+ if self.last_handler:
+ try:
+ remove_handler(self.last_handler)
+ except Exception:
+ pass
+ self.last_handler = 0
+
+ def remove_watchdog_handler(self):
+ if self.watchdog_handler:
+ try:
+ GLib.source_remove(self.watchdog_handler)
+ except Exception:
+ pass
+ self.watchdog_handler = 0
+
+ def cleanup_all_handlers(self):
+ self.remove_last_handler()
+ self.remove_watchdog_handler()
+ self.show_timestamp = 0.0
+ self._update_in_progress = False
+
+ def hide_window(self):
+ if self.osd and hasattr(self.osd, "revealer"):
+ self.osd.revealer.set_reveal_child(False)
+ GLib.timeout_add(150, lambda: self.window.hide() or False)
+ else:
+ self.window.hide()
+
+ def watchdog_force_hide(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ elapsed = time.time() - self.show_timestamp
+ if elapsed >= self.MAX_VISIBLE_TIME:
+ if not self.is_hovered():
+ self.hide_window()
+ self.cleanup_all_handlers()
+ return False
+ else:
+ return True
+ return True
+
+ def update(self, *_):
+ if self._update_in_progress:
+ return
+
+ current_time = time.time()
+ time_since_last_trigger = (current_time - self._last_trigger_time) * 1000
+
+ if time_since_last_trigger < self.COOLDOWN_MS:
+ return
+
+ self._last_trigger_time = current_time
+ self._update_in_progress = True
+
+ self.cleanup_all_handlers()
+ self.show_timestamp = time.time()
+
+ if self.osd:
+ self.osd.show_container(self)
+
+ self.window.show()
+
+ self.focus()
+ self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False)
+ self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide)
+ self._update_in_progress = False
+
+ def focus(self, *_):
+ self.add_style_class("focused")
+ return False
+
+ def unfocus(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ self.remove_style_class("focused")
+ self.remove_last_handler()
+ self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False)
+ return False
+
+ def unpop(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ if not self.is_hovered():
+ self.hide_window()
+ self.cleanup_all_handlers()
+ else:
+ self.last_handler = invoke_repeater(500, self.unpop, initial_call=False)
+ return False
+
+ @abstractmethod
+ def _setup_specific_components(self):
+ pass
+
+ @abstractmethod
+ def _connect_specific_signals(self):
+ pass
diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py
new file mode 100644
index 0000000..fd54841
--- /dev/null
+++ b/src/window/osd/components/brightness.py
@@ -0,0 +1,59 @@
+import math
+from services.brightness import Brightness
+from utils.utils import svg_file
+from fabric.widgets.scale import ScaleMark
+from .animated_scale import AnimatedScale
+from .base import BaseOSDContainer
+
+
+class BrightnessOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.brightness_service = Brightness.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_window_image = svg_file(
+ "brightness/brightness.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(0, 101, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ orientation="h",
+ )
+ self.add(self.osd_window_image)
+ self.add(self.scale)
+
+ def _connect_specific_signals(self):
+ self.brightness_service.connect("screen", self._on_screen_brightness_changed)
+
+ def _on_screen_brightness_changed(self, _sender, value, *_args):
+ self.update()
+
+ def _get_normalized_brightness(self):
+ return (
+ self.brightness_service.screen_brightness
+ / self.brightness_service.max_screen
+ ) * 100
+
+ def _update_display(self):
+ normalized = self._get_normalized_brightness()
+ level = 0 if normalized == 0 else min(int(math.ceil(normalized / 33)), 3)
+
+ self.osd_window_image.set_from_file(f"brightness/brightness-{level}.svg")
+
+ self.scale.animate_value(normalized)
+
+ def update(self, *_):
+ self._update_display()
+ super().update()
diff --git a/src/window/osd/components/capslock.py b/src/window/osd/components/capslock.py
new file mode 100644
index 0000000..4ff7543
--- /dev/null
+++ b/src/window/osd/components/capslock.py
@@ -0,0 +1,47 @@
+from fabric.widgets.label import Label
+from utils.utils import svg_file
+from .base import BaseOSDContainer
+from services.capslock import CapsLock
+
+
+class CapsLockOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.capslock = CapsLock.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_image = svg_file(
+ "misc/caps-lock.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.label = Label(name="osd-label")
+ self.add(self.osd_image)
+ self._update_display(self.capslock.is_on)
+
+ def _connect_specific_signals(self):
+ self.capslock.connect("state_changed", self._on_caps_lock_state_changed)
+
+ def _on_caps_lock_state_changed(self, _, is_on: bool):
+ self._update_display(is_on)
+ self.update()
+
+ def _update_display(self, is_on: bool):
+ icon_path = "caps-lock.svg" if is_on else "caps-lock-off.svg"
+ self.osd_image.set_from_file(f"misc/{icon_path}")
+ if is_on:
+ self.add_style_class("capslock-on")
+ self.remove_style_class("capslock-off")
+ else:
+ self.add_style_class("capslock-off")
+ self.remove_style_class("capslock-on")
+
+ def update(self, *_):
+ # We don't need a separate display update here as it's handled in the signal
+ super().update()
diff --git a/src/window/osd/components/keyboard_layout.py b/src/window/osd/components/keyboard_layout.py
new file mode 100644
index 0000000..45de256
--- /dev/null
+++ b/src/window/osd/components/keyboard_layout.py
@@ -0,0 +1,39 @@
+from fabric.widgets.label import Label
+from utils.utils import svg_file
+from .base import BaseOSDContainer
+from services.keyboard_layout import KeyboardLayout
+
+
+class KeyboardLayoutOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.keyboard_layout = KeyboardLayout.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_image = svg_file(
+ "misc/keyboard-layout.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.label = Label(name="osd-label")
+ self.add(self.osd_image)
+ self.add(self.label)
+ self.label.set_text(self.keyboard_layout.current_layout)
+
+ def _connect_specific_signals(self):
+ self.keyboard_layout.connect("layout_changed", self._on_layout_changed)
+
+ def _on_layout_changed(self, service, layout: str):
+ if not self.is_hovered():
+ self.label.set_text(layout)
+ self.update()
+
+ def update(self, *_):
+ self.label.set_text(self.keyboard_layout.current_layout)
+ super().update()
diff --git a/src/window/osd/components/microphone.py b/src/window/osd/components/microphone.py
new file mode 100644
index 0000000..a7fd590
--- /dev/null
+++ b/src/window/osd/components/microphone.py
@@ -0,0 +1,100 @@
+import math
+from fabric.audio import Audio
+from utils.utils import svg_file
+from fabric.widgets.scale import ScaleMark
+from .animated_scale import AnimatedScale
+from .base import BaseOSDContainer
+
+
+class MicrophoneOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.audio = Audio()
+ self._last_volume = None
+ self._last_muted = None
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_window_image = svg_file(
+ "mic/microphone.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ orientation="h",
+ )
+ self.add(self.osd_window_image)
+ self.add(self.scale)
+
+ def _connect_specific_signals(self):
+ self.audio.connect("notify::microphone", self._on_microphone_changed)
+ if self.audio.microphone:
+ self._connect_microphone_signals()
+
+ def _connect_microphone_signals(self):
+ if self.audio.microphone:
+ self.audio.microphone.connect("changed", self._on_microphone_stream_changed)
+ self._sync_with_audio()
+
+ def _on_microphone_changed(self, *_):
+ self._connect_microphone_signals()
+ self.update()
+
+ def _on_microphone_stream_changed(self, *_):
+ if self.audio.microphone:
+ current_volume = round(self.audio.microphone.volume)
+ current_muted = self.audio.microphone.muted
+ if self._last_volume != current_volume or self._last_muted != current_muted:
+ self._last_volume = current_volume
+ self._last_muted = current_muted
+ self.update()
+
+ def _sync_with_audio(self):
+ if self.audio.microphone:
+ self._last_volume = round(self.audio.microphone.volume)
+ self._last_muted = self.audio.microphone.muted
+ self.scale.set_value(self._last_volume)
+ self._update_display()
+
+ def _update_display(self):
+ if not self.audio.microphone:
+ return
+
+ volume = (
+ self._last_volume
+ if self._last_volume is not None
+ else round(self.audio.microphone.volume)
+ )
+ muted = (
+ self._last_muted
+ if self._last_muted is not None
+ else self.audio.microphone.muted
+ )
+
+ display_volume = 0 if (volume == 0 or muted) else volume
+ level = (
+ 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3)
+ )
+
+ self.osd_window_image.set_from_file(f"mic/microphone-{level}.svg")
+
+ if muted or volume == 0:
+ self.scale.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+
+ self.scale.animate_value(volume)
+
+ def update(self, *_):
+ self._update_display()
+ super().update()
diff --git a/src/window/osd/main.py b/src/window/osd/main.py
new file mode 100644
index 0000000..9c1fa4b
--- /dev/null
+++ b/src/window/osd/main.py
@@ -0,0 +1,83 @@
+from fabric.widgets.box import Box
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.wayland import WaylandWindow as Window
+from .components import (
+ AudioOSDContainer,
+ BrightnessOSDContainer,
+ MicrophoneOSDContainer,
+ CapsLockOSDContainer,
+ KeyboardLayoutOSDContainer,
+)
+
+
+class OSD(Box):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(name="osd", **kwargs)
+ self.window = window
+
+ self.revealer = Revealer(
+ transition_type="slide-up",
+ transition_duration=100,
+ child_revealed=False,
+ )
+ self.children = [self.revealer]
+
+ self.containers = {
+ "audio": AudioOSDContainer(window),
+ "brightness": BrightnessOSDContainer(window),
+ "microphone": MicrophoneOSDContainer(window),
+ "capslock": CapsLockOSDContainer(window),
+ "kb_layout": KeyboardLayoutOSDContainer(window),
+ }
+
+ # Set the OSD reference in containers
+ for container in self.containers.values():
+ container.osd = self
+
+ self.current_container = None
+
+ def show_container(self, container):
+ if self.current_container != container:
+ self.revealer.children = [container]
+ self.current_container = container
+ self.revealer.set_reveal_child(True)
+
+ def show_audio_osd(self):
+ self.show_container(self.containers.get("audio"))
+
+ def show_brightness_osd(self):
+ self.show_container(self.containers.get("brightness"))
+
+ def show_microphone_osd(self):
+ self.show_container(self.containers.get("microphone"))
+
+ def show_capslock_osd(self):
+ self.show_container(self.containers.get("capslock"))
+
+ def show_kb_layout_osd(self):
+ self.show_container(self.containers.get("kb_layout"))
+
+
+class OSDWindow(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="osd-window",
+ title="modus-osd",
+ anchor="bottom",
+ margin="0px 0px 40px 0px",
+ visible=False,
+ all_visible=False,
+ pass_through=True,
+ layer="overlay",
+ **kwargs,
+ )
+ self.osd = OSD(window=self)
+ self.add(self.osd)
+
+
+if __name__ == "__main__":
+ from fabric import Application
+
+ osd_window = OSDWindow()
+ app = Application("modus-osd", osd_window)
+ app.run()
diff --git a/src/window/panel/components/__init__.py b/src/window/panel/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/window/panel/components/enhanced_system_tray.py b/src/window/panel/components/enhanced_system_tray.py
new file mode 100644
index 0000000..f542e39
--- /dev/null
+++ b/src/window/panel/components/enhanced_system_tray.py
@@ -0,0 +1,106 @@
+from pathlib import Path
+import weakref
+
+from fabric.system_tray.widgets import SystemTrayItem
+from fabric.utils import GdkPixbuf, GLib, logger
+
+from services.config import get_config, on_config_change
+
+# Track active tray items for hot-reloading
+_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", [])
+ if not ignore_list:
+ return False
+
+ title = item.title if item.title else ""
+ identifier = item.identifier if item.identifier else ""
+
+ # Check both title and identifier (case-insensitive for convenience)
+ for pattern in ignore_list:
+ p = pattern.lower()
+ if p in title.lower() or p in identifier.lower():
+ return True
+ return False
+
+
+def patched_do_update_properties(self, *_):
+ # Determine the icon name to use
+ item = self._item
+ icon_name = item.icon_name
+ attention_icon_name = item.attention_icon_name
+
+ # Log item info for user debugging
+ logger.info(
+ f"[SysTray] Item: title='{getattr(item, 'title', '')}', identifier='{getattr(item, 'identifier', '')}'"
+ )
+
+ # Visibility check
+ is_hidden = should_hide(item)
+ self.set_visible(not is_hidden)
+
+ if is_hidden:
+ return
+
+ if item.status == "NeedsAttention" and attention_icon_name:
+ preferred_icon_name = attention_icon_name
+ else:
+ preferred_icon_name = icon_name
+
+ # 1. Try the standard fabric/GTK implementation first
+ pixbuf = item.get_preferred_icon_pixbuf(self._icon_size)
+
+ # 2. If standard lookup fails, try your specific absolute path logic
+ if pixbuf is None and preferred_icon_name:
+ path = Path(preferred_icon_name)
+ if path.is_absolute() and path.exists():
+ try:
+ target_size = self._icon_size if self._icon_size else 24
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ str(path),
+ target_size,
+ target_size,
+ True, # Preserve aspect ratio
+ )
+ except GLib.GError:
+ pixbuf = None
+
+ # Apply the resulting pixbuf or the fallback "missing" icon
+ if pixbuf:
+ self._image.set_from_pixbuf(pixbuf)
+ else:
+ self._image.set_from_icon_name("image-missing", self._icon_size)
+
+ tooltip = item.tooltip
+ self.set_tooltip_markup(
+ tooltip.description
+ or tooltip.title
+ or (item.title.title() if item.title else None)
+ or "Unknown"
+ )
+
+
+def apply_enhanced_system_tray():
+ # Patch __init__ to track instances
+ original_init = SystemTrayItem.__init__
+
+ def patched_init(self, *args, **kwargs):
+ original_init(self, *args, **kwargs)
+ _tracked_items.add(self)
+
+ SystemTrayItem.__init__ = patched_init
+
+ # Replace the method on the class so all tray items use this logic
+ SystemTrayItem.do_update_properties = patched_do_update_properties
+
+ # Setup hot-reload listener
+ def on_reload(new_config, old_config):
+ if new_config.get("systray_ignore") != old_config.get("systray_ignore"):
+ logger.info("[SysTray] Config changed, updating tray item visibility")
+ for item_widget in _tracked_items:
+ item_widget.do_update_properties()
+
+ on_config_change(on_reload)
diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py
new file mode 100644
index 0000000..458d648
--- /dev/null
+++ b/src/window/panel/components/globalmenu.py
@@ -0,0 +1,337 @@
+from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
+from fabric.utils import FormattedString, exec_shell_command_async
+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 shared.dialogs.about import About, AboutApp
+from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns
+from shared.window.mousecapture import DropDownMouseCapture
+from utils.app_name_resolver import format_window
+from utils.roam import modus_service
+from utils.utils import setup_cursor_hover
+
+
+def has_active_window():
+ return (
+ modus_service.current_active_app_name
+ and modus_service.current_active_app_name != "Finder"
+ )
+
+
+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, layer="top"):
+ dropdown = ModusDropdown(
+ dropdown_id=dropdown_id,
+ parent=parent,
+ dropdown_children=dropdown_children,
+ )
+ mouse_capture = DropDownMouseCapture(layer=layer, child_window=dropdown)
+ return mouse_capture
+
+
+def manage_button_style_classes(buttons, active_button=None, style_class="active"):
+ for button in buttons:
+ if button: # Check if button exists (some might be None)
+ button.remove_style_class(style_class)
+
+ if active_button:
+ active_button.add_style_class(style_class)
+
+
+def show_about_app(_=None):
+ if not has_active_window():
+ return
+
+ app_name = modus_service.current_active_app_name
+ wmclass = getattr(modus_service, "current_active_wm_class", "")
+ about_window = AboutApp(app_name=app_name, wmclass=wmclass)
+ about_window.toggle(None)
+
+
+def on_click_subthread(button, on_clicked, on_click):
+ if on_clicked:
+ on_clicked(button)
+ else:
+ exec_shell_command_async(on_click, lambda *_: None)
+
+ for dropdown in dropdowns:
+ if dropdown.is_visible():
+ dropdown.hide_via_mousecapture()
+ break
+
+
+def dropdown_option(
+ label: str = "",
+ keybind: str = "",
+ on_click='echo "ModusPanelDropdown Action"',
+ on_clicked=None,
+):
+ btn = Button(
+ child=CenterBox(
+ start_children=[
+ Label(label=label, h_align="start", name="dropdown-option-label"),
+ ],
+ end_children=[
+ Label(label=keybind, h_align="end", name="dropdown-option-keybind")
+ ],
+ orientation="horizontal",
+ h_align="fill",
+ h_expand=True,
+ v_expand=True,
+ ),
+ name="dropdown-option",
+ h_align="fill",
+ on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click),
+ h_expand=True,
+ v_expand=True,
+ )
+ setup_cursor_hover(btn, "pointer")
+ return btn
+
+
+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 _: About().toggle(_)
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option(
+ "System Settings...",
+ # TODO: Open Modus own setting
+ # on_click="xdg-open settings",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option("Force Quit", "", "hyprctl 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", "hyprlock"),
+ ],
+ **kwargs,
+ )
+
+
+class GlobalMenuDropdowns:
+ def __init__(self, parent):
+ self.parent = parent
+
+ self.system_dropdown = SystemDropdown(parent=parent)
+ self.menu_button_dropdown = DropDownMouseCapture(
+ layer="top", child_window=self.system_dropdown
+ )
+ self.menu_button = Button(
+ label="Modus",
+ name="global-menu",
+ on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(),
+ )
+ self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button)
+
+ self.global_title_menu_about = dropdown_option(
+ f"About {modus_service.current_active_app_name}",
+ on_clicked=show_about_app,
+ )
+ self.global_menu_title = create_dropdown_with_capture(
+ "global-menu-title",
+ parent,
+ [self.global_title_menu_about],
+ )
+
+ self.global_menu_view = create_dropdown_with_capture(
+ "global-menu-view",
+ parent,
+ [
+ dropdown_option(
+ "Enter Full Screen",
+ on_click="hyprctl dispatch fullscreen",
+ ),
+ ],
+ )
+ self.global_menu_window = create_dropdown_with_capture(
+ "global-menu-window",
+ parent,
+ [
+ dropdown_option(
+ "Zoom In",
+ " +",
+ on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')",
+ ),
+ dropdown_option(
+ "Zoom Out",
+ " -",
+ on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option(
+ "Move Window to Left",
+ on_click="hyprctl dispatch movewindow l",
+ ),
+ dropdown_option(
+ "Move Window to Right",
+ on_click="hyprctl dispatch movewindow r",
+ ),
+ dropdown_option(
+ "Cycle Through Windows",
+ on_click="hyprctl dispatch cyclenext",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option("Float", on_click="hyprctl dispatch togglefloating"),
+ dropdown_option("Quit", on_click="hyprctl dispatch killactive"),
+ dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"),
+ dropdown_option(
+ "Toggle Split", on_click="hyprctl dispatch togglesplit"
+ ),
+ dropdown_option("Center", on_click="hyprctl dispatch centerwindow"),
+ dropdown_option("Group", on_click="hyprctl dispatch togglegroup"),
+ dropdown_option(
+ "Pin",
+ on_clicked=lambda _: exec_shell_command_async(
+ "bash ~/.config/scripts/winpin.sh", lambda *_: None
+ ),
+ ),
+ ],
+ )
+
+ self.global_menu_help = create_dropdown_with_capture(
+ "global-menu-help",
+ parent,
+ [
+ dropdown_option(
+ "Modus",
+ on_click="xdg-open https://github.com/S4NKALP/Modus/issues",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option(
+ "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/"
+ ),
+ ],
+ )
+
+ modus_service.connect(
+ "current-active-app-name-changed", self._on_active_app_changed
+ )
+
+ self.global_menu_button_title = Button(
+ child=ActiveWindow(
+ formatter=FormattedString(
+ "{ format_window(win_title, win_class) }",
+ format_window=format_window,
+ )
+ ),
+ name="global-menu",
+ on_clicked=self._on_title_button_clicked,
+ )
+
+ self.global_menu_title.child_window.set_pointing_to(
+ self.global_menu_button_title
+ )
+ # File, Edit and Go buttons are placeholders - no dropdowns implemented yet
+ self.global_menu_button_file = create_menu_button("File")
+ self.global_menu_button_edit = create_menu_button("Edit")
+ self.global_menu_button_go = create_menu_button("Go")
+
+ self.global_menu_button_view = create_menu_button(
+ "View",
+ lambda _: self.global_menu_view.toggle_mousecapture(),
+ )
+ self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view)
+ self.global_menu_button_window = create_menu_button(
+ "Window",
+ lambda _: self.global_menu_window.toggle_mousecapture(),
+ )
+ self.global_menu_window.child_window.set_pointing_to(
+ self.global_menu_button_window
+ )
+ self.global_menu_button_help = create_menu_button(
+ "Help",
+ lambda _: self.global_menu_help.toggle_mousecapture(),
+ )
+ self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help)
+
+ self.all_menu_buttons = [
+ self.menu_button,
+ self.global_menu_button_title,
+ self.global_menu_button_file,
+ self.global_menu_button_edit,
+ self.global_menu_button_view,
+ self.global_menu_button_go,
+ self.global_menu_button_window,
+ self.global_menu_button_help,
+ ]
+
+ self.dropdown_button_map = {
+ "os-menu": self.menu_button,
+ "global-menu-title": self.global_menu_button_title,
+ "global-menu-file": self.global_menu_button_file,
+ "global-menu-edit": self.global_menu_button_edit,
+ "global-menu-view": self.global_menu_button_view,
+ "global-menu-go": self.global_menu_button_go,
+ "global-menu-window": self.global_menu_button_window,
+ "global-menu-help": self.global_menu_button_help,
+ }
+
+ modus_service.connect("current-dropdown-changed", self.changed_dropdown)
+ modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns)
+
+ def _on_title_button_clicked(self, _):
+ if has_active_window():
+ self.global_menu_title.toggle_mousecapture()
+
+ def _on_active_app_changed(self, _, value):
+ self.global_title_menu_about.set_property("label", f"About {value}")
+
+ def hide_dropdowns(self, *_):
+ manage_button_style_classes(self.all_menu_buttons)
+
+ def changed_dropdown(self, _, dropdown_id):
+ active_button = self.dropdown_button_map.get(dropdown_id)
+ manage_button_style_classes(self.all_menu_buttons, active_button)
+
+
+class GlobalMenu(Box):
+ def __init__(self, parent_window=None, **kwargs):
+ if parent_window is None:
+ parent_window = kwargs.pop("parent_window", None)
+
+ super().__init__(
+ name="globalmenu", orientation="horizontal", spacing=0, **kwargs
+ )
+
+ self.dropdown_system = GlobalMenuDropdowns(parent=parent_window)
+
+ self.children = [
+ self.dropdown_system.global_menu_button_title,
+ self.dropdown_system.global_menu_button_file,
+ self.dropdown_system.global_menu_button_edit,
+ self.dropdown_system.global_menu_button_view,
+ self.dropdown_system.global_menu_button_go,
+ self.dropdown_system.global_menu_button_window,
+ self.dropdown_system.global_menu_button_help,
+ ]
+
+ def show_system_dropdown(self, imac_button):
+ self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to(
+ imac_button
+ )
+ mouse_capture = self.dropdown_system.menu_button_dropdown
+ mouse_capture.set_child_window_visible(not mouse_capture.is_visible())
diff --git a/src/window/panel/components/indicators.py b/src/window/panel/components/indicators.py
new file mode 100644
index 0000000..2b9a462
--- /dev/null
+++ b/src/window/panel/components/indicators.py
@@ -0,0 +1,459 @@
+from fabric.bluetooth import BluetoothClient
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from services.battery import BatteryService, DeviceState
+from services.network import NetworkClient
+from shared.window.battery_widget import BatteryControl
+from shared.window.mousecapture import DropDownMouseCapture
+from utils.functions import format_duration, get_wifi_icon_for_strength
+from utils.roam import modus_service
+from utils.utils import setup_cursor_hover, svg_file
+from window.controlcenter.bluetooth import BluetoothConnections
+from window.controlcenter.wifi import WifiConnections
+
+
+def create_control_window(name_prefix):
+ return Window(
+ layer="overlay",
+ title="modus",
+ anchor="top right",
+ margin="2px 10px 0px 0px",
+ exclusivity="auto",
+ keyboard_mode="on-demand",
+ name=f"{name_prefix}-window",
+ visible=False,
+ )
+
+
+def create_mouse_capture(window):
+ return DropDownMouseCapture(layer="top", child_window=window)
+
+
+def setup_control_center(
+ show_window, name_prefix, widget_class, parent, **widget_kwargs
+):
+ if not show_window:
+ return None, None, None
+
+ window = create_control_window(name_prefix)
+ widget = widget_class(parent, **widget_kwargs)
+ window.children = [widget]
+ mouse_capture = create_mouse_capture(window)
+
+ return window, widget, mouse_capture
+
+
+def handle_indicator_click(mouse_capture):
+ if mouse_capture:
+ mouse_capture.toggle_mousecapture()
+
+
+def hide_control_center(mouse_capture):
+ if mouse_capture:
+ mouse_capture.hide_child_window()
+
+
+class BluetoothIndicator(Box):
+ def __init__(self, show_window=True, **kwargs):
+ super().__init__(name="bluetooth-indicator", orientation="h", **kwargs)
+ self.show_window = show_window
+
+ self.bluetooth = BluetoothClient()
+ self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22)
+
+ self.bt_button = Button(
+ name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked
+ )
+
+ self.add(self.bt_button)
+
+ # Set pointer cursor on hover
+ setup_cursor_hover(self.bt_button, "pointer")
+
+ # Setup control center using shared function
+ (
+ self.bluetooth_window,
+ self.bluetooth_widget,
+ self.bluetooth_mousecapture,
+ ) = setup_control_center(
+ self.show_window,
+ "bluetooth",
+ BluetoothConnections,
+ self,
+ show_back_button=False,
+ )
+ if self.bluetooth_window:
+ self.bluetooth_window._pointing_widget = self.bt_button
+
+ modus_service.connect("bluetooth-changed", self.on_bluetooth_changed)
+ self.bluetooth.connect("changed", self.on_bluetooth_direct_changed)
+ self.bluetooth.connect("device-added", self.on_device_added)
+ self.bluetooth.connect("device-removed", self.on_device_removed)
+
+ self.update_modus_service_bluetooth_state()
+ self.update_state()
+
+ def update_state(self):
+ if not self.bluetooth.enabled:
+ self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg")
+ tooltip = "Bluetooth disabled"
+ else:
+ connected_devices = self.bluetooth.connected_devices
+ if connected_devices:
+ self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
+ if len(connected_devices) >= 1:
+ self.bt_icon.dynamic_file("applets/bluetooth-paired.svg")
+ device = connected_devices[0]
+ tooltip = f"Connected to {device.alias}"
+ if device.battery_percentage > 0:
+ tooltip += f" ({device.battery_percentage:.0f}%)"
+ else:
+ tooltip = f"Connected to {len(connected_devices)} devices"
+ else:
+ self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
+ tooltip = "No devices connected"
+
+ self.bt_button.set_tooltip_text(tooltip)
+
+ def on_bluetooth_changed(self, service, new_bluetooth_state):
+ self.update_state()
+
+ def on_bluetooth_direct_changed(self, *args):
+ self.update_modus_service_bluetooth_state()
+ self.update_state()
+
+ def on_device_added(self, _, address):
+ self.update_modus_service_bluetooth_state()
+ self.update_state()
+
+ def on_device_removed(self, _, address):
+ self.update_modus_service_bluetooth_state()
+ self.update_state()
+
+ def update_modus_service_bluetooth_state(self):
+ if not self.bluetooth.enabled:
+ bluetooth_state = "disabled"
+ else:
+ connected_devices = self.bluetooth.connected_devices
+ if connected_devices:
+ if len(connected_devices) == 1:
+ device = connected_devices[0]
+ bluetooth_state = f"connected:{device.alias}"
+ if (
+ hasattr(device, "battery_percentage")
+ and device.battery_percentage > 0
+ ):
+ bluetooth_state += f":{device.battery_percentage:.0f}%"
+ else:
+ bluetooth_state = f"connected:{len(connected_devices)}_devices"
+ else:
+ bluetooth_state = "enabled"
+
+ modus_service.bluetooth = bluetooth_state
+
+ def on_bluetooth_clicked(self, *args):
+ handle_indicator_click(self.bluetooth_mousecapture)
+
+ def close_bluetooth(self, *args):
+ hide_control_center(self.bluetooth_mousecapture)
+
+ def hide_controlcenter(self, *args):
+ hide_control_center(self.bluetooth_mousecapture)
+
+
+class NetworkIndicator(Box):
+ def __init__(self, show_window=True, **kwargs):
+ super().__init__(name="network-indicator", orientation="h", **kwargs)
+ self.show_window = show_window
+
+ self.network_service = NetworkClient()
+
+ self.network_icon = svg_file("applets/wifi-clear.svg", size=22)
+
+ self.network_button = Button(
+ name="network-button",
+ child=self.network_icon,
+ on_clicked=self.on_wifi_clicked,
+ )
+
+ self.add(self.network_button)
+
+ # Set pointer cursor on hover
+ setup_cursor_hover(self.network_button, "pointer")
+
+ # Setup control center using shared function
+ (
+ self.wifi_window,
+ self.wifi_widget,
+ self.wifi_mousecapture,
+ ) = setup_control_center(
+ self.show_window,
+ "wifi",
+ WifiConnections,
+ self,
+ show_back_button=False,
+ )
+ if self.wifi_window:
+ self.wifi_window._pointing_widget = self.network_button
+
+ modus_service.connect("wlan-changed", self.on_wlan_changed)
+ self.network_service.connect("wifi-device-added", self.on_wifi_device_added)
+ self.network_service.connect(
+ "ethernet-device-added", self.on_ethernet_device_added
+ )
+ self.network_service.connect("changed", self.on_network_changed)
+
+ self.update_modus_service_wlan_state()
+ self.update_state()
+
+ def on_wlan_changed(self, service, new_wlan_state):
+ self.update_state()
+
+ def on_wifi_device_added(self, *args):
+ """Called when WiFi device is added"""
+ if self.network_service.wifi_device:
+ self.network_service.wifi_device.connect(
+ "changed", self.on_network_direct_changed
+ )
+ self.update_modus_service_wlan_state()
+ self.update_state()
+
+ def on_ethernet_device_added(self, *args):
+ if self.network_service.ethernet_device:
+ self.network_service.ethernet_device.connect(
+ "changed", self.on_network_direct_changed
+ )
+ self.update_modus_service_wlan_state()
+ self.update_state()
+
+ def on_network_direct_changed(self, *args):
+ self.update_modus_service_wlan_state()
+ self.update_state()
+
+ def on_network_changed(self, *args):
+ self.update_modus_service_wlan_state()
+ self.update_state()
+
+ def update_modus_service_wlan_state(self):
+ wlan_state = "disconnected"
+
+ if self.network_service.wifi_device:
+ wifi = self.network_service.wifi_device
+ if not wifi.wireless_enabled:
+ wlan_state = "disabled"
+ elif wifi.active_access_point:
+ ap = wifi.active_access_point
+ wlan_state = f"connected:{ap.ssid}"
+ if ap.strength >= 0:
+ wlan_state += f":{ap.strength}%"
+ else:
+ wlan_state = "enabled"
+
+ # Check Ethernet if WiFi is not connected
+ elif self.network_service.ethernet_device:
+ ethernet = self.network_service.ethernet_device
+ if ethernet.internet == "activated":
+ wlan_state = "ethernet:connected"
+ if hasattr(ethernet, "speed") and ethernet.speed:
+ wlan_state += f":{ethernet.speed}"
+ elif ethernet.internet == "activating":
+ wlan_state = "ethernet:connecting"
+ else:
+ wlan_state = "ethernet:disconnected"
+
+ modus_service.wlan = wlan_state
+
+ def update_state(self):
+ tooltip = "No network connection"
+ icon_file = "wifi-off-clear.svg"
+
+ # Check WiFi first (prioritize WiFi over Ethernet)
+ if self.network_service.wifi_device:
+ wifi = self.network_service.wifi_device
+ if not wifi.wireless_enabled:
+ icon_file = "wifi-off-clear.svg"
+ tooltip = "WiFi disabled"
+ elif wifi.active_access_point:
+ ap = wifi.active_access_point
+ # Use dynamic WiFi icon based on signal strength
+ wifi_icon_path = get_wifi_icon_for_strength(ap.strength)
+ self.network_icon.set_from_file(wifi_icon_path)
+ tooltip = f"Connected to {ap.ssid}"
+ if ap.strength >= 0:
+ tooltip += f" ({ap.strength}%)"
+ self.network_button.set_tooltip_text(tooltip)
+ return # Early return to avoid setting icon again
+ else:
+ icon_file = "wifi-off-clear.svg"
+ tooltip = "WiFi disconnected"
+
+ # Check Ethernet if WiFi is not connected
+ elif self.network_service.ethernet_device:
+ ethernet = self.network_service.ethernet_device
+ if ethernet.internet == "activated":
+ icon_file = "network-wired.svg"
+ tooltip = "Ethernet connected"
+ if hasattr(ethernet, "speed") and ethernet.speed:
+ tooltip += f" ({ethernet.speed})"
+ elif ethernet.internet == "activating":
+ icon_file = "network-wired.svg"
+ tooltip = "Ethernet connecting..."
+ else:
+ icon_file = "network-wired-offline.svg"
+ tooltip = "Ethernet disconnected"
+
+ self.network_icon.dynamic_file(f"applets/{icon_file}")
+ self.network_button.set_tooltip_text(tooltip)
+
+ def on_wifi_clicked(self, *args):
+ handle_indicator_click(self.wifi_mousecapture)
+
+ def close_wifi(self, *args):
+ hide_control_center(self.wifi_mousecapture)
+
+ def hide_controlcenter(self, *args):
+ hide_control_center(self.wifi_mousecapture)
+
+
+class BatteryIndicator(Box):
+ def __init__(self, show_window=True, **kwargs):
+ super().__init__(name="battery-indicator", orientation="h", **kwargs)
+ self.show_window = show_window
+
+ self.battery_service = BatteryService()
+
+ self.battery_icon = svg_file("battery/battery-100.svg", size=23)
+
+ self.battery_button = Button(
+ name="battery-button",
+ child=self.battery_icon,
+ on_clicked=self.on_battery_clicked,
+ )
+
+ self.battery_label = Label(name="battery-label", label="--- %")
+
+ self.add(self.battery_label)
+ self.add(self.battery_button)
+
+ # Set pointer cursor on hover
+ setup_cursor_hover(self.battery_button, "pointer")
+
+ (
+ self.battery_window,
+ self.battery_widget,
+ self.battery_mousecapture,
+ ) = setup_control_center(
+ self.show_window,
+ "battery",
+ BatteryControl,
+ self,
+ show_back_button=False,
+ )
+ if self.battery_window:
+ self.battery_window._pointing_widget = self.battery_button
+
+ modus_service.connect("battery-changed", self.on_battery_changed)
+ self.battery_service.connect("changed", self.on_battery_direct_changed)
+
+ self.update_modus_service_battery_state()
+ self.update_state()
+
+ def on_battery_changed(self, service, new_battery_state):
+ self.update_state()
+
+ def on_battery_direct_changed(self, *args):
+ self.update_modus_service_battery_state()
+ self.update_state()
+
+ def _get_percentage(self):
+ return int(self.battery_service.get_property("Percentage") or 0)
+
+ def _get_state(self):
+ state_value = self.battery_service.get_property("State")
+ return DeviceState.get(state_value, "UNKNOWN")
+
+ def _is_present(self):
+ return bool(self.battery_service.get_property("IsPresent"))
+
+ def _get_time_to_empty(self):
+ return int(self.battery_service.get_property("TimeToEmpty") or 0)
+
+ def _get_time_to_full(self):
+ return int(self.battery_service.get_property("TimeToFull") or 0)
+
+ def _format_time(self, seconds: int) -> str:
+ return format_duration(seconds)
+
+ def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str:
+ clamped = max(0, min(100, percentage))
+ step = (clamped // 10) * 10
+ filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
+ return f"battery/{filename}"
+
+ def update_modus_service_battery_state(self):
+ if not self._is_present():
+ battery_state = "not_present"
+ else:
+ percentage = self._get_percentage()
+ state_str = self._get_state().lower()
+
+ battery_state = f"{state_str}:{percentage}%"
+
+ if state_str == "discharging":
+ time_to_empty = self._format_time(self._get_time_to_empty())
+ if time_to_empty != "N/A":
+ battery_state += f":{time_to_empty}"
+ elif state_str == "charging":
+ time_to_full = self._format_time(self._get_time_to_full())
+ if time_to_full != "N/A":
+ battery_state += f":{time_to_full}"
+
+ modus_service.battery = battery_state
+
+ def get_battery_tooltip(self, percentage, state):
+ tooltip = f"Battery: {percentage}%"
+
+ if state == "CHARGING":
+ tooltip += " (Charging)"
+ time_to_full = self._format_time(self._get_time_to_full())
+ if time_to_full != "N/A":
+ tooltip += f" - {time_to_full} until full"
+ elif state == "DISCHARGING":
+ time_to_empty = self._format_time(self._get_time_to_empty())
+ if time_to_empty != "N/A":
+ tooltip += f" - {time_to_empty} remaining"
+ elif state == "FULLY_CHARGED":
+ tooltip += " (Fully charged)"
+
+ return tooltip
+
+ def update_state(self):
+ if not self._is_present():
+ self.set_visible(False)
+ return
+ else:
+ self.set_visible(True)
+
+ percentage = self._get_percentage()
+ state = self._get_state()
+ is_charging = state in ["CHARGING", "FULLY_CHARGED"]
+
+ icon_file = self._get_battery_icon_file(percentage, is_charging)
+ tooltip = self.get_battery_tooltip(percentage, state)
+ percentage_text = f"{percentage}%"
+
+ self.battery_icon.dynamic_file(icon_file)
+ self.battery_button.set_tooltip_text(tooltip)
+ self.battery_label.set_label(percentage_text)
+
+ def on_battery_clicked(self, *args):
+ handle_indicator_click(self.battery_mousecapture)
+
+ def close_battery(self, *args):
+ hide_control_center(self.battery_mousecapture)
+
+ def hide_controlcenter(self, *args):
+ hide_control_center(self.battery_mousecapture)
diff --git a/src/window/panel/components/recording_indicator.py b/src/window/panel/components/recording_indicator.py
new file mode 100644
index 0000000..241346d
--- /dev/null
+++ b/src/window/panel/components/recording_indicator.py
@@ -0,0 +1,212 @@
+from fabric.utils import GLib, os, time
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from services.screencapture import screen_capture_service
+
+from utils.utils import setup_cursor_hover, svg_file
+
+
+class RecordingIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(name="panel-button", visible=True, **kwargs)
+
+ self.recording_start_time = None
+ self.last_process_check = 0
+ self.process_check_interval = 1.0
+ self.timer_update_interval = 1000
+ self.status_check_interval = 2000
+
+ self.timer_timeout_id = None
+ self.status_timeout_id = None
+
+ self.recording_icon = svg_file("misc/media-record.svg", size=24)
+
+ self.time_label = Label(
+ name="recording-time-label",
+ markup="00:00",
+ max_width_chars=5,
+ ellipsize="none",
+ )
+
+ self.recording_box = Box(
+ orientation="h",
+ spacing=2,
+ children=[self.recording_icon, self.time_label],
+ size=(80, -1),
+ )
+
+ self.add(self.recording_box)
+
+ # Prevent container.show_all() from forcing this visible when no recording
+ try:
+ self.set_no_show_all(True)
+ except Exception:
+ pass
+
+ self.connect("clicked", self.on_stop_recording)
+ try:
+ setup_cursor_hover(self, "pointer")
+ except Exception:
+ pass
+ self.connect("button-press-event", self.on_button_press)
+ self.set_visible(False)
+
+ GLib.timeout_add(100, self._delayed_init)
+
+ def on_button_press(self, *args):
+ GLib.timeout_add(100, lambda: self.remove_style_class("pressed") or False)
+ return False
+
+ def is_recorder_running(self):
+ return screen_capture_service.is_recording
+
+ def check_recording_status(self):
+ current_time = time.time()
+ self.last_process_check = current_time
+
+ try:
+ is_recording = self.is_recorder_running()
+
+ if is_recording:
+ if not self.get_visible():
+ self.set_visible(True)
+ if self.timer_timeout_id is None:
+ self.timer_timeout_id = GLib.timeout_add(
+ self.timer_update_interval, self.update_timer_display
+ )
+
+ if self.recording_start_time is None:
+ self.recording_start_time = self.get_recording_start_time()
+
+ self.update_timer_display()
+ else:
+ # Ensure it stays hidden when not recording
+ self.set_visible(False)
+ self.cleanup_recording_state()
+
+ except Exception as e:
+ print(f"[DEBUG] Error checking recording status: {e}")
+ if self.get_visible():
+ self.set_visible(False)
+ self.cleanup_recording_state()
+
+ return True
+
+ def update_timer_display(self):
+ if not self.get_visible() or self.recording_start_time is None:
+ return False
+
+ try:
+ elapsed_seconds = int(time.time() - self.recording_start_time)
+ minutes = elapsed_seconds // 60
+ seconds = elapsed_seconds % 60
+ time_text = f"{minutes:02d}:{seconds:02d}"
+
+ self.time_label.set_markup(time_text)
+ self.set_tooltip_text(
+ f"Recording in progress ({time_text}) - Click to stop"
+ )
+
+ return True
+ except Exception as e:
+ print(f"[DEBUG] Error updating timer display: {e}")
+ return False
+
+ def cleanup_recording_state(self):
+ self.recording_start_time = None
+
+ if self.timer_timeout_id:
+ GLib.source_remove(self.timer_timeout_id)
+ self.timer_timeout_id = None
+
+ def get_recording_start_time(self):
+ wf_file = "/tmp/recording_start_time.txt"
+ gpu_file = "/tmp/gpu_recording_start_time.txt"
+
+ def read_timestamp(path):
+ try:
+ with open(path, "r") as f:
+ content = f.read().strip()
+ if content:
+ t = float(content)
+ if abs(t - time.time()) <= 3600:
+ return t
+ return os.path.getmtime(path)
+ except (OSError, ValueError):
+ return None
+
+ if os.path.exists(wf_file):
+ t = read_timestamp(wf_file)
+ if t:
+ print("[DEBUG] Using wf-recorder start time")
+ return t
+
+ if os.path.exists(gpu_file):
+ t = read_timestamp(gpu_file)
+ if t:
+ print("[DEBUG] Using gpu-screen-recorder start time")
+ return t
+
+ print("[DEBUG] No start time file found, using current time")
+ return time.time()
+
+ def on_stop_recording(self, *args):
+ try:
+ self.set_visible(False)
+ self.cleanup_recording_state()
+ screen_capture_service.stop_recording()
+
+ GLib.timeout_add(500, self._verify_recording_stopped)
+ GLib.timeout_add(1500, self._verify_recording_stopped)
+ GLib.timeout_add(3000, self._verify_recording_stopped)
+
+ except Exception:
+ self.set_visible(False)
+ self.cleanup_recording_state()
+
+ def _verify_recording_stopped(self):
+ try:
+ if self.is_recorder_running():
+ if self.recording_start_time is None:
+ self.recording_start_time = self.get_recording_start_time()
+
+ if self.recording_start_time:
+ self.set_visible(True)
+ self.update_timer_display()
+
+ if self.timer_timeout_id is None:
+ self.timer_timeout_id = GLib.timeout_add(
+ self.timer_update_interval, self.update_timer_display
+ )
+ else:
+ self.set_visible(False)
+ self.cleanup_recording_state()
+
+ except Exception:
+ self.set_visible(False)
+ self.cleanup_recording_state()
+
+ return False
+
+ def _delayed_init(self):
+ try:
+ self.check_recording_status()
+ self.status_timeout_id = GLib.timeout_add(
+ self.status_check_interval, self.check_recording_status
+ )
+
+ except Exception as e:
+ print(f"[DEBUG] Error in delayed recording indicator init: {e}")
+ return False
+
+ def destroy(self):
+ if self.timer_timeout_id:
+ GLib.source_remove(self.timer_timeout_id)
+ self.timer_timeout_id = None
+
+ if self.status_timeout_id:
+ GLib.source_remove(self.status_timeout_id)
+ self.status_timeout_id = None
+
+ super().destroy()
diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py
new file mode 100644
index 0000000..8ceebc6
--- /dev/null
+++ b/src/window/panel/components/workspace.py
@@ -0,0 +1,79 @@
+from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton
+from fabric.widgets.box import Box
+
+from services.config import get_config, on_config_change
+from utils.functions import is_special_workspace_id
+from utils.utils import setup_cursor_hover
+
+# TODO: Support multi-monitor setups
+
+
+class WorkspaceIndicator(Box):
+ def __init__(self, **kwargs):
+ Box.__init__(
+ self, name="workspace-indicator", orientation="h", spacing=4, **kwargs
+ )
+ self._current_config = {"hide_special_workspace": True}
+ on_config_change(self._on_config_changed)
+
+ self.workspaces = HyprlandWorkspaces(
+ name="workspaces",
+ spacing=4,
+ buttons_factory=self._get_button_factory(),
+ )
+
+ self.add(self.workspaces)
+ self.show_all()
+
+ self._apply_initial_config()
+
+ def _apply_initial_config(self):
+ new_value = get_config("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"
+ ]
+ }
+ )
+
+ def _get_button_factory(self):
+ if self._current_config.get("hide_special_workspace", True):
+ return self._create_filtered_button
+ else:
+ return lambda ws_id: self._create_button_with_hover(ws_id)
+
+ def _create_filtered_button(self, ws_id):
+ if self._current_config.get(
+ "hide_special_workspace", True
+ ) and is_special_workspace_id(ws_id):
+ return None
+ return self._create_button_with_hover(ws_id)
+
+ def _create_button_with_hover(self, ws_id):
+ button = WorkspaceButton(id=ws_id, label=str(ws_id))
+ setup_cursor_hover(button, "pointer")
+ return button
+
+ def update_config(self, new_config: dict):
+ if "hide_special_workspace" in new_config:
+ button_factory = self._get_button_factory()
+ self.remove(self.workspaces)
+ self.workspaces = HyprlandWorkspaces(
+ name="workspaces",
+ spacing=4,
+ buttons_factory=button_factory,
+ )
+ self.add(self.workspaces)
+ self.workspaces.show_all()
diff --git a/src/window/panel/main.py b/src/window/panel/main.py
new file mode 100644
index 0000000..f0c55ab
--- /dev/null
+++ b/src/window/panel/main.py
@@ -0,0 +1,269 @@
+from fabric.system_tray.widgets import SystemTray
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+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 services.config import get_config, on_config_change
+from services.modus import notification_service
+from shared.window.mousecapture import MouseCapture
+from utils.roam import modus_service
+from utils.utils import setup_cursor_hover, svg_file
+from window.controlcenter.main import ModusControlCenter
+from window.notification.notification_center import NotificationCenter
+from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray
+from window.panel.components.globalmenu import GlobalMenu
+from window.panel.components.indicators import (
+ BatteryIndicator,
+ BluetoothIndicator,
+ NetworkIndicator,
+)
+from window.panel.components.recording_indicator import RecordingIndicator
+from window.panel.components.workspace import WorkspaceIndicator
+
+apply_enhanced_system_tray()
+
+
+class Panel(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="bar",
+ title="modus",
+ layer="top",
+ anchor="left top right",
+ exclusivity="auto",
+ visible=False,
+ all_visible=False,
+ )
+ 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)),
+ )
+ setup_cursor_hover(self.imac, "pointer")
+
+ self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20)
+
+ self.tray_revealer = Revealer(
+ name="tray-revealer",
+ child=self.tray,
+ child_revealed=False,
+ transition_type="slide-left",
+ transition_duration=300,
+ )
+
+ self.chevron_button = Button(
+ name="panel-button",
+ child=svg_file("misc/chevron-right.svg", size=16),
+ on_clicked=self.toggle_tray,
+ )
+ setup_cursor_hover(self.chevron_button, "pointer")
+
+ # Hide tray elements if empty
+ self.tray.connect("add", self._update_tray_visibility)
+ self.tray.connect("remove", self._update_tray_visibility)
+
+ self.indicators = Box(
+ name="indicators",
+ orientation="h",
+ spacing=4,
+ )
+
+ self.search = Button(
+ name="panel-button", child=svg_file("misc/search.svg", size=22)
+ )
+ setup_cursor_hover(self.search, "pointer")
+
+ self.control_center = MouseCapture(
+ layer="top", child_window=ModusControlCenter()
+ )
+
+ self.control_center_btn = Button(
+ name="panel-button",
+ child=svg_file("misc/control-center.svg", size=22),
+ on_clicked=self.control_center.toggle_mousecapture,
+ )
+ setup_cursor_hover(self.control_center_btn, "pointer")
+ self.control_center.child_window._pointing_widget = self.control_center_btn
+
+ self.notification_center = MouseCapture(
+ layer="overlay", child_window=NotificationCenter()
+ )
+
+ self.notification_icon = svg_file(
+ "notifications/notification-inactive.svg", size=22
+ )
+
+ self.notification_center_btn = Button(
+ name="panel-button",
+ child=self.notification_icon,
+ on_clicked=self.on_notification_icon_clicked,
+ )
+ setup_cursor_hover(self.notification_center_btn, "pointer")
+ self.notification_center.child_window._pointing_widget = (
+ self.notification_center_btn
+ )
+
+ self.datetime_btn = Button(
+ name="panel-button",
+ child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]),
+ )
+ setup_cursor_hover(self.datetime_btn, "pointer")
+
+ self.workspace_indicator = WorkspaceIndicator()
+ self.recording_indicator = RecordingIndicator()
+
+ # Create boxes and mount, to allow live updates
+ self.left_box = Box(name="window-left")
+ self.center_box = Box(name="window-center", children=self.recording_indicator)
+ self.right_box = Box(name="window-right", spacing=4, orientation="h")
+
+ self.children = CenterBox(
+ name="panel",
+ start_children=self.left_box,
+ center_children=self.center_box,
+ end_children=self.right_box,
+ )
+
+ # Connect to DND state changes for notification icon
+ modus_service.connect("dont-disturb-changed", self.on_dnd_changed)
+
+ # Connect to notification service for icon state updates
+ notification_service.connect(
+ "notify::count", self.on_notification_count_changed
+ )
+
+ # Set initial notification icon state
+ self.update_notification_icon()
+
+ # Initial layout build
+ self._rebuild_layout_from_config()
+
+ # Live updates
+ on_config_change(self._on_config_changed)
+
+ self._update_tray_visibility()
+ self.show_all()
+
+ def on_dnd_changed(self, _, dnd_state):
+ self.update_notification_icon() # Update notification icon when DND changes
+
+ def on_notification_count_changed(self, service, *args):
+ self.update_notification_icon()
+
+ def on_notification_icon_clicked(self, *args):
+ count = notification_service.count
+ if count > 0:
+ # Only open notification center if there are notifications
+ self.notification_center.toggle_mousecapture()
+ # Do nothing if no notifications
+
+ def update_notification_icon(self):
+ count = notification_service.count
+ dnd_enabled = modus_service.dont_disturb
+
+ if dnd_enabled:
+ # DND is enabled - show disabled icon
+ icon_file = "notification-disabled.svg"
+ elif count > 0:
+ # Has notifications - show active icon
+ icon_file = "notification-active.svg"
+ else:
+ # No notifications - show inactive icon
+ icon_file = "notification-inactive.svg"
+
+ self.notification_icon.dynamic_file(f"notifications/{icon_file}")
+
+ def _rebuild_layout_from_config(self):
+ # Left
+ left_children = []
+ if get_config("imac_button", True):
+ left_children.append(self.imac)
+ if get_config("global_menu", True):
+ left_children.append(self.globalmenu)
+ self.left_box.children = left_children
+
+ # Indicators (create fresh instances on each rebuild)
+ indicators_children = []
+ if get_config("battery", True):
+ indicators_children.append(BatteryIndicator())
+ if get_config("network", True):
+ indicators_children.append(NetworkIndicator())
+ if get_config("bluetooth", True):
+ indicators_children.append(BluetoothIndicator())
+ self.indicators.children = indicators_children
+
+ # Right
+ right_children = []
+ if get_config("workspace_indicator", True):
+ right_children.append(self.workspace_indicator)
+
+ if get_config("systray", True):
+ right_children.extend([self.tray_revealer, self.chevron_button])
+
+ right_children.append(self.indicators)
+
+ if get_config("search", True):
+ right_children.append(self.search)
+ if get_config("control_center", True):
+ right_children.append(self.control_center_btn)
+ if get_config("date_time", True):
+ right_children.append(self.datetime_btn)
+ if get_config("notification_center", True):
+ right_children.append(self.notification_center_btn)
+
+ self.right_box.children = right_children
+ self._update_tray_visibility()
+ self.show_all()
+
+ def _on_config_changed(self, new_config, old_config):
+ keys = {
+ "imac_button",
+ "global_menu",
+ "workspace_indicator",
+ "systray",
+ "battery",
+ "network",
+ "bluetooth",
+ "search",
+ "control_center",
+ "date_time",
+ "notification_center",
+ }
+ if any(new_config.get(k) != old_config.get(k) for k in keys):
+ self._rebuild_layout_from_config()
+
+ # Always update tray visibility on config change just in case
+ self._update_tray_visibility()
+
+ def _update_tray_visibility(self, *_):
+ # We check if there are any visible children in the tray
+ visible_children = [
+ child for child in self.tray.get_children() if child.get_visible()
+ ]
+ has_items = len(visible_children) > 0
+
+ self.tray_revealer.set_visible(has_items)
+ self.chevron_button.set_visible(has_items)
+
+ if not has_items:
+ # Reset state if it becomes hidden
+ self.tray_revealer.child_revealed = False
+ self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg")
+ # Add extra spacing between workspace indicator and indicators when tray is hidden
+ self.indicators.set_margin_left(10)
+ else:
+ self.indicators.set_margin_left(0)
+
+ def toggle_tray(self, *_):
+ current_state = self.tray_revealer.child_revealed
+ self.tray_revealer.child_revealed = not current_state
+
+ if self.tray_revealer.child_revealed:
+ self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg")
+ else:
+ self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg")
diff --git a/src/window/switcher.py b/src/window/switcher.py
new file mode 100644
index 0000000..c16b305
--- /dev/null
+++ b/src/window/switcher.py
@@ -0,0 +1,307 @@
+import json
+
+from fabric.hyprland.widgets import get_hyprland_connection
+from fabric.utils import Gdk
+from fabric.widgets.box import Box
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
+from utils.functions import is_special_workspace
+from utils.icon_resolver import IconResolver
+
+
+class ApplicationSwitcher(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="application-switcher",
+ title="modus-switcher",
+ layer="top",
+ anchor="center",
+ exclusivity="auto",
+ keyboard_mode="exclusive",
+ visible=False, # Start hidden until explicitly shown
+ **kwargs,
+ )
+
+ self.conn = get_hyprland_connection()
+ self.icon_resolver = IconResolver()
+ self.windows = []
+ self.current_index = 0
+ self.tab_pressed = False
+ self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW
+ self.icon_size = 96
+ self.items_per_row = 100 # Single row focus
+
+ container = Box(
+ name="application-switcher-container",
+ orientation="v",
+ h_align="center",
+ v_align="center",
+ expand=True,
+ )
+ self.add(container)
+
+ self.view = Box(
+ name="application-switcher-view",
+ orientation="h",
+ spacing=16,
+ h_align="center",
+ v_align="center",
+ )
+ container.add(self.view)
+
+ self.selection_label = Label(
+ name="switcher-selection-label",
+ label="",
+ h_align="center",
+ v_align="center",
+ style_classes=["switcher-selection-label"],
+ )
+ container.add(self.selection_label)
+
+ self.workspace_label = Label(
+ name="switcher-workspace-label",
+ label="",
+ h_align="center",
+ v_align="center",
+ style_classes=["switcher-workspace-label"],
+ )
+ container.add(self.workspace_label)
+ self.connect("key-press-event", self.on_key_press)
+ self.connect("key-release-event", self.on_key_release)
+
+ self.show_all()
+ self.hide()
+
+ def show_switcher(self) -> None:
+ self.update_windows()
+ if not self.windows:
+ return
+
+ self.show()
+ self.grab_keyboard()
+ self.tab_pressed = False
+
+ def hide_switcher(self) -> None:
+ self.hide()
+ self.ungrab_keyboard()
+
+ def create_icon_for_window(self, window):
+ """Create an icon image for a specific window"""
+ class_name = window.get("class", "").lower()
+ icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size)
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(
+ "application-x-executable-symbolic", self.icon_size
+ )
+
+ icon_image = Image()
+ if icon_img:
+ icon_image.set_from_pixbuf(icon_img)
+
+ return icon_image
+
+ def _is_special_workspace(self, client):
+ return is_special_workspace(client)
+
+ def update_windows(self) -> None:
+ for child in self.view.get_children():
+ self.view.remove(child)
+
+ try:
+ clients_data = self.conn.send_command("j/clients").reply
+ if not clients_data:
+ return
+ clients = json.loads(clients_data.decode("utf-8"))
+
+ # Filter out hidden windows and optionally special workspace windows
+ filtered_windows = []
+ for c in clients:
+ if c.get("hidden", False):
+ continue
+ # Skip clients in special workspaces if the setting is enabled
+ if data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS and self._is_special_workspace(
+ c
+ ):
+ continue
+ filtered_windows.append(c)
+
+ self.windows = filtered_windows
+
+ active_data = self.conn.send_command("j/activewindow").reply
+ active_window = (
+ json.loads(active_data.decode("utf-8")) if active_data else None
+ )
+
+ self.current_index = 0
+ if active_window:
+ for i, window in enumerate(self.windows):
+ if window.get("address") == active_window.get("address"):
+ self.current_index = i
+ break
+
+ current_row = Box(
+ name="window-row",
+ orientation="h",
+ spacing=16,
+ h_align="center",
+ v_align="center",
+ )
+ self.view.add(current_row)
+
+ for i, window in enumerate(self.windows):
+ # Create icon image for this window
+ icon_image = self.create_icon_for_window(window)
+
+ button_content = Box(
+ name="switcher-button",
+ orientation="v",
+ h_align="center",
+ v_align="center",
+ children=[
+ Box(
+ name="switcher-icon-box",
+ style_classes=["window-basic", "sleek-border"],
+ children=[icon_image],
+ h_align="center",
+ v_align="center",
+ ),
+ ],
+ )
+
+ event_box = EventBox(
+ name="window-button",
+ style_classes=["active"] if i == self.current_index else None,
+ child=button_content,
+ )
+ current_row.add(event_box)
+
+ self.view.show_all()
+ self.update_selection()
+ except Exception as e:
+ print(f"Failed to update windows: {e}")
+
+ def on_key_press(self, _, event):
+ keyval = event.keyval
+ state = event.state
+ alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK)
+
+ if not self.windows:
+ return False
+
+ if keyval == Gdk.KEY_Escape:
+ self.hide_switcher()
+ return True
+
+ if keyval == Gdk.KEY_Tab:
+ if not self.tab_pressed or alt_pressed:
+ self.current_index = (self.current_index + 1) % len(self.windows)
+ self.update_selection()
+ self.tab_pressed = True
+ return True
+
+ if keyval == Gdk.KEY_ISO_Left_Tab or (
+ keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK)
+ ):
+ self.current_index = (self.current_index - 1) % len(self.windows)
+ self.update_selection()
+ return True
+
+ if keyval == Gdk.KEY_Return:
+ self.activate_selected()
+ self.hide_switcher()
+ return True
+
+ if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l:
+ self.current_index = (self.current_index + 1) % len(self.windows)
+ self.update_selection()
+ return True
+
+ if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h:
+ self.current_index = (self.current_index - 1) % len(self.windows)
+ self.update_selection()
+ return True
+
+ if keyval == Gdk.KEY_Down:
+ next_index = self.current_index + self.items_per_row
+ if next_index < len(self.windows):
+ self.current_index = next_index
+ self.update_selection()
+ return True
+
+ if keyval == Gdk.KEY_Up:
+ next_index = self.current_index - self.items_per_row
+ if next_index >= 0:
+ self.current_index = next_index
+ self.update_selection()
+ return True
+
+ return False
+
+ def on_key_release(self, _, event):
+ keyval = event.keyval
+
+ if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R):
+ self.activate_selected()
+ self.hide_switcher()
+ return True
+
+ if keyval == Gdk.KEY_Tab:
+ self.tab_pressed = False
+ return True
+
+ return False
+
+ def update_selection(self):
+ for row in self.view.get_children():
+ for i, child in enumerate(row.get_children()):
+ index = self.view.get_children().index(row) * self.items_per_row + i
+ if index == self.current_index:
+ child.add_style_class("active")
+ # Update selection label
+ current_window = self.windows[self.current_index]
+ app_class = current_window.get("class", "Unknown")
+ # capitalize if it's all lowercase
+ if app_class.islower():
+ app_class = app_class.capitalize()
+ self.selection_label.set_label(app_class)
+
+ # Update workspace label
+ workspace = current_window.get("workspace", {})
+ workspace_name = workspace.get("name", "Unknown")
+ self.workspace_label.set_label(f"Workspace {workspace_name}")
+ else:
+ child.remove_style_class("active")
+
+ def activate_selected(self):
+ if not self.windows or self.current_index >= len(self.windows):
+ return
+
+ window = self.windows[self.current_index]
+ address = window.get("address")
+ if address:
+ try:
+ command = f"/dispatch focuswindow address:{address}"
+ self.conn.send_command(command)
+ except Exception as e:
+ print(f"Failed to focus window: {e}")
+
+ def grab_keyboard(self):
+ try:
+ display = Gdk.Display.get_default()
+ seat = display.get_default_seat()
+ window = self.get_window()
+ seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None)
+ except Exception as e:
+ print(f"Failed to grab keyboard: {e}")
+
+ def ungrab_keyboard(self):
+ try:
+ display = Gdk.Display.get_default()
+ seat = display.get_default_seat()
+ seat.ungrab()
+ except Exception as e:
+ print(f"Failed to ungrab keyboard: {e}")
diff --git a/src/window/todo/__init__.py b/src/window/todo/__init__.py
new file mode 100644
index 0000000..c9af2aa
--- /dev/null
+++ b/src/window/todo/__init__.py
@@ -0,0 +1 @@
+# Todo module
diff --git a/src/window/todo/todo_widget.py b/src/window/todo/todo_widget.py
new file mode 100644
index 0000000..a487ae5
--- /dev/null
+++ b/src/window/todo/todo_widget.py
@@ -0,0 +1,443 @@
+from datetime import datetime
+
+from fabric.utils import GLib
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.entry import Entry
+from fabric.widgets.label import Label
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from services.todo import get_todo_service
+from shared.window.mousecapture import MouseCapture
+from utils.utils import svg_file
+
+
+class TodoItem(Box):
+ """Individual todo item widget"""
+
+ def __init__(self, todo_data, todo_list_widget, **kwargs):
+ self.todo_data = todo_data
+ self.todo_list_widget = todo_list_widget
+ self.editing = False
+
+ super().__init__(
+ name="todo-item",
+ orientation="h",
+ spacing=8,
+ style_classes=["menu"],
+ **kwargs,
+ )
+
+ self._build_ui()
+
+ def _build_ui(self):
+ """Build the todo item UI"""
+ # Checkbox for completion - using SVG icons
+ checkbox_icon = (
+ "checkbox-check.svg"
+ if self.todo_data["completed"]
+ else "checkbox-uncheck.svg"
+ )
+ self.checkbox_icon = svg_file(
+ "todo/" + checkbox_icon,
+ name="todo-checkbox-icon",
+ size=24,
+ )
+ self.checkbox = Button(
+ name="todo-checkbox",
+ child=self.checkbox_icon,
+ on_clicked=self._toggle_completion,
+ )
+
+ # Todo text (can be converted to entry for editing)
+ text_content = self.todo_data["text"]
+ if self.todo_data["completed"]:
+ text_content = f"{text_content}"
+
+ self.text_label = Label(
+ markup=text_content,
+ name="todo-text",
+ h_align="start",
+ h_expand=True,
+ line_wrap="word-char",
+ style_classes=(
+ ["title-widget"]
+ if not self.todo_data["completed"]
+ else ["status-label"]
+ ),
+ )
+
+ # Date/time label
+ created_at = datetime.fromisoformat(self.todo_data["created_at"])
+ date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
+
+ self.date_label = Label(
+ label=date_text,
+ name="todo-date",
+ h_align="start",
+ style_classes=["todo-date-text"],
+ )
+
+ self.text_entry = Entry(
+ name="todo-text-entry",
+ text=self.todo_data["text"],
+ h_expand=True,
+ visible=False,
+ )
+ self.text_entry.connect("activate", self._save_edit)
+
+ # Priority indicator
+ # priority_symbols = {"high": "🔴", "medium": "🟡", "low": "🟢"}
+ #
+ # self.priority_label = Label(
+ # label=priority_symbols.get(self.todo_data["priority"], "🟡"),
+ # name="todo-priority-label",
+ # )
+ # self.priority_button = Button(
+ # name="todo-priority",
+ # size=(20, 20),
+ # child=self.priority_label,
+ # on_clicked=self._cycle_priority,
+ # )
+ #
+ # Edit button - using SVG icon
+ self.edit_icon = svg_file(
+ "todo/edit.svg",
+ name="todo-edit-icon",
+ size=12,
+ )
+ self.edit_button = Button(
+ name="todo-edit",
+ child=self.edit_icon,
+ on_clicked=self._start_edit,
+ )
+
+ # Delete button - using SVG icon
+ self.delete_icon = svg_file(
+ "todo/delete-symbolic.svg",
+ name="todo-delete-icon",
+ size=12,
+ )
+ self.delete_button = Button(
+ name="todo-delete",
+ child=self.delete_icon,
+ on_clicked=self._delete_todo,
+ )
+
+ # Text container that switches between label and entry
+ self.text_container = Box(
+ orientation="v",
+ h_expand=True,
+ children=[
+ Box(orientation="h", children=[self.text_label]),
+ self.date_label,
+ ],
+ )
+
+ self.children = [
+ self.checkbox,
+ self.text_container,
+ # self.priority_button,
+ self.edit_button,
+ self.delete_button,
+ ]
+
+ def _toggle_completion(self, *_):
+ """Toggle todo completion status"""
+ get_todo_service().toggle_todo(self.todo_data["id"])
+
+ def _cycle_priority(self, *_):
+ """Cycle through priority levels"""
+ priorities = ["low", "medium", "high"]
+ current_index = priorities.index(self.todo_data["priority"])
+ new_priority = priorities[(current_index + 1) % len(priorities)]
+ get_todo_service().set_priority(self.todo_data["id"], new_priority)
+
+ def _start_edit(self, *_):
+ """Start editing the todo text"""
+ if self.editing:
+ return
+
+ self.editing = True
+ self.text_container.children = [
+ Box(orientation="h", children=[self.text_entry]),
+ self.date_label,
+ ]
+ self.text_entry.set_visible(True)
+ self.text_entry.grab_focus()
+ self.text_entry.set_position(-1) # Move cursor to end
+
+ def _save_edit(self, *_):
+ """Save the edited todo text"""
+ if not self.editing:
+ return
+
+ new_text = self.text_entry.get_text().strip()
+ if new_text:
+ get_todo_service().edit_todo(self.todo_data["id"], new_text)
+
+ self._cancel_edit()
+
+ def _cancel_edit(self):
+ """Cancel editing and revert to label"""
+ self.editing = False
+ self.text_container.children = [
+ Box(orientation="h", children=[self.text_label]),
+ self.date_label,
+ ]
+ self.text_entry.set_visible(False)
+
+ def _delete_todo(self, *_):
+ """Delete this todo"""
+ get_todo_service().delete_todo(self.todo_data["id"])
+
+ def update_from_data(self, todo_data):
+ """Update the widget from new todo data"""
+ self.todo_data = todo_data
+
+ # Update checkbox icon by recreating it
+ checkbox_icon = (
+ "checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg"
+ )
+ new_checkbox_icon = svg_file(
+ "todo/" + checkbox_icon,
+ name="todo-checkbox-icon",
+ size=20,
+ )
+ self.checkbox.set_child(new_checkbox_icon)
+ self.checkbox_icon = new_checkbox_icon
+
+ # Update text and styling with markup
+ text_content = todo_data["text"]
+ if todo_data["completed"]:
+ text_content = f"{text_content}"
+
+ self.text_label.set_markup(text_content)
+ self.text_label.style_classes = (
+ ["title-widget"] if not todo_data["completed"] else ["status-label"]
+ )
+
+ # Update date/time
+ created_at = datetime.fromisoformat(todo_data["created_at"])
+ date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
+ self.date_label.set_label(date_text)
+
+
+class TodoListWidget(Window):
+ """Main todo list widget window"""
+
+ def __init__(self, **kwargs):
+ super().__init__(
+ title="modus-todo",
+ anchor="top right",
+ margin="2px 10px 0px 0px",
+ exclusivity="auto",
+ keyboard_mode="on-demand",
+ name="todo-list-window",
+ visible=False, # Back to hidden by default
+ **kwargs,
+ )
+
+ self.todo_items = {} # Maps todo IDs to TodoItem widgets
+
+ # Register callback with todo service
+ get_todo_service().add_callback(self._on_todo_event)
+
+ self._build_ui()
+ self._refresh_todos()
+
+ # Add keybinding for escape
+ self.add_keybinding("Escape", self.hide_todo_list)
+
+ def _build_ui(self):
+ """Build the main UI"""
+ # Header with title and stats
+ self.stats_label = Label(
+ label="",
+ name="todo-stats",
+ style_classes=["status-label"],
+ h_align="start",
+ )
+
+ self.header = Box(
+ name="todo-header",
+ orientation="v",
+ children=[
+ Label(
+ label="Todo List",
+ name="todo-title",
+ style_classes=["title"],
+ h_align="start",
+ ),
+ self.stats_label,
+ ],
+ )
+
+ # Add new todo section
+ self.new_todo_entry = Entry(
+ name="new-todo-entry",
+ placeholder_text="Add a new task...",
+ h_expand=True,
+ )
+ self.new_todo_entry.connect("activate", self._add_todo)
+
+ # Add button - using SVG icon
+ self.add_icon = svg_file(
+ "todo/plus-symbolic.svg",
+ name="add-todo-icon",
+ size=12,
+ )
+ self.add_button = Button(
+ name="add-todo-button",
+ child=self.add_icon,
+ on_clicked=self._add_todo,
+ )
+
+ self.add_section = Box(
+ name="todo-add-section",
+ orientation="h",
+ spacing=8,
+ style_classes=["menu"],
+ children=[
+ self.new_todo_entry,
+ self.add_button,
+ ],
+ )
+
+ # Todo items container
+ self.todos_container = Box(
+ name="todos-container",
+ orientation="v",
+ spacing=4,
+ )
+
+ # Scrolled window for todos
+ self.scrolled = ScrolledWindow(
+ name="todos-scrolled",
+ min_content_height=300,
+ max_content_height=500,
+ min_content_width=400,
+ child=self.todos_container,
+ policy="automatic",
+ v_expand=True, # Allow vertical expansion
+ )
+
+ # Clear completed button
+ self.clear_button = Button(
+ name="clear-completed-button",
+ label="Clear Completed",
+ style_classes=["status-label"],
+ on_clicked=self._clear_completed,
+ )
+
+ # Main container
+ self.main_container = Box(
+ name="todo-main-container",
+ orientation="v",
+ spacing=8,
+ style_classes=["menu"],
+ children=[
+ self.header,
+ self.add_section,
+ self.scrolled,
+ self.clear_button,
+ ],
+ )
+
+ self.children = [self.main_container]
+
+ def _add_todo(self, *_):
+ """Add a new todo"""
+ text = self.new_todo_entry.get_text().strip()
+ if text:
+ get_todo_service().add_todo(text)
+ self.new_todo_entry.set_text("")
+
+ def _clear_completed(self, *_):
+ """Clear all completed todos"""
+ get_todo_service().clear_completed()
+
+ def _on_todo_event(self, event_type, data=None):
+ """Handle todo service events via callback"""
+ if event_type == "todos-changed":
+ GLib.idle_add(self._refresh_todos)
+ elif event_type == "todo-added":
+ GLib.idle_add(self._refresh_todos)
+ elif event_type == "todo-deleted":
+ GLib.idle_add(self._refresh_todos)
+ elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]:
+ if data and data["id"] in self.todo_items:
+ GLib.idle_add(
+ lambda: self.todo_items[data["id"]].update_from_data(data)
+ )
+ GLib.idle_add(self._update_stats)
+
+ def _refresh_todos(self, *_):
+ """Refresh the entire todo list"""
+ # Clear existing items
+ self.todo_items.clear()
+ self.todos_container.children = []
+
+ # Get all todos
+ todos = get_todo_service().todos
+
+ # Sort todos: incomplete first, then by priority, then by creation date
+ def sort_key(todo):
+ priority_order = {"high": 0, "medium": 1, "low": 2}
+ return (
+ todo["completed"], # False (incomplete) comes before True (completed)
+ priority_order.get(todo["priority"], 1),
+ todo["created_at"],
+ )
+
+ sorted_todos = sorted(todos, key=sort_key)
+
+ # Create todo item widgets
+ for todo in sorted_todos:
+ todo_item = TodoItem(todo, self)
+ self.todo_items[todo["id"]] = todo_item
+
+ # Update container children
+ self.todos_container.children = list(self.todo_items.values())
+
+ # Update stats
+ self._update_stats()
+
+ def _update_stats(self):
+ """Update the statistics display"""
+ stats = get_todo_service().get_stats()
+ stats_text = f"{stats['pending']} pending, {stats['completed']} completed"
+ self.stats_label.set_label(stats_text)
+
+ def set_visible(self, visible):
+ """Override set_visible for debugging"""
+ super().set_visible(visible)
+
+ def hide_todo_list(self, *_):
+ """Hide the todo list"""
+ if hasattr(self, "_mousecapture_parent"):
+ self._mousecapture_parent.toggle_mousecapture()
+ self.set_visible(False)
+
+ def _init_mousecapture(self, mousecapture):
+ """Initialize mousecapture parent reference"""
+ self._mousecapture_parent = mousecapture
+
+ def destroy(self):
+ """Clean up when destroyed"""
+ # Remove callback from todo service
+ get_todo_service().remove_callback(self._on_todo_event)
+
+ super().destroy()
+
+
+class TodoListCapture(MouseCapture):
+ """MouseCapture wrapper for the todo list"""
+
+ def __init__(self, **kwargs):
+ super().__init__(
+ layer="top",
+ child_window=TodoListWidget(),
+ **kwargs,
+ )
diff --git a/start.py b/start.py
new file mode 100644
index 0000000..f9ff948
--- /dev/null
+++ b/start.py
@@ -0,0 +1,24 @@
+import os
+import sys
+
+# Ensure the 'src' directory is in the python path
+# This allows 'import main' and other src-relative imports to work from the root
+src_path = os.path.join(os.path.dirname(__file__), "src")
+if src_path not in sys.path:
+ sys.path.insert(0, src_path)
+
+def run_app():
+ from main import main as app_main
+ app_main()
+
+
+def run_lock():
+ from lock import main as lock_main
+ lock_main()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] == "lock":
+ run_lock()
+ else:
+ run_app()
diff --git a/styles/colors.css b/styles/colors.css
deleted file mode 100644
index 1a391d3..0000000
--- a/styles/colors.css
+++ /dev/null
@@ -1,32 +0,0 @@
-:vars {
- --foreground: #e4e1e9;
- --background: #131318;
- --cursor: #e4e1e9;
- --primary: #bec2ff;
- --on-primary: #262b60;
- --secondary: #c5c4dd;
- --on-secondary: #2e2f42;
- --tertiary: #e7b9d5;
- --on-tertiary: #45263c;
- --surface: #131318;
- --surface-bright: #39393f;
- --error: #ffb4ab;
- --error-dim: #ff8678;
- --on-error: #690005;
- --error-container: #93000a;
- --outline: #91909a;
- --shadow: #000000;
- --red: #ffb2b9;
- --red-dim: #ff7f8b;
- --green: #95d5a7;
- --green-dim: #70c789;
- --yellow: #b8cf84;
- --yellow-dim: #a3c15f;
- --blue: #bec2ff;
- --blue-dim: #8b92ff;
- --magenta: #e4b7f3;
- --magenta-dim: #d48bec;
- --cyan: #82d3e2;
- --cyan-dim: #59c4d8;
- --white: #82d3e0;
-}
diff --git a/uv.lock b/uv.lock
index feaadb7..b444ac0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -95,7 +95,7 @@ wheels = [
[[package]]
name = "modus"
version = "0.1.0"
-source = { virtual = "." }
+source = { editable = "." }
dependencies = [
{ name = "fabric" },
{ name = "pam" },