-
-
-
-
-
-
-
-
-
-> [!WARNING]
-> This configuration is tailored to my preferences with little regard for others. It's not designed to be "good"—just comfortable for me.
-
-
Installation
-
-> [!NOTE]
-> You need a functioning Hyprland installation.
-
-### Arch Linux
-
-> [!NOTE]
-> You need to clone it into the Home directory.
-
-> [!TIP]
-> This command also works for updating an existing installation!
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash
-```
-
-### Manual Installation
-
-1. Dependencies:
-
-```sh
-fabric fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit
-```
-
-```sh
-python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg
-```
-
-```sh
-ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot
-```
-
-- Icon:
-
-```sh
-git clone https://github.com/vinceliuice/Tela-icon-theme
-cd Tela-icon-theme
-./install.sh nord
-```
-
-
Screenshots
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Todo
-
-- [x] App Launcher
-- [x] Power Menu
-- [x] Wallpaper Picker
-- [x] Todo
-- [x] Bluetooth Manager
-- [x] Clipboard Manager
-- [x] OSD
-- [x] Sh Runner
-- [x] Emoji Picker
-- [x] Update Screenshot
-- [x] Installation script
-- [x] Network Manager
-- [x] Calendar
-- [x] Power Manager
-- [x] Dock
-- [x] Web search in Launcher
-- [ ] Notification Panel
-- [ ] Improve CSS Styling
-- [ ] Update Screenshot
-
-## Special Thanks
-
-A big thank you to the following people for their incredible help with code and creative ideas. Your help made a real difference!
-
-- [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible.
-- [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort.
-- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldn’t resist borrowing.
-- [koeqaife](https://github.com/koeqaife): thanks for material-colors scripts
-
-I truly appreciate your support
diff --git a/assets/default.png b/assets/default.png
new file mode 100644
index 0000000..774a4e0
Binary files /dev/null and b/assets/default.png differ
diff --git a/assets/logo.svg b/assets/logo.svg
deleted file mode 100644
index 5d19dc1..0000000
--- a/assets/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/assets/modus.png b/assets/modus.png
new file mode 100644
index 0000000..eb680cc
Binary files /dev/null and b/assets/modus.png differ
diff --git a/assets/screenshots/bluetooth.png b/assets/screenshots/bluetooth.png
deleted file mode 100644
index 7033d48..0000000
Binary files a/assets/screenshots/bluetooth.png and /dev/null differ
diff --git a/assets/screenshots/cliphist.png b/assets/screenshots/cliphist.png
deleted file mode 100644
index 6e21db0..0000000
Binary files a/assets/screenshots/cliphist.png and /dev/null differ
diff --git a/assets/screenshots/emoji.png b/assets/screenshots/emoji.png
deleted file mode 100644
index 3202bf9..0000000
Binary files a/assets/screenshots/emoji.png and /dev/null differ
diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png
deleted file mode 100644
index c7ba7c3..0000000
Binary files a/assets/screenshots/home.png and /dev/null differ
diff --git a/assets/screenshots/notification.png b/assets/screenshots/notification.png
deleted file mode 100644
index c8c9f30..0000000
Binary files a/assets/screenshots/notification.png and /dev/null differ
diff --git a/assets/screenshots/osd.png b/assets/screenshots/osd.png
deleted file mode 100644
index a02dec6..0000000
Binary files a/assets/screenshots/osd.png and /dev/null differ
diff --git a/assets/screenshots/powermenu.png b/assets/screenshots/powermenu.png
deleted file mode 100644
index 165fc0e..0000000
Binary files a/assets/screenshots/powermenu.png and /dev/null differ
diff --git a/assets/screenshots/shrunner.png b/assets/screenshots/shrunner.png
deleted file mode 100644
index 40f184b..0000000
Binary files a/assets/screenshots/shrunner.png and /dev/null differ
diff --git a/assets/screenshots/todo.png b/assets/screenshots/todo.png
deleted file mode 100644
index 0718afb..0000000
Binary files a/assets/screenshots/todo.png and /dev/null differ
diff --git a/assets/screenshots/wallpaper.png b/assets/screenshots/wallpaper.png
deleted file mode 100644
index 4e6789b..0000000
Binary files a/assets/screenshots/wallpaper.png and /dev/null differ
diff --git a/assets/wallpaper/example-1.png b/assets/wallpapers_example/example-1.png
similarity index 100%
rename from assets/wallpaper/example-1.png
rename to assets/wallpapers_example/example-1.png
diff --git a/assets/wallpaper/example-2.png b/assets/wallpapers_example/example-2.png
similarity index 100%
rename from assets/wallpaper/example-2.png
rename to assets/wallpapers_example/example-2.png
diff --git a/assets/wallpaper/example-3.jpg b/assets/wallpapers_example/example-3.jpg
similarity index 100%
rename from assets/wallpaper/example-3.jpg
rename to assets/wallpapers_example/example-3.jpg
diff --git a/assets/wallpaper/example-4.jpeg b/assets/wallpapers_example/example-4.jpeg
similarity index 100%
rename from assets/wallpaper/example-4.jpeg
rename to assets/wallpapers_example/example-4.jpeg
diff --git a/assets/wallpaper/example-5.jpg b/assets/wallpapers_example/example-5.jpg
similarity index 100%
rename from assets/wallpaper/example-5.jpg
rename to assets/wallpapers_example/example-5.jpg
diff --git a/config/__init__.py b/config/__init__.py
index f94e240..9a75817 100644
--- a/config/__init__.py
+++ b/config/__init__.py
@@ -1,6 +1,7 @@
-from .data import *
+"""
+Ax-Shell configuration package.
+"""
# Import only specific names actually defined in data.py
# This prevents circular imports by not importing everything
-
-from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE, STATE_DIR
+from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE
diff --git a/config/assets/config.json b/config/assets/config.json
deleted file mode 100644
index 2cdda50..0000000
--- a/config/assets/config.json
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "prefix_restart": "SHIFT ALT",
- "suffix_restart": "T",
- "prefix_bluetooth": "SHIFT ALT",
- "suffix_bluetooth": "B",
- "prefix_emoji": "SUPER",
- "suffix_emoji": "E",
- "prefix_cliphist": "SUPER",
- "suffix_cliphist": "V",
- "prefix_kanban": "SUPER",
- "suffix_kanban": "T",
- "prefix_toolbox": "SUPER",
- "suffix_toolbox": "S",
- "prefix_walls": "SUPER",
- "suffix_walls": "W",
- "prefix_launcher": "SUPER",
- "suffix_launcher": "D",
- "prefix_power": "SUPER",
- "suffix_power": "X",
- "prefix_tmux": "SUPER",
- "suffix_tmux": "Z",
- "prefix_notifications": "SUPER",
- "suffix_notifications": "N",
- "prefix_window_switcher": "ALT",
- "suffix_window_switcher": "W",
- "prefix_restart_inspector": "SUPER SHIFT",
- "suffix_restart_inspector": "T",
- "vertical": false,
- "vertical_right_align": false,
- "bottom_bar": true,
- "centered_bar": true,
- "terminal_command": "kitty -e",
- "wallpapers_dir": "/home/sankalp/Pictures/wallpapers",
- "dock_enabled": true,
- "dock_always_occluded": false,
- "dock_icon_size": 28,
- "osd_enabled": true,
- "bar_button_app_visible": true,
- "bar_tray_visible": true,
- "bar_workspaces_visible": true,
- "bar_metrics_visible": true,
- "bar_language_visible": true,
- "bar_date_time_visible": true,
- "bar_updates_visible": true,
- "bar_indicators_visible": true,
- "corners_visible": true,
- "bar_metrics_disks": [
- "/"
- ],
- "metrics_visible": {
- "cpu": true,
- "ram": true,
- "disk": true,
- "swap": true,
- "gpu": false
- },
- "enabled_osd": true
-}
\ No newline at end of file
diff --git a/config/assets/launcher.json b/config/assets/launcher.json
new file mode 100644
index 0000000..2890dc5
--- /dev/null
+++ b/config/assets/launcher.json
@@ -0,0 +1,97 @@
+{
+ "launcher_config": {
+ "em": {
+ "icon": "emoji",
+ "description": "Emoji - Search and copy emojis"
+ },
+ "clip": {
+ "icon": "clipboard",
+ "description": "Clipboard - Search and manage clipboard history"
+ },
+ "=": {
+ "examples": ["= 10*5", "= 2^8", "= sin(30)"],
+ "icon": "calculator",
+ "description": "Quick Math - Fast mathematical expressions"
+ },
+ "app": {
+ "icon": "apps",
+ "description": "Applications - Launch installed applications"
+ },
+ "bin": {
+ "icon": "terminal",
+ "description": "Bins - Search and run executable binaries"
+ },
+ "power": {
+ "icon": "shutdown",
+ "description": "Power - System power management and session control"
+ },
+ "caffeine": {
+ "examples": ["caffeine 30m", "caffeine 1h", "caffeine 2h", "caffeine on"],
+ "icon": "coffee",
+ "description": "Caffeine - Prevent system from going idle"
+ },
+ "sc": {
+ "icon": "toolbox",
+ "description": "Screencapture - Record screen and audio"
+ },
+ "wall": {
+ "icon": "wallpapers",
+ "description": "Wallpapers - Set wallpapers, and tons of features"
+ },
+ "?": {
+ "icon": "world",
+ "description": "Quick Web Search - Fast web search with question mark",
+ "examples": ["? cats", "? google cats", "? youtube music"]
+ },
+ "kanban": {
+ "icon": "kanban",
+ "description": "Kanban - Task management with kanban-style columns"
+ },
+ "cal": {
+ "icon": "calendar",
+ "description": "Calendar - View calendar, navigate dates, and get date/time info"
+ },
+ "remind": {
+ "icon": "timer_on",
+ "description": "Reminders - Set time-based reminders with notifications"
+ },
+ "otp": {
+ "icon": "auth",
+ "description": "Manage TOTP codes and 2FA authentication"
+ },
+ "pass": {
+ "icon": "key",
+ "description": "Password Manager - Search and manage passwords"
+ },
+ "bm": {
+ "icon": "bookmark",
+ "description": "Bookmarks - Search and manage bookmarks"
+ },
+ "ps": {
+ "icon": "process",
+ "description": "Process Manager - View and manage running processes"
+ },
+ "script": {
+ "icon": "terminal",
+ "description": "Bash Scripts - Manage and execute bash scripts"
+ },
+ "bash": {
+ "icon": "terminal",
+ "description": "Bash Scripts - Manage and execute bash scripts"
+ },
+ "sh": {
+ "icon": "terminal",
+ "description": "Bash Scripts - Manage and execute bash scripts"
+ },
+ "tmux": {
+ "icon": "terminal",
+ "description": "Tmux Manager - Create, attach, rename, and kill tmux sessions"
+ }
+ },
+ "settings": {
+ "max_examples_shown": 2,
+ "default_icon": "apps",
+ "fallback_example_template": "{trigger} ",
+ "config_version": "1.0"
+ }
+}
diff --git a/config/assets/modus.json b/config/assets/modus.json
deleted file mode 100644
index 530c99d..0000000
--- a/config/assets/modus.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "commands": {
- "/wp": "launcher.open(\"wallpapers\")",
- "/em": "launcher.open(\"emoji\")",
- "/ch": "launcher.open(\"cliphist\")",
- "/kb": "launcher.open(\"kanban\")",
- "/bt": "launcher.open(\"bluetooth\")",
- "/sh": "launcher.open(\"sh\")",
- "/pw": "launcher.open(\"power\")",
- "/nw": "launcher.open(\"wifi\")",
- "/cl": "launcher.open(\"calendar\")",
- "/tl": "launcher.open(\"tools\")",
- "/tx": "launcher.open(\"tmux\")",
- "/ws": "launcher.open(\"window-switcher\")"
- },
- "pinned_apps": []
-}
\ No newline at end of file
diff --git a/config/assets/todos.json b/config/assets/todos.json
new file mode 100644
index 0000000..dd6113e
--- /dev/null
+++ b/config/assets/todos.json
@@ -0,0 +1,171 @@
+{
+ "todos": [
+ {
+ "id": 14,
+ "title": "yo",
+ "description": "",
+ "priority": "low",
+ "category": "",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:51:06.643095",
+ "completed_at": null
+ },
+ {
+ "id": 16,
+ "title": "Medium priority task",
+ "description": "",
+ "priority": "medium",
+ "category": "normal",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:52:59.579365",
+ "completed_at": null
+ },
+ {
+ "id": 17,
+ "title": "Low priority task",
+ "description": "",
+ "priority": "low",
+ "category": "later",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:52:59.583278",
+ "completed_at": null
+ },
+ {
+ "id": 18,
+ "title": "🔥 URGENT: Fix critical bug",
+ "description": "",
+ "priority": "high",
+ "category": "work",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:53:51.412081",
+ "completed_at": "2025-06-26T16:53:51.418575"
+ },
+ {
+ "id": 19,
+ "title": "📝 Review documentation",
+ "description": "",
+ "priority": "medium",
+ "category": "work",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:53:51.413594",
+ "completed_at": null
+ },
+ {
+ "id": 20,
+ "title": "🧹 Clean desk",
+ "description": "",
+ "priority": "low",
+ "category": "personal",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:53:51.415181",
+ "completed_at": null
+ },
+ {
+ "id": 21,
+ "title": "Test high priority",
+ "description": "",
+ "priority": "high",
+ "category": "test",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:55:12.629834",
+ "completed_at": "2025-06-26T17:00:48.989919"
+ },
+ {
+ "id": 22,
+ "title": "Test medium priority",
+ "description": "",
+ "priority": "medium",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:55:12.631496",
+ "completed_at": null
+ },
+ {
+ "id": 23,
+ "title": "Test low priority",
+ "description": "",
+ "priority": "low",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:55:12.632521",
+ "completed_at": null
+ },
+ {
+ "id": 24,
+ "title": "High priority task",
+ "description": "",
+ "priority": "high",
+ "category": "test",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:57:15.780304",
+ "completed_at": "2025-06-26T16:57:15.800393"
+ },
+ {
+ "id": 25,
+ "title": "Medium priority task",
+ "description": "",
+ "priority": "medium",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:15.783358",
+ "completed_at": null
+ },
+ {
+ "id": 26,
+ "title": "Low priority task",
+ "description": "",
+ "priority": "low",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:15.792488",
+ "completed_at": null
+ },
+ {
+ "id": 27,
+ "title": "🔥 High priority",
+ "description": "",
+ "priority": "high",
+ "category": "test",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:57:50.943800",
+ "completed_at": "2025-06-26T17:00:50.482300"
+ },
+ {
+ "id": 28,
+ "title": "📝 Medium priority",
+ "description": "",
+ "priority": "medium",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:50.952343",
+ "completed_at": null
+ },
+ {
+ "id": 29,
+ "title": "🧹 Low priority",
+ "description": "",
+ "priority": "low",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:50.968381",
+ "completed_at": null
+ }
+ ],
+ "next_id": 30,
+ "last_modified": "2025-06-26T17:00:50.482384"
+}
\ No newline at end of file
diff --git a/config/config.py b/config/config.py
index 44b7424..6aac044 100644
--- a/config/config.py
+++ b/config/config.py
@@ -1,1246 +1,36 @@
import os
import shutil
-import json
import sys
from pathlib import Path
-import random
-import subprocess
-import toml
-import gi
-gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk
-
-# Fabric imports
from fabric import Application
-from fabric.widgets.window import Window
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.entry import Entry
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.image import Image
-from fabric.widgets.stack import Stack
-from fabric.widgets.scale import Scale
-from fabric.utils import exec_shell_command, exec_shell_command_async
-
-# Assuming data.py exists in the same directory or is accessible via sys.path
-# If data.py is in ./config/data.py relative to this script's original location:
-try:
- # Adjust path relative to the *original* location if needed
- sys.path.insert(0, str(Path(__file__).resolve().parent / "../config"))
- from data import (
- APP_NAME,
- APP_NAME_CAP,
- WALLPAPERS_DIR_DEFAULT,
- )
-except ImportError as e:
- print(f"Error importing data constants: {e}")
- # Provide fallback defaults if import fails
- APP_NAME = "modus"
- APP_NAME_CAP = "Modus"
- CONFIG_DIR = "~/Modus"
- HOME_DIR = "~"
- WALLPAPERS_DIR_DEFAULT = "~/Pictures/wallpapers"
-
-SOURCE_STRING = f"""
-# {APP_NAME_CAP}
-source = ~/{APP_NAME_CAP}/config/hypr/{APP_NAME}.conf
-"""
-
-DEFAULTS = {
- "prefix_restart": "SHIFT ALT",
- "suffix_restart": "T",
- "prefix_bluetooth": "SHIFT ALT",
- "suffix_bluetooth": "B",
- "prefix_emoji": "SUPER",
- "suffix_emoji": "E",
- "prefix_cliphist": "SUPER",
- "suffix_cliphist": "V",
- "prefix_kanban": "SUPER",
- "suffix_kanban": "T",
- "prefix_toolbox": "SUPER",
- "suffix_toolbox": "S",
- "prefix_walls": "SUPER",
- "suffix_walls": "W",
- "prefix_launcher": "SUPER",
- "suffix_launcher": "D",
- "prefix_power": "SUPER",
- "suffix_power": "X",
- "prefix_tmux": "SUPER",
- "suffix_tmux": "Z",
- "prefix_notifications": "SUPER",
- "suffix_notifications": "N",
- "prefix_window_switcher": "ALT",
- "suffix_window_switcher": "W",
- "prefix_restart_inspector": "SUPER SHIFT",
- "suffix_restart_inspector": "T",
- "vertical": False,
- "vertical_right_align": True,
- "bottom_bar": False,
- "centered_bar": False,
- "terminal_command": "kitty -e",
- "wallpapers_dir": WALLPAPERS_DIR_DEFAULT,
- "dock_enabled": True,
- "dock_always_occluded": False, # Added default
- "dock_icon_size": 28,
- "osd_enabled": True, # Added default
- # Defaults for bar components (assuming True initially)
- "bar_button_app_visible": True,
- "bar_tray_visible": True,
- "bar_workspaces_visible": True,
- "bar_metrics_visible": True,
- "bar_language_visible": True,
- "bar_date_time_visible": True,
- "bar_updates_visible": True,
- "bar_indicators_visible": True,
- "corners_visible": True, # Added default for corners visibility
- "bar_metrics_disks": ["/"],
- # Add metric visibility defaults
- "metrics_visible": {
- "cpu": True,
- "ram": True,
- "disk": True,
- "swap": True,
- },
-}
-
-bind_vars = DEFAULTS.copy()
-def deep_update(target: dict, update: dict) -> dict:
+def _configure_sys_path_for_direct_execution():
"""
- Recursively update a nested dictionary with values from another dictionary.
+ Ajusta sys.path si este script se ejecuta directamente,
+ para asegurar que las importaciones relativas dentro del paquete 'config' funcionen.
+ Esto permite ejecutar `python config/config.py` desde cualquier directorio.
"""
- for key, value in update.items():
- if isinstance(value, dict):
- target[key] = deep_update(target.get(key, {}), value)
- else:
- target[key] = value
- return target
+ if __name__ == "__main__":
+ current_file_dir = Path(__file__).resolve().parent
+ project_root = current_file_dir.parent
+ if str(project_root) not in sys.path:
+ sys.path.insert(0, str(project_root))
-def ensure_matugen_config():
- """
- Ensure that the matugen configuration file exists and is updated
- with the expected settings.
- """
- expected_config = {
- "config": {
- "reload_apps": True,
- "wallpaper": {
- "command": "swww",
- "arguments": [
- "img",
- "-t",
- "outer",
- "--transition-duration",
- "1.5",
- "--transition-step",
- "255",
- "--transition-fps",
- "60",
- "-f",
- "Nearest",
- ],
- "set": True,
- },
- "custom_colors": {
- "red": {"color": "#FF0000", "blend": True},
- "green": {"color": "#00FF00", "blend": True},
- "yellow": {"color": "#FFFF00", "blend": True},
- "blue": {"color": "#0000FF", "blend": True},
- "magenta": {"color": "#FF00FF", "blend": True},
- "cyan": {"color": "#00FFFF", "blend": True},
- "white": {"color": "#FFFFFF", "blend": True},
- },
- },
- "templates": {
- "hyprland": {
- "input_path": f"~/{APP_NAME_CAP}/config/matugen/templates/hyprland-colors.conf",
- "output_path": f"~/{APP_NAME_CAP}/config/hypr/colors.conf",
- },
- f"{APP_NAME}": {
- "input_path": f"~/{APP_NAME_CAP}/config/matugen/templates/{APP_NAME}.css",
- "output_path": f"~/{APP_NAME_CAP}/styles/colors.css",
- "post_hook": f"fabric-cli exec {APP_NAME} 'app.set_css()' &",
- },
- },
- }
- config_path = os.path.expanduser("~/.config/matugen/config.toml")
- os.makedirs(os.path.dirname(config_path), exist_ok=True)
+_configure_sys_path_for_direct_execution()
- # Load any existing configuration
- existing_config = {}
- if os.path.exists(config_path):
- with open(config_path, "r") as f:
- existing_config = toml.load(f)
- # Backup existing configuration
- shutil.copyfile(config_path, config_path + ".bak")
- # Merge configurations
- merged_config = deep_update(existing_config, expected_config)
- with open(config_path, "w") as f:
- toml.dump(merged_config, f)
-
- # Check if colors.css exists in styles directory
- colors_css_path = os.path.expanduser(f"~/{APP_NAME_CAP}/styles/colors.css")
- if not os.path.exists(colors_css_path):
- # Get the wallpapers directory from bind_vars or use default
- wallpapers_dir = bind_vars.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT)
- wallpapers_dir = os.path.expanduser(wallpapers_dir)
-
- # Get list of image files in the directory
- image_extensions = (".png", ".jpg", ".jpeg", ".gif", ".bmp")
- image_files = [
- f
- for f in os.listdir(wallpapers_dir)
- if f.lower().endswith(image_extensions)
- ]
-
- if image_files:
- # Select a random image
- random_image = random.choice(image_files)
- image_path = os.path.join(wallpapers_dir, random_image)
- # Apply the random wallpaper using matugen
- os.system(f"matugen image {image_path}")
- else:
- # If no images found in user's directory, try the default directory
- default_dir = os.path.expanduser(f"~/{APP_NAME_CAP}/assets/wallpaper/")
- default_images = [
- f
- for f in os.listdir(default_dir)
- if f.lower().endswith(image_extensions)
- ]
-
- random_image = random.choice(default_images)
- image_path = os.path.join(default_dir, random_image)
- os.system(f"matugen image {image_path}")
-
-
-def load_bind_vars():
- """
- Load saved key binding variables from JSON, if available.
- """
- config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/assets/config.json")
- if os.path.exists(config_json):
- try:
- with open(config_json, "r") as f:
- saved_vars = json.load(f)
- # Update defaults with saved values, ensuring all keys exist
- for key in DEFAULTS:
- if key in saved_vars:
- bind_vars[key] = saved_vars[key]
- else:
- bind_vars[key] = DEFAULTS[
- key
- ] # Use default if missing in saved
- # Add any new keys from DEFAULTS not present in saved_vars
- for key in saved_vars:
- if key not in bind_vars:
- bind_vars[key] = saved_vars[
- key
- ] # Keep saved if it's not in new defaults (less likely)
- for vis_key in ["metrics_visible"]:
- if vis_key in DEFAULTS:
- if vis_key not in bind_vars or not isinstance(
- bind_vars[vis_key], dict
- ):
- bind_vars[vis_key] = DEFAULTS[vis_key].copy()
- else:
- for m in DEFAULTS[vis_key]:
- if m not in bind_vars[vis_key]:
- bind_vars[vis_key][m] = DEFAULTS[vis_key][m]
- except json.JSONDecodeError:
- print(f"Warning: Could not decode JSON from {config_json}. Using defaults.")
- bind_vars.update(DEFAULTS) # Ensure defaults on error
- except Exception as e:
- print(f"Error loading config from {config_json}: {e}. Using defaults.")
- bind_vars.update(DEFAULTS) # Ensure defaults on error
- else:
- # Ensure defaults are set if file doesn't exist
- bind_vars.update(DEFAULTS)
-
-
-def generate_hyprconf() -> str:
- """
- Generate the Hypr configuration string using the current bind_vars.
- """
- home = os.path.expanduser("~")
- return f"""# Generated by {APP_NAME_CAP} - DO NOT EDIT MANUALLY
-exec-once = python {home}/{APP_NAME_CAP}/main.py
-exec = pgrep -x "hypridle" > /dev/null || hypridle
-exec = swww-daemon
-exec-once = wl-paste --type text --watch cliphist store
-exec-once = wl-paste --type image --watch cliphist store
-
-$fabricSend = fabric-cli exec {APP_NAME}
-$scriptsDir = {home}/{APP_NAME_CAP}/config/scripts/
-
-# Key Bindings
-bind = {bind_vars["prefix_restart"]}, {bind_vars["suffix_restart"]}, exec, killall {APP_NAME}; python {home}/{APP_NAME_CAP}/main.py # Reload {APP_NAME_CAP}
-bind = {bind_vars["prefix_bluetooth"]}, {bind_vars["suffix_bluetooth"]}, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth
-bind = {bind_vars["prefix_notifications"]}, {bind_vars["suffix_notifications"]}, exec, $fabricSend 'launcher.open("notification-center")' # Notifications
-bind = {bind_vars["prefix_emoji"]}, {bind_vars["suffix_emoji"]}, exec, $fabricSend 'launcher.open("emoji")' # Emoji
-bind = {bind_vars["prefix_cliphist"]}, {bind_vars["suffix_cliphist"]}, exec, $fabricSend 'launcher.open("cliphist")' # Clipboard History
-bind = {bind_vars["prefix_kanban"]}, {bind_vars["suffix_kanban"]}, exec, $fabricSend 'launcher.open("kanban")' # Kanban
-bind = {bind_vars["prefix_tmux"]}, {bind_vars["suffix_tmux"]}, exec, $fabricSend 'launcher.open("tmux")' # Tmux Manager
-bind = {bind_vars["prefix_toolbox"]}, {bind_vars["suffix_toolbox"]}, exec, $fabricSend 'launcher.open("tools")' # Toolbox
-bind = {bind_vars["prefix_walls"]}, {bind_vars["suffix_walls"]}, exec, $fabricSend 'launcher.open("wallpapers")' # Wallpaper Selector
-bind = {bind_vars["prefix_launcher"]}, {bind_vars["suffix_launcher"]}, exec, $fabricSend 'launcher.open("launcher")' # App Launcher
-bind = {bind_vars["prefix_window_switcher"]}, {bind_vars["suffix_window_switcher"]}, exec, $fabricSend 'launcher.open("window-switcher")' # Window Switcher
-bind = {bind_vars["prefix_power"]}, {bind_vars["suffix_power"]}, exec, $fabricSend 'launcher.open("power")' # Power Menu
-bind = {bind_vars["prefix_restart_inspector"]}, {bind_vars["suffix_restart_inspector"]}, exec, killall {APP_NAME}; GTK_DEBUG=interactive python {home}/{APP_NAME_CAP}/main.py # Restart with inspector
-
-# Wallpapers directory: {bind_vars["wallpapers_dir"]}
-
-# Source color scheme
-source = {home}/{APP_NAME_CAP}/config/hypr/colors.conf
-
-general {{
- col.active_border = 0xff$primary
- col.inactive_border = 0xff$surface
- gaps_in = 2
- gaps_out = 4
- border_size = 2
- layout = dwindle
-}}
-
-decoration {{
- blur {{
- enabled = yes
- size = 5
- passes = 3
- new_optimizations = yes
- contrast = 1
- brightness = 1
- }}
- rounding = 14
- shadow {{
- enabled = true
- range = 10
- render_power = 2
- color = rgba(0, 0, 0, 0.25)
- }}
-}}
-
-animations {{
- enabled = true
- # Animation curves
- bezier = linear, 0, 0, 1, 1
- bezier = md3_standard, 0.2, 0, 0, 1
- bezier = md3_decel, 0.05, 0.7, 0.1, 1
- bezier = md3_accel, 0.3, 0, 0.8, 0.15
- bezier = overshot, 0.05, 0.9, 0.1, 1.1
- bezier = crazyshot, 0.1, 1.5, 0.76, 0.92
- bezier = hyprnostretch, 0.05, 0.9, 0.1, 1.0
- bezier = menu_decel, 0.1, 1, 0, 1
- bezier = menu_accel, 0.38, 0.04, 1, 0.07
- bezier = easeInOutCirc, 0.85, 0, 0.15, 1
- bezier = easeOutCirc, 0, 0.55, 0.45, 1
- bezier = easeOutExpo, 0.16, 1, 0.3, 1
- bezier = softAcDecel, 0.26, 0.26, 0.15, 1
- bezier = md2, 0.4, 0, 0.2, 1
-
- animation = windows, 1, 3, md3_decel, popin 60%
- animation = windowsIn, 1, 3, md3_decel, popin 60%
- animation = windowsOut, 1, 3, md3_accel, popin 60%
- animation = border, 1, 10, default
- animation = fade, 1, 3, md3_decel
- animation = layers, 1, 2, md3_decel, slide
- animation = layersIn, 1, 3, menu_decel, slide
- animation = layersOut, 1, 1.6, menu_accel, slide
- animation = fadeLayersIn, 1, 2, menu_decel
- animation = fadeLayersOut, 1, 4.5, menu_accel
- animation = workspaces, 1, 7, menu_decel, slide
- animation = specialWorkspace, 1, 3, md3_decel, slidevert
-}}
-"""
-
-
-def backup_and_replace(src: str, dest: str, config_name: str):
- """
- Backup the existing configuration file and replace it with a new one.
- """
- if os.path.exists(dest):
- backup_path = dest + ".bak"
- shutil.copy(dest, backup_path)
- print(f"{config_name} config backed up to {backup_path}")
- shutil.copy(src, dest)
- print(f"{config_name} config replaced from {src}")
-
-
-class HyprConfGUI(Window):
- def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs):
- super().__init__(
- title="Modus Settings",
- name="modus-settings-widnow",
- size=(650, 550),
- **kwargs,
- )
- self.set_resizable(False)
-
- self.show_lock_checkbox = show_lock_checkbox
- self.show_idle_checkbox = show_idle_checkbox
-
- # Overall vertical box to hold the main content and bottom buttons
- root_box = Box(orientation="v", spacing=10, style="margin: 10px;")
- self.add(root_box)
-
- # Main horizontal box for switcher and stack
- main_content_box = Box(orientation="h", spacing=6, v_expand=True, h_expand=True)
- root_box.add(main_content_box)
-
- # --- Tab Control ---
- self.tab_stack = Stack(
- transition_type="slide-up-down", # Change transition for vertical feel
- transition_duration=250,
- v_expand=True,
- h_expand=True,
- )
-
- # Create tabs and add to stack
- self.key_bindings_tab_content = self.create_key_bindings_tab()
- self.appearance_tab_content = self.create_appearance_tab()
- self.system_tab_content = self.create_system_tab()
- self.about_tab_content = self.create_about_tab()
-
- self.tab_stack.add_titled(
- self.key_bindings_tab_content, "key_bindings", "Key Bindings"
- )
- self.tab_stack.add_titled(
- self.appearance_tab_content, "appearance", "Appearance"
- )
- self.tab_stack.add_titled(self.system_tab_content, "system", "System")
- self.tab_stack.add_titled(
- self.about_tab_content, "about", "About"
- ) # Add About tab to stack
-
- # Use Gtk.StackSwitcher vertically on the left
- tab_switcher = Gtk.StackSwitcher()
- tab_switcher.set_stack(self.tab_stack)
- tab_switcher.set_orientation(
- Gtk.Orientation.VERTICAL
- ) # Set vertical orientation
- # Optional: Adjust alignment if needed
- # tab_switcher.set_valign(Gtk.Align.START)
-
- # Add switcher to the left of the main content box
- main_content_box.add(tab_switcher)
-
- # Add stack to the right of the main content box
- main_content_box.add(self.tab_stack)
-
- # --- Bottom Buttons ---
- button_box = Box(orientation="h", spacing=10, h_align="end")
-
- reset_btn = Button(label="Reset to Defaults", on_clicked=self.on_reset)
- button_box.add(reset_btn)
-
- # Add Close button back
- close_btn = Button(label="Close", on_clicked=self.on_close)
- button_box.add(close_btn)
-
- accept_btn = Button(label="Apply & Reload", on_clicked=self.on_accept)
- button_box.add(accept_btn)
-
- # Add button box to the bottom of the root box
- root_box.add(button_box)
-
- def create_key_bindings_tab(self):
- """Create tab for key bindings configuration using Fabric widgets and Gtk.Grid."""
- scrolled_window = ScrolledWindow(
- h_scrollbar_policy="never",
- v_scrollbar_policy="automatic",
- h_expand=True,
- v_expand=True,
- )
- # Remove fixed height constraints to allow stack to fill space
- scrolled_window.set_min_content_height(300)
- scrolled_window.set_max_content_height(300)
-
- # Main container with padding
- main_vbox = Box(orientation="v", spacing=10, style="margin: 15px;")
- scrolled_window.add(main_vbox)
-
- # Create a grid for key bindings
- keybind_grid = Gtk.Grid()
- keybind_grid.set_column_spacing(10)
- keybind_grid.set_row_spacing(8)
- keybind_grid.set_margin_start(5)
- keybind_grid.set_margin_end(5)
- keybind_grid.set_margin_top(5)
- keybind_grid.set_margin_bottom(5)
-
- # Header Row
- action_label = Label(
- markup="Action", h_align="start", style="margin-bottom: 5px;"
- )
- modifier_label = Label(
- markup="Modifier", h_align="start", style="margin-bottom: 5px;"
- )
- separator_label = Label(
- label="+", h_align="center", style="margin-bottom: 5px;"
- )
- key_label = Label(
- markup="Key", h_align="start", style="margin-bottom: 5px;"
- )
-
- keybind_grid.attach(action_label, 0, 0, 1, 1)
- keybind_grid.attach(modifier_label, 1, 0, 1, 1)
- keybind_grid.attach(separator_label, 2, 0, 1, 1)
- keybind_grid.attach(key_label, 3, 0, 1, 1)
-
- self.entries = []
- bindings = [
- (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"),
- ("Bluetooth", "prefix_bluetooth", "suffix_bluetooth"),
- ("Notifications", "prefix_notifications", "suffix_notifications"),
- ("Kanban", "prefix_kanban", "suffix_kanban"),
- ("App Launcher", "prefix_launcher", "suffix_launcher"),
- ("Tmux", "prefix_tmux", "suffix_tmux"),
- ("Toolbox", "prefix_toolbox", "suffix_toolbox"),
- ("Wallpapers", "prefix_walls", "suffix_walls"),
- ("Emoji Picker", "prefix_emoji", "suffix_emoji"),
- ("Clipboard History", "prefix_cliphist", "suffix_cliphist"),
- ("Window Switcher", "prefix_window_switcher", "suffix_window_switcher"),
- ("Power Menu", "prefix_power", "suffix_power"),
- (
- "Restart with inspector",
- "prefix_restart_inspector",
- "suffix_restart_inspector",
- ),
- ]
-
- # Populate the grid with entries
- for i, (label_text, prefix_key, suffix_key) in enumerate(bindings):
- row = i + 1 # Start at row 1 after headers
-
- # Action label
- binding_label = Label(label=label_text, h_align="start")
- keybind_grid.attach(binding_label, 0, row, 1, 1)
-
- # Prefix entry
- prefix_entry = Entry(text=bind_vars[prefix_key])
- keybind_grid.attach(prefix_entry, 1, row, 1, 1)
-
- # Plus separator
- plus_label = Label(label="+", h_align="center")
- keybind_grid.attach(plus_label, 2, row, 1, 1)
-
- # Suffix entry
- suffix_entry = Entry(text=bind_vars[suffix_key])
- keybind_grid.attach(suffix_entry, 3, row, 1, 1)
-
- self.entries.append((prefix_key, suffix_key, prefix_entry, suffix_entry))
-
- main_vbox.add(keybind_grid)
- return scrolled_window
-
- def create_appearance_tab(self):
- """Create tab for appearance settings using Fabric widgets and Gtk.Grid."""
- scrolled_window = ScrolledWindow(
- h_scrollbar_policy="never",
- v_scrollbar_policy="automatic",
- h_expand=True,
- v_expand=True,
- )
- scrolled_window.set_min_content_height(300)
- scrolled_window.set_max_content_height(300)
-
- # Main container with padding
- vbox = Box(orientation="v", spacing=15, style="margin: 15px;")
- scrolled_window.add(vbox)
-
- # === WALLPAPERS SECTION ===
- wall_header = Label(markup="Wallpapers", h_align="start")
- vbox.add(wall_header)
-
- wall_box = Box(orientation="h", spacing=10, style="margin-left: 10px;")
- vbox.add(wall_box)
-
- wall_label = Label(label="Directory:", h_align="start", v_align="center")
- wall_box.add(wall_label)
-
- self.wall_dir_chooser = Gtk.FileChooserButton(
- title="Select a folder", action=Gtk.FileChooserAction.SELECT_FOLDER
- )
- self.wall_dir_chooser.set_tooltip_text(
- "Select the directory containing your wallpaper images"
- )
- self.wall_dir_chooser.set_filename(bind_vars["wallpapers_dir"])
- self.wall_dir_chooser.set_size_request(180, -1)
- wall_box.add(self.wall_dir_chooser)
-
- # === LAYOUT OPTIONS SECTION ===
- layout_header = Label(markup="Layout Options", h_align="start")
- vbox.add(layout_header)
-
- # Create a grid for layout options
- layout_grid = Gtk.Grid()
- layout_grid.set_column_spacing(20)
- layout_grid.set_row_spacing(15)
- layout_grid.set_margin_start(10)
- layout_grid.set_margin_top(5)
- layout_grid.set_margin_bottom(10)
- vbox.add(layout_grid)
-
- # Column 1 (Left)
- # Vertical Layout
- vertical_label = Label(
- label="Vertical Layout", h_align="start", v_align="center"
- )
- layout_grid.attach(vertical_label, 0, 0, 1, 1)
-
- vertical_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- vertical_switch_container.set_halign(Gtk.Align.START)
- vertical_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.vertical_switch = Gtk.Switch()
- self.vertical_switch.set_active(bind_vars.get("vertical", False))
- self.vertical_switch.connect("notify::active", self.on_vertical_changed)
- vertical_switch_container.add(self.vertical_switch)
-
- layout_grid.attach(vertical_switch_container, 1, 0, 1, 1)
-
- # Vertical Right Align (new)
- vertical_right_label = Label(
- label="Right Side (Vertical)", h_align="start", v_align="center"
- )
- layout_grid.attach(vertical_right_label, 0, 1, 1, 1)
-
- vertical_right_switch_container = Gtk.Box(
- orientation=Gtk.Orientation.HORIZONTAL
- )
- vertical_right_switch_container.set_halign(Gtk.Align.START)
- vertical_right_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.vertical_right_switch = Gtk.Switch()
- self.vertical_right_switch.set_active(
- bind_vars.get("vertical_right_align", True)
- )
- self.vertical_right_switch.set_sensitive(self.vertical_switch.get_active())
- vertical_right_switch_container.add(self.vertical_right_switch)
- layout_grid.attach(vertical_right_switch_container, 1, 1, 1, 1)
-
- # Bottom Layout
- bottom_bar_label = Label(
- label="Bottom Layout", h_align="start", v_align="center"
- )
- layout_grid.attach(bottom_bar_label, 0, 2, 1, 1)
-
- bottom_bar_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- bottom_bar_switch_container.set_halign(Gtk.Align.START)
- bottom_bar_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.bottom_bar_switch = Gtk.Switch()
- self.bottom_bar_switch.set_active(bind_vars.get("bottom_bar", False))
- self.bottom_bar_switch.connect("notify::active", self.on_bottom_bar_changed)
- bottom_bar_switch_container.add(self.bottom_bar_switch)
- layout_grid.attach(bottom_bar_switch_container, 1, 2, 1, 1)
-
- # Dock Options
- dock_label = Label(label="Show Dock", h_align="start", v_align="center")
- layout_grid.attach(dock_label, 0, 3, 1, 1)
-
- dock_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- dock_switch_container.set_halign(Gtk.Align.START)
- dock_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.dock_switch = Gtk.Switch()
- self.dock_switch.set_active(bind_vars.get("dock_enabled", True))
- self.dock_switch.connect("notify::active", self.on_dock_enabled_changed)
- dock_switch_container.add(self.dock_switch)
- layout_grid.attach(dock_switch_container, 1, 3, 1, 1)
-
- # Column 2 (Right)
- # Centered Bar
- centered_label = Label(
- label="Centered Bar (Vertical Only)", h_align="start", v_align="center"
- )
- layout_grid.attach(centered_label, 2, 0, 1, 1)
-
- centered_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- centered_switch_container.set_halign(Gtk.Align.START)
- centered_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.centered_switch = Gtk.Switch()
- self.centered_switch.set_active(bind_vars.get("centered_bar", False))
- self.centered_switch.set_sensitive(self.vertical_switch.get_active())
- centered_switch_container.add(self.centered_switch)
- layout_grid.attach(centered_switch_container, 3, 0, 1, 1)
-
- # Dock Hover
- dock_hover_label = Label(
- label="Show Dock Only on Hover", h_align="start", v_align="center"
- )
- layout_grid.attach(dock_hover_label, 2, 1, 1, 1)
-
- dock_hover_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- dock_hover_switch_container.set_halign(Gtk.Align.START)
- dock_hover_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.dock_hover_switch = Gtk.Switch()
- self.dock_hover_switch.set_active(bind_vars.get("dock_always_occluded", False))
- self.dock_hover_switch.set_sensitive(self.dock_switch.get_active())
- dock_hover_switch_container.add(self.dock_hover_switch)
- layout_grid.attach(dock_hover_switch_container, 3, 1, 1, 1)
-
- # Show OSD
- osd_label = Label(label="Show OSD", h_align="start", v_align="center")
- layout_grid.attach(osd_label, 2, 2, 1, 1)
-
- osd_hover_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- osd_hover_switch_container.set_halign(Gtk.Align.START)
- osd_hover_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.osd_switch = Gtk.Switch()
- self.osd_switch.set_active(bind_vars.get("osd_enabled", True))
- osd_hover_switch_container.add(self.osd_switch)
- layout_grid.attach(osd_hover_switch_container, 3, 2, 1, 1)
-
- # Dock Icon Size (Full width)
- dock_size_label = Label(
- label="Dock Icon Size", h_align="start", v_align="center"
- )
- layout_grid.attach(dock_size_label, 0, 4, 1, 1)
-
- self.dock_size_scale = Scale(
- min_value=16,
- max_value=48,
- value=bind_vars.get("dock_icon_size", 28),
- increments=(2, 4),
- draw_value=True,
- value_position="right",
- digits=0,
- h_expand=True,
- )
- layout_grid.attach(self.dock_size_scale, 1, 4, 3, 1)
-
- # === BAR COMPONENTS SECTION ===
- components_header = Label(markup="Bar Components", h_align="start")
- vbox.add(components_header)
-
- # Create a grid for bar components
- components_grid = Gtk.Grid()
- components_grid.set_column_spacing(15)
- components_grid.set_row_spacing(8)
- components_grid.set_margin_start(10)
- components_grid.set_margin_top(5)
- vbox.add(components_grid)
-
- self.component_switches = {}
- component_display_names = {
- "button_app": "App Launcher Button",
- "tray": "System Tray",
- "workspaces": "Workspaces",
- "metrics": "System Metrics",
- "language": "Language Indicator",
- "date_time": "Date & Time",
- "updates": "Update Indicator",
- "indicators": "System Indicators",
- }
-
- # Add corners visibility switch
- self.corners_switch = Gtk.Switch()
- self.corners_switch.set_active(bind_vars.get("corners_visible", True))
-
- # Calculate number of rows needed (we'll use 2 columns)
- num_components = len(component_display_names) + 1 # +1 for corners
- rows_per_column = (num_components + 1) // 2 # Ceiling division
-
- # First add corners to the top of first column
- corners_label = Label(
- label="Rounded Corners", h_align="start", v_align="center"
- )
- components_grid.attach(corners_label, 0, 0, 1, 1)
-
- switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- switch_container.set_halign(Gtk.Align.START)
- switch_container.set_valign(Gtk.Align.CENTER)
- switch_container.add(self.corners_switch)
- components_grid.attach(switch_container, 1, 0, 1, 1)
-
- # Add components to grid in two columns
- for i, (component_name, display_name) in enumerate(
- component_display_names.items()
- ):
- # Determine position: first half in column 0, second half in column 2
- # Start at row 1 to account for corners at row 0
- row = (i + 1) % rows_per_column # +1 to start after corners
- col = 0 if i < (rows_per_column - 1) else 2 # Adjust column calculation
-
- component_label = Label(
- label=display_name, h_align="start", v_align="center"
- )
- components_grid.attach(component_label, col, row, 1, 1)
-
- # Container for switch to prevent stretching
- switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- switch_container.set_halign(Gtk.Align.START)
- switch_container.set_valign(Gtk.Align.CENTER)
-
- component_switch = Gtk.Switch()
- config_key = f"bar_{component_name}_visible"
- component_switch.set_active(bind_vars.get(config_key, True))
- switch_container.add(component_switch)
-
- components_grid.attach(switch_container, col + 1, row, 1, 1)
-
- self.component_switches[component_name] = component_switch
-
- return scrolled_window
-
- def create_system_tab(self):
- """Create tab for system configurations using Fabric widgets and Gtk.Grid."""
- scrolled_window = ScrolledWindow(
- h_scrollbar_policy="never",
- v_scrollbar_policy="automatic",
- h_expand=True,
- v_expand=True,
- )
- # Remove fixed height constraints
- scrolled_window.set_min_content_height(300)
- scrolled_window.set_max_content_height(300)
-
- # Main container with padding
- vbox = Box(orientation="v", spacing=15, style="margin: 15px;")
- scrolled_window.add(vbox)
-
- # Create a grid for system settings
- system_grid = Gtk.Grid()
- system_grid.set_column_spacing(20)
- system_grid.set_row_spacing(10)
- system_grid.set_margin_bottom(15)
- vbox.add(system_grid)
-
- # === TERMINAL SETTINGS ===
- terminal_header = Label(markup="Terminal Settings", h_align="start")
- system_grid.attach(terminal_header, 0, 0, 2, 1)
-
- terminal_label = Label(label="Command:", h_align="start", v_align="center")
- system_grid.attach(terminal_label, 0, 1, 1, 1)
-
- self.terminal_entry = Entry(
- text=bind_vars["terminal_command"],
- tooltip_text="Command used to launch terminal apps (e.g., 'kitty -e')",
- h_expand=True,
- )
- system_grid.attach(self.terminal_entry, 1, 1, 1, 1)
-
- hint_label = Label(
- markup="Examples: 'kitty -e', 'alacritty -e', 'foot -e'",
- h_align="start",
- )
- system_grid.attach(hint_label, 0, 2, 2, 1)
-
- # === HYPRLAND INTEGRATION ===
- hypr_header = Label(markup="Hyprland Integration", h_align="start")
- system_grid.attach(hypr_header, 2, 0, 2, 1)
-
- row = 1
-
- # Hyprland locks and idle settings
- self.lock_switch = None
- if self.show_lock_checkbox:
- lock_label = Label(
- label="Replace Hyprlock config", h_align="start", v_align="center"
- )
- system_grid.attach(lock_label, 2, row, 1, 1)
-
- # Container for switch to prevent stretching
- lock_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- lock_switch_container.set_halign(Gtk.Align.START)
- lock_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.lock_switch = Gtk.Switch()
- self.lock_switch.set_tooltip_text(
- "Replace Hyprlock configuration with Ax-Shell's custom config"
- )
- lock_switch_container.add(self.lock_switch)
-
- system_grid.attach(lock_switch_container, 3, row, 1, 1)
- row += 1
-
- self.idle_switch = None
- if self.show_idle_checkbox:
- idle_label = Label(
- label="Replace Hypridle config", h_align="start", v_align="center"
- )
- system_grid.attach(idle_label, 2, row, 1, 1)
-
- # Container for switch to prevent stretching
- idle_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- idle_switch_container.set_halign(Gtk.Align.START)
- idle_switch_container.set_valign(Gtk.Align.CENTER)
-
- self.idle_switch = Gtk.Switch()
- self.idle_switch.set_tooltip_text(
- "Replace Hypridle configuration with Ax-Shell's custom config"
- )
- idle_switch_container.add(self.idle_switch)
-
- system_grid.attach(idle_switch_container, 3, row, 1, 1)
- row += 1
-
- if self.show_lock_checkbox or self.show_idle_checkbox:
- note_label = Label(
- markup="Existing configs will be backed up",
- h_align="start",
- )
- system_grid.attach(note_label, 2, row, 2, 1)
-
- # --- System Metrics Options (moved from appearance tab) ---
- metrics_header = Label(markup="System Metrics Options", h_align="start")
- vbox.add(metrics_header)
-
- # Metrics visibility toggles
- metrics_grid = Gtk.Grid()
- metrics_grid.set_column_spacing(15)
- metrics_grid.set_row_spacing(8)
- metrics_grid.set_margin_start(10)
- metrics_grid.set_margin_top(5)
- vbox.add(metrics_grid)
-
- self.metrics_switches = {}
-
- metric_names = {
- "cpu": "CPU",
- "ram": "RAM",
- "disk": "Disk",
- "swap": "Swap",
- }
-
- # metrics toggles
- metrics_grid.attach(Label(label="Show in Metrics", h_align="start"), 2, 0, 1, 1)
- for i, (key, label) in enumerate(metric_names.items()):
- switch = Gtk.Switch()
- switch.set_active(bind_vars.get("metrics_visible", {}).get(key, True))
- self.metrics_switches[key] = switch
- metrics_grid.attach(Label(label=label, h_align="start"), 2, i + 1, 1, 1)
- metrics_grid.attach(switch, 4, i + 1, 1, 1)
-
- # Enforce minimum 4 enabled metrics
- def enforce_minimum_metrics(switch_dict):
- enabled = [k for k, s in switch_dict.items() if s.get_active()]
- for k, s in switch_dict.items():
- if len(enabled) <= 4 and s.get_active():
- s.set_sensitive(False)
- else:
- s.set_sensitive(True)
-
- def on_metric_toggle(switch, gparam, which):
- enforce_minimum_metrics(self.metrics_switches)
-
- for k, s in self.metrics_switches.items():
- s.connect("notify::active", on_metric_toggle, k)
- enforce_minimum_metrics(self.metrics_switches)
-
- # Disk directories
- disks_label = Label(label="Disk directories", h_align="start", v_align="center")
- vbox.add(disks_label)
-
- self.disk_entries = Box(orientation="v", spacing=8, h_align="start")
-
- def create_disk_edit(path):
- bar = Box(orientation="h", spacing=10, h_align="start")
- entry = Entry(text=path, h_expand=True)
- bar.add(entry)
- x = Button(label="X", on_clicked=lambda _: self.disk_entries.remove(bar))
- bar.add(x)
- self.disk_entries.add(bar)
-
- vbox.add(self.disk_entries)
- for path in bind_vars.get("bar_metrics_disks"):
- create_disk_edit(path)
- add_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- add_container.set_halign(Gtk.Align.START)
- add_container.set_valign(Gtk.Align.CENTER)
- add = Button(label="Add new disk", on_clicked=lambda _: create_disk_edit("/"))
- add_container.add(add)
- vbox.add(add_container)
-
- return scrolled_window
-
- def create_about_tab(self):
- """Create an About tab with project info, repo link, and Ko-Fi button."""
- vbox = Box(orientation="v", spacing=18, style="margin: 30px;")
- # Project title
- vbox.add(
- Label(
- markup=f"{APP_NAME_CAP}",
- h_align="start",
- style="font-size: 1.5em; margin-bottom: 8px;",
- )
- )
- # Description
- vbox.add(
- Label(
- label="A hackable shell for Hyprland, powered by Fabric.",
- h_align="start",
- style="margin-bottom: 12px;",
- )
- )
- # Repo link
- repo_box = Box(orientation="h", spacing=6, h_align="start")
- repo_label = Label(label="GitHub:", h_align="start")
- repo_link = Label()
- repo_link.set_markup(
- f'https://github.com/S4NKALP/Modus'
- )
- repo_box.add(repo_label)
- repo_box.add(repo_link)
- vbox.add(repo_box)
-
- # Ko-Fi button
- def on_kofi_clicked(_):
- import webbrowser
-
- webbrowser.open("https://ko-fi.com/S4NKALP")
-
- kofi_btn = Button(
- label="Support on Ko-Fi ❤️",
- on_clicked=on_kofi_clicked,
- tooltip_text="Support S4NKALP on Ko-Fi",
- )
- kofi_btn.set_style("margin-top: 18px; min-width: 160px;")
- vbox.add(kofi_btn)
- # Spacer
- vbox.add(Box(v_expand=True))
- return vbox
-
- def on_vertical_changed(self, switch, gparam):
- """Callback for vertical switch."""
- is_active = switch.get_active()
- self.centered_switch.set_sensitive(is_active)
- self.vertical_right_switch.set_sensitive(is_active)
-
- if is_active:
- # Disable bottom bar when vertical layout is enabled
- bind_vars["bottom_bar"] = False
- self.bottom_bar_switch.set_active(False)
-
- def on_dock_enabled_changed(self, switch, gparam):
- """Callback for dock enabled switch."""
- is_active = switch.get_active()
- self.dock_hover_switch.set_sensitive(is_active)
- if not is_active:
- self.dock_hover_switch.set_active(False)
-
- def on_bottom_bar_changed(self, switch, gparam):
- """Handle bottom bar switch changes"""
- bind_vars["bottom_bar"] = switch.get_active()
- if switch.get_active():
- # Disable vertical layout
- bind_vars["vertical"] = False
- self.vertical_switch.set_active(False)
-
- # Only disable centered bar without turning it off
- self.centered_switch.set_sensitive(False)
-
- # Disable right side option and reset it to False
- self.vertical_right_switch.set_sensitive(False)
- self.vertical_right_switch.set_active(False)
- bind_vars["vertical_right_align"] = False
-
- def on_accept(self, widget):
- """
- Save the configuration and update the necessary files without closing the window.
- """
- for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries:
- bind_vars[prefix_key] = prefix_entry.get_text()
- bind_vars[suffix_key] = suffix_entry.get_text()
-
- bind_vars["wallpapers_dir"] = self.wall_dir_chooser.get_filename()
- bind_vars["vertical"] = self.vertical_switch.get_active()
- bind_vars["vertical_right_align"] = self.vertical_right_switch.get_active()
- bind_vars["bottom_bar"] = self.bottom_bar_switch.get_active()
- bind_vars["enabled_osd"] = self.osd_switch.get_active()
- bind_vars["centered_bar"] = self.centered_switch.get_active()
- bind_vars["dock_enabled"] = self.dock_switch.get_active()
- bind_vars["dock_always_occluded"] = self.dock_hover_switch.get_active()
- bind_vars["dock_icon_size"] = int(self.dock_size_scale.get_value())
- bind_vars["terminal_command"] = self.terminal_entry.get_text()
- bind_vars["corners_visible"] = self.corners_switch.get_active()
-
- for component_name, switch in self.component_switches.items():
- config_key = f"bar_{component_name}_visible"
- bind_vars[config_key] = switch.get_active()
-
- # Save metrics visibility
- bind_vars["metrics_visible"] = {
- k: s.get_active() for k, s in self.metrics_switches.items()
- }
-
- bind_vars["bar_metrics_disks"] = []
- for entry in self.disk_entries.children:
- bind_vars["bar_metrics_disks"].append(entry.children[0].get_text())
-
- config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/assets/config.json")
- os.makedirs(os.path.dirname(config_json), exist_ok=True)
- try:
- with open(config_json, "w") as f:
- json.dump(bind_vars, f, indent=4)
- except Exception as e:
- print(f"Error saving config.json: {e}")
-
- if self.lock_switch and self.lock_switch.get_active():
- src_lock = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hyprlock.conf")
- dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf")
- if os.path.exists(src_lock):
- backup_and_replace(src_lock, dest_lock, "Hyprlock")
- else:
- print(f"Warning: Source hyprlock config not found at {src_lock}")
-
- if self.idle_switch and self.idle_switch.get_active():
- src_idle = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hypridle.conf")
- dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf")
- if os.path.exists(src_idle):
- backup_and_replace(src_idle, dest_idle, "Hypridle")
- else:
- print(f"Warning: Source hypridle config not found at {src_idle}")
-
- hyprland_config_path = os.path.expanduser("~/.config/hypr/hyprland.conf")
- try:
- needs_append = True
- if os.path.exists(hyprland_config_path):
- with open(hyprland_config_path, "r") as f:
- content = f.read()
- if SOURCE_STRING.strip() in content:
- needs_append = False
- else:
- os.makedirs(os.path.dirname(hyprland_config_path), exist_ok=True)
-
- if needs_append:
- with open(hyprland_config_path, "a") as f:
- f.write("\n" + SOURCE_STRING)
- print(f"Appended source string to {hyprland_config_path}")
-
- except Exception as e:
- print(f"Error updating {hyprland_config_path}: {e}")
-
- start_config()
-
- # Restart Modus using fabric's async command executor
- main_script_path = os.path.expanduser(f"~/{APP_NAME_CAP}/main.py")
- kill_cmd = f"killall {APP_NAME}"
- start_cmd = f"python {main_script_path}"
-
- try:
- # Use fabric's helper to run the command asynchronously
- exec_shell_command(kill_cmd)
- exec_shell_command_async(start_cmd)
- print(f"{APP_NAME_CAP} restart initiated.")
- except Exception as e:
- print(f"Error restarting {APP_NAME_CAP}: {e}")
-
- def on_reset(self, widget):
- """
- Reset all settings to default values. Uses Gtk.MessageDialog.
- """
- dialog = Gtk.MessageDialog(
- transient_for=self.get_toplevel(),
- flags=0,
- message_type=Gtk.MessageType.QUESTION,
- buttons=Gtk.ButtonsType.YES_NO,
- text="Reset all settings to defaults?",
- )
- dialog.format_secondary_text(
- "This will reset all keybindings and appearance settings to their default values."
- )
- response = dialog.run()
- dialog.destroy()
-
- if response == Gtk.ResponseType.YES:
- global bind_vars
- bind_vars = DEFAULTS.copy()
-
- for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries:
- prefix_entry.set_text(bind_vars[prefix_key])
- suffix_entry.set_text(bind_vars[suffix_key])
-
- self.wall_dir_chooser.set_filename(bind_vars["wallpapers_dir"])
- self.vertical_switch.set_active(bind_vars.get("vertical", False))
- self.vertical_right_switch.set_active(
- bind_vars.get("vertical_right_align", True)
- )
- self.centered_switch.set_active(bind_vars.get("centered_bar", False))
- self.centered_switch.set_sensitive(self.vertical_switch.get_active())
- self.vertical_right_switch.set_sensitive(self.vertical_switch.get_active())
- self.dock_switch.set_active(bind_vars.get("dock_enabled", True))
- self.dock_hover_switch.set_active(
- bind_vars.get("dock_always_occluded", False)
- )
- self.dock_hover_switch.set_sensitive(self.dock_switch.get_active())
- self.dock_size_scale.value = bind_vars.get("dock_icon_size", 28)
- self.terminal_entry.set_text(bind_vars["terminal_command"])
- self.osd_switch.set_active(bind_vars.get("osd_enabled", True))
- self.bottom_bar_switch.set_active(bind_vars.get("bottom_bar", False))
-
- for component_name, switch in self.component_switches.items():
- config_key = f"bar_{component_name}_visible"
- switch.set_active(bind_vars.get(config_key, True))
-
- self.corners_switch.set_active(bind_vars.get("corners_visible", True))
-
- # Reset metrics visibility
- for k, s in self.metrics_switches.items():
- s.set_active(DEFAULTS["metrics_visible"][k])
-
- # Reset disk entries
- if True:
- for i in self.disk_entries.children:
- self.disk_entries.remove(i)
- bar = Box(orientation="h", spacing=10, h_align="start")
- entry = Entry(text="/", h_expand=True)
- bar.add(entry)
- x = Button(
- label="X", on_clicked=lambda _: self.disk_entries.remove(bar)
- )
- bar.add(x)
- self.disk_entries.add(bar)
-
- if self.lock_switch:
- self.lock_switch.set_active(False)
- if self.idle_switch:
- self.idle_switch.set_active(False)
-
- print("Settings reset to defaults.")
-
- def on_close(self, widget):
- """Close the settings window."""
- if self.application:
- self.application.quit()
- else:
- self.destroy()
-
-
-def start_config():
- """
- Run final configuration steps: ensure necessary configs, write the hyprconf, and reload.
- """
- ensure_matugen_config()
-
- hypr_config_dir = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/")
- os.makedirs(hypr_config_dir, exist_ok=True)
- hypr_conf_path = os.path.join(hypr_config_dir, f"{APP_NAME}.conf")
- try:
- with open(hypr_conf_path, "w") as f:
- f.write(generate_hyprconf())
- print(f"Generated Hyprland config at {hypr_conf_path}")
- except Exception as e:
- print(f"Error writing Hyprland config: {e}")
-
- try:
- subprocess.run(["hyprctl", "reload"], check=True, capture_output=True)
- print("Hyprland configuration reloaded.")
- except subprocess.CalledProcessError as e:
- print(f"Error reloading Hyprland: {e}\nstderr: {e.stderr.decode()}")
- except FileNotFoundError:
- print("Error: hyprctl command not found. Cannot reload Hyprland.")
- except Exception as e:
- print(f"An unexpected error occurred during hyprctl reload: {e}")
+if __name__ == "__main__" and (__package__ is None or __package__ == ""):
+ from config.data import APP_NAME, APP_NAME_CAP
+ from config.settings.gui import HyprConfGUI
+ from config.settings.utils import load_bind_vars
+else:
+ from .data import APP_NAME, APP_NAME_CAP
+ from .settings.gui import HyprConfGUI
+ from .settings.utils import load_bind_vars
def open_config():
@@ -1249,9 +39,9 @@ def open_config():
"""
load_bind_vars()
- dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf")
- src_lock = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hyprlock.conf")
show_lock_checkbox = True
+ dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf")
+ src_lock = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf")
if not os.path.exists(dest_lock) and os.path.exists(src_lock):
try:
os.makedirs(os.path.dirname(dest_lock), exist_ok=True)
@@ -1262,9 +52,9 @@ def open_config():
print(f"Error copying default hyprlock config: {e}")
show_lock_checkbox = os.path.exists(src_lock)
- dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf")
- src_idle = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hypridle.conf")
show_idle_checkbox = True
+ dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf")
+ src_idle = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf")
if not os.path.exists(dest_idle) and os.path.exists(src_idle):
try:
os.makedirs(os.path.dirname(dest_idle), exist_ok=True)
diff --git a/config/data.py b/config/data.py
index f3b3271..4e82928 100644
--- a/config/data.py
+++ b/config/data.py
@@ -1,120 +1,132 @@
-import os
import json
-import gi
+import os
-gi.require_version("Gtk", "3.0")
+import gi
+from fabric.utils.helpers import get_relative_path
from gi.repository import Gdk, GLib
-from fabric.utils import get_relative_path
+gi.require_version("Gtk", "3.0")
APP_NAME = "modus"
APP_NAME_CAP = "Modus"
-CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
-STATE_DIR = str(GLib.get_user_state_dir() + f"/{APP_NAME}")
+ALLOWED_PLAYERS = ["vlc", "cmus", "firefox", "spotify", "chromium", "vivaldi", "brave"]
+NOTIF_POS_KEY = "notif_pos"
+NOTIF_POS_DEFAULT = "Top"
+
+CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
USERNAME = os.getlogin()
HOSTNAME = os.uname().nodename
HOME_DIR = os.path.expanduser("~")
-CONFIG_DIR = os.path.expanduser(f"~/{APP_NAME}")
+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/wallpaper")
-MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
+
+WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example")
CONFIG_FILE = get_relative_path("../config/assets/config.json")
+MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
+
+WORKSPACE_NUMS = False
+WORKSPACE_USE_CHINESE_NUMERAL = False
+WORKSPACE_DOTS = False
+
+DOCK_THEME = "Pills"
+
+
+def load_config():
+ """Load the configuration from config.json"""
+ 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 = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT)
- VERTICAL = config.get("vertical", False) # Use saved value or False as default
- VERTICAL_RIGHT_ALIGN = config.get("vertical_right_align", False) # Right side by default
- BOTTOM_BAR = config.get("bottom_bar", False) # Use saved value or False as default
- CENTERED_BAR = config.get("centered_bar", False) # Load centered bar setting
- TERMINAL_COMMAND = config.get(
- "terminal_command", "kitty -e"
- ) # Load terminal command
- DOCK_ENABLED = config.get("dock_enabled", True) # Load dock visibility setting
- DOCK_ALWAYS_OCCLUDED = config.get(
- "dock_always_occluded", False
- ) # Load dock hover-only setting
- DOCK_ICON_SIZE = config.get("dock_icon_size", 28) # Load dock icon size setting
- OSD_ENABLED = config.get("osd_enabled", True) # Load OSD visibility setting
+ DOCK_POSITION = config.get("dock_position", "Bottom")
+ VERTICAL = DOCK_POSITION in ["Left", "Right"]
+ TERMINAL_COMMAND = config.get("terminal_command", "kitty -e")
+ 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_THEME = config.get("dock_theme", "Pills")
+ DOCK_ICON_SIZE = config.get("dock_icon_size", 20)
+ WORKSPACE_NUMS = config.get("workspace_nums", False)
+ WORKSPACE_USE_CHINESE_NUMERAL = config.get("workspace_use_chinese_numerals", False)
+ WORKSPACE_DOTS = config.get("workspace_dots", False)
+ WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 13)
+ DOCK_HIDE_SPECIAL_WORKSPACE = config.get("dock_hide_special_workspace", True)
+ DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(
+ "dock_hide_special_workspace_apps", True
+ )
- # Load bar component visibility settings
- BAR_COMPONENTS_VISIBILITY = {
- "button_app": config.get("bar_button_app_visible", True),
- "tray": config.get("bar_tray_visible", True),
- # "button_tools": config.get("bar_button_tools_visible", True),
- "workspaces": config.get("bar_workspaes_visible", True),
- "metrics": config.get("bar_metrics_visible", True),
- "language": config.get("bar_language_visible", True),
- "date_time": config.get("bar_date_time_visible", True),
- "updates": config.get("bar_updates_visible", True),
- "indicators": config.get("bar_indicators", True),
+ NOTIF_POS = config.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT)
+
+ DOCK_COMPONENTS_VISIBILITY = {
+ "workspace": config.get("dock_workspace_visible", True),
+ "metrics": config.get("dock_metrics_visible", True),
+ "battery": config.get("dock_battery_visible", True),
+ "date_time": config.get("dock_date_time_visible", True),
+ "controls": config.get("dock_controls_visible", True),
+ "indicators": config.get("dock_indicators_visible", True),
+ "notifications": config.get("dock_notifications_visible", True),
+ "systray": config.get("dock_tray_visible", True),
+ "applications": config.get("dock_applications_visible", True),
+ "language": config.get("dock_language_visible", True),
}
- BAR_METRICS_DISKS = config.get("bar_metrics_disks", ["/"])
+ METRICS_DISKS = config.get("metrics_disks", ["/"])
METRICS_VISIBLE = config.get(
"metrics_visible",
- {"cpu": True, "ram": True, "disk": True, "swap": True},
+ {"cpu": True, "ram": True, "disk": True, "swap": True, "gpu": False},
)
else:
WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT
- VERTICAL = False # Default value when no config exists
- VERTICAL_RIGHT_ALIGN = False # Default to right alignment
- BOTTOM_BAR = False # Default value for bottom bar
- CENTERED_BAR = False # Default value for centered bar
- DOCK_ENABLED = True # Default value for dock visibility
- DOCK_ALWAYS_OCCLUDED = False # Default value for dock hover-only mode
- OSD_ENABLED = True # Default value for OSD visibility
- TERMINAL_COMMAND = "kitty -e" # Default terminal command when no config
- DOCK_ICON_SIZE = 28 # Default dock icon size when no config
+ DOCK_POSITION = "Bottom"
+ VERTICAL = False
+ DOCK_ENABLED = True
+ DOCK_ALWAYS_OCCLUDED = False
+ DOCK_AUTO_HIDE = True
+ TERMINAL_COMMAND = "kitty -e"
+ DOCK_THEME = "Pills"
+ DOCK_ICON_SIZE = 20
+ WINDOW_SWITCHER_ITEMS_PER_ROW = 13
+ NOTIF_POS = NOTIF_POS_DEFAULT
+ DOCK_HIDE_SPECIAL_WORKSPACE = True
+ DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
- # Default values for component visibility (all visible)
- BAR_COMPONENTS_VISIBILITY = {
- "button_aps": True,
- "tray": True,
- # "button_tools": True,
- "workspaces": True,
+ DOCK_COMPONENTS_VISIBILITY = {
+ "workspace": True,
"metrics": True,
- "language": True,
+ "battery": True,
"date_time": True,
+ "controls": True,
"indicators": True,
- "updates": True,
+ "notifications": True,
+ "systray": True,
+ "applications": True,
+ "language": True,
}
- BAR_METRICS_DISKS = ["/"]
+ METRICS_DISKS = ["/"]
METRICS_VISIBLE = {
"cpu": True,
"ram": True,
"disk": True,
"swap": True,
+ "gpu": False,
}
-
-
-def read_config():
- config_path = get_relative_path("./assets/modus.json")
-
- with open(config_path, "r") as file:
- # Load JSON data into a Python dictionary
- data = json.load(file)
- return data
-
-
-def load_config():
- """Load the configuration from config.json"""
- config_path = get_relative_path("./assets/config.json")
- config = {}
- if os.path.exists(config_path):
- try:
- with open(config_path, "r") as f:
- config = json.load(f)
- except Exception as e:
- print(f"Error loading config: {e}")
- return config
diff --git a/config/hypr/colors.conf b/config/hypr/colors.conf
index e839fe7..b077af8 100644
--- a/config/hypr/colors.conf
+++ b/config/hypr/colors.conf
@@ -1,11 +1,27 @@
-$wallpaper = /home/sankalp/Pictures/wallpapers/background.png
-$background = 0e1514
-$foreground = dde4e2
+$wallpaper = /home/sankalp/Pictures/wallpapers/54.png
+$background = 1a110f
+$foreground = f1dfda
-$primary = 81d5ca
-$secondary = b1ccc7
-$tertiary = aec9e6
-$surface = 0e1514
-$surface_bright = 343a39
-$outline = 899390
+$primary = ffb59d
+$secondary = e7bdb1
+$tertiary = d7c68d
+$surface = 1a110f
+$surface_bright = 423733
+$outline = a08d87
$error = ff5945
+
+$shadow = 000000
+
+$red = ffb4a2
+$green = b7d085
+$yellow = dec56e
+$blue = cebdfe
+$magenta = fcb0d5
+$cyan = 84d5c4
+$white = 82d3e0
+$red_dim = ff623c
+$green_dim = 89ad42
+$yellow_dim = bb9b2b
+$blue_dim = 8359fc
+$magenta_dim = f84ea1
+$cyan_dim = 3eb59c
diff --git a/config/hypr/hypridle.conf b/config/hypr/hypridle.conf
index 48bd7d7..340ff14 100644
--- a/config/hypr/hypridle.conf
+++ b/config/hypr/hypridle.conf
@@ -1,12 +1,5 @@
-
-listener {
- timeout = 500 # in seconds.
- on-timeout = notify-send "You are idle!" # command to run when timeout has passed.
- on-resume = notify-send "Welcome back!" # command to run when activity is detected after timeout has fired.
-}
-
general {
- lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances.
+ lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances.
before_sleep_cmd = loginctl lock-session # lock before suspend.
after_sleep_cmd = hyprctl dispatch dpms on # to avoid having to press a key twice to turn on the display.
}
@@ -17,13 +10,6 @@ listener {
on-resume = brightnessctl -r # monitor backlight restore.
}
-# turn off keyboard backlight, comment out this section if you dont have a keyboard backlight.
-listener {
- timeout = 150 # 2.5min.
- on-timeout = brightnessctl -sd rgb:kbd_backlight set 0 # turn off keyboard backlight.
- on-resume = brightnessctl -rd rgb:kbd_backlight # turn on keyboard backlight.
-}
-
listener {
timeout = 300 # 5min
on-timeout = loginctl lock-session # lock screen when timeout has passed
diff --git a/config/hypr/hyprlock.conf b/config/hypr/hyprlock.conf
index 80977af..7da736f 100644
--- a/config/hypr/hyprlock.conf
+++ b/config/hypr/hyprlock.conf
@@ -1,150 +1,91 @@
+source = ~/.config/Ax-Shell/config/hypr/colors.conf
+# BACKGROUND
+background {
+ monitor =
+ path = ~/.current.wall #path to background image
+ blur_passes = 3
+ blur_size = 3
+ contrast = 1.0
+ brightness = 0.5
+ vibrancy = 0.0
+ vibrancy_darkness = 0.0
+}
-source = ~/Modus/config/hypr/colors.conf
+# GENERAL
+general {
+ no_fade_in = false
+ grace = 0
+ disable_loading_bar = true
+ hide_cursor = true
+}
-# Configuration
- # (general settings)
- general {
- grace = 2
- hide_cursor = true
- ignore_empty_input = true
- text_trim = true
- }
- # (background config)
- background {
- monitor =
- path = $wallpaper # only png supported for now
- # color = rgba(26,17,16, 1.0)
- blur_passes = 3 # 0 disables blurring
- blur_size = 1
- noise = 0.01
- contrast = 0.8916
- brightness = 0.7
- vibrancy = 0.1696
- vibrancy_darkness = 0.0
- }
+# INPUT FIELD
+input-field {
+ monitor =
+ size = 256, 48
+ outline_thickness = 0
+ dots_size = 0.2 # Scale of input-field height, 0.2 - 0.8
+ dots_spacing = 0.5 # Scale of dots' absolute size, 0.0 - 1.0
+ dots_center = true
+ outer_color = rgba(00000000)
+ inner_color = rgba(0, 0, 0, 1)
+ font_color = rgb($foreground)
+ fail_color = rgb($error)
+ check_color = rgb($tertiary)
+ capslock_color = rgb($secondary)
+ fade_on_empty = false
+ font_family = Iosevka Nerd Font
+ placeholder_text = ... #text for input password
+ hide_input = false
+ position = 0, -100
+ halign = center
+ valign = center
+ shadow_passes = 1
+ shadow_size = 5
+ shadow_boost = 0.5
+}
+# TIME
+label {
+ monitor =
+ text = cmd[update:1000] echo "$(date +"%H:%M:%S")"
+ color = rgb($foreground)
+ font_size = 14
+ font_family = Iosevka Nerd Font Bold
+ position = 0, -150
+ halign = center
+ valign = center
+ shadow_passes = 1
+ shadow_size = 5
+ shadow_boost = 0.5
+}
-# Password input
- input-field {
- monitor =
- size = 256, 48
- outline_thickness = -1
- dots_size = 0.33
- dots_spacing = 0.45
- dots_center = true
- dots_rounding = -1
- outer_color = rgba(0,0,0,0)
- inner_color = rgba(0,0,0,0)
- font_color = 0xff$foreground
- fade_on_empty = true
- fade_timeout = 1000
- font_family = Product Sans
- placeholder_text =
- hide_input = false
- rounding = 4
- check_color = 0xff$tertiary
- fail_color = 0xff$error
- capslock_color = 0xff$secondary
- fail_text = $FAIL ($ATTEMPTS)
- fail_transition = 200
- capslock_color = -1
- numlock_color = -1
- bothlock_color = -1
- invert_numlock = false
- swap_font_color = false
- position = 0, 150
- halign = center
- valign = bottom
- }
-#
-
-
-# Time
- # (hours)
- label {
- monitor =
- text = cmd[update:1000] echo -e "$(date +"%H")"
- color = 0xff$foreground
- shadow_pass = 2
- shadow_size = 3
- shadow_color = rgb(0,0,0)
- shadow_boost = 1.2
- font_size = 150
- font_family = Product Sans Light
- position = 0, 90
- halign = center
- valign = center
- }
- # (minutes)
- label {
- monitor =
- text = cmd[update:1000] echo -e "$(date +"%M")"
- color = 0xff$foreground
- font_size = 150
- font_family = Product Sans Light
- position = 0, -90
- halign = center
- valign = center
- }
-#
-
-# Date
- # (label)
- label {
- monitor =
- text = cmd[update:1000] echo -e "$(date +"%A, %B %d")"
- color = 0xff$foreground
- font_size = 15
- font_family = Product Sans Medium
- position = 100, -75
- shadow_passes = 5
- shadow_size = 10
- halign = left
- valign = top
- }
- # (icon)
- label {
- monitor =
- text = calendar_today
- color = 0xff$foreground
- font_size = 15
- font_family = Material Symbols Outlined
- position = 75, -75
- shadow_passes = 5
- shadow_size = 10
- halign = left
- valign = top
- }
-#
-
-# Input layout
- # (label)
- label {
- monitor =
- text = $LAYOUT
- color = 0xff$foreground
- font_size = 15
- font_family = Product Sans Medium
- position = 100, -110
- shadow_passes = 5
- shadow_size = 10
- halign = left
- valign = top
- }
- # (icon)
- label {
- monitor =
- text = keyboard
- color = 0xff$foreground
- font_size = 15
- font_family = Material Symbols Outlined
- position = 75, -112
- shadow_passes = 5
- shadow_size = 10
- halign = left
- valign = top
- }
-#
-
+# USER
+label {
+ monitor =
+ text = cmd[update:1000] echo "$USER@$(hostname)"
+ color = rgb($foreground)
+ font_size = 14
+ font_family = Iosevka Nerd Font Bold Italic
+ position = 0, -50
+ halign = center
+ valign = center
+ shadow_passes = 1
+ shadow_size = 5
+ shadow_boost = 0.5
+}
+# PICTURE
+image {
+ path = .face.icon
+ size = 200
+ position = 0, 75
+ halign = center
+ valign = center
+ border_size = 3
+ border_color = rgb($primary)
+ shadow_passes = 1
+ shadow_size = 5
+ shadow_boost = 0.5
+}
diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf
deleted file mode 100644
index edd9ec3..0000000
--- a/config/hypr/modus.conf
+++ /dev/null
@@ -1,88 +0,0 @@
-# Generated by Modus - DO NOT EDIT MANUALLY
-exec-once = python /home/sankalp/Modus/main.py
-exec = pgrep -x "hypridle" > /dev/null || hypridle
-exec = swww-daemon
-exec-once = wl-paste --type text --watch cliphist store
-exec-once = wl-paste --type image --watch cliphist store
-
-$fabricSend = fabric-cli exec modus
-$scriptsDir = /home/sankalp/Modus/config/scripts/
-
-# Key Bindings
-bind = SHIFT ALT, T, exec, killall modus; python /home/sankalp/Modus/main.py # Reload Modus
-bind = SHIFT ALT, B, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth
-bind = SUPER, N, exec, $fabricSend 'launcher.open("notification-center")' # Notifications
-bind = SUPER, E, exec, $fabricSend 'launcher.open("emoji")' # Emoji
-bind = SUPER, V, exec, $fabricSend 'launcher.open("cliphist")' # Clipboard History
-bind = SUPER, T, exec, $fabricSend 'launcher.open("kanban")' # Kanban
-bind = SUPER, Z, exec, $fabricSend 'launcher.open("tmux")' # Tmux Manager
-bind = SUPER, S, exec, $fabricSend 'launcher.open("tools")' # Toolbox
-bind = SUPER, W, exec, $fabricSend 'launcher.open("wallpapers")' # Wallpaper Selector
-bind = SUPER, D, exec, $fabricSend 'launcher.open("launcher")' # App Launcher
-bind = ALT, W, exec, $fabricSend 'launcher.open("window-switcher")' # Window Switcher
-bind = SUPER, X, exec, $fabricSend 'launcher.open("power")' # Power Menu
-bind = SUPER SHIFT, T, exec, killall modus; GTK_DEBUG=interactive python /home/sankalp/Modus/main.py # Restart with inspector
-
-# Wallpapers directory: /home/sankalp/Pictures/wallpapers
-
-# Source color scheme
-source = /home/sankalp/Modus/config/hypr/colors.conf
-
-general {
- col.active_border = 0xff$primary
- col.inactive_border = 0xff$surface
- gaps_in = 2
- gaps_out = 4
- border_size = 2
- layout = dwindle
-}
-
-decoration {
- blur {
- enabled = yes
- size = 5
- passes = 3
- new_optimizations = yes
- contrast = 1
- brightness = 1
- }
- rounding = 14
- shadow {
- enabled = true
- range = 10
- render_power = 2
- color = rgba(0, 0, 0, 0.25)
- }
-}
-
-animations {
- enabled = true
- # Animation curves
- bezier = linear, 0, 0, 1, 1
- bezier = md3_standard, 0.2, 0, 0, 1
- bezier = md3_decel, 0.05, 0.7, 0.1, 1
- bezier = md3_accel, 0.3, 0, 0.8, 0.15
- bezier = overshot, 0.05, 0.9, 0.1, 1.1
- bezier = crazyshot, 0.1, 1.5, 0.76, 0.92
- bezier = hyprnostretch, 0.05, 0.9, 0.1, 1.0
- bezier = menu_decel, 0.1, 1, 0, 1
- bezier = menu_accel, 0.38, 0.04, 1, 0.07
- bezier = easeInOutCirc, 0.85, 0, 0.15, 1
- bezier = easeOutCirc, 0, 0.55, 0.45, 1
- bezier = easeOutExpo, 0.16, 1, 0.3, 1
- bezier = softAcDecel, 0.26, 0.26, 0.15, 1
- bezier = md2, 0.4, 0, 0.2, 1
-
- animation = windows, 1, 3, md3_decel, popin 60%
- animation = windowsIn, 1, 3, md3_decel, popin 60%
- animation = windowsOut, 1, 3, md3_accel, popin 60%
- animation = border, 1, 10, default
- animation = fade, 1, 3, md3_decel
- animation = layers, 1, 2, md3_decel, slide
- animation = layersIn, 1, 3, menu_decel, slide
- animation = layersOut, 1, 1.6, menu_accel, slide
- animation = fadeLayersIn, 1, 2, menu_decel
- animation = fadeLayersOut, 1, 4.5, menu_accel
- animation = workspaces, 1, 7, menu_decel, slide
- animation = specialWorkspace, 1, 3, md3_decel, slidevert
-}
diff --git a/config/matugen/templates/hyprland-colors.conf b/config/matugen/templates/hyprland-colors.conf
index a375c24..9aeb090 100644
--- a/config/matugen/templates/hyprland-colors.conf
+++ b/config/matugen/templates/hyprland-colors.conf
@@ -9,3 +9,19 @@ $surface = {{colors.surface.default.hex_stripped}}
$surface_bright = {{colors.surface_bright.default.hex_stripped}}
$outline = {{colors.outline.default.hex_stripped}}
$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}}
+
+$shadow = {{colors.shadow.default.hex_stripped}}
+
+$red = {{colors.red.default.hex_stripped}}
+$green = {{colors.green.default.hex_stripped}}
+$yellow = {{colors.yellow.default.hex_stripped}}
+$blue = {{colors.blue.default.hex_stripped}}
+$magenta = {{colors.magenta.default.hex_stripped}}
+$cyan = {{colors.cyan.default.hex_stripped}}
+$white = {{colors.white.default.hex_stripped}}
+$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}}
+$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}}
+$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}}
+$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}}
+$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}}
+$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}}
diff --git a/config/matugen/templates/modus.css b/config/matugen/templates/modus.css
index db192d7..7d8c607 100644
--- a/config/matugen/templates/modus.css
+++ b/config/matugen/templates/modus.css
@@ -3,29 +3,18 @@
--background: {{colors.background.default.hex}};
--cursor: {{colors.on_background.default.hex}};
--primary: {{colors.primary.default.hex}};
- --primary-container: {{colors.primary_container.default.hex}};
--on-primary: {{colors.on_primary.default.hex}};
- --on-primary-container: {{colors.on_primary_container.default.hex}};
--secondary: {{colors.secondary.default.hex}};
--on-secondary: {{colors.on_secondary.default.hex}};
- --secondary-container: {{colors.secondary_container.default.hex}};
--tertiary: {{colors.tertiary.default.hex}};
- --tertiary-container: {{colors.tertiary_container.default.hex}};
--on-tertiary: {{colors.on_tertiary.default.hex}};
- --on-tertiary-container: {{colors.on_tertiary_container.default.hex}};
--surface: {{colors.surface.default.hex}};
- --surface-container: {{colors.surface_container.default.hex}};
--surface-bright: {{colors.surface_bright.default.hex}};
- --surface-variant: {{colors.surface_variant.default.hex}};
- --surface-container-low: {{colors.surface_container_low.default.hex}};
- --on-surface: {{colors.on_surface.default.hex}};
--error: {{colors.error.default.hex}};
--error-dim: {{colors.error.default.hex | set_lightness: -10.0}};
--on-error: {{colors.on_error.default.hex}};
--error-container: {{colors.error_container.default.hex}};
- --on-error-container: {{colors.on_error_container.default.hex}};
--outline: {{colors.outline.default.hex}};
- --outline-variant: {{colors.outline_variant.default.hex}};
--shadow: {{colors.shadow.default.hex}};
--red: {{colors.red.default.hex}};
--red-dim: {{colors.red.default.hex | set_lightness: -10.0}};
@@ -40,5 +29,4 @@
--cyan: {{colors.cyan.default.hex}};
--cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};
--white: {{colors.white.default.hex}};
-
}
diff --git a/config/matugen/templates/modus.scss b/config/matugen/templates/modus.scss
deleted file mode 100644
index aba4239..0000000
--- a/config/matugen/templates/modus.scss
+++ /dev/null
@@ -1,29 +0,0 @@
-@use "sass:color";
-<* for name, value in colors *>
-${{name}}: {{value.default.hex}};
-<* endfor *>
-
-// Custom Color
-$red: {{colors.red.default.hex}};
-$red-dim: {{colors.red.default.hex | set_lightness: -10.0}};
-$green: {{colors.green.default.hex}};
-$green-dim: {{colors.green.default.hex | set_lightness: -10.0}};
-$yellow: {{colors.yellow.default.hex}};
-$yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}};
-$blue: {{colors.blue.default.hex}};
-$blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}};
-$magenta: {{colors.magenta.default.hex}};
-$magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}};
-$cyan: {{colors.cyan.default.hex}};
-$cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};
-$white: {{colors.white.default.hex}};
-
-$theme: 'dark';
-$surface_transparent: color.scale($surface, $alpha: -20%);
-$inverse_surface_transparent: color.scale($inverse_surface, $alpha: -92%);
-$inverse_surface_transparent_variant: color.scale(
- $inverse_surface,
- $alpha: -82%
-);
-$inverse_surface_transparent_overlay: linear-gradient(0deg, $inverse_surface_transparent, $inverse_surface_transparent);
-$inverse_surface_transparent_variant_overlay: linear-gradient(0deg, $inverse_surface_transparent_variant, $inverse_surface_transparent_variant);
diff --git a/config/scripts/capture.sh b/config/scripts/capture.sh
deleted file mode 100755
index 8204108..0000000
--- a/config/scripts/capture.sh
+++ /dev/null
@@ -1,166 +0,0 @@
-#!/bin/bash
-
-# Print usage information
-print_usage() {
- cat <<"EOF"
-Usage: screen-tools.sh [mode] [option]
-Modes:
- screenshot [option] Take a screenshot
- Options:
- p Print all screens
- s Snip current screen
- sf Snip current screen (frozen)
- m Print focused monitor
- [mockup] Optional: Add mockup effect to screenshot
-
- record Start/Stop screen recording
-
- ocr Perform OCR on selected area
-EOF
-}
-
-# OCR functionality
-do_ocr() {
- sleep 0.25
-
- ocr_text=$(grimblast --freeze save area - | tesseract -l eng - - 2>/dev/null)
-
- # Check if OCR was successful
- if [[ -n "$ocr_text" ]]; then
- echo -n "$ocr_text" | wl-copy
- notify-send -a "Modus" "OCR Success" "Text Copied to Clipboard"
- else
- notify-send -a "Modus" "OCR Failed" "No text recognized or operation failed"
- fi
-}
-
-# Screen recording functionality
-do_screenrecord() {
-
- if [ -z "$XDG_VIDEOS_DIR" ]; then
- XDG_VIDEOS_DIR="$HOME/Videos"
-
- fi
- local save_dir="${XDG_VIDEOS_DIR:-$HOME/Videos}/Recordings"
- mkdir -p "$save_dir"
-
- if pgrep -f "gpu-screen-recorder" >/dev/null; then
- pkill -SIGINT -f "gpu-screen-recorder"
- sleep 1
-
- local last_video=$(ls -t "$save_dir"/*.mp4 2>/dev/null | head -n 1)
- local action=$(notify-send -a "Modus" "⬜ Recording stopped" \
- -A "view=View" -A "open=Open folder")
-
- if [ "$action" = "view" ] && [ -n "$last_video" ]; then
- xdg-open "$last_video"
- elif [ "$action" = "open" ]; then
- xdg-open "$save_dir"
- fi
- exit 0
- fi
-
- local output_file="$save_dir/$(date +%Y-%m-%d-%H-%M-%S).mp4"
- notify-send -a "Modus" "🔴 Recording started"
- gpu-screen-recorder -w screen -q ultra -a default_output -ac opus -cr full -f 60 -o "$output_file"
-}
-
-# Screenshot functionality
-do_screenshot() {
- sleep 0.25
-
- if [ -z "$XDG_PICTURES_DIR" ]; then
- XDG_PICTURES_DIR="$HOME/Pictures"
- fi
-
- local save_dir="${3:-$XDG_PICTURES_DIR/Screenshots}"
- local save_file=$(date +'%y%m%d_%Hh%Mm%Ss_screenshot.png')
- local full_path="$save_dir/$save_file"
- mkdir -p "$save_dir"
-
- local mockup_mode="$2"
-
- case $1 in
- p) grimblast copysave screen "$full_path" ;;
- s) grimblast copysave area "$full_path" ;;
- sf) grimblast --freeze copysave area "$full_path" ;;
- m) grimblast copysave output "$full_path" ;;
- *)
- echo "Invalid screenshot mode"
- print_usage
- exit 1
- ;;
- esac
-
- if [ -f "$full_path" ]; then
- if [ "$mockup_mode" = "mockup" ]; then
- local temp_file="${full_path%.png}_temp.png"
- cropped_file="${full_path%.png}_cropped.png"
- local mockup_file="${full_path%.png}_mockup.png"
-
- # First crop the top pixel using a different approach
-
- # Use +0+1 to start at y-coordinate 1 (skipping the first pixel row)
-
- magick "$full_path" -crop +0+1 +repage "$cropped_file"
-
- magick "$cropped_file" \
- \( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \
- \( +clone -flip \) -compose Multiply -composite \
- \( +clone -flop \) -compose Multiply -composite \
- \) -alpha off -compose CopyOpacity -composite "$temp_file"
-
- magick "$temp_file" \
- \( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \
- +swap -background none -layers merge +repage "$mockup_file"
-
- rm "$temp_file" "$cropped_file"
- mv "$mockup_file" "$full_path"
-
- if command -v wl-copy >/dev/null 2>&1; then
- wl-copy <"$full_path"
- elif command -v xclip >/dev/null 2>&1; then
- xclip -selection clipboard -t image/png <"$full_path"
- fi
- fi
-
- local action=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \
- -A "view=View" -A "edit=Edit" -A "open=Open Folder")
-
- case "$action" in
- view) xdg-open "$full_path" ;;
- edit) swappy -f "$full_path" ;;
- open) xdg-open "$save_dir" ;;
- esac
- else
- notify-send -a "Modus" "Screenshot Aborted"
- fi
-}
-
-# Handle command line arguments
-if [ $# -eq 0 ]; then
- print_usage
- exit 1
-fi
-
-case "$1" in
-screenshot)
- if [ $# -lt 2 ]; then
- echo "Error: Screenshot mode requires an option"
- print_usage
- exit 1
- fi
- do_screenshot "$2" "$3"
- ;;
-record)
- do_screenrecord
- ;;
-ocr)
- do_ocr
- ;;
-*)
- echo "Error: Invalid mode '$1'"
- print_usage
- exit 1
- ;;
-esac
diff --git a/config/scripts/systemupdates.sh b/config/scripts/systemupdates.sh
deleted file mode 100755
index a2677ff..0000000
--- a/config/scripts/systemupdates.sh
+++ /dev/null
@@ -1,182 +0,0 @@
-#!/bin/bash
-
-# Word of caution: Changing this may break the update module, be careful!
-
-# Function to check updates for Arch-based distros
-check_arch_updates() {
- official_updates=0
- aur_updates=0
- flatpak_updates=0
- tooltip=""
-
- # Get the number of official updates
- official_updates=$(checkupdates 2>/dev/null | wc -l)
-
- # Determine the AUR helper
- if command -v paru &>/dev/null; then
- aur_helper="paru"
- else
- aur_helper="yay"
- fi
-
- # Get the number of AUR updates
- aur_updates=$($aur_helper -Qum 2>/dev/null | wc -l)
-
- # Check for Flatpak updates
- if command -v flatpak &>/dev/null; then
- flatpak_updates=$(flatpak remote-ls --updates | wc -l)
- fi
-
- # Calculate total updates
- total_updates=$((official_updates + aur_updates + flatpak_updates))
-
- # Build the tooltip
- tooltip=" Official: $official_updates\n AUR: $aur_updates\n Flatpak: $flatpak_updates"
-
- # Output JSON
- echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}"
-}
-
-# Function to check updates for Ubuntu-based distros
-check_ubuntu_updates() {
- official_updates=0
- flatpak_updates=0
- tooltip=""
-
- # Get the number of official updates
- official_updates=$(apt-get -s -o Debug::NoLocking=true upgrade | grep -c ^Inst)
-
- # Check for Flatpak updates
- if command -v flatpak &>/dev/null; then
- flatpak_updates=$(flatpak remote-ls --updates | wc -l)
- fi
-
- # Calculate total updates
- total_updates=$((official_updates + flatpak_updates))
-
- # Build the tooltip
- tooltip=" Official: $official_updates\n Flatpak: $flatpak_updates"
-
- # Output JSON
- echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}"
-}
-
-# Function to check updates for Fedora-based distros
-check_fedora_updates() {
- official_updates=0
- flatpak_updates=0
- tooltip=""
-
- # Get the number of official updates
- official_updates=$(dnf list updates -q | tail -n +2 | wc -l)
-
- # Check for Flatpak updates
- if command -v flatpak &>/dev/null; then
- flatpak_updates=$(flatpak remote-ls --updates | wc -l)
- fi
-
- # Calculate total updates
- total_updates=$((official_updates + flatpak_updates))
-
- # Build the tooltip
- tooltip=" Official: $official_updates\n Flatpak: $flatpak_updates"
-
- # Output JSON
- echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}"
-}
-
-# Function to check updates for openSUSE-based distros
-check_opensuse_updates() {
- official_updates=0
- flatpak_updates=0
- tooltip=""
-
- # Get the number of official updates (skip header lines)
- official_updates=$(zypper lu | tail -n +3 | wc -l)
-
- # Check for Flatpak updates
- if command -v flatpak &>/dev/null; then
- flatpak_updates=$(flatpak remote-ls --updates | wc -l)
- fi
-
- # Calculate total updates
- total_updates=$((official_updates + flatpak_updates))
-
- # Build the tooltip
- tooltip=" Official: $official_updates\n Flatpak: $flatpak_updates"
-
- # Output JSON
- echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}"
-}
-
-# Function to update Arch-based distros
-update_arch() {
- kitty --title "System Update" sh -c "
- fastfetch
- yay -Syu
- read -n 1 -p 'Press any key to continue...'
- "
-}
-
-# Function to update Ubuntu-based distros
-update_ubuntu() {
- kitty --title "System Update" sh -c "
- neofetch
- sudo apt update && sudo apt upgrade -y
- read -n 1 -p 'Press any key to continue...'
- "
-}
-
-# Function to update Fedora-based distros
-update_fedora() {
- kitty --title "System Update" sh -c "
- neofetch
- sudo dnf upgrade -y
- read -n 1 -p 'Press any key to continue...'
- "
-}
-
-# Function to update openSUSE-based distros
-update_opensuse() {
- kitty --title "System Update" sh -c "
- neofetch
- sudo zypper up -y
- read -n 1 -p 'Press any key to continue...'
- "
-}
-
-# Main case handling for different distributions
-case "$1" in
--arch)
- if [ -z "$2" ]; then
- check_arch_updates
- else
- update_arch
- fi
- ;;
--ubuntu)
- if [ -z "$2" ]; then
- check_ubuntu_updates
- else
- update_ubuntu
- fi
- ;;
--fedora)
- if [ -z "$2" ]; then
- check_fedora_updates
- else
- update_fedora
- fi
- ;;
--suse)
- if [ -z "$2" ]; then
- check_opensuse_updates
- else
- update_opensuse
- fi
- ;;
-*)
- echo "Usage: $0 [-arch|-ubuntu|-fedora|-suse] [up (optional)]"
- exit 1
- ;;
-esac
diff --git a/config/settings/about_tab.py b/config/settings/about_tab.py
new file mode 100644
index 0000000..b475efc
--- /dev/null
+++ b/config/settings/about_tab.py
@@ -0,0 +1,40 @@
+from fabric.widgets.box import Box
+from fabric.widgets.label import Label
+
+from config.data import APP_NAME_CAP
+
+
+class AboutTab:
+ """About information tab for settings"""
+
+ def __init__(self):
+ pass
+
+ def create_about_tab(self):
+ """Create the About tab content"""
+ vbox = Box(orientation="v", spacing=18, style="margin: 30px;")
+ vbox.add(
+ Label(
+ markup=f"{APP_NAME_CAP}",
+ h_align="start",
+ style="font-size: 1.5em; margin-bottom: 8px;",
+ )
+ )
+ vbox.add(
+ Label(
+ label="A hackable shell for Hyprland, powered by Fabric.",
+ h_align="start",
+ style="margin-bottom: 12px;",
+ )
+ )
+ repo_box = Box(orientation="h", spacing=6, h_align="start")
+ repo_label = Label(label="GitHub:", h_align="start")
+ repo_link = Label(
+ markup='https://github.com/S4NKALP/Modus'
+ )
+ repo_box.add(repo_label)
+ repo_box.add(repo_link)
+ vbox.add(repo_box)
+
+ vbox.add(Box(v_expand=True))
+ return vbox
diff --git a/config/settings/appearance_tab.py b/config/settings/appearance_tab.py
new file mode 100644
index 0000000..fcc29f0
--- /dev/null
+++ b/config/settings/appearance_tab.py
@@ -0,0 +1,667 @@
+import os
+
+import gi
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.grid import Grid
+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 gi.repository import GdkPixbuf, Gtk
+
+from config.data import NOTIF_POS_DEFAULT, NOTIF_POS_KEY
+from config.settings.utils import bind_vars
+
+gi.require_version("Gtk", "3.0")
+
+
+class AppearanceTab:
+ """Appearance and layout management tab for settings"""
+
+ def __init__(self, themes, parent_window=None):
+ self.themes = themes
+ self.parent_window = parent_window
+ self.selected_face_icon = None
+
+ # Widget references
+ self.wall_dir_chooser = None
+ self.face_image = None
+ self.face_status_label = None
+ self.ws_mode_combo = None
+ self.ws_chinese_switch = None
+ self.position_combo = None
+ self.dock_theme_combo = None
+ self.icon_size_scale = None
+ self.application_switcher_scale = None
+ self.dock_switch = None
+ self.dock_auto_hide_switch = None
+ self.dock_hover_switch = None
+ self.dock_hide_special_switch = None
+ self.dock_hide_special_apps_switch = None
+ self.notification_pos_combo = None
+ self.component_switches = {}
+ self.corners_switch = None
+
+ def create_appearance_tab(self):
+ """Create the Appearance tab content"""
+ scrolled_window = ScrolledWindow(
+ h_scrollbar_policy="never",
+ v_scrollbar_policy="automatic",
+ h_expand=False,
+ v_expand=False,
+ propagate_width=False,
+ propagate_height=False,
+ )
+ # Set fixed size to match tab stack dimensions
+ scrolled_window.set_size_request(580, 580)
+
+ vbox = Box(orientation="v", spacing=15, style="margin: 15px;")
+ scrolled_window.add(vbox)
+
+ # Create wallpaper and profile icon section
+ self._create_wallpaper_profile_section(vbox)
+
+ # Add separator
+ separator1 = Box(
+ style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;",
+ h_expand=True,
+ )
+ vbox.add(separator1)
+
+ # Create layout options section
+ self._create_layout_options_section(vbox)
+
+ # Add separator
+ separator2 = Box(
+ style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;",
+ h_expand=True,
+ )
+ vbox.add(separator2)
+
+ # Create modules section
+ self._create_modules_section(vbox)
+
+ return scrolled_window
+
+ def _create_wallpaper_profile_section(self, vbox):
+ """Create wallpaper and profile icon section"""
+ top_grid = Grid(column_spacing=20, row_spacing=5, style="margin-bottom: 10px;")
+ vbox.add(top_grid)
+
+ # Wallpaper section
+ wall_header = Label(markup="Wallpapers", h_align="start")
+ top_grid.attach(wall_header, 0, 0, 1, 1)
+ wall_label = Label(label="Directory:", h_align="start", v_align="center")
+ top_grid.attach(wall_label, 0, 1, 1, 1)
+
+ chooser_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.wall_dir_chooser = Gtk.FileChooserButton(
+ title="Select a folder", action=Gtk.FileChooserAction.SELECT_FOLDER
+ )
+ self.wall_dir_chooser.set_tooltip_text(
+ "Select the directory containing your wallpaper images"
+ )
+ self.wall_dir_chooser.set_filename(bind_vars.get("wallpapers_dir", ""))
+ self.wall_dir_chooser.set_size_request(180, -1)
+ chooser_container.add(self.wall_dir_chooser)
+ top_grid.attach(chooser_container, 1, 1, 1, 1)
+
+ # Profile icon section
+ face_header = Label(markup="Profile Icon", h_align="start")
+ top_grid.attach(face_header, 2, 0, 2, 1)
+ current_face = os.path.expanduser("~/.face.icon")
+ face_image_container = Box(
+ style_classes=["image-frame"], h_align="center", v_align="center"
+ )
+ self.face_image = Image(size=64)
+ try:
+ if os.path.exists(current_face):
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64)
+ self.face_image.set_from_pixbuf(pixbuf)
+ else:
+ self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG)
+ except Exception as e:
+ print(f"Error loading face icon: {e}")
+ self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG)
+ face_image_container.add(self.face_image)
+ top_grid.attach(face_image_container, 2, 1, 1, 1)
+
+ browse_btn_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ face_btn = Button(
+ label="Browse...",
+ tooltip_text="Select a square image for your profile icon",
+ on_clicked=self.on_select_face_icon,
+ )
+ browse_btn_container.add(face_btn)
+ top_grid.attach(browse_btn_container, 3, 1, 1, 1)
+ self.face_status_label = Label(label="", h_align="start")
+ top_grid.attach(self.face_status_label, 2, 2, 2, 1)
+
+ def _create_layout_options_section(self, vbox):
+ """Create layout options section"""
+ layout_header = Label(markup="Layout Options", h_align="start")
+ vbox.add(layout_header)
+ layout_grid = Grid(
+ column_spacing=20,
+ row_spacing=10,
+ style="margin-left: 10px; margin-top: 5px;",
+ )
+ vbox.add(layout_grid)
+
+ # Workspace mode
+ ws_mode_label = Label(label="Workspace Mode", h_align="start", v_align="center")
+ layout_grid.attach(ws_mode_label, 0, 0, 1, 1)
+
+ ws_mode_combo_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.ws_mode_combo = Gtk.ComboBoxText()
+ self.ws_mode_combo.set_tooltip_text("Select how workspaces are displayed")
+ ws_modes = ["Dots", "Numbers", "Single Button"]
+ for mode in ws_modes:
+ self.ws_mode_combo.append_text(mode)
+
+ # Set current mode based on config
+ current_dots = bind_vars.get("workspace_dots", True)
+ current_nums = bind_vars.get("workspace_nums", False)
+ if current_dots:
+ current_mode = "Dots"
+ elif current_nums:
+ current_mode = "Numbers"
+ else:
+ current_mode = "Single Button"
+
+ try:
+ self.ws_mode_combo.set_active(ws_modes.index(current_mode))
+ except ValueError:
+ self.ws_mode_combo.set_active(0)
+
+ self.ws_mode_combo.connect("changed", self.on_ws_mode_changed)
+ ws_mode_combo_container.add(self.ws_mode_combo)
+ layout_grid.attach(ws_mode_combo_container, 1, 0, 1, 1)
+
+ # Chinese numerals option
+ ws_chinese_label = Label(
+ label="Use Chinese Numerals", h_align="start", v_align="center"
+ )
+ layout_grid.attach(ws_chinese_label, 2, 0, 1, 1)
+ ws_chinese_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.ws_chinese_switch = Gtk.Switch(
+ active=bind_vars.get("workspace_use_chinese_numerals", False),
+ sensitive=current_nums, # Only enabled when numbers mode is selected
+ )
+ self.ws_chinese_switch.set_tooltip_text(
+ "Use Chinese numerals (一, 二, 三...) instead of Arabic numbers"
+ )
+ ws_chinese_switch_container.add(self.ws_chinese_switch)
+ layout_grid.attach(ws_chinese_switch_container, 3, 0, 1, 1)
+
+ # Dock position
+ position_label = Label(label="Dock Position", h_align="start", v_align="center")
+ layout_grid.attach(position_label, 0, 1, 1, 1)
+ position_combo_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.position_combo = Gtk.ComboBoxText()
+ self.position_combo.set_tooltip_text("Select the position of the dock")
+ positions = ["Bottom", "Top", "Left", "Right"]
+ for pos in positions:
+ self.position_combo.append_text(pos)
+ current_position = bind_vars.get("dock_position", "Bottom")
+ try:
+ self.position_combo.set_active(positions.index(current_position))
+ except ValueError:
+ self.position_combo.set_active(0)
+ self.position_combo.connect("changed", self.on_position_changed)
+ position_combo_container.add(self.position_combo)
+ layout_grid.attach(position_combo_container, 1, 1, 1, 1)
+
+ # Dock theme
+ dock_theme_label = Label(label="Dock Theme", h_align="start", v_align="center")
+ layout_grid.attach(dock_theme_label, 2, 1, 1, 1)
+ dock_theme_combo_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.dock_theme_combo = Gtk.ComboBoxText()
+ self.dock_theme_combo.set_tooltip_text("Select the visual theme for the dock")
+ for theme in self.themes:
+ self.dock_theme_combo.append_text(theme)
+ current_dock_theme = bind_vars.get("dock_theme", "Pills")
+ try:
+ self.dock_theme_combo.set_active(self.themes.index(current_dock_theme))
+ except ValueError:
+ self.dock_theme_combo.set_active(0)
+ dock_theme_combo_container.add(self.dock_theme_combo)
+ layout_grid.attach(dock_theme_combo_container, 3, 1, 1, 1)
+
+ # Show Dock switch
+ dock_label = Label(label="Show Dock", h_align="start", v_align="center")
+ layout_grid.attach(dock_label, 0, 2, 1, 1)
+ dock_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.dock_switch = Gtk.Switch(active=bind_vars.get("dock_enabled", True))
+ self.dock_switch.connect("notify::active", self.on_dock_enabled_changed)
+ dock_switch_container.add(self.dock_switch)
+ layout_grid.attach(dock_switch_container, 1, 2, 1, 1)
+
+ # Show Dock Only on Hover
+ dock_hover_label = Label(
+ label="Show Dock Only on Hover", h_align="start", v_align="center"
+ )
+ layout_grid.attach(dock_hover_label, 2, 2, 1, 1)
+ dock_hover_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.dock_hover_switch = Gtk.Switch(
+ active=bind_vars.get("dock_always_occluded", False),
+ sensitive=self.dock_switch.get_active(),
+ )
+ dock_hover_switch_container.add(self.dock_hover_switch)
+ layout_grid.attach(dock_hover_switch_container, 3, 2, 1, 1)
+
+ # Auto-hide Dock
+ dock_auto_hide_label = Label(
+ label="Auto-hide Dock", h_align="start", v_align="center"
+ )
+ layout_grid.attach(dock_auto_hide_label, 0, 3, 1, 1)
+ dock_auto_hide_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.dock_auto_hide_switch = Gtk.Switch(
+ active=bind_vars.get("dock_auto_hide", False),
+ sensitive=self.dock_switch.get_active(),
+ )
+ dock_auto_hide_switch_container.add(self.dock_auto_hide_switch)
+ layout_grid.attach(dock_auto_hide_switch_container, 1, 3, 1, 1)
+
+ # Hide Special Workspace
+ dock_hide_special_label = Label(
+ label="Hide Special Workspace", h_align="start", v_align="center"
+ )
+ layout_grid.attach(dock_hide_special_label, 2, 3, 1, 1)
+ dock_hide_special_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.dock_hide_special_switch = Gtk.Switch(
+ active=bind_vars.get("dock_hide_special_workspace", True),
+ sensitive=self.dock_switch.get_active(),
+ )
+ self.dock_hide_special_switch.set_tooltip_text(
+ "Hide the dock when on special workspaces (scratchpad)"
+ )
+ dock_hide_special_switch_container.add(self.dock_hide_special_switch)
+ layout_grid.attach(dock_hide_special_switch_container, 3, 3, 1, 1)
+
+ # Icon size scale
+ icon_size_label = Label(
+ label="Taskbar Icon Size", h_align="start", v_align="center"
+ )
+ layout_grid.attach(icon_size_label, 0, 4, 1, 1)
+
+ self.icon_size_scale = Scale(
+ min_value=20,
+ max_value=30,
+ value=bind_vars.get("dock_icon_size", 20),
+ increments=(2, 4),
+ draw_value=True,
+ value_position="right",
+ digits=0,
+ h_expand=True,
+ )
+ self.icon_size_scale.set_tooltip_text("Adjust the size of dock icons")
+ layout_grid.attach(self.icon_size_scale, 1, 4, 3, 1) # Span 3 columns
+
+ # Application switcher items scale (below taskbar icon size)
+ application_switcher_label = Label(
+ label="Application Switcher Items", h_align="start", v_align="center"
+ )
+ layout_grid.attach(application_switcher_label, 0, 5, 1, 1)
+
+ self.application_switcher_scale = Scale(
+ min_value=5,
+ max_value=20,
+ value=bind_vars.get("window_switcher_items_per_row", 13),
+ increments=(2, 4),
+ draw_value=True,
+ value_position="right",
+ digits=0,
+ h_expand=True,
+ )
+ self.application_switcher_scale.set_tooltip_text(
+ "Adjust the number of items per row in the application switcher"
+ )
+ layout_grid.attach(
+ self.application_switcher_scale, 1, 5, 3, 1
+ ) # Span 3 columns
+
+ # Hide Special Workspace Apps
+ dock_hide_special_apps_label = Label(
+ label="Hide Special Workspace Apps", h_align="start", v_align="center"
+ )
+ layout_grid.attach(dock_hide_special_apps_label, 0, 6, 1, 1)
+ dock_hide_special_apps_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.dock_hide_special_apps_switch = Gtk.Switch(
+ active=bind_vars.get("dock_hide_special_workspace_apps", True),
+ sensitive=self.dock_switch.get_active(),
+ )
+ self.dock_hide_special_apps_switch.set_tooltip_text(
+ "Hide applications that are in special workspaces (scratchpad) from the dock"
+ )
+ dock_hide_special_apps_switch_container.add(self.dock_hide_special_apps_switch)
+ layout_grid.attach(dock_hide_special_apps_switch_container, 1, 6, 1, 1)
+
+ # Notification position
+ notification_pos_label = Label(
+ label="Notification Position", h_align="start", v_align="center"
+ )
+ layout_grid.attach(notification_pos_label, 2, 6, 1, 1)
+
+ notification_pos_combo_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+
+ self.notification_pos_combo = Gtk.ComboBoxText()
+ self.notification_pos_combo.set_tooltip_text(
+ "Select where notifications appear on the screen."
+ )
+
+ notification_positions_list = ["Top", "Bottom"]
+ for pos in notification_positions_list:
+ self.notification_pos_combo.append_text(pos)
+
+ current_notif_pos = bind_vars.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT)
+ try:
+ self.notification_pos_combo.set_active(
+ notification_positions_list.index(current_notif_pos)
+ )
+ except ValueError:
+ self.notification_pos_combo.set_active(0)
+
+ self.notification_pos_combo.connect(
+ "changed", self.on_notification_position_changed
+ )
+
+ notification_pos_combo_container.add(self.notification_pos_combo)
+ layout_grid.attach(notification_pos_combo_container, 3, 6, 1, 1)
+
+ def _create_modules_section(self, vbox):
+ """Create modules section"""
+ components_header = Label(markup="Modules", h_align="start")
+ vbox.add(components_header)
+ components_grid = Grid(
+ column_spacing=15,
+ row_spacing=8,
+ style="margin-left: 10px; margin-top: 5px;",
+ )
+ vbox.add(components_grid)
+
+ self.component_switches = {}
+ component_display_names = {
+ "workspace": "Workspaces",
+ "metrics": "System Metrics",
+ "date_time": "Date & Time",
+ "battery": "Battery Indicator",
+ "controls": "Control Panel",
+ "indicators": "Indicators",
+ "music_player": "Music Player",
+ "applications": "Taskbar",
+ "language": "Language Indicator",
+ }
+
+ self.corners_switch = Gtk.Switch(active=bind_vars.get("corners_visible", True))
+ num_components = len(component_display_names) + 1
+ rows_per_column = (num_components + 1) // 2
+
+ corners_label = Label(
+ label="Rounded Corners", h_align="start", v_align="center"
+ )
+ components_grid.attach(corners_label, 0, 0, 1, 1)
+ switch_container_corners = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ switch_container_corners.add(self.corners_switch)
+ components_grid.attach(switch_container_corners, 1, 0, 1, 1)
+
+ item_idx = 0
+ for name, display in component_display_names.items():
+ if item_idx < (rows_per_column - 1):
+ row = item_idx + 1
+ col = 0
+ else:
+ row = item_idx - (rows_per_column - 1)
+ col = 2
+
+ component_label = Label(label=display, h_align="start", v_align="center")
+ components_grid.attach(component_label, col, row, 1, 1)
+
+ switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ component_switch = Gtk.Switch(
+ active=bind_vars.get(f"dock_{name}_visible", True)
+ )
+ switch_container.add(component_switch)
+ components_grid.attach(switch_container, col + 1, row, 1, 1)
+ self.component_switches[name] = component_switch
+ item_idx += 1
+
+ # Event handlers
+ def on_ws_mode_changed(self, combo):
+ """Handle workspace mode change"""
+ mode = combo.get_active_text()
+ is_numbers_mode = mode == "Numbers"
+ self.ws_chinese_switch.set_sensitive(is_numbers_mode)
+ if not is_numbers_mode:
+ self.ws_chinese_switch.set_active(False)
+
+ def on_position_changed(self, _combo):
+ """Handle dock position change"""
+ # Note: centered_switch is not used in appearance tab,
+ # this method is kept for compatibility
+ pass
+
+ def on_dock_enabled_changed(self, switch, _gparam):
+ """Handle dock enabled/disabled change"""
+ is_active = switch.get_active()
+ self.dock_hover_switch.set_sensitive(is_active)
+ self.dock_auto_hide_switch.set_sensitive(is_active)
+ self.dock_hide_special_switch.set_sensitive(is_active)
+ self.dock_hide_special_apps_switch.set_sensitive(is_active)
+ if not is_active:
+ self.dock_hover_switch.set_active(False)
+ self.dock_auto_hide_switch.set_active(False)
+ self.dock_hide_special_switch.set_active(False)
+ self.dock_hide_special_apps_switch.set_active(False)
+
+ def on_notification_position_changed(self, combo):
+ """Handle notification position change"""
+ selected_text = combo.get_active_text()
+ if selected_text:
+ bind_vars[NOTIF_POS_KEY] = selected_text
+ print(
+ f"Notification position updated in bind_vars: {
+ bind_vars[NOTIF_POS_KEY]
+ }"
+ )
+
+ def on_select_face_icon(self, _widget):
+ """Handle face icon selection"""
+ dialog = Gtk.FileChooserDialog(
+ title="Select Face Icon",
+ transient_for=self.parent_window,
+ action=Gtk.FileChooserAction.OPEN,
+ )
+ dialog.add_buttons(
+ Gtk.STOCK_CANCEL,
+ Gtk.ResponseType.CANCEL,
+ Gtk.STOCK_OPEN,
+ Gtk.ResponseType.OK,
+ )
+ image_filter = Gtk.FileFilter()
+ image_filter.set_name("Image files")
+ for mime in ["image/png", "image/jpeg"]:
+ image_filter.add_mime_type(mime)
+ for pattern in ["*.png", "*.jpg", "*.jpeg"]:
+ image_filter.add_pattern(pattern)
+ dialog.add_filter(image_filter)
+ if dialog.run() == Gtk.ResponseType.OK:
+ self.selected_face_icon = dialog.get_filename()
+ self.face_status_label.label = (
+ f"Selected: {os.path.basename(self.selected_face_icon)}"
+ )
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
+ self.selected_face_icon, 64, 64
+ )
+ self.face_image.set_from_pixbuf(pixbuf)
+ except Exception as e:
+ print(f"Error loading selected face icon preview: {e}")
+ self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG)
+ dialog.destroy()
+
+ def get_appearance_values(self):
+ """Get current appearance values from widgets"""
+ values = {}
+
+ values["wallpapers_dir"] = self.wall_dir_chooser.get_filename()
+ values["dock_position"] = self.position_combo.get_active_text()
+ values["vertical"] = values["dock_position"] in ["Left", "Right"]
+ values["dock_enabled"] = self.dock_switch.get_active()
+ values["dock_auto_hide"] = self.dock_auto_hide_switch.get_active()
+ values["dock_always_occluded"] = self.dock_hover_switch.get_active()
+ values["dock_hide_special_workspace"] = (
+ self.dock_hide_special_switch.get_active()
+ )
+ values["dock_hide_special_workspace_apps"] = (
+ self.dock_hide_special_apps_switch.get_active()
+ )
+ values["dock_icon_size"] = int(self.icon_size_scale.get_value())
+ values["window_switcher_items_per_row"] = int(
+ self.application_switcher_scale.get_value()
+ )
+ values["corners_visible"] = self.corners_switch.get_active()
+ values["dock_theme"] = self.dock_theme_combo.get_active_text()
+
+ selected_notif_pos_text = self.notification_pos_combo.get_active_text()
+ if selected_notif_pos_text:
+ values[NOTIF_POS_KEY] = selected_notif_pos_text
+ else:
+ values[NOTIF_POS_KEY] = NOTIF_POS_DEFAULT
+
+ for component_name, switch in self.component_switches.items():
+ values[f"dock_{component_name}_visible"] = switch.get_active()
+
+ # Save workspace settings
+ ws_mode = self.ws_mode_combo.get_active_text()
+ values["workspace_dots"] = ws_mode == "Dots"
+ values["workspace_nums"] = ws_mode == "Numbers"
+ values["workspace_use_chinese_numerals"] = self.ws_chinese_switch.get_active()
+
+ return values
+
+ def get_selected_face_icon(self):
+ """Get the selected face icon path"""
+ return self.selected_face_icon
+
+ def clear_selected_face_icon(self):
+ """Clear the selected face icon"""
+ self.selected_face_icon = None
+ self.face_status_label.label = ""
+
+ def reset_to_defaults(self, defaults):
+ """Reset appearance settings to default values"""
+ self.wall_dir_chooser.set_filename(defaults.get("wallpapers_dir", ""))
+
+ positions = ["Bottom", "Top", "Left", "Right"]
+ default_position = defaults.get("dock_position", "Bottom")
+ try:
+ self.position_combo.set_active(positions.index(default_position))
+ except ValueError:
+ self.position_combo.set_active(0)
+
+ self.dock_switch.set_active(defaults.get("dock_enabled", True))
+ self.dock_auto_hide_switch.set_active(defaults.get("dock_auto_hide", False))
+ self.dock_hover_switch.set_active(defaults.get("dock_always_occluded", False))
+ self.dock_hide_special_switch.set_active(
+ defaults.get("dock_hide_special_workspace", True)
+ )
+ self.dock_hide_special_apps_switch.set_active(
+ defaults.get("dock_hide_special_workspace_apps", True)
+ )
+
+ # Update sensitivity based on dock enabled state
+ dock_enabled = self.dock_switch.get_active()
+ self.dock_hover_switch.set_sensitive(dock_enabled)
+ self.dock_auto_hide_switch.set_sensitive(dock_enabled)
+ self.dock_hide_special_switch.set_sensitive(dock_enabled)
+ self.dock_hide_special_apps_switch.set_sensitive(dock_enabled)
+
+ default_dock_theme_val = defaults.get("dock_theme", "Pills")
+ try:
+ self.dock_theme_combo.set_active(self.themes.index(default_dock_theme_val))
+ except ValueError:
+ self.dock_theme_combo.set_active(0)
+
+ default_notif_pos_val = defaults.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT)
+ notification_positions_list = ["Top", "Bottom"]
+ try:
+ self.notification_pos_combo.set_active(
+ notification_positions_list.index(default_notif_pos_val)
+ )
+ except ValueError:
+ self.notification_pos_combo.set_active(0)
+
+ for name, switch in self.component_switches.items():
+ switch.set_active(defaults.get(f"dock_{name}_visible", True))
+
+ # Reset workspace settings
+ default_dots = defaults.get("workspace_dots", True)
+ default_nums = defaults.get("workspace_nums", False)
+ if default_dots:
+ default_mode = "Dots"
+ elif default_nums:
+ default_mode = "Numbers"
+ else:
+ default_mode = "Single Button"
+
+ ws_modes = ["Dots", "Numbers", "Single Button"]
+ try:
+ self.ws_mode_combo.set_active(ws_modes.index(default_mode))
+ except ValueError:
+ self.ws_mode_combo.set_active(0)
+
+ self.ws_chinese_switch.set_active(
+ defaults.get("workspace_use_chinese_numerals", False)
+ )
+ self.ws_chinese_switch.set_sensitive(default_nums)
+ self.corners_switch.set_active(defaults.get("corners_visible", True))
+
+ # Reset face icon
+ self.selected_face_icon = None
+ self.face_status_label.label = ""
+ current_face = os.path.expanduser("~/.face.icon")
+ try:
+ if os.path.exists(current_face):
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64)
+ self.face_image.set_from_pixbuf(pixbuf)
+ else:
+ self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG)
+ except Exception as e:
+ print(f"Error loading face icon: {e}")
+ self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG)
+
+ # Reset scales
+ self.icon_size_scale.set_value(defaults.get("dock_icon_size", 20))
+ self.application_switcher_scale.set_value(
+ defaults.get("window_switcher_items_per_row", 13)
+ )
diff --git a/config/settings/constants.py b/config/settings/constants.py
new file mode 100644
index 0000000..4b59b0f
--- /dev/null
+++ b/config/settings/constants.py
@@ -0,0 +1,79 @@
+from config.data import (
+ APP_NAME,
+ APP_NAME_CAP,
+)
+
+SOURCE_STRING = f"""
+# {APP_NAME_CAP}
+source = ~/.config/{APP_NAME_CAP}/config/hypr/{APP_NAME}.conf
+"""
+
+DEFAULTS = {
+ "prefix_restart": "ALT SHIFT",
+ "suffix_restart": "R",
+ "prefix_msg": "SUPER",
+ "suffix_msg": "A",
+ "prefix_application_switcher": "ALT",
+ "suffix_application_switcher": "TAB",
+ "prefix_kanban": "SUPER",
+ "suffix_kanban": "T",
+ "prefix_launcher": "SUPER",
+ "suffix_launcher": "SPACE",
+ "suffix_app_launcher": "D",
+ "prefix_app_launcher": "SUPER",
+ "prefix_cliphist": "SUPER",
+ "suffix_cliphist": "V",
+ "prefix_wallpapers": "SUPER",
+ "suffix_wallpapers": "W",
+ "prefix_randwall": "ALT SHIFT",
+ "suffix_randwall": "W",
+ "prefix_emoji": "SUPER",
+ "suffix_emoji": "E",
+ "prefix_power": "SUPER",
+ "suffix_power": "ESCAPE",
+ "prefix_caffeine": "SUPER SHIFT",
+ "suffix_caffeine": "M",
+ "prefix_restart_inspector": "SUPER CTRL ALT",
+ "suffix_restart_inspector": "B",
+ "prefix_settings": "SUPER",
+ "suffix_settings": "I",
+ "prefix_dashboard": "SUPER",
+ "suffix_dashboard": "Z",
+ "wallpapers_dir": "/home/sankalp/Pictures/wallpapers",
+ "vertical": True,
+ "dock_enabled": True,
+ "dock_always_occluded": False,
+ "dock_auto_hide": True,
+ "terminal_command": "kitty -e",
+ "corners_visible": False,
+ "dock_theme": "Pills",
+ "notif_pos": "Top",
+ "dock_position": "Bottom",
+ "dock_icon_size": 20,
+ "dock_workspaces_visible": True,
+ "dock_metrics_visible": True,
+ "dock_battery_visible": True,
+ "dock_date_time_visible": True,
+ "dock_controls_visible": True,
+ "dock_indicators_visible": True,
+ "dock_music_player_visible": True,
+ "dock_notifications_visible": True,
+ "dock_applications_visible": True,
+ "dock_language_visible": True,
+ "dock_hide_special_workspace": True,
+ "dock_hide_special_workspace_apps": True,
+ "workspace_nums": False,
+ "workspace_use_chinese_numerals": False,
+ "workspace_dots": False,
+ "window_switcher_items_per_row": 13,
+ "metrics_visible": {
+ "cpu": True,
+ "ram": True,
+ "disk": True,
+ "swap": True,
+ "gpu": False,
+ },
+ "metrics_disks": ["/"],
+ "limited_apps_history": ["Spotify"],
+ "history_ignored_apps": ["Modus"],
+}
diff --git a/config/settings/gui.py b/config/settings/gui.py
new file mode 100644
index 0000000..900eda7
--- /dev/null
+++ b/config/settings/gui.py
@@ -0,0 +1,341 @@
+import json
+import os
+import subprocess
+import threading
+import time
+
+import gi
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.stack import Stack
+from fabric.widgets.window import Window
+from gi.repository import Gdk, GdkPixbuf, GLib, Gtk
+from PIL import Image
+
+from config.data import APP_NAME, APP_NAME_CAP
+from config.settings.about_tab import AboutTab
+from config.settings.appearance_tab import AppearanceTab
+from config.settings.key_bindings_tab import KeyBindingsTab
+from config.settings.system_tab import SystemTab
+from config.settings.utils import backup_and_replace, start_config
+
+gi.require_version("Gtk", "3.0")
+
+
+class HyprConfGUI(Window):
+ def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs):
+ super().__init__(
+ title="Modus Settings",
+ name="modus-settings-window",
+ size=(640, 640),
+ **kwargs,
+ )
+
+ self.set_resizable(False)
+ # Set strict size constraints to prevent dynamic resizing
+ self.set_size_request(640, 640)
+ self.set_default_size(640, 640)
+ # Set geometry hints to enforce fixed size
+ geometry = Gdk.Geometry()
+ geometry.min_width = 640
+ geometry.max_width = 640
+ geometry.min_height = 640
+ geometry.max_height = 640
+ self.set_geometry_hints(
+ None, geometry, Gdk.WindowHints.MIN_SIZE | Gdk.WindowHints.MAX_SIZE
+ )
+
+ self.themes = ["Pills", "Dense", "Edge"]
+ self.selected_face_icon = None
+ self.show_lock_checkbox = show_lock_checkbox
+ self.show_idle_checkbox = show_idle_checkbox
+
+ # Initialize all tabs
+ self.key_bindings_tab = KeyBindingsTab()
+ self.appearance_tab = AppearanceTab(self.themes, self)
+ self.system_tab = SystemTab(
+ show_lock_checkbox, show_idle_checkbox, self.enforce_window_size
+ )
+ self.about_tab = AboutTab()
+
+ root_box = Box(orientation="v", spacing=10, style="margin: 10px;")
+ # Set fixed size for root container
+ root_box.set_size_request(620, 620)
+ self.add(root_box)
+
+ main_content_box = Box(
+ orientation="h", spacing=6, v_expand=False, h_expand=False
+ )
+ # Set fixed size for main content to prevent expansion
+ main_content_box.set_size_request(620, 580)
+ root_box.add(main_content_box)
+
+ self.tab_stack = Stack(
+ transition_type="slide-up-down",
+ transition_duration=250,
+ v_expand=False,
+ h_expand=False,
+ )
+ # Set fixed size for tab stack
+ self.tab_stack.set_size_request(580, 580)
+
+ self.key_bindings_tab_content = self.key_bindings_tab.create_key_bindings_tab()
+ self.appearance_tab_content = self.appearance_tab.create_appearance_tab()
+ self.system_tab_content = self.system_tab.create_system_tab()
+ self.about_tab_content = self.about_tab.create_about_tab()
+
+ self.tab_stack.add_titled(
+ self.key_bindings_tab_content, "key_bindings", "Key Bindings"
+ )
+ self.tab_stack.add_titled(
+ self.appearance_tab_content, "appearance", "Appearance"
+ )
+ self.tab_stack.add_titled(self.system_tab_content, "system", "System")
+ self.tab_stack.add_titled(self.about_tab_content, "about", "About")
+
+ tab_switcher = Gtk.StackSwitcher()
+ tab_switcher.set_stack(self.tab_stack)
+ tab_switcher.set_orientation(Gtk.Orientation.VERTICAL)
+ # Set fixed width for tab switcher
+ tab_switcher.set_size_request(40, -1)
+ main_content_box.add(tab_switcher)
+ main_content_box.add(self.tab_stack)
+
+ button_box = Box(orientation="h", spacing=10, h_align="end")
+ # Set fixed height for button box
+ button_box.set_size_request(-1, 40)
+ reset_btn = Button(label="Reset to Defaults", on_clicked=self.on_reset)
+ button_box.add(reset_btn)
+ close_btn = Button(label="Close", on_clicked=self.on_close)
+ button_box.add(close_btn)
+ accept_btn = Button(label="Apply & Reload", on_clicked=self.on_accept)
+ button_box.add(accept_btn)
+ root_box.add(button_box)
+
+ # Force window size after all content is added and on any size changes
+ self.connect("realize", self._force_window_size)
+ self.connect("size-allocate", self._on_size_allocate)
+
+ def _force_window_size(self, widget):
+ """Force the window to respect the declared size"""
+ self.resize(640, 640)
+ self.set_size_request(640, 640)
+
+ def _on_size_allocate(self, widget, allocation):
+ """Prevent window from changing size during allocation"""
+ if allocation.width != 640 or allocation.height != 640:
+ GLib.idle_add(lambda: self.resize(640, 640))
+
+ def enforce_window_size(self):
+ """Public method to enforce window size - can be called by tabs"""
+ self._force_window_size(self)
+
+ # Event handlers moved to respective tab classes
+
+ def on_accept(self, _widget):
+ current_bind_vars_snapshot = {}
+
+ # Get values from key bindings tab
+ current_bind_vars_snapshot.update(
+ self.key_bindings_tab.get_key_binding_values()
+ )
+
+ # Get values from appearance tab
+ current_bind_vars_snapshot.update(self.appearance_tab.get_appearance_values())
+
+ # Get values from system tab
+ current_bind_vars_snapshot.update(self.system_tab.get_system_values())
+
+ # Get face icon and hyprland switches
+ selected_icon_path = self.appearance_tab.get_selected_face_icon()
+ hyprland_switches = self.system_tab.get_hyprland_switches()
+ replace_lock = hyprland_switches["replace_lock"]
+ replace_idle = hyprland_switches["replace_idle"]
+
+ if selected_icon_path:
+ self.appearance_tab.clear_selected_face_icon()
+
+ def _apply_and_reload_task_thread():
+ nonlocal current_bind_vars_snapshot
+
+ from . import utils
+
+ utils.bind_vars.clear()
+ utils.bind_vars.update(current_bind_vars_snapshot)
+
+ start_time = time.time()
+ print(f"{start_time:.4f}: Background task started.")
+
+ config_json = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/config/assets/config.json"
+ )
+ os.makedirs(os.path.dirname(config_json), exist_ok=True)
+ try:
+ with open(config_json, "w") as f:
+ json.dump(utils.bind_vars, f, indent=4)
+ print(f"{time.time():.4f}: Saved config.json.")
+ except Exception as e:
+ print(f"Error saving config.json: {e}")
+
+ if selected_icon_path:
+ print(f"{time.time():.4f}: Processing face icon...")
+ try:
+ img = Image.open(selected_icon_path)
+ side = min(img.size)
+ left = (img.width - side) // 2
+ top = (img.height - side) // 2
+ cropped_img = img.crop((left, top, left + side, top + side))
+ face_icon_dest = os.path.expanduser("~/.face.icon")
+ cropped_img.save(face_icon_dest, format="PNG")
+ print(f"{time.time():.4f}: Face icon saved to {face_icon_dest}")
+ GLib.idle_add(self._update_face_image_widget, face_icon_dest)
+ except Exception as e:
+ print(f"Error processing face icon: {e}")
+ print(f"{time.time():.4f}: Finished processing face icon.")
+
+ if replace_lock:
+ print(f"{time.time():.4f}: Replacing hyprlock config...")
+ src = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf"
+ )
+ dest = os.path.expanduser("~/.config/hypr/hyprlock.conf")
+ if os.path.exists(src):
+ backup_and_replace(src, dest, "Hyprlock")
+ else:
+ print(f"Warning: Source hyprlock config not found at {src}")
+ print(f"{time.time():.4f}: Finished replacing hyprlock config.")
+
+ if replace_idle:
+ print(f"{time.time():.4f}: Replacing hypridle config...")
+ src = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf"
+ )
+ dest = os.path.expanduser("~/.config/hypr/hypridle.conf")
+ if os.path.exists(src):
+ backup_and_replace(src, dest, "Hypridle")
+ else:
+ print(f"Warning: Source hypridle config not found at {src}")
+ print(f"{time.time():.4f}: Finished replacing hypridle config.")
+
+ print(
+ f"{time.time():.4f}: Checking/Appending hyprland.conf source string..."
+ )
+ hypr_path = os.path.expanduser("~/.config/hypr/hyprland.conf")
+ try:
+ from .constants import SOURCE_STRING
+
+ needs_append = True
+ if os.path.exists(hypr_path):
+ with open(hypr_path, "r") as f:
+ if SOURCE_STRING.strip() in f.read():
+ needs_append = False
+ else:
+ os.makedirs(os.path.dirname(hypr_path), exist_ok=True)
+
+ if needs_append:
+ with open(hypr_path, "a") as f:
+ f.write("\n" + SOURCE_STRING)
+ print(f"Appended source string to {hypr_path}")
+ except Exception as e:
+ print(f"Error updating {hypr_path}: {e}")
+ print(
+ f"{time.time():.4f}: Finished checking/appending hyprland.conf source string."
+ )
+
+ print(f"{time.time():.4f}: Running start_config()...")
+ start_config()
+ print(f"{time.time():.4f}: Finished start_config().")
+
+ print(f"{time.time():.4f}: Initiating Modus restart using Popen...")
+ main_py = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/main.py")
+ kill_cmd = f"killall {APP_NAME}"
+ start_cmd = ["uwsm", "app", "--", "python", main_py]
+ try:
+ kill_proc = subprocess.Popen(
+ kill_cmd,
+ shell=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ kill_proc.wait(timeout=2)
+ print(f"{time.time():.4f}: killall process finished (or timed out).")
+ except subprocess.TimeoutExpired:
+ print("Warning: killall command timed out.")
+ except Exception as e:
+ print(f"Error running killall: {e}")
+
+ try:
+ subprocess.Popen(
+ start_cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ print(f"{APP_NAME_CAP} restart initiated via Popen.")
+ except FileNotFoundError as e:
+ print(f"Error restarting {APP_NAME_CAP}: Command not found ({e})")
+ except Exception as e:
+ print(f"Error restarting {APP_NAME_CAP} via Popen: {e}")
+
+ print(f"{time.time():.4f}: Modus restart commands issued via Popen.")
+ end_time = time.time()
+ total_time = end_time - start_time
+ print(
+ f"{end_time:.4f}: Background task finished (Total: {total_time:.4f}s)."
+ )
+
+ thread = threading.Thread(target=_apply_and_reload_task_thread)
+ thread.daemon = True
+ thread.start()
+ print("Configuration apply/reload task started in background.")
+
+ def _update_face_image_widget(self, icon_path):
+ try:
+ if self.face_image and self.face_image.get_window():
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_path, 64, 64)
+ self.face_image.set_from_pixbuf(pixbuf)
+ except Exception as e:
+ print(f"Error reloading face icon preview: {e}")
+ if self.face_image and self.face_image.get_window():
+ self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG)
+ return GLib.SOURCE_REMOVE
+
+ def on_reset(self, _widget):
+ dialog = Gtk.MessageDialog(
+ transient_for=self.get_toplevel(),
+ flags=0,
+ message_type=Gtk.MessageType.QUESTION,
+ buttons=Gtk.ButtonsType.YES_NO,
+ text="Reset all settings to defaults?",
+ )
+ dialog.format_secondary_text(
+ "This will reset all keybindings and appearance settings to their default values."
+ )
+ response = dialog.run()
+ dialog.hide() # Hide the dialog first
+ dialog.destroy() # Then destroy it
+
+ # Process any pending events
+ while Gtk.events_pending():
+ Gtk.main_iteration()
+
+ if response == Gtk.ResponseType.YES:
+ from . import utils
+ from .constants import DEFAULTS
+
+ utils.bind_vars.clear()
+ utils.bind_vars.update(DEFAULTS.copy())
+
+ # Reset all tabs to defaults
+ self.key_bindings_tab.reset_to_defaults(utils.bind_vars)
+ self.appearance_tab.reset_to_defaults(utils.bind_vars)
+ self.system_tab.reset_to_defaults(utils.bind_vars)
+
+ self._update_panel_position_sensitivity()
+ print("Settings reset to defaults.")
+
+ def on_close(self, _widget):
+ if self.application:
+ self.application.quit()
+ else:
+ self.destroy()
diff --git a/config/settings/key_bindings_tab.py b/config/settings/key_bindings_tab.py
new file mode 100644
index 0000000..896cd0a
--- /dev/null
+++ b/config/settings/key_bindings_tab.py
@@ -0,0 +1,107 @@
+from fabric.widgets.box import Box
+from fabric.widgets.entry import Entry
+from fabric.widgets.grid import Grid
+from fabric.widgets.label import Label
+from fabric.widgets.scrolledwindow import ScrolledWindow
+
+from config.data import APP_NAME_CAP
+from config.settings.utils import bind_vars
+
+
+class KeyBindingsTab:
+ """Key bindings management tab for settings"""
+
+ def __init__(self):
+ self.entries = []
+
+ def create_key_bindings_tab(self):
+ """Create the Key Bindings tab content"""
+ scrolled_window = ScrolledWindow(
+ h_scrollbar_policy="never",
+ v_scrollbar_policy="automatic",
+ h_expand=False,
+ v_expand=False,
+ propagate_width=False,
+ propagate_height=False,
+ )
+ # Set fixed size to match tab stack dimensions
+ scrolled_window.set_size_request(580, 580)
+
+ main_vbox = Box(orientation="v", spacing=10, style="margin: 15px;")
+ scrolled_window.add(main_vbox)
+
+ keybind_grid = Grid(column_spacing=10, row_spacing=8, style="margin: 5px;")
+
+ action_label = Label(
+ markup="Action", h_align="start", style="margin-bottom: 5px;"
+ )
+ modifier_label = Label(
+ markup="Modifier", h_align="start", style="margin-bottom: 5px;"
+ )
+ separator_label = Label(
+ label="+", h_align="center", style="margin-bottom: 5px;"
+ )
+ key_label = Label(
+ markup="Key", h_align="start", style="margin-bottom: 5px;"
+ )
+
+ keybind_grid.attach(action_label, 0, 0, 1, 1)
+ keybind_grid.attach(modifier_label, 1, 0, 1, 1)
+ keybind_grid.attach(separator_label, 2, 0, 1, 1)
+ keybind_grid.attach(key_label, 3, 0, 1, 1)
+
+ self.entries = []
+ bindings = [
+ (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"),
+ ("Message", "prefix_msg", "suffix_msg"),
+ (
+ "Application Switcher",
+ "prefix_application_switcher",
+ "suffix_application_switcher",
+ ),
+ ("Launcher", "prefix_launcher", "suffix_launcher"),
+ ("App Launcher", "prefix_app_launcher", "suffix_app_launcher"),
+ ("Clipboard History", "prefix_cliphist", "suffix_cliphist"),
+ ("Wallpapers", "prefix_wallpapers", "suffix_wallpapers"),
+ ("Random Wallpaper", "prefix_randwall", "suffix_randwall"),
+ ("Emoji Picker", "prefix_emoji", "suffix_emoji"),
+ ("Kanban", "prefix_kanban", "suffix_kanban"),
+ ("Power Menu", "prefix_power", "suffix_power"),
+ ("Toggle Caffeine", "prefix_caffeine", "suffix_caffeine"),
+ ("Dashboard", "prefix_dashboard", "suffix_dashboard"),
+ ("Settings", "prefix_settings", "suffix_settings"),
+ (
+ "Restart with inspector",
+ "prefix_restart_inspector",
+ "suffix_restart_inspector",
+ ),
+ ]
+
+ for i, (label_text, prefix_key, suffix_key) in enumerate(bindings):
+ row = i + 1
+ binding_label = Label(label=label_text, h_align="start")
+ keybind_grid.attach(binding_label, 0, row, 1, 1)
+ prefix_entry = Entry(text=bind_vars.get(prefix_key, ""))
+ keybind_grid.attach(prefix_entry, 1, row, 1, 1)
+ plus_label = Label(label="+", h_align="center")
+ keybind_grid.attach(plus_label, 2, row, 1, 1)
+ suffix_entry = Entry(text=bind_vars.get(suffix_key, ""))
+ keybind_grid.attach(suffix_entry, 3, row, 1, 1)
+ self.entries.append((prefix_key, suffix_key, prefix_entry, suffix_entry))
+
+ main_vbox.add(keybind_grid)
+ return scrolled_window
+
+ def get_key_binding_values(self):
+ """Get current key binding values from entries"""
+ values = {}
+ for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries:
+ values[prefix_key] = prefix_entry.get_text()
+ values[suffix_key] = suffix_entry.get_text()
+ return values
+
+ def reset_to_defaults(self, defaults):
+ """Reset key bindings to default values"""
+ for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries:
+ prefix_entry.set_text(defaults.get(prefix_key, ""))
+ suffix_entry.set_text(defaults.get(suffix_key, ""))
diff --git a/config/settings/system_tab.py b/config/settings/system_tab.py
new file mode 100644
index 0000000..ddaf3de
--- /dev/null
+++ b/config/settings/system_tab.py
@@ -0,0 +1,382 @@
+import gi
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.entry import Entry
+from fabric.widgets.grid import Grid
+from fabric.widgets.label import Label
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from gi.repository import GLib, Gtk
+
+from config.settings.utils import bind_vars
+
+gi.require_version("Gtk", "3.0")
+
+
+class SystemTab:
+ """System settings and metrics management tab for settings"""
+
+ def __init__(
+ self,
+ show_lock_checkbox=False,
+ show_idle_checkbox=False,
+ window_size_enforcer=None,
+ ):
+ self.show_lock_checkbox = show_lock_checkbox
+ self.show_idle_checkbox = show_idle_checkbox
+ self.window_size_enforcer = window_size_enforcer
+
+ # Widget references
+ self.terminal_entry = None
+ self.lock_switch = None
+ self.idle_switch = None
+ self.limited_apps_entry = None
+ self.ignored_apps_entry = None
+ self.metrics_switches = {}
+ self.disk_entries = None
+ self._create_disk_edit_entry_func = None
+
+ def create_system_tab(self):
+ """Create the System tab content"""
+ scrolled_window = ScrolledWindow(
+ h_scrollbar_policy="never",
+ v_scrollbar_policy="automatic",
+ h_expand=False,
+ v_expand=False,
+ propagate_width=False,
+ propagate_height=False,
+ )
+ # Set fixed size to match tab stack dimensions
+ scrolled_window.set_size_request(580, 580)
+
+ vbox = Box(orientation="v", spacing=15, style="margin: 15px;")
+ scrolled_window.add(vbox)
+
+ # Create terminal and hyprland settings section
+ self._create_terminal_hyprland_section(vbox)
+
+ # Create notification settings section
+ self._create_notification_settings_section(vbox)
+
+ # Create system metrics section
+ self._create_system_metrics_section(vbox)
+
+ return scrolled_window
+
+ def _create_terminal_hyprland_section(self, vbox):
+ """Create terminal and Hyprland integration section"""
+ system_grid = Grid(
+ column_spacing=20, row_spacing=10, style="margin-bottom: 15px;"
+ )
+ vbox.add(system_grid)
+
+ # Terminal settings
+ terminal_header = Label(markup="Terminal Settings", h_align="start")
+ system_grid.attach(terminal_header, 0, 0, 2, 1)
+ terminal_label = Label(label="Command:", h_align="start", v_align="center")
+ system_grid.attach(terminal_label, 0, 1, 1, 1)
+ self.terminal_entry = Entry(
+ text=bind_vars.get("terminal_command", "kitty -e"),
+ tooltip_text="Command used to launch terminal apps (e.g., 'kitty -e')",
+ h_expand=True,
+ )
+ system_grid.attach(self.terminal_entry, 1, 1, 1, 1)
+ hint_label = Label(
+ markup="Examples: 'kitty -e', 'alacritty -e', 'foot -e'",
+ h_align="start",
+ )
+ system_grid.attach(hint_label, 0, 2, 2, 1)
+
+ # Hyprland integration
+ hypr_header = Label(markup="Hyprland Integration", h_align="start")
+ system_grid.attach(hypr_header, 2, 0, 2, 1)
+ row = 1
+ self.lock_switch = None
+ if self.show_lock_checkbox:
+ lock_label = Label(
+ label="Replace Hyprlock config", h_align="start", v_align="center"
+ )
+ system_grid.attach(lock_label, 2, row, 1, 1)
+ lock_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.lock_switch = Gtk.Switch(
+ tooltip_text="Replace Hyprlock configuration with Modus's custom config"
+ )
+ lock_switch_container.add(self.lock_switch)
+ system_grid.attach(lock_switch_container, 3, row, 1, 1)
+ row += 1
+ self.idle_switch = None
+ if self.show_idle_checkbox:
+ idle_label = Label(
+ label="Replace Hypridle config", h_align="start", v_align="center"
+ )
+ system_grid.attach(idle_label, 2, row, 1, 1)
+ idle_switch_container = Box(
+ orientation="horizontal", h_align="start", v_align="center"
+ )
+ self.idle_switch = Gtk.Switch(
+ tooltip_text="Replace Hypridle configuration with Modus's custom config"
+ )
+ idle_switch_container.add(self.idle_switch)
+ system_grid.attach(idle_switch_container, 3, row, 1, 1)
+ row += 1
+ if self.show_lock_checkbox or self.show_idle_checkbox:
+ note_label = Label(
+ markup="Existing configs will be backed up",
+ h_align="start",
+ )
+ system_grid.attach(note_label, 2, row, 2, 1)
+
+ def _create_notification_settings_section(self, vbox):
+ """Create notification settings section"""
+ notifications_header = Label(
+ markup="Notification Settings", h_align="start"
+ )
+ vbox.add(notifications_header)
+
+ notif_grid = Grid(
+ column_spacing=20,
+ row_spacing=10,
+ style="margin-left: 10px; margin-top: 5px; margin-bottom: 15px;",
+ )
+ vbox.add(notif_grid)
+
+ # Limited Apps History
+ limited_apps_label = Label(
+ label="Limited Apps History:", h_align="start", v_align="center"
+ )
+ notif_grid.attach(limited_apps_label, 0, 0, 1, 1)
+
+ limited_apps_list = bind_vars.get("limited_apps_history", ["Spotify"])
+ limited_apps_text = ", ".join(f'"{app}"' for app in limited_apps_list)
+ self.limited_apps_entry = Entry(
+ text=limited_apps_text,
+ tooltip_text='Enter app names separated by commas, e.g: "Spotify", "Discord"',
+ h_expand=True,
+ )
+ notif_grid.attach(self.limited_apps_entry, 1, 0, 1, 1)
+
+ limited_apps_hint = Label(
+ markup='Apps with limited notification history (format: "App1", "App2")',
+ h_align="start",
+ )
+ notif_grid.attach(limited_apps_hint, 0, 1, 2, 1)
+
+ # History Ignored Apps
+ ignored_apps_label = Label(
+ label="History Ignored Apps:", h_align="start", v_align="center"
+ )
+ notif_grid.attach(ignored_apps_label, 0, 2, 1, 1)
+
+ ignored_apps_list = bind_vars.get("history_ignored_apps", ["Modus"])
+ ignored_apps_text = ", ".join(f'"{app}"' for app in ignored_apps_list)
+ self.ignored_apps_entry = Entry(
+ text=ignored_apps_text,
+ tooltip_text='Enter app names separated by commas, e.g: "Modus", "Screenshot"',
+ h_expand=True,
+ )
+ notif_grid.attach(self.ignored_apps_entry, 1, 2, 1, 1)
+
+ ignored_apps_hint = Label(
+ markup='Apps whose notifications are ignored in history (format: "App1", "App2")',
+ h_align="start",
+ )
+ notif_grid.attach(ignored_apps_hint, 0, 3, 2, 1)
+
+ def _create_system_metrics_section(self, vbox):
+ """Create system metrics section"""
+ metrics_header = Label(markup="System Metrics Options", h_align="start")
+ vbox.add(metrics_header)
+ metrics_grid = Grid(
+ column_spacing=15,
+ row_spacing=8,
+ style="margin-left: 10px; margin-top: 5px;",
+ )
+ vbox.add(metrics_grid)
+
+ self.metrics_switches = {}
+ metric_names = {
+ "cpu": "CPU",
+ "ram": "RAM",
+ "swap": "Swap",
+ "disk": "Disk",
+ "gpu": "GPU",
+ }
+
+ metrics_grid.attach(Label(label="Show in Metrics", h_align="start"), 0, 0, 1, 1)
+ for i, (key, label_text) in enumerate(metric_names.items()):
+ switch = Gtk.Switch(
+ active=bind_vars.get("metrics_visible", {}).get(key, True)
+ )
+ self.metrics_switches[key] = switch
+ metrics_grid.attach(
+ Label(label=label_text, h_align="start"), 0, i + 1, 1, 1
+ )
+ metrics_grid.attach(switch, 1, i + 1, 1, 1)
+
+ def enforce_minimum_metrics(switch_dict):
+ enabled_switches = [s for s in switch_dict.values() if s.get_active()]
+ can_disable = len(enabled_switches) > 1
+ for s in switch_dict.values():
+ s.set_sensitive(True if can_disable or not s.get_active() else False)
+
+ def on_metric_toggle(_switch, _gparam, switch_dict):
+ enforce_minimum_metrics(switch_dict)
+
+ for s_s in self.metrics_switches.values():
+ s_s.connect("notify::active", on_metric_toggle, self.metrics_switches)
+ enforce_minimum_metrics(self.metrics_switches)
+
+ # Disk directories section
+ disks_label = Label(
+ label="Disk directories for Metrics", h_align="start", v_align="center"
+ )
+ vbox.add(disks_label)
+
+ # Create a scrolled container for disk entries to prevent overflow
+ disk_entries_scrolled = ScrolledWindow(
+ h_scrollbar_policy="never",
+ v_scrollbar_policy="automatic",
+ h_expand=False,
+ v_expand=False,
+ propagate_width=False,
+ propagate_height=False,
+ )
+ # Set fixed size for disk entries container
+ disk_entries_scrolled.set_size_request(550, 120)
+
+ self.disk_entries = Box(orientation="v", spacing=8, h_align="start")
+ # Set size constraints for the disk entries box
+ self.disk_entries.set_size_request(530, -1)
+ disk_entries_scrolled.add(self.disk_entries)
+
+ self._create_disk_edit_entry_func = lambda path: self._add_disk_entry_widget(
+ path
+ )
+
+ for p in bind_vars.get("metrics_disks", ["/"]):
+ self._create_disk_edit_entry_func(p)
+ vbox.add(disk_entries_scrolled)
+
+ add_container = Box(orientation="horizontal", h_align="start", v_align="center")
+ add_btn = Button(
+ label="Add new disk",
+ on_clicked=lambda _: self._create_disk_edit_entry_func("/"),
+ )
+ add_container.add(add_btn)
+ vbox.add(add_container)
+
+ def _add_disk_entry_widget(self, path):
+ """Helper to add a disk entry row to the disk_entries Box."""
+ bar = Box(orientation="h", spacing=10, h_align="start")
+ # Set fixed height for disk entry rows
+ bar.set_size_request(-1, 30)
+ entry = Entry(text=path, h_expand=True)
+ bar.add(entry)
+ x_btn = Button(label="X")
+ x_btn.connect(
+ "clicked",
+ lambda _, current_bar_to_remove=bar: self._remove_disk_entry(
+ current_bar_to_remove
+ ),
+ )
+ bar.add(x_btn)
+ self.disk_entries.add(bar)
+ self.disk_entries.show_all()
+ # Enforce window size after adding content
+ if self.window_size_enforcer:
+ GLib.idle_add(self.window_size_enforcer)
+
+ def _remove_disk_entry(self, bar_to_remove):
+ """Helper to remove a disk entry and enforce window size"""
+ self.disk_entries.remove(bar_to_remove)
+ # Enforce window size after removing content
+ if self.window_size_enforcer:
+ GLib.idle_add(self.window_size_enforcer)
+
+ def get_system_values(self):
+ """Get current system values from widgets"""
+ values = {}
+
+ values["terminal_command"] = self.terminal_entry.get_text()
+
+ values["metrics_visible"] = {
+ k: s.get_active() for k, s in self.metrics_switches.items()
+ }
+ values["metrics_disks"] = [
+ child.get_children()[0].get_text()
+ for child in self.disk_entries.get_children()
+ if isinstance(child, Box)
+ and child.get_children()
+ and isinstance(child.get_children()[0], Entry)
+ ]
+
+ # Parse notification app lists
+ def parse_app_list(text):
+ """Parse comma-separated app names with quotes"""
+ if not text.strip():
+ return []
+ apps = []
+ for app in text.split(","):
+ app = app.strip()
+ if app.startswith('"') and app.endswith('"'):
+ app = app[1:-1]
+ elif app.startswith("'") and app.endswith("'"):
+ app = app[1:-1]
+ if app:
+ apps.append(app)
+ return apps
+
+ values["limited_apps_history"] = parse_app_list(
+ self.limited_apps_entry.get_text()
+ )
+ values["history_ignored_apps"] = parse_app_list(
+ self.ignored_apps_entry.get_text()
+ )
+
+ return values
+
+ def get_hyprland_switches(self):
+ """Get the state of Hyprland integration switches"""
+ return {
+ "replace_lock": self.lock_switch and self.lock_switch.get_active(),
+ "replace_idle": self.idle_switch and self.idle_switch.get_active(),
+ }
+
+ def reset_to_defaults(self, defaults):
+ """Reset system settings to default values"""
+ self.terminal_entry.set_text(defaults.get("terminal_command", "kitty -e"))
+
+ metrics_vis_defaults = defaults.get("metrics_visible", {})
+ for k, s_widget in self.metrics_switches.items():
+ s_widget.set_active(metrics_vis_defaults.get(k, True))
+
+ def enforce_minimum_metrics(switch_dict):
+ enabled_switches = [s for s in switch_dict.values() if s.get_active()]
+ can_disable = len(enabled_switches) > 1
+ for s in switch_dict.values():
+ s.set_sensitive(True if can_disable or not s.get_active() else False)
+
+ enforce_minimum_metrics(self.metrics_switches)
+
+ # Reset disk entries
+ for child in list(self.disk_entries.get_children()):
+ self.disk_entries.remove(child)
+
+ for p in defaults.get("metrics_disks", ["/"]):
+ self._create_disk_edit_entry_func(p)
+
+ # Reset notification app lists
+ limited_apps_list = defaults.get("limited_apps_history", ["Spotify"])
+ limited_apps_text = ", ".join(f'"{app}"' for app in limited_apps_list)
+ self.limited_apps_entry.set_text(limited_apps_text)
+
+ ignored_apps_list = defaults.get("history_ignored_apps", ["Modus"])
+ ignored_apps_text = ", ".join(f'"{app}"' for app in ignored_apps_list)
+ self.ignored_apps_entry.set_text(ignored_apps_text)
+
+ # Reset Hyprland switches
+ if self.lock_switch:
+ self.lock_switch.set_active(False)
+ if self.idle_switch:
+ self.idle_switch.set_active(False)
diff --git a/config/settings/utils.py b/config/settings/utils.py
new file mode 100644
index 0000000..e73fa9c
--- /dev/null
+++ b/config/settings/utils.py
@@ -0,0 +1,416 @@
+import json
+import os
+import shutil
+import subprocess
+import time
+
+import gi
+import toml
+from fabric.utils.helpers import exec_shell_command_async
+
+from config.data import (
+ APP_NAME,
+ APP_NAME_CAP,
+)
+from config.settings import constants as settings_constants
+
+gi.require_version("Gtk", "3.0")
+
+
+# Global variable to store binding variables, managed by this module
+bind_vars = {}
+
+
+def deep_update(target: dict, update: dict) -> dict:
+ """
+ Recursively update a nested dictionary with values from another dictionary.
+ Modifies target in-place.
+ """
+ for key, value in update.items():
+ if isinstance(value, dict) and key in target and isinstance(target[key], dict):
+ # If the value is a dictionary and the key already exists in target as a dictionary,
+ # then update recursively.
+ deep_update(target[key], value)
+ else:
+ # Otherwise, simply set/overwrite the value.
+ target[key] = value
+ return target # Although it modifies in-place, returning it is a common convention
+
+
+def ensure_matugen_config():
+ """
+ Ensure that the matugen configuration file exists and is updated
+ with the expected settings.
+ """
+ expected_config = {
+ "config": {
+ "reload_apps": True,
+ "wallpaper": {
+ "command": "swww",
+ "arguments": [
+ "img",
+ "-t",
+ "outer",
+ "--transition-duration",
+ "1.5",
+ "--transition-step",
+ "255",
+ "--transition-fps",
+ "60",
+ "-f",
+ "Nearest",
+ ],
+ "set": True,
+ },
+ "custom_colors": {
+ "red": {"color": "#FF0000", "blend": True},
+ "green": {"color": "#00FF00", "blend": True},
+ "yellow": {"color": "#FFFF00", "blend": True},
+ "blue": {"color": "#0000FF", "blend": True},
+ "magenta": {"color": "#FF00FF", "blend": True},
+ "cyan": {"color": "#00FFFF", "blend": True},
+ "white": {"color": "#FFFFFF", "blend": True},
+ },
+ },
+ "templates": {
+ "hyprland": {
+ "input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/hyprland-colors.conf",
+ "output_path": f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf",
+ },
+ f"{APP_NAME}": {
+ "input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/{APP_NAME}.css",
+ "output_path": f"~/.config/{APP_NAME_CAP}/styles/colors.css",
+ "post_hook": f"fabric-cli exec {APP_NAME} 'app.set_css()' &",
+ },
+ },
+ }
+
+ config_path = os.path.expanduser("~/.config/matugen/config.toml")
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
+
+ existing_config = {}
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, "r") as f:
+ existing_config = toml.load(f)
+ shutil.copyfile(config_path, config_path + ".bak")
+ except toml.TomlDecodeError:
+ print(
+ f"Warning: Could not decode TOML from {
+ config_path
+ }. A new default config will be created."
+ )
+ existing_config = {} # Reset if corrupted
+ except Exception as e:
+ print(f"Error reading or backing up {config_path}: {e}")
+ # existing_config could be partially loaded or empty.
+ # Continue to try merging with defaults.
+
+ # We use a copy of existing_config for deep_update if we don't want to modify it directly
+ # or ensure that deep_update doesn't do it if not desired.
+ # The current implementation of deep_update modifies 'target'.
+ # To be safer, we can pass a copy if existing_config should not change.
+ # merged_config = deep_update(existing_config.copy(), expected_config)
+ # Or if existing_config can be modified:
+ # existing_config is modified in-place
+ merged_config = deep_update(existing_config, expected_config)
+
+ try:
+ with open(config_path, "w") as f:
+ toml.dump(merged_config, f)
+ except Exception as e:
+ print(f"Error writing matugen config to {config_path}: {e}")
+
+ current_wall = os.path.expanduser("~/.current.wall")
+ hypr_colors = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf"
+ )
+ css_colors = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/styles/colors.css")
+
+ if (
+ not os.path.exists(current_wall)
+ or not os.path.exists(hypr_colors)
+ or not os.path.exists(css_colors)
+ ):
+ os.makedirs(os.path.dirname(hypr_colors), exist_ok=True)
+ os.makedirs(os.path.dirname(css_colors), exist_ok=True)
+
+ image_path = ""
+ if not os.path.exists(current_wall):
+ example_wallpaper_path = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg"
+ )
+ if os.path.exists(example_wallpaper_path):
+ try:
+ # If it already exists (possibly a broken link or regular file), remove and re-link
+ # lexists to not follow the link if it is one
+ if os.path.lexists(current_wall):
+ os.remove(current_wall)
+ os.symlink(example_wallpaper_path, current_wall)
+ image_path = example_wallpaper_path
+ except Exception as e:
+ print(f"Error creating symlink for wallpaper: {e}")
+ else:
+ image_path = (
+ os.path.realpath(current_wall)
+ if os.path.islink(current_wall)
+ else current_wall
+ )
+
+ if image_path and os.path.exists(image_path):
+ print(f"Generating color theme from wallpaper: {image_path}")
+ try:
+ matugen_cmd = f"matugen image '{image_path}'"
+ exec_shell_command_async(matugen_cmd)
+ print("Matugen color theme generation initiated.")
+ except FileNotFoundError:
+ print("Error: matugen command not found. Please install matugen.")
+ except Exception as e:
+ print(f"Error initiating matugen: {e}")
+ elif not image_path:
+ print(
+ "Warning: No wallpaper path determined to generate matugen theme from."
+ )
+ else: # image_path exists but the file doesn't
+ print(
+ f"Warning: Wallpaper at {
+ image_path
+ } not found. Cannot generate matugen theme."
+ )
+
+
+def load_bind_vars():
+ """
+ Load saved key binding variables from JSON, if available.
+ Populates the global `bind_vars` in-place.
+ """
+ global bind_vars # Necessary to modify the global bind_vars object
+
+ # 1. Clear the existing bind_vars dictionary.
+ bind_vars.clear()
+ # 2. Update it with a copy of DEFAULTS.
+ # Use .copy() to not accidentally modify DEFAULTS
+ bind_vars.update(settings_constants.DEFAULTS.copy())
+
+ config_json = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/config/assets/config.json"
+ )
+ if os.path.exists(config_json):
+ try:
+ with open(config_json, "r") as f:
+ saved_vars = json.load(f)
+ # 3. Use deep_update to merge saved_vars into the existing bind_vars.
+ deep_update(bind_vars, saved_vars)
+
+ # The logic to ensure the structure of nested dictionaries
+ # like 'metrics_visible' and 'metrics_small_visible'
+ # should operate on the already updated 'bind_vars'.
+ for vis_key in ["metrics_visible"]:
+ # Ensure that the key exists in DEFAULTS as a structure reference
+ if vis_key in settings_constants.DEFAULTS:
+ default_sub_dict = settings_constants.DEFAULTS[vis_key]
+ # If the key is not in bind_vars or is not a dictionary after deep_update,
+ # restore it from a copy of DEFAULTS for that key.
+ if not isinstance(bind_vars.get(vis_key), dict):
+ bind_vars[vis_key] = default_sub_dict.copy()
+ else:
+ # If it is a dictionary, ensure that all sub-keys from DEFAULTS are present.
+ current_sub_dict = bind_vars[vis_key]
+ for m_key, m_val in default_sub_dict.items():
+ if m_key not in current_sub_dict:
+ current_sub_dict[m_key] = m_val
+ except json.JSONDecodeError:
+ print(
+ f"Warning: Could not decode JSON from {
+ config_json
+ }. Using defaults (already initialized)."
+ )
+ # bind_vars is already populated with DEFAULTS, no additional action needed here.
+ except Exception as e:
+ print(
+ f"Error loading config from {config_json}: {
+ e
+ }. Using defaults (already initialized)."
+ )
+ # bind_vars is already populated with DEFAULTS.
+ # else:
+ # If config_json doesn't exist, bind_vars is already populated with DEFAULTS.
+ # print(f"Config file {config_json} not found. Using defaults (already initialized).")
+
+
+def generate_hyprconf() -> str:
+ """
+ Generate the Hypr configuration string using the current bind_vars.
+ """
+ home = os.path.expanduser("~")
+ # Determine animation type based on bar position
+ dock_position = bind_vars.get("dock_position", "Bottom")
+ is_vertical = dock_position in ["Left", "Right"]
+ animation_type = "slidefadevert" if is_vertical else "slidefade"
+
+ return f"""exec-once = uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py)
+exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle
+exec = uwsm app -- swww-daemon
+exec-once = wl-paste --type text --watch cliphist store
+exec-once = wl-paste --type image --watch cliphist store
+
+$fabricSend = fabric-cli exec {APP_NAME}
+$Message = notify-send "Modus" "FIRE IN THE HOLE‼️🗣️🔥🕳️" -i "{home}/.config/{APP_NAME_CAP}/assets/modus.png" -A "🗣️" -A "🔥" -A "🕳️" -a "Source Code"
+
+# Reload {APP_NAME_CAP}
+bind = {bind_vars.get("prefix_restart", "ALT SHIFT")}, {bind_vars.get("suffix_restart", "R")}, exec, killall {APP_NAME}; uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py)
+# Message
+bind = {bind_vars.get("prefix_msg", "SUPER")}, {bind_vars.get("suffix_msg", "A")}, exec, $Message
+# Application Switcher
+bind = {bind_vars.get("prefix_application_switcher", "ALT")}, {bind_vars.get("suffix_application_switcher", "TAB")}, exec, $fabricSend 'switcher.show_switcher()'
+# Kanban
+bind = {bind_vars.get("prefix_kanban", "SUPER")}, {bind_vars.get("suffix_kanban", "T")}, exec, $fabricSend "launcher.show_launcher('kanban')" # Kanban
+# App Launcher
+bind = {bind_vars.get("prefix_launcher", "SUPER")}, {bind_vars.get("suffix_launcher", "SPACE")}, exec, $fabricSend "launcher.show_launcher()"
+bind = {bind_vars.get("prefix_app_launcher", "SUPER")}, {bind_vars.get("suffix_app_launcher", "D")}, exec, $fabricSend "launcher.show_launcher('app')"
+# Clipboard History
+bind = {bind_vars.get("prefix_cliphist", "SUPER")}, {bind_vars.get("suffix_cliphist", "V")}, exec, $fabricSend "launcher.show_launcher('clip')"
+# Wallpapers
+bind = {bind_vars.get("prefix_wallpapers", "SUPER")}, {bind_vars.get("suffix_wallpapers", "W")}, exec, $fabricSend "launcher.show_launcher('wall')"
+# Random Wallpaper
+bind = {bind_vars.get("prefix_randwall", "SUPER")}, {bind_vars.get("suffix_randwall", "W")}, exec, $fabricSend "launcher.show_launcher('wall random', external=True)"
+# Emoji Picker
+bind = {bind_vars.get("prefix_emoji", "SUPER")}, {bind_vars.get("suffix_emoji", "E")}, exec, $fabricSend "launcher.show_launcher('em')"
+# Power Menu
+bind = {bind_vars.get("prefix_power", "SUPER")}, {bind_vars.get("suffix_power", "ESCAPE")}, exec, $fabricSend "launcher.show_launcher('power')"
+# Toggle Caffeine
+bind = {bind_vars.get("prefix_caffeine", "SUPER SHIFT")}, {bind_vars.get("suffix_caffeine", "M")}, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)"
+# Settings
+bind = {bind_vars.get("prefix_settings", "SUPER")}, {bind_vars.get("suffix_settings", "I")}, exec, uwsm-app $(python {home}/.config/{APP_NAME_CAP}/config/config.py) # Settings
+# Dashboard
+bind = {bind_vars.get("prefix_dashboard", "SUPER")}, {bind_vars.get("suffix_dashboard", "Z")}, exec, $fabricSend "dashboard.toggle_dashboard()"
+# Restart Modus
+bind = {bind_vars.get("prefix_restart_inspector", "SUPER CTRL ALT")}, {bind_vars.get("suffix_restart_inspector", "B")}, exec, killall {APP_NAME}; uwsm-app $(GTK_DEBUG=interactive python {home}/.config/{APP_NAME_CAP}/main.py) # Restart with inspector
+
+# Wallpapers directory: {bind_vars.get("wallpapers_dir", "~/Modus/assets/wallpapers_example")}
+
+source = {home}/.config/{APP_NAME_CAP}/config/hypr/colors.conf
+
+layerrule = noanim, fabric
+
+exec = cp $wallpaper ~/.current.wall
+
+general {{
+ col.active_border = rgb($primary)
+ col.inactive_border = rgb($surface)
+ gaps_in = 2
+ gaps_out = 4
+ border_size = 2
+ layout = dwindle
+}}
+
+cursor {{
+ no_warps=true
+}}
+
+decoration {{
+ blur {{
+ enabled = yes
+ size = 5
+ passes = 3
+ new_optimizations = yes
+ contrast = 1
+ brightness = 1
+ }}
+ rounding = 14
+ shadow {{
+ enabled = true
+ range = 10
+ render_power = 2
+ color = rgba(0, 0, 0, 0.25)
+ }}
+}}
+
+animations {{
+ enabled = yes
+ bezier = myBezier, 0.4, 0.0, 0.2, 1.0
+ animation = windows, 1, 2.5, myBezier, popin 80%
+ animation = border, 1, 2.5, myBezier
+ animation = fade, 1, 2.5, myBezier
+ animation = workspaces, 1, 2.5, myBezier, {animation_type} 20%
+}}
+"""
+
+
+def ensure_face_icon():
+ """
+ Ensure the face icon exists. If not, copy the default icon.
+ """
+ face_icon_path = os.path.expanduser("~/.face.icon")
+ default_icon_path = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/assets/default.png"
+ )
+ if not os.path.exists(face_icon_path) and os.path.exists(default_icon_path):
+ try:
+ shutil.copy(default_icon_path, face_icon_path)
+ except Exception as e:
+ print(f"Error copying default face icon: {e}")
+
+
+def backup_and_replace(src: str, dest: str, config_name: str):
+ """
+ Backup the existing configuration file and replace it with a new one.
+ """
+ try:
+ if os.path.exists(dest):
+ backup_path = dest + ".bak"
+ # Ensure that the backup directory exists if it's different
+ # os.makedirs(os.path.dirname(backup_path), exist_ok=True)
+ shutil.copy(dest, backup_path)
+ print(f"{config_name} config backed up to {backup_path}")
+ # Ensure dest directory exists
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
+ shutil.copy(src, dest)
+ print(f"{config_name} config replaced from {src}")
+ except Exception as e:
+ print(f"Error backing up/replacing {config_name} config: {e}")
+
+
+def start_config():
+ """
+ Run final configuration steps: ensure necessary configs, write the hyprconf, and reload.
+ """
+ print(f"{time.time():.4f}: start_config: Ensuring matugen config...")
+ ensure_matugen_config()
+ print(f"{time.time():.4f}: start_config: Ensuring face icon...")
+ ensure_face_icon()
+ print(f"{time.time():.4f}: start_config: Generating hypr conf...")
+
+ hypr_config_dir = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/")
+ os.makedirs(hypr_config_dir, exist_ok=True)
+ # Use APP_NAME for the .conf file name to match the corrected SOURCE_STRING
+ hypr_conf_path = os.path.join(hypr_config_dir, f"{APP_NAME}.conf")
+ try:
+ with open(hypr_conf_path, "w") as f:
+ f.write(generate_hyprconf())
+ print(f"Generated Hyprland config at {hypr_conf_path}")
+ except Exception as e:
+ print(f"Error writing Hyprland config: {e}")
+ print(f"{time.time():.4f}: start_config: Finished generating hypr conf.")
+
+ print(f"{time.time():.4f}: start_config: Initiating hyprctl reload...")
+ try:
+ # subprocess.run(["hyprctl", "reload"], check=True, capture_output=True, text=True)
+ # Keep async to not block
+ exec_shell_command_async("hyprctl reload")
+ print(
+ f"{time.time():.4f}: start_config: Hyprland configuration reload initiated."
+ )
+ except FileNotFoundError:
+ print("Error: hyprctl command not found. Cannot reload Hyprland.")
+ except (
+ subprocess.CalledProcessError
+ ) as e: # If we used subprocess.run with check=True
+ print(
+ f"Error reloading Hyprland with hyprctl: {e}\nOutput:\n{e.stdout}\n{
+ e.stderr
+ }"
+ )
+ except Exception as e:
+ print(f"An error occurred initiating hyprctl reload: {e}")
+ print(f"{time.time():.4f}: start_config: Finished initiating hyprctl reload.")
diff --git a/install.sh b/install.sh
old mode 100644
new mode 100755
index dc640c4..093cc9d
--- a/install.sh
+++ b/install.sh
@@ -1,23 +1,46 @@
#!/bin/bash
+# ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗███████╗
+# ████╗ ████║██╔═══██╗██╔══██╗██║ ██║██╔════╝
+# ██╔████╔██║██║ ██║██║ ██║██║ ██║███████╗
+# ██║╚██╔╝██║██║ ██║██║ ██║██║ ██║╚════██║
+# ██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████║
+# ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
+#
+# A hackable shell for Hyprland
+# Installation Script for Arch Linux
+#
+# Repository: https://github.com/S4NKALP/Modus
+# License: GPLv3
+
set -e # Exit immediately if a command exits with a non-zero status
set -u # Treat unset variables as an error
set -o pipefail # Prevent errors in a pipeline from being masked
-REPO_URL="https://github.com/S4NKALP/Modus"
-INSTALL_DIR="$HOME/Modus"
+REPO_URL="https://github.com/S4NKALP/Modus.git"
+INSTALL_DIR="$HOME/.config/Modus"
PACKAGES=(
+ python-fabric-git
fabric-cli-git
+ gray-git
+ cliphist
gnome-bluetooth-3.0
+ gobject-introspection
+ slurp
+ ffmpeg
grimblast
hypridle
hyprlock
hyprpicker
imagemagick
libnotify
+ matugen-bin
+ noto-fonts-emoji
+ nvtop
+ playerctl
python-fabric-git
- python-materialyoucolor-git
+ python-gobject
python-pillow
python-setproctitle
python-toml
@@ -26,21 +49,25 @@ PACKAGES=(
python-pywayland
python-pyxdg
python-ijson
- sddm-theme-corners-git
+ python-watchdog
+ python-pyotp
+ python-pyzbar
+ ttf-nerd-fonts-symbols-mono
swww
swappy
wl-clipboard
+ webp-pixbuf-loader
wf-recorder
- libadwaita
acpi
- adw-gtk-theme
brightnessctl
- wlinhibit
power-profile-daemon
- ttf-chakra-petch
ttf-tabler-icons
+ uwsm
)
+echo "Starting Modus installation..."
+echo "=================================="
+
# Ensure running on Arch Linux
if ! grep -q "arch" /etc/os-release; then
echo "This script is designed to run on Arch Linux."
@@ -70,6 +97,8 @@ else
aur_helper="paru"
fi
+echo "Using AUR helper: $aur_helper"
+
# Clone or update the Modus repository
if [ -d "$INSTALL_DIR" ]; then
echo "Updating Modus..."
@@ -94,43 +123,16 @@ for pkg in "${PACKAGES[@]}"; do
done
if [ ${#to_update[@]} -gt 0 ]; then
- echo "Updating outdated packages..."
+ echo "⬆Updating outdated packages..."
$aur_helper -S --noconfirm "${to_update[@]}" || true
else
echo "All required packages are up-to-date."
fi
-# Backup and replace GTK configurations
-echo "Updating GTK configurations..."
-mkdir -p "$HOME/.config"
-[[ -d "$HOME/.config/gtk-3.0" ]] && mv "$HOME/.config/gtk-3.0" "$HOME/.config/gtk-3.0-bk-$(date +%s)"
-[[ -d "$HOME/.config/gtk-4.0" ]] && mv "$HOME/.config/gtk-4.0" "$HOME/.config/gtk-4.0-bk-$(date +%s)"
-
-cp -r "$INSTALL_DIR/assets/gtk-3.0" "$HOME/.config/" || true
-cp -r "$INSTALL_DIR/assets/gtk-4.0" "$HOME/.config/" || true
-
-# Install Icon Theme
-echo "Installing Tela icon theme..."
-tmpdir=$(mktemp -d)
-git clone https://github.com/vinceliuice/Tela-icon-theme "$tmpdir/Tela-icon-theme"
-cd "$tmpdir/Tela-icon-theme"
-./install.sh nord
-rm -rf "$tmpdir"
-
-# Install Vencord
-echo "Installing Vencord..."
-if command -v curl &>/dev/null; then
- sh -c "$(curl -sS https://raw.githubusercontent.com/Vendicated/VencordInstaller/main/install.sh)" || true
- mkdir -p "$HOME/.config/Vencord/settings/"
- ln -sf "$HOME/.cache/material/material-discord.css" "$HOME/.config/Vencord/settings/quickCss.css"
-else
- echo "Skipping Vencord installation (curl not found)."
-fi
-
-# Launch Modus
+python "$INSTALL_DIR/config/config.py"
echo "Starting Modus..."
killall modus 2>/dev/null || true
-python "$INSTALL_DIR/main.py" >/dev/null 2>&1 &
+uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 &
disown
-echo "Installation complete."
+echo "Installation complete!"
diff --git a/main.css b/main.css
new file mode 100644
index 0000000..c1bf7a0
--- /dev/null
+++ b/main.css
@@ -0,0 +1,72 @@
+@import url("./styles/dock.css");
+@import url("./styles/colors.css");
+@import url("./styles/components.css");
+@import url("./styles/controls.css");
+@import url("./styles/indicators.css");
+@import url("./styles/workspaces.css");
+@import url("./styles/switcher.css");
+@import url("./styles/osd.css");
+@import url("./styles/launcher.css");
+@import url("./styles/kanban.css");
+@import url("./styles/calendar.css");
+@import url("./styles/notification.css");
+@import url("./styles/player.css");
+@import url("./styles/dashboard.css");
+
+* {
+ all: unset;
+ color: var(--foreground);
+ font-size: unset;
+ font-family: unset;
+ border-radius: 16px;
+}
+
+#corner {
+ background-color: var(--shadow);
+ border-radius: 0;
+}
+
+#corner-container {
+ min-width: 20px;
+ min-height: 20px;
+}
+
+tooltip {
+ border: solid 1px;
+ border-color: var(--surface);
+ background-color: var(--shadow);
+ animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+}
+
+@keyframes tooltipShow {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+tooltip > * {
+ padding: 6px 10px;
+ border-radius: 10px;
+}
+
+#workspaces,
+#workspaces-container,
+#launcher,
+#date-time,
+#music-player,
+#osd,
+#tray,
+#battery,
+#controls,
+#notification-stack-box,
+#metrics,
+#language,
+#applications-dock,
+#dock {
+ box-shadow: 0 0 3px alpha(black, 0.7);
+}
+
diff --git a/main.py b/main.py
index 7488f5d..95bebf4 100644
--- a/main.py
+++ b/main.py
@@ -2,42 +2,58 @@ import os
import setproctitle
from fabric import Application
-from fabric.utils import get_relative_path, monitor_file, exec_shell_command_async
+from fabric.utils import exec_shell_command_async, get_relative_path, monitor_file
from loguru import logger
-from modules.bar.bar import Bar, Corners
-from modules.launcher.launcher import Launcher
-
-from modules.dock import Dock
+from config.data import APP_NAME, APP_NAME_CAP, CONFIG_FILE
+from modules.corners import Corners
+from modules.dock.main import Dock
+from modules.launcher import Launcher
from modules.notification_popup import NotificationPopup
from modules.osd import OSD
-from config.data import APP_NAME, CACHE_DIR, CONFIG_FILE, DOCK_ICON_SIZE, VERTICAL, APP_NAME_CAP
+from modules.switcher import ApplicationSwitcher
+from modules.dashboard.main import DashboardWindow
for log in [
"fabric.hyprland.widgets",
"fabric.audio.service",
"fabric.bluetooth.service",
+ "services.network",
+ "services.mpris",
]:
logger.disable(log)
-
-
if __name__ == "__main__":
setproctitle.setproctitle(APP_NAME)
if not os.path.isfile(CONFIG_FILE):
- exec_shell_command_async(f"python {get_relative_path('../config/config.py')}")
- bar = Bar()
- dock = Dock()
- corners = Corners()
- osd = OSD()
- notif = NotificationPopup()
- launcher = Launcher()
- bar.launcher = launcher
- launcher.bar = bar
+ config_script_path = get_relative_path("config/config.py")
+ exec_shell_command_async(f"python {config_script_path}")
- app = Application(f"{APP_NAME}", bar, launcher, osd, dock)
+ current_wallpaper = os.path.expanduser("~/.current.wall")
+ if not os.path.exists(current_wallpaper):
+ example_wallpaper = os.path.expanduser(
+ f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.png"
+ )
+ os.symlink(example_wallpaper, current_wallpaper)
+
+ # Load configuration
+ from config.data import load_config
+
+ config = load_config()
+
+ corners = Corners()
+ dock = Dock()
+ switcher = ApplicationSwitcher()
+ osd = OSD()
+ launcher = Launcher()
+ nc = NotificationPopup()
+ dashboard = DashboardWindow()
+
+ # Set corners visibility based on config
+ corners_visible = config.get("corners_visible", True)
+ corners.set_visible(corners_visible)
# Monitor CSS files for changes
css_file = monitor_file(get_relative_path("styles"))
@@ -46,13 +62,14 @@ if __name__ == "__main__":
color_css_file = monitor_file(get_relative_path("./styles/colors.css"))
_ = color_css_file.connect("changed", lambda *_: set_css())
+ app = Application(
+ f"{APP_NAME}", dock, corners, switcher, osd, launcher, nc, dashboard
+ ) # Make sure corners is added to the app
+
def set_css():
- logger.info("[Main] Applying CSS")
- app.set_stylesheet_from_file(get_relative_path("styles/main.css"),
- exposed_functions={
- "dock_nmargin": lambda: f"margin-bottom: -{32 + DOCK_ICON_SIZE}px;" if not VERTICAL else f"margin-right: -{32 + DOCK_ICON_SIZE}px;",
- "dock_sep": lambda: f"margin: 8px 0;" if not VERTICAL else f"margin: 0 8px;",
- },)
+ app.set_stylesheet_from_file(
+ get_relative_path("main.css"),
+ )
app.set_css = set_css
diff --git a/modules/bar/bar.py b/modules/bar/bar.py
deleted file mode 100644
index 61a2653..0000000
--- a/modules/bar/bar.py
+++ /dev/null
@@ -1,312 +0,0 @@
-from fabric.hyprland.widgets import Language, get_hyprland_connection
-from fabric.hyprland.service import HyprlandEvent
-from fabric.system_tray.widgets import SystemTray
-from fabric.widgets.shapes import Corner
-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.label import Label
-from fabric.widgets.wayland import WaylandWindow as Window
-from modules.bar.components import Metrics, workspace, SystemIndicators, UpdatesWidget
-import config.data as data
-from gi.repository import Gdk, GLib
-import utils.icons as icons
-import os
-import json
-
-
-class Bar(Window):
- def __init__(self, **kwargs):
- super().__init__(
- name="bar",
- layer="top",
- anchor="left top right"
- if not data.VERTICAL and not data.BOTTOM_BAR
- else "top right bottom"
- if data.VERTICAL and data.VERTICAL_RIGHT_ALIGN
- else "top left bottom"
- if data.VERTICAL
- else "bottom left right",
- exclusivity="auto",
- visible=True,
- all_visible=True,
- )
-
- self.launcher = kwargs.get("launcher", None)
- self.component_visibility = data.BAR_COMPONENTS_VISIBILITY
-
- # Determine if we should use vertical layout for components
- is_vertical_layout = data.VERTICAL
-
- self.workspaces = Button(
- child=workspace,
- name="workspaces",
- )
-
- self.connection = get_hyprland_connection()
- self.tray = SystemTray(
- name="tray",
- spacing=4,
- icon_size=16,
- orientation="h" if not is_vertical_layout else "v",
- )
-
- self.date_time = Button(
- name="datetime",
- child=DateTime(
- formatters=["%-I:%M:%p %a %d %b"]
- if not is_vertical_layout
- else ["%I\n%M\n%p"],
- h_align="center" if not is_vertical_layout else "fill",
- v_align="center",
- ),
- on_clicked=lambda *_: self.calendar(),
- )
- self.date_time.connect("enter_notify_event", self.on_button_enter)
- self.date_time.connect("leave_notify_event", self.on_button_leave)
-
- self.button_app = Button(
- name="button-bar",
- child=Label(name="button-bar-label", markup=icons.apps),
- on_clicked=lambda *_: self.search_apps(),
- )
- self.button_app.connect("enter_notify_event", self.on_button_enter)
- self.button_app.connect("leave_notify_event", self.on_button_leave)
-
- self.updates = UpdatesWidget()
- self.updates.connect("enter_notify_event", self.on_button_enter)
- self.updates.connect("leave_notify_event", self.on_button_leave)
-
- self.lang_label = Label(name="lang-label")
- self.language = Button(
- name="language", h_align="center", v_align="center", child=self.lang_label
- )
- self.on_language_switch()
- self.connection.connect("event::activelayout", self.on_language_switch)
-
- self.metrics = Metrics()
- self.indicators = SystemIndicators(launcher=self.launcher)
-
- self.applets = Box(
- name="system-indicator",
- spacing=4,
- orientation="h" if not is_vertical_layout else "v",
- children=[self.language, self.indicators],
- )
-
- # Apply visibility settings to components
- self.apply_component_visibility()
-
- self.h_start_children = [self.button_app, self.updates]
-
- self.h_center_children = [
- self.date_time,
- self.workspaces,
- self.metrics,
- self.applets,
- ]
-
- self.h_end_children = [self.tray]
-
- self.v_start_children = [
- self.button_app,
- self.updates,
- self.workspaces,
- self.metrics,
- ]
- self.v_end_children = [self.tray, self.applets, self.date_time]
-
- self.v_all_children = []
- self.v_all_children.extend(self.v_start_children)
- self.v_all_children.extend(self.v_end_children)
-
- # Use centered layout when both vertical and centered_bar are enabled
- is_centered_bar = is_vertical_layout and getattr(data, "CENTERED_BAR", False)
-
- self.bar = CenterBox(
- name="bar",
- orientation="h" if not is_vertical_layout else "v",
- h_align="fill",
- v_align="fill",
- start_children=None
- if is_centered_bar
- else Box(
- name="start-container",
- spacing=4,
- orientation="h" if not is_vertical_layout else "v",
- children=self.h_start_children
- if not is_vertical_layout
- else self.v_start_children,
- ),
- center_children=Box(
- orientation="v",
- spacing=4,
- children=self.v_all_children
- if is_centered_bar
- else Box(
- name="center-container",
- spacing=4,
- orientation="h" if not is_vertical_layout else "v",
- children=self.h_center_children if not is_vertical_layout else None,
- ),
- )
- if is_vertical_layout
- else Box(
- name="center-container",
- spacing=4,
- orientation="h" if not is_vertical_layout else "v",
- children=self.h_center_children if not is_vertical_layout else None,
- ),
- end_children=None
- if is_centered_bar
- else Box(
- name="end-container",
- spacing=4,
- orientation="h" if not is_vertical_layout else "v",
- children=self.h_end_children
- if not is_vertical_layout
- else self.v_end_children,
- ),
- )
-
- self.children = self.bar
-
- def apply_component_visibility(self):
- """Apply saved visibility settings to all components"""
- components = {
- "metrics": self.metrics,
- "indicators": self.applets,
- "updates": self.updates,
- "workspaces": self.workspaces,
- "button_app": self.button_app,
- # "button_tools": self.button_tools,
- "language": self.language,
- "date_time": self.date_time,
- "tray": self.tray,
- }
-
- for component_name, widget in components.items():
- if component_name in self.component_visibility:
- widget.set_visible(self.component_visibility[component_name])
-
- def toggle_component_visibility(self, component_name):
- """Toggle visibility for a specific component"""
- components = {
- "metrics": self.metrics,
- "indicators": self.applets,
- "updates": self.updates,
- "workspaces": self.workspaces,
- "button_app": self.button_app,
- # "button_tools": self.button_tools,
- "language": self.language,
- "date_time": self.date_time,
- "tray": self.tray,
- }
-
- if component_name in components and component_name in self.component_visibility:
- # Toggle the visibility state
- self.component_visibility[component_name] = not self.component_visibility[
- component_name
- ]
- # Apply the new state
- components[component_name].set_visible(
- self.component_visibility[component_name]
- )
-
- # Update the configuration
- config_file = os.path.expanduser(
- f"~/{data.APP_NAME}/config/assets/config.json"
- )
- try:
- if os.path.exists(config_file):
- with open(config_file, "r") as f:
- config = json.load(f)
-
- # Update the config with the new visibility state
- config[f"bar_{component_name}_visible"] = self.component_visibility[
- component_name
- ]
-
- # Write the updated config back to file
- with open(config_file, "w") as f:
- json.dump(config, f, indent=4)
- except (json.JSONDecodeError, IOError) as e:
- print(f"Error updating config file: {e}")
- # Revert the visibility change if config update fails
- self.component_visibility[
- component_name
- ] = not self.component_visibility[component_name]
- components[component_name].set_visible(
- self.component_visibility[component_name]
- )
-
- return self.component_visibility[component_name]
-
- return None
-
- def on_button_enter(self, widget, event):
- window = widget.get_window()
- if window:
- window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2))
-
- def on_button_leave(self, widget, event):
- window = widget.get_window()
- if window:
- window.set_cursor(None)
-
- def search_apps(self):
- self.launcher.open("launcher")
-
- def calendar(self):
- self.launcher.open("calendar")
-
- def on_language_switch(self, _=None, event: HyprlandEvent = None):
- lang = event.data[1] if event else Language().get_label()
- self.language.set_tooltip_text(lang)
- if not data.VERTICAL:
- self.lang_label.set_label(lang[:2].lower())
- else:
- self.lang_label.add_style_class("icon")
- self.lang_label.set_markup(icons.keyboard)
-
-
-class MyCorner(Box):
- def __init__(self, position):
- super().__init__(
- name="corner-container",
- children=Corner(
- name="corner",
- orientation=position,
- size=20,
- ),
- )
-
-
-class Corners(Window):
- def __init__(self):
- super().__init__(
- layer="top",
- anchor="top left bottom right",
- pass_through=True,
- child=Box(
- orientation="vertical",
- children=[
- Box(
- children=[
- MyCorner("top-left"),
- Box(h_expand=True),
- MyCorner("top-right"),
- ]
- ),
- Box(v_expand=True),
- Box(
- children=[
- MyCorner("bottom-left"),
- Box(h_expand=True),
- MyCorner("bottom-right"),
- ]
- ),
- ],
- ),
- )
diff --git a/modules/bar/components/__init__.py b/modules/bar/components/__init__.py
deleted file mode 100644
index 161c235..0000000
--- a/modules/bar/components/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .metrics import Metrics
-from .workspace import workspace
-from .system_indicators import SystemIndicators
-from .updates import UpdatesWidget
diff --git a/modules/bar/components/metrics.py b/modules/bar/components/metrics.py
deleted file mode 100644
index b716b4d..0000000
--- a/modules/bar/components/metrics.py
+++ /dev/null
@@ -1,368 +0,0 @@
-import subprocess
-import json
-import psutil
-from gi.repository import GLib, Gdk
-
-from fabric.widgets.label import Label
-from fabric.widgets.box import Box
-from fabric.widgets.circularprogressbar import CircularProgressBar
-from fabric.widgets.revealer import Revealer
-from fabric.core.fabricator import Fabricator
-from fabric.widgets.button import Button
-import utils.icons as icons
-import config.data as data
-
-
-class MetricsProvider:
- def __init__(self):
- self.cpu = 0.0
- self.mem = 0.0
- self.swap = 0.0
- self.disk = []
- self.bat_percent = 0.0
- self.bat_charging = None
-
- GLib.timeout_add_seconds(1, self._update)
-
- def _update(self):
- # Get non-blocking usage percentages (interval=0)
- # The first call may return 0, but subsequent calls will provide consistent values.
- self.cpu = psutil.cpu_percent(interval=0)
- mem = psutil.virtual_memory()
- self.mem = mem.percent
- swap = psutil.swap_memory()
- self.swap = swap.percent
- self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS]
-
- battery = psutil.sensors_battery()
- if battery is None:
- self.bat_percent = 0.0
- self.bat_charging = None
- else:
- self.bat_percent = battery.percent
- self.bat_charging = battery.power_plugged
- return True
-
- def get_metrics(self):
- return (self.cpu, self.mem, self.swap, self.disk)
-
- def get_battery(self):
- return (self.bat_percent, self.bat_charging)
-
-
-shared_provider = MetricsProvider()
-
-
-class SingularMetric:
- def __init__(self, id, name, icon):
- self.is_vertical_layout = data.VERTICAL
- self.name_markup = name
- self.icon_markup = icon
-
- self.icon = Label(name="metrics-icon", markup=icon)
- self.circle = CircularProgressBar(
- name="metrics-circle",
- value=0,
- size=26,
- line_width=2,
- start_angle=150,
- end_angle=390,
- style_classes=id,
- child=self.icon,
- )
-
- self.circle_button = Button(
- name=f"metrics-{id}-button",
- child=self.circle,
- style="padding: 0; margin: 0; background: none;",
- )
-
- self.circle_button.add_events(
- Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
- )
- self.circle_button.connect("enter-notify-event", self.on_button_enter)
- self.circle_button.connect("leave-notify-event", self.on_button_leave)
-
- self.circle_button.connect("clicked", self.on_circle_clicked)
-
- self.level = Label(
- name="metrics-level",
- style_classes=id,
- label="0%",
- style="margin: 4px 0px;" if self.is_vertical_layout else "margin: 0 4px;",
- )
- self.revealer = Revealer(
- name=f"metrics-{id}-revealer",
- transition_duration=250,
- transition_type="slide-up" if self.is_vertical_layout else "slide-left",
- child=self.level,
- child_revealed=False,
- )
-
- self.box = Box(
- name=f"metrics-{id}-box",
- orientation="v" if self.is_vertical_layout else "h",
- spacing=0,
- children=[self.circle_button, self.revealer],
- )
-
- # Connect events for cursor changes to the box as well
- self.box.connect("enter-notify-event", self.on_button_enter)
- self.box.connect("leave-notify-event", self.on_button_leave)
-
- # Set initial tooltip - moved after box is created
- self.update_tooltip("0%")
-
- def update_tooltip(self, value_text):
- tooltip = f"{self.icon_markup} {self.name_markup}: {value_text}"
- # self.box.set_tooltip_markup(tooltip)
- self.circle_button.set_tooltip_markup(tooltip)
- self.circle.set_tooltip_markup(tooltip)
- self.icon.set_tooltip_markup(tooltip)
-
- def markup(self):
- """Get formatted text for tooltip display"""
- return f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
-
- def on_button_enter(self, widget, event):
- window = widget.get_window()
- if window:
- window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2))
-
- def on_button_leave(self, widget, event):
- window = widget.get_window()
- if window:
- window.set_cursor(None)
-
- def on_circle_clicked(self, button):
- # Toggle the revealed state of the label
- current_state = self.revealer.get_child_revealed()
- self.revealer.set_reveal_child(not current_state)
-
-
-class Metrics(Box):
- def __init__(self, **kwargs):
- # Determine if we should use vertical layout for components
- self.is_vertical_layout = data.VERTICAL
-
- super().__init__(
- name="metrics",
- spacing=0,
- orientation="h" if not self.is_vertical_layout else "v",
- visible=True,
- all_visible=True,
- style="padding:4px 0px" if self.is_vertical_layout else "padding: 0px 4px;",
- **kwargs,
- )
-
- self.batt_fabricator = Fabricator(
- poll_from=lambda v: shared_provider.get_battery(),
- on_changed=lambda f, v: self.update_battery,
- interval=1000,
- stream=False,
- default_value=0,
- )
- self.batt_fabricator.changed.connect(self.update_battery)
- GLib.idle_add(self.update_battery, None, shared_provider.get_battery())
-
- visible = getattr(
- data,
- "METRICS_VISIBLE",
- {"cpu": True, "ram": True, "disk": True, "swap": True},
- )
- disks = (
- [
- SingularMetric(
- "disk",
- f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK",
- icons.disk,
- )
- for path in data.BAR_METRICS_DISKS
- ]
- if visible.get("disk", True)
- else []
- )
-
- self.cpu = (
- SingularMetric("cpu", "CPU", icons.cpu)
- if visible.get("cpu", True)
- else None
- )
- self.ram = (
- SingularMetric("ram", "RAM", icons.memory)
- if visible.get("ram", True)
- else None
- )
- self.swap = (
- SingularMetric("swap", "SWAP", icons.swap)
- if visible.get("swap", True)
- else None
- )
- self.bat = (
- SingularMetric("bat", "BATTERY", icons.battery)
- if visible.get("bat", True)
- else None
- )
- self.disk = disks
-
- # Add only enabled metrics
- for disk in self.disk:
- self.add(disk.box)
- self.add(Box(name="metrics-sep"))
- if self.ram:
- self.add(self.ram.box)
- self.add(Box(name="metrics-sep"))
- if self.cpu:
- self.add(self.cpu.box)
- if self.swap:
- self.add(Box(name="metrics-sep"))
- self.add(self.swap.box)
- if self.bat:
- self.add(Box(name="metrics-sep"))
- self.add(self.bat.box)
-
- # Connect events directly to the button
- self.connect("enter-notify-event", self.on_mouse_enter)
- self.connect("leave-notify-event", self.on_mouse_leave)
-
- GLib.timeout_add_seconds(1, self.update_metrics)
-
- self.hide_timer = None
- self.hover_counter = 0
-
- def _format_percentage(self, value: int) -> str:
- return f"{value}%"
-
- def on_mouse_enter(self, widget, event):
- window = widget.get_window()
- if window:
- window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2))
-
- if not data.VERTICAL:
- self.hover_counter += 1
- if self.hide_timer is not None:
- GLib.source_remove(self.hide_timer)
- self.hide_timer = None
- if self.cpu:
- self.cpu.revealer.set_reveal_child(True)
- if self.ram:
- self.ram.revealer.set_reveal_child(True)
- for disk in self.disk:
- disk.revealer.set_reveal_child(True)
- if self.swap:
- self.swap.revealer.set_reveal_child(True)
- if self.bat:
- self.bat.revealer.set_reveal_child(True)
- return False
-
- def on_mouse_leave(self, widget, event):
- window = widget.get_window()
- if window:
- window.set_cursor(None)
-
- if not data.VERTICAL:
- if self.hover_counter > 0:
- self.hover_counter -= 1
- if self.hover_counter == 0:
- if self.hide_timer is not None:
- GLib.source_remove(self.hide_timer)
- self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
- return False
-
- def hide_revealer(self):
- if not data.VERTICAL:
- if self.cpu:
- self.cpu.revealer.set_reveal_child(False)
- if self.ram:
- self.ram.revealer.set_reveal_child(False)
- for disk in self.disk:
- disk.revealer.set_reveal_child(False)
- self.hide_timer = None
- return False
-
- def update_metrics(self):
- cpu, mem, swap, disk = shared_provider.get_metrics()
- if self.cpu:
- self.cpu.circle.set_value(cpu / 100.0)
- value_text = self._format_percentage(int(cpu))
- self.cpu.level.set_label(value_text)
- self.cpu.update_tooltip(value_text)
- if self.ram:
- self.ram.circle.set_value(mem / 100.0)
- value_text = self._format_percentage(int(mem))
- self.ram.level.set_label(value_text)
- self.ram.update_tooltip(value_text)
- for i, disk_metric in enumerate(self.disk):
- if i < len(disk):
- value_text = self._format_percentage(int(disk[i]))
- disk_metric.circle.set_value(disk[i] / 100.0)
- disk_metric.level.set_label(value_text)
- disk_metric.update_tooltip(value_text)
- if self.swap:
- self.swap.circle.set_value(swap / 100.0)
- value_text = self._format_percentage(int(swap))
- self.swap.level.set_label(value_text)
- self.swap.update_tooltip(value_text)
- if self.bat:
- bat_percent, _ = shared_provider.get_battery()
- self.bat.circle.set_value(bat_percent / 100.0)
- value_text = self._format_percentage(int(bat_percent))
- self.bat.level.set_label(value_text)
- self.bat.update_tooltip(value_text)
-
- # Tooltip: only show enabled metrics
- tooltip_metrics = []
- if self.disk: tooltip_metrics.extend(self.disk)
- if self.swap: tooltip_metrics.append(self.swap)
- if self.bat: tooltip_metrics.append(self.bat)
- if self.ram: tooltip_metrics.append(self.ram)
- if self.cpu: tooltip_metrics.append(self.cpu)
- self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics]))
-
- return True
-
- def update_battery(self, sender, battery_data):
- if self.bat is None:
- return
-
- value, charging = battery_data
- if value == 0:
- if self.bat:
- self.bat.box.set_visible(False)
- else:
- if self.bat:
- self.bat.box.set_visible(True)
- self.bat.circle.set_value(value / 100)
- percentage = int(value)
- self.bat.level.set_label(self._format_percentage(percentage))
-
- # Apply alert styling if battery is low AND not charging
- if percentage <= 15 and charging == False:
- self.bat.icon.add_style_class("alert")
- self.bat.circle.add_style_class("alert")
- else:
- self.bat.icon.remove_style_class("alert")
- self.bat.circle.remove_style_class("alert")
-
- # Choose the icon based on charging state first, then battery level
- if percentage == 100:
- self.bat.icon.set_markup(icons.battery)
- charging_status = f"{icons.bat_full} Fully Charged"
- elif charging == True:
- self.bat.icon.set_markup(icons.charging)
- charging_status = f"{icons.bat_charging} Charging"
- elif percentage <= 15 and charging == False:
- self.bat.icon.set_markup(icons.alert)
- charging_status = f"{icons.bat_low} Low Battery"
- elif charging == False:
- self.bat.icon.set_markup(icons.discharging)
- charging_status = f"{icons.bat_discharging} Discharging"
- else:
- self.bat.icon.set_markup(icons.battery)
- charging_status = "Battery"
-
- # Update individual tooltip for battery
- self.bat.update_tooltip(f"{charging_status}: {self._format_percentage(percentage)}")
-
- # Update the combined tooltip
- self.update_metrics()
diff --git a/modules/bar/components/system_indicators.py b/modules/bar/components/system_indicators.py
deleted file mode 100644
index 7d9b453..0000000
--- a/modules/bar/components/system_indicators.py
+++ /dev/null
@@ -1,258 +0,0 @@
-import subprocess
-from fabric import Fabricator
-from fabric.widgets.label import Label
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.bluetooth import BluetoothClient
-from services import network_client, audio, notification_service
-import utils.icons as icons
-import config.data as data
-
-
-class SystemIndicators(Box):
- def __init__(self, **kwargs):
- # Determine if we should use vertical layout for components
- is_vertical_layout = data.VERTICAL
-
- super().__init__(
- orientation="h" if not is_vertical_layout else "v", spacing=0, **kwargs
- )
- self.launcher = kwargs.get("launcher", None)
-
- self.bluetooth_icon = Label(name="system-indicator-icon")
- self.wifi_icon = Label(name="system-indicator-icon")
- self.volume_icon_button = Label(name="system-indicator-icon")
- self.microphone_icon = Label(name="system-indicator-icon")
- self.idle_label = Label(name="system-indicator-icon")
- self.night_label = Label(name="system-indicator-icon")
- self.power_label = Label(name="system-indicator-icon")
- self.notification_icon = Button(name="system-indicator-icon")
- self.notification_label = Label(name="system-notif-label")
- self.notification_icon.add(self.notification_label)
- self.notification_icon.connect("clicked", self.open_notif_center)
-
- for widget in [
- self.bluetooth_icon,
- self.wifi_icon,
- self.volume_icon_button,
- self.microphone_icon,
- self.idle_label,
- self.night_label,
- self.power_label,
- self.notification_icon,
- ]:
- self.add(widget)
-
- self.audio_service = audio
- self.bluetooth_client = BluetoothClient()
- self.network_client = network_client
- self.notification_service = notification_service
-
- # Connect signals
- self.bluetooth_client.connect("changed", self.update_bluetooth_status)
- self.audio_service.connect("microphone_changed", self.update_mic_status)
- self.audio_service.connect("changed", self.update_volume_status)
- self.network_client.connect("device_ready", self.update_network_status)
- self.notification_service.connect("dnd", self.update_notification_status)
- self.notification_service.connect(
- "notification_count", self.update_notification_status
- )
-
- if self.network_client.wifi_device:
- self.network_client.wifi_device.connect(
- "changed", self.update_network_status
- )
-
- if self.network_client.ethernet_device:
- self.network_client.ethernet_device.connect(
- "changed", self.update_network_status
- )
-
- Fabricator(interval=1000, poll_from=self.update_all_statuses)
-
- self.update_bluetooth_status()
- self.update_notification_status()
-
- def update_all_statuses(self, *_args):
- self.update_idle_night_status()
- self.update_power_profile()
- self.update_network_status()
-
- def update_idle_night_status(self, *_):
- # Update idle status
- has_idle = (
- subprocess.run(["pgrep", "-x", "wlinhibit"], capture_output=True).returncode
- == 0
- )
- self.idle_label.set_visible(has_idle)
- self.idle_label.set_markup(icons.coffee if has_idle else "")
-
- # Update night mode status
- has_night = (
- subprocess.run(
- ["pgrep", "-x", "hyprsunset"], capture_output=True
- ).returncode
- == 0
- )
- self.night_label.set_visible(has_night)
- self.night_label.set_markup(icons.night if has_night else "")
-
- def update_volume_status(self, *_):
- stream = self.audio_service.speaker
- if not stream:
- self.volume_icon_button.set_visible(False)
- return
-
- self.volume_icon_button.set_visible(True)
- # Normalize volume to integer percentage (0-100)
- volume_level = stream.volume
- if volume_level > 1:
- volume_level = min(int(volume_level), 100)
- else:
- volume_level = int(volume_level * 100)
-
- is_muted = stream.muted
-
- if is_muted:
- icon = icons.vol_off
- elif volume_level > 74:
- icon = icons.vol_high
- elif volume_level > 0:
- icon = icons.vol_medium
- else:
- icon = icons.vol_mute
-
- self.volume_icon_button.set_markup(icon)
-
- tooltip_text = "Muted" if is_muted else f"Volume: {volume_level}%"
- self.volume_icon_button.set_tooltip_text(tooltip_text)
-
- def update_mic_status(self, *_):
- mic = self.audio_service.microphone
- if not mic:
- self.microphone_icon.set_visible(False)
- return
-
- self.microphone_icon.set_visible(True)
- # Normalize volume to integer percentage (0-100)
- volume_level = mic.volume
- if volume_level > 1:
- volume_level = min(int(volume_level), 100)
- else:
- volume_level = int(volume_level * 100)
-
- is_muted = mic.muted
- icon = icons.mic_muted if is_muted else icons.mic
-
- self.microphone_icon.set_markup(icon)
- tooltip_text = "Muted" if is_muted else f"Microphone: {volume_level}%"
- self.microphone_icon.set_tooltip_text(tooltip_text)
-
- def update_bluetooth_status(self, *_):
- if self.bluetooth_client.enabled:
- self.bluetooth_icon.set_visible(True)
- # Get a list of connected devices
- connected_devices = [
- device.name
- for device in self.bluetooth_client.devices
- if device.connected
- ]
-
- if connected_devices:
- self.bluetooth_icon.set_markup(icons.bluetooth_connected)
- self.bluetooth_icon.set_tooltip_text(f"{', '.join(connected_devices)}")
- else:
- self.bluetooth_icon.set_markup(icons.bluetooth)
- self.bluetooth_icon.set_tooltip_text(
- "Bluetooth is enabled, no devices connected"
- )
- else:
- self.bluetooth_icon.set_visible(True) # Keep it visible
- self.bluetooth_icon.set_markup(icons.bluetooth_off)
- self.bluetooth_icon.set_tooltip_text("Bluetooth is disabled")
-
- def update_network_status(self, *_):
- primary_device = self.network_client.primary_device
- self.wifi_icon.set_visible(True)
-
- if primary_device == "wifi" and self.network_client.wifi_device:
- wifi_device = self.network_client.wifi_device
-
- self.wifi_icon.set_tooltip_text(
- wifi_device.ssid if wifi_device.ssid else "No WiFi Connection"
- )
-
- if wifi_device.enabled:
- strength = wifi_device.strength
-
- if strength > 0:
- if strength < 25:
- icon_label = icons.wifi_0
- elif strength < 50:
- icon_label = icons.wifi_1
- elif strength < 75:
- icon_label = icons.wifi_2
- else:
- icon_label = icons.wifi_3
- else:
- icon_label = icons.wifi_off
- else:
- icon_label = icons.wifi_off
-
- elif primary_device == "wired":
- icon_label = icons.lan
- self.wifi_icon.set_tooltip_text("Wired Connection")
-
- else:
- icon_label = icons.wifi_off # No network
-
- self.wifi_icon.set_markup(icon_label)
-
- def update_power_profile(self, *_):
- try:
- result = subprocess.run(
- ["powerprofilesctl", "get"], capture_output=True, text=True
- )
- current_profile = result.stdout.strip()
-
- profile_icons = {
- "performance": icons.power_performance,
- "balanced": "",
- "power-saver": icons.power_saving,
- }
-
- icon = profile_icons.get(current_profile)
- tooltip = (
- f"Power Profile: {current_profile.capitalize()}"
- if current_profile
- else "Unknown Profile"
- )
-
- self.power_label.set_visible(bool(icon)) # Only show if there's an icon
- self.power_label.set_markup(icon)
- self.power_label.set_tooltip_text(tooltip)
-
- except Exception as e:
- self.power_label.set_visible(False)
- self.power_label.set_tooltip_text(f"Error fetching power profile: {e}")
-
- def update_notification_status(self, *_):
- """Update the notification icon based on DND state and notification count."""
- count = self.notification_service.count
-
- if count == 0 and not self.notification_service.dont_disturb:
- self.notification_icon.set_visible(False)
- return
-
- if self.notification_service.dont_disturb:
- self.notification_label.set_markup(icons.notifications_off)
- self.notification_icon.set_tooltip_text("Do Not Disturb: On")
- self.notification_icon.set_visible(True)
- else:
- # At this point count must be > 0
- self.notification_label.set_markup(icons.notifications_clear)
- self.notification_icon.set_tooltip_text(f"Notifications: {count}")
- self.notification_icon.set_visible(True)
-
- def open_notif_center(self, *_):
- self.launcher.open("notification-center")
diff --git a/modules/bar/components/updates.py b/modules/bar/components/updates.py
deleted file mode 100644
index f43add2..0000000
--- a/modules/bar/components/updates.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import json
-from fabric.utils import exec_shell_command_async, get_relative_path, invoke_repeater
-from fabric.widgets.box import Box
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.stack import Stack
-from gi.repository import Gtk
-import utils.icons as icons
-import config.data as data
-
-
-class UpdatesWidget(Button):
- """A widget to display the number of available updates."""
-
- def __init__(
- self,
- **kwargs,
- ):
- # Determine if we should use vertical layout for components
- is_vertical_layout = data.VERTICAL
-
- # Initialize the button with specific name and style
- super().__init__(
- name="update",
- orientation="h" if not is_vertical_layout else "v",
- **kwargs,
- )
-
- self.script_file = get_relative_path("../../../config/scripts/systemupdates.sh")
-
- self.update_level_label = Label(
- name="update-label",
- label="0",
- visible=False, # Initially hidden
- )
-
- self.update_icon = Label(name="update-icon", markup=icons.update)
- self.updated_icon = Label(name="updated-icon", markup=icons.updated)
-
- # Create boxes with proper orientation
- self.update_box = CenterBox(
- orientation="v" if is_vertical_layout else "h",
- center_children=[self.update_icon, self.update_level_label],
- )
-
- self.updated_box = CenterBox(
- orientation="v" if is_vertical_layout else "h",
- center_children=[self.updated_icon],
- )
-
- # Stack to switch between states
- self.stack = Stack()
- self.stack.add_named(self.update_box, "update_box")
- self.stack.add_named(self.updated_box, "updated_box")
- self.stack.set_visible_child_name("update_box")
-
- self.children = Box(
- orientation="v" if is_vertical_layout else "h",
- children=self.stack,
- )
-
- # Configure stack transitions
- self.stack.set_homogeneous(False)
- self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
-
- # Connect click handler
- self.connect("button-press-event", self.on_button_press)
-
- # Check for updates every minute
- invoke_repeater(60000, self.update, initial_call=True)
-
- def update_values(self, value: str) -> bool:
- """Update the widget state based on update check results"""
- try:
- value = json.loads(value)
- except json.JSONDecodeError:
- return False
-
- update_count = str(value["total"])
- self.update_level_label.set_label(update_count)
-
- # Show appropriate state based on update count
- if update_count == "0":
- self.stack.set_visible_child_name("updated_box")
- self.update_level_label.set_visible(False)
- else:
- self.stack.set_visible_child_name("update_box")
- self.update_level_label.set_visible(True)
-
- self.set_tooltip_text(value.get("tooltip", ""))
- return True
-
- def on_button_press(self, _, event) -> bool:
- """Handle button press events"""
- if event.button == 1: # Left click
- # Run update command
- exec_shell_command_async(
- f"{self.script_file} -arch -up",
- lambda _: None,
- )
- self.update()
- return True
- else:
- # Other clicks just refresh
- self.update()
-
- def update(self) -> bool:
- """Check for system updates"""
- exec_shell_command_async(
- f"{self.script_file} -arch",
- lambda output: self.update_values(output),
- )
-
- return True
diff --git a/modules/bar/components/workspace.py b/modules/bar/components/workspace.py
deleted file mode 100644
index 072364b..0000000
--- a/modules/bar/components/workspace.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import json
-
-from fabric.hyprland.service import Hyprland
-from fabric.widgets.label import Label
-import config.data as data
-
-connection = Hyprland()
-
-workspaceData = connection.send_command("j/activeworkspace").reply
-activeWorkspace = json.loads(workspaceData.decode("utf-8"))["name"]
-
-# Create the workspace label with proper initialization
-if not data.VERTICAL:
- workspace = Label(label=f"Workspace {activeWorkspace}", name="workspace-name")
-else:
- workspace = Label(
- v_align="center",
- label="W\no\nr\nk\ns\np\na\nc\ne\n\n" + activeWorkspace,
- name="workspace-name",
- )
-
-def on_workspace(obj, signal):
- global activeWorkspace
- activeWorkspace = json.loads(signal.data[0])
- if not data.VERTICAL:
- workspace.set_label(f"Workspace {activeWorkspace}")
- else:
- workspace.set_label(f"W\no\nr\nk\ns\np\na\nc\ne\n\n{activeWorkspace}")
-
-connection.connect("event::workspace", on_workspace)
diff --git a/modules/corners.py b/modules/corners.py
new file mode 100644
index 0000000..9ec8e6e
--- /dev/null
+++ b/modules/corners.py
@@ -0,0 +1,69 @@
+from fabric.widgets.box import Box
+from fabric.widgets.shapes import Corner
+
+from utils.wayland import WaylandWindow as Window
+
+
+class MyCorner(Box):
+ def __init__(self, corner):
+ super().__init__(
+ name="corner-container",
+ children=Corner(
+ name="corner",
+ orientation=corner,
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ size=20,
+ ),
+ )
+
+
+class Corners(Window):
+ def __init__(self):
+ super().__init__(
+ name="corners",
+ layer="bottom",
+ anchor="top bottom left right",
+ exclusivity="normal",
+ # pass_through=True,
+ visible=False,
+ all_visible=False,
+ )
+
+ self.all_corners = Box(
+ name="all-corners",
+ orientation="v",
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ v_align="fill",
+ children=[
+ Box(
+ name="top-corners",
+ orientation="h",
+ h_align="fill",
+ children=[
+ MyCorner("top-left"),
+ Box(h_expand=True),
+ MyCorner("top-right"),
+ ],
+ ),
+ Box(v_expand=True),
+ Box(
+ name="bottom-corners",
+ orientation="h",
+ h_align="fill",
+ children=[
+ MyCorner("bottom-left"),
+ Box(h_expand=True),
+ MyCorner("bottom-right"),
+ ],
+ ),
+ ],
+ )
+
+ self.add(self.all_corners)
+
+ self.show_all()
diff --git a/modules/dashboard/bluetooth.py b/modules/dashboard/bluetooth.py
new file mode 100644
index 0000000..d76e81f
--- /dev/null
+++ b/modules/dashboard/bluetooth.py
@@ -0,0 +1,272 @@
+from fabric.bluetooth import BluetoothClient, BluetoothDevice
+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.scrolledwindow import ScrolledWindow
+
+import utils.icons as icons
+from modules.dashboard.tile import Tile, add_hover_cursor
+
+
+class BluetoothDeviceSlot(CenterBox):
+ """A widget representing a single Bluetooth device in the dashboard."""
+
+ def __init__(self, device: BluetoothDevice, **kwargs):
+ super().__init__(name="bluetooth-device", **kwargs)
+ self.device = device
+ self.device.connect("changed", self.on_changed)
+ self.device.connect(
+ "notify::closed", lambda *_: self.device.closed and self.destroy()
+ )
+
+ self.connection_label = Label(
+ name="bluetooth-connection", markup=icons.bluetooth_disconnected
+ )
+ self.connect_button = Button(
+ name="bluetooth-connect",
+ label="Connect",
+ on_clicked=lambda *_: self.device.set_connecting(not self.device.connected),
+ style_classes=["connected"] if self.device.connected else None,
+ )
+
+ self.start_children = [
+ Box(
+ spacing=8,
+ h_expand=True,
+ h_align="fill",
+ children=[
+ Image(icon_name=device.icon_name + "-symbolic", size=16),
+ Label(
+ label=device.name[:8] + "..." if len(device.name) > 8 else device.name,
+ h_expand=True,
+ h_align="start",
+ ),
+ self.connection_label,
+ ],
+ )
+ ]
+ self.end_children = self.connect_button
+
+ self.device.emit("changed")
+
+ def on_changed(self, *_):
+ """Handle device state changes."""
+ self.connection_label.set_markup(
+ icons.bluetooth_connected
+ if self.device.connected
+ else icons.bluetooth_disconnected
+ )
+ if self.device.connecting:
+ self.connect_button.set_label(
+ "Connecting..." if not self.device.connecting else "..."
+ )
+ else:
+ self.connect_button.set_label(
+ "Connect" if not self.device.connected else "Disconnect"
+ )
+ if self.device.connected:
+ self.connect_button.add_style_class("connected")
+ else:
+ self.connect_button.remove_style_class("connected")
+
+
+class Bluetooth(Tile):
+ """Bluetooth tile for the dashboard that shows Bluetooth status and devices."""
+
+ def __init__(self, **kwargs):
+ # Create status label
+ self.label = Label(
+ style_classes=["desc-label", "off"],
+ label="Off",
+ h_align="start",
+ )
+ self.state = False
+
+ super().__init__(
+ label="Bluetooth",
+ props=self.label,
+ markup=icons.bluetooth,
+ menu=True,
+ **kwargs,
+ )
+
+ # Initialize bluetooth client with device handling
+ self.bluetooth_client = BluetoothClient(on_device_added=self.on_device_added)
+ self.bluetooth_client.connect(
+ "notify::enabled", self._on_bluetooth_state_changed
+ )
+ self.bluetooth_client.connect("notify::scanning", self._update_scan_label)
+ self.bluetooth_client.connect(
+ "device-added", self._on_device_connection_changed
+ )
+ self.bluetooth_client.connect(
+ "device-removed", self._on_device_connection_changed
+ )
+
+ # Initialize state
+ self._update_tile_state()
+
+ # Override the type_box button behavior to toggle Bluetooth
+ self._setup_content_button()
+
+ def _update_tile_state(self):
+ """Update tile visual state based on Bluetooth status."""
+ self.state = self.bluetooth_client.enabled
+ if self.state:
+ self.remove_style_class("off")
+ self.add_style_class("on")
+ self.label.set_label("On")
+ else:
+ self.remove_style_class("on")
+ self.add_style_class("off")
+ self.label.set_label("Off")
+
+ # Update tile icon based on Bluetooth state
+ self._update_tile_icon()
+
+ def _setup_content_button(self):
+ """Setup the content button to toggle Bluetooth instead of showing menu."""
+ if hasattr(self, "type_box"):
+ # Disconnect the existing click handler
+ self.type_box.disconnect_by_func(self.handle_click)
+ # Connect to Bluetooth toggle handler
+ self.type_box.connect("clicked", self._on_bluetooth_toggle)
+
+ def _on_bluetooth_toggle(self, *_):
+ """Handle Bluetooth toggle when clicking on the main content area."""
+ # Toggle Bluetooth state
+ self.bluetooth_client.enabled = not self.bluetooth_client.enabled
+
+ def _update_tile_icon(self):
+ """Update the tile icon based on Bluetooth status and connected devices."""
+ if not self.bluetooth_client.enabled:
+ # Bluetooth is disabled
+ self.icon.set_markup(icons.bluetooth_off)
+ return
+
+ # Bluetooth is enabled - check for connected devices
+ connected_devices = self.bluetooth_client.connected_devices
+ if connected_devices:
+ # Has connected devices
+ self.icon.set_markup(icons.bluetooth_connected)
+ else:
+ # Enabled but no connected devices
+ if self.bluetooth_client.scanning:
+ # Currently scanning - could add animation here if desired
+ self.icon.set_markup(icons.bluetooth)
+ else:
+ # Idle state
+ self.icon.set_markup(icons.bluetooth)
+
+ def create_content(self):
+ """Create the detailed bluetooth content for the dashboard."""
+ # Create back button with chevron left icon
+ self.back_button_icon = Label(
+ name="bluetooth-back-label", markup=icons.chevron_left
+ )
+ self.back_button = Button(
+ name="bluetooth-back",
+ child=self.back_button_icon,
+ tooltip_text="Back to notifications",
+ on_clicked=self._on_back_clicked,
+ )
+ add_hover_cursor(self.back_button)
+
+ # Create scan button with icon
+ self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar)
+ self.scan_button = Button(
+ name="bluetooth-scan",
+ child=self.scan_label,
+ tooltip_text="Scan for Bluetooth devices",
+ on_clicked=lambda *_: self.bluetooth_client.toggle_scan(),
+ )
+ add_hover_cursor(self.scan_button)
+
+ # Create device containers
+ self.paired_box = Box(spacing=2, orientation="v")
+ self.available_box = Box(spacing=2, orientation="v")
+
+ # Create content structure
+ content_box = Box(spacing=4, orientation="v")
+ content_box.add(self.paired_box)
+ content_box.add(Label(name="bluetooth-section", label="Available"))
+ content_box.add(self.available_box)
+
+ # Create header
+ header_box = CenterBox(
+ name="bluetooth-header",
+ start_children=[self.back_button],
+ center_children=[Label(name="bluetooth-title", label="Bluetooth Devices")],
+ end_children=[self.scan_button],
+ )
+
+ # Main container
+ main_container = Box(
+ name="bluetooth-content",
+ orientation="v",
+ spacing=8,
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ style_classes=["tile-content"],
+ children=[
+ header_box,
+ ScrolledWindow(
+ name="bluetooth-devices",
+ min_content_size=(-1, -1),
+ child=content_box,
+ v_expand=True,
+ propagate_width=False,
+ propagate_height=False,
+ ),
+ ],
+ )
+
+ # Initialize notifications
+ self.bluetooth_client.notify("scanning")
+ self.bluetooth_client.notify("enabled")
+
+ return main_container
+
+ def on_device_added(self, client: BluetoothClient, address: str):
+ """Handle new device discovery."""
+ if not (device := client.get_device(address)):
+ return
+
+ # Connect to device changes to update tile icon when connection state changes
+ device.connect("changed", self._on_device_connection_changed)
+
+ slot = BluetoothDeviceSlot(device)
+
+ if device.paired:
+ return self.paired_box.add(slot)
+ return self.available_box.add(slot)
+
+ def _update_scan_label(self, *_):
+ """Update scan button appearance based on scanning state."""
+ if self.bluetooth_client.scanning:
+ self.scan_label.add_style_class("scanning")
+ self.scan_button.add_style_class("scanning")
+ self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices")
+ else:
+ self.scan_label.remove_style_class("scanning")
+ self.scan_button.remove_style_class("scanning")
+ self.scan_button.set_tooltip_text("Scan for Bluetooth devices")
+
+ # Update tile icon when scanning state changes
+ self._update_tile_icon()
+
+ def _on_back_clicked(self, *_):
+ """Handle back button click - return to default (notifications) view."""
+ if self.dashboard_instance:
+ self.dashboard_instance.reset_to_default()
+
+ def _on_bluetooth_state_changed(self, *_):
+ """Handle bluetooth state change."""
+ self._update_tile_state()
+
+ def _on_device_connection_changed(self, *_):
+ """Handle device connection/disconnection changes."""
+ self._update_tile_icon()
diff --git a/modules/dashboard/main.py b/modules/dashboard/main.py
new file mode 100644
index 0000000..c93e060
--- /dev/null
+++ b/modules/dashboard/main.py
@@ -0,0 +1,166 @@
+from fabric.core.service import Property
+from fabric.widgets.box import Box
+from fabric.widgets.stack import Stack
+
+from modules.dashboard.bluetooth import Bluetooth
+from modules.dashboard.network import Network
+from modules.dashboard.notifications import DashboardNotifications
+from modules.dashboard.player_mini import PlayerContainerMini
+from modules.dashboard.tile import TileSpecial
+from utils.wayland import WaylandWindow as Window
+
+
+class Dashboard(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="dashboard-box",
+ visible=True,
+ orientation="v",
+ spacing=8,
+ all_visible=True,
+ **kwargs,
+ )
+ self.wifi = Network(name="wifi-tile")
+ self.bluetooth = Bluetooth(name="bluetooth-tile")
+ self.mini_player = PlayerContainerMini()
+ self.mini_player_tile = TileSpecial(
+ props=self.mini_player, mini_props=self.mini_player.get_mini_view()
+ )
+
+ # Set dashboard instance reference for tiles that need it
+ self.wifi.dashboard_instance = self
+ self.bluetooth.dashboard_instance = self
+
+ self.dashboard_notifications = DashboardNotifications()
+ self.active_tile = None
+
+ self.tiles = Box(
+ children=[
+ self.wifi,
+ self.bluetooth,
+ self.mini_player_tile,
+ ],
+ )
+
+ # Create content containers for each tile
+ self.wifi_content = self.wifi.create_content()
+ self.bluetooth_content = self.bluetooth.create_content()
+
+ # Default content (notifications)
+ self.default_content = Box(
+ name="notification-history-popup-content",
+ orientation="v",
+ spacing=8,
+ h_expand=True,
+ v_expand=True,
+ children=[self.dashboard_notifications],
+ style_classes=["notification-history-popup"],
+ )
+
+ # Content stack to switch between different tile contents
+ self.content_stack = Stack(
+ transition_type="crossfade",
+ transition_duration=200,
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ children=[
+ self.default_content,
+ self.wifi_content,
+ self.bluetooth_content,
+ ],
+ )
+
+ # Set default visible child
+ self.content_stack.set_visible_child(self.default_content)
+
+ self.children = [
+ Box(name="inner", children=self.tiles),
+ Box(
+ children=[
+ self.content_stack,
+ ]
+ ),
+ ]
+
+ def handle_tile_menu_expand(self, tile: str, toggle: bool):
+ print(
+ f"Tile menu expand: {tile}, toggle: {toggle}, active_tile: {
+ self.active_tile
+ }"
+ )
+
+ # If clicking the same tile that's already active, deactivate it
+ if self.active_tile == tile and toggle:
+ toggle = False
+ # Update the tile's toggle state
+ for i in self.tiles:
+ if i.get_name() == tile:
+ i.toggle = False
+ break
+
+ if toggle:
+ # Show the specific tile's content in the content area
+ self.active_tile = tile
+ if tile == "wifi-tile":
+ self.content_stack.set_visible_child(self.wifi_content)
+ print("Showing WiFi content")
+ elif tile == "bluetooth-tile":
+ self.content_stack.set_visible_child(self.bluetooth_content)
+ print("Showing Bluetooth content")
+ else:
+ # Show default content (notifications)
+ self.active_tile = None
+ self.content_stack.set_visible_child(self.default_content)
+ print("Showing default content")
+
+ def reset_to_default(self):
+ """Reset dashboard to default state (notifications visible, no active tile)"""
+ print("Resetting dashboard to default state")
+ self.active_tile = None
+ self.content_stack.set_visible_child(self.default_content)
+
+ # Reset all tile toggle states
+ for tile in self.tiles:
+ if hasattr(tile, "toggle"):
+ tile.toggle = False
+
+
+class DashboardWindow(Window):
+ visible = Property(bool, flags="read-write", default_value=False)
+
+ def __init__(self, **kwargs):
+ # Create dashboard instance and store reference
+ self.dashboard = Dashboard()
+
+ super().__init__(
+ name="dashboard",
+ layer="top",
+ anchor="bottom",
+ exclusivity="none",
+ keyboard_mode="on-demand",
+ child=self.dashboard,
+ visible=False,
+ all_visible=False,
+ **kwargs,
+ )
+
+ # Add escape key binding to close dashboard
+ self.add_keybinding("Escape", lambda *_: self.close_dashboard())
+ self.hide()
+
+ def show_dashboard(self):
+ self.show_all()
+ self.visible = True
+
+ def close_dashboard(self):
+ # Reset dashboard to default state when closing
+ self.dashboard.reset_to_default()
+ self.hide()
+ self.visible = False
+
+ def toggle_dashboard(self):
+ if self.visible:
+ self.close_dashboard()
+ else:
+ self.show_dashboard()
diff --git a/modules/dashboard/network.py b/modules/dashboard/network.py
new file mode 100644
index 0000000..248f719
--- /dev/null
+++ b/modules/dashboard/network.py
@@ -0,0 +1,428 @@
+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.scrolledwindow import ScrolledWindow
+from gi.repository import GLib
+
+import utils.icons as icons
+from modules.dashboard.tile import Tile, add_hover_cursor
+from services.network import NetworkClient
+
+
+class WifiAccessPointSlot(CenterBox):
+ """A widget representing a single WiFi access point in the dashboard."""
+
+ def __init__(
+ self, access_point, network_service: NetworkClient, wifi_service, **kwargs
+ ):
+ super().__init__(name="wifi-ap-slot", **kwargs)
+ self.access_point = access_point
+ self.network_service = network_service
+ self.wifi_service = wifi_service
+
+ ssid = access_point.ssid
+ icon_name = access_point.icon
+
+ self.is_active = access_point.is_active
+
+ self.ap_icon = Image(icon_name=icon_name, size=24)
+ self.ap_label = Label(
+ label=ssid, h_expand=True, h_align="start", ellipsization="end"
+ )
+
+ self.connect_button = Button(
+ name="wifi-connect-button",
+ label="Connected" if self.is_active else "Connect",
+ sensitive=True, # Always clickable - can connect or disconnect
+ on_clicked=self._on_connect_clicked,
+ style_classes=["connected"] if self.is_active else None,
+ )
+ add_hover_cursor(self.connect_button)
+
+ self.set_start_children(
+ [
+ Box(
+ spacing=8,
+ h_expand=True,
+ h_align="fill",
+ children=[
+ self.ap_icon,
+ self.ap_label,
+ ],
+ )
+ ]
+ )
+ self.set_end_children([self.connect_button])
+
+ def _on_connect_clicked(self, *_):
+ """Handle connect button click."""
+ if self.is_active:
+ # Disconnect from current network
+ self.connect_button.set_label("Disconnecting...")
+ self.connect_button.set_sensitive(False)
+ self.wifi_service.disconnect_wifi()
+ elif self.access_point.bssid:
+ # Connect to this network
+ self.connect_button.set_label("Connecting...")
+ self.connect_button.set_sensitive(False)
+ self.wifi_service.connect_to_wifi(self.access_point)
+
+
+class Network(Tile):
+ """Network tile for the dashboard that shows WiFi status and networks."""
+
+ def __init__(self, **kwargs):
+ # Create status label
+ self.label = Label(
+ style_classes=["desc-label", "off"],
+ label="Disconnected",
+ h_align="start",
+ )
+ self.state = False
+
+ # Animation properties
+ self._animation_timeout_id = None
+ self._animation_step = 0
+ self._animation_direction = 1
+
+ super().__init__(
+ label="Wi-Fi",
+ props=self.label,
+ markup=icons.wifi_3,
+ menu=True,
+ **kwargs,
+ )
+
+ # Initialize network client
+ self.network_client = NetworkClient()
+ self.network_client.connect("changed", self.handle_connection_change)
+ self.network_client.connect("ready", self.handle_connection_change)
+ self.network_client.connect("wifi_device_added", self._on_wifi_device_added)
+
+ # Initialize state
+ self._update_tile_state()
+
+ # Track if content has been created to avoid showing status during init
+ self._content_created = False
+
+ # Check if WiFi device is already available
+ if self.network_client.wifi_device:
+ self._setup_wifi_device()
+
+ # Override the type_box button behavior to toggle WiFi
+ self._setup_content_button()
+
+ def handle_connection_change(self, *_):
+ """Handle network connection state changes."""
+ self._update_tile_state()
+
+ def _animate_searching(self):
+ """Animate wifi icon when searching for networks"""
+ wifi_icons = [
+ icons.wifi_0,
+ icons.wifi_1,
+ icons.wifi_2,
+ icons.wifi_3,
+ icons.wifi_2,
+ icons.wifi_1,
+ ]
+
+ wifi = self.network_client.wifi_device
+ if not self.icon or not wifi or not wifi.wireless_enabled:
+ self._stop_animation()
+ return False
+
+ if wifi.active_access_point:
+ self._stop_animation()
+ return False
+
+ GLib.idle_add(self.icon.set_markup, wifi_icons[self._animation_step])
+
+ self._animation_step = (self._animation_step + 1) % len(wifi_icons)
+
+ return True
+
+ def _start_animation(self):
+ if self._animation_timeout_id is None:
+ self._animation_step = 0
+ self._animation_direction = 1
+ self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching)
+
+ def _stop_animation(self):
+ if self._animation_timeout_id is not None:
+ GLib.source_remove(self._animation_timeout_id)
+ self._animation_timeout_id = None
+
+ def _update_tile_state(self):
+ """Update tile visual state based on WiFi status."""
+ connected = False
+ con_label = "Disconnected"
+ wifi = self.network_client.wifi_device
+
+ if wifi and wifi.active_access_point:
+ connected = True
+ # Hacky truncation: limit to 8 characters and add ellipsis
+ ssid = wifi.active_access_point.ssid
+ if len(ssid) > 8:
+ con_label = ssid[:8] + "..."
+ else:
+ con_label = ssid
+ elif wifi and not wifi.wireless_enabled:
+ con_label = "Off"
+
+ if self.state != connected:
+ self.state = connected
+ if self.state:
+ self.remove_style_class("off")
+ self.add_style_class("on")
+ else:
+ self.remove_style_class("on")
+ self.add_style_class("off")
+ print("WiFi state change:", connected)
+
+ self.label.set_label(con_label)
+
+ # Update tile icon based on WiFi state
+ self._update_tile_icon()
+
+ def _update_tile_icon(self):
+ """Update the tile icon based on network status"""
+ wifi = self.network_client.wifi_device
+ ethernet = self.network_client.ethernet_device
+
+ if wifi and not wifi.wireless_enabled:
+ self._stop_animation()
+ self.icon.set_markup(icons.wifi_off)
+ return
+
+ if wifi and wifi.wireless_enabled:
+ if wifi.active_access_point:
+ self._stop_animation()
+ # Show signal strength icon
+ if wifi.active_access_point.strength > 0:
+ strength = wifi.active_access_point.strength
+ if strength < 25:
+ self.icon.set_markup(icons.wifi_0)
+ elif strength < 50:
+ self.icon.set_markup(icons.wifi_1)
+ elif strength < 75:
+ self.icon.set_markup(icons.wifi_2)
+ else:
+ self.icon.set_markup(icons.wifi_3)
+ else:
+ self.icon.set_markup(icons.wifi_1)
+ else:
+ # WiFi is enabled but not connected - start animation
+ self._start_animation()
+
+ try:
+ primary_device = self.network_client.primary_device
+ except AttributeError:
+ primary_device = "wireless"
+
+ if primary_device == "wired":
+ self._stop_animation()
+ if ethernet and ethernet.internet == "activated":
+ self.icon.set_markup(icons.world)
+ else:
+ self.icon.set_markup(icons.world_off)
+ else:
+ if not wifi:
+ self._stop_animation()
+ self.icon.set_markup(icons.wifi_off)
+
+ def _setup_content_button(self):
+ """Setup the content button to toggle WiFi instead of showing menu."""
+ if hasattr(self, "type_box"):
+ # Disconnect the existing click handler
+ self.type_box.disconnect_by_func(self.handle_click)
+ # Connect to WiFi toggle handler
+ self.type_box.connect("clicked", self._on_wifi_toggle)
+
+ def _on_wifi_toggle(self, *_):
+ """Handle WiFi toggle when clicking on the main content area."""
+ wifi = self.network_client.wifi_device
+ if wifi:
+ # Toggle WiFi state
+ wifi.wireless_enabled = not wifi.wireless_enabled
+
+ def create_content(self):
+ """Create the detailed network content for the dashboard."""
+ # Create status label
+ self.status_label = Label(
+ label="Initializing ", h_expand=True, visible=False, h_align="center"
+ )
+
+ # Create refresh button with icon
+ self.refresh_button_icon = Label(
+ name="network-refresh-label", markup=icons.reload
+ )
+ self.refresh_button = Button(
+ name="network-refresh",
+ child=self.refresh_button_icon,
+ tooltip_text="Scan for Wi-Fi networks",
+ on_clicked=self._refresh_access_points,
+ )
+ add_hover_cursor(self.refresh_button)
+
+ # Create back button with chevron left icon
+ self.back_button_icon = Label(
+ name="network-back-label", markup=icons.chevron_left
+ )
+ self.back_button = Button(
+ name="network-back",
+ child=self.back_button_icon,
+ tooltip_text="Back to notifications",
+ on_clicked=self._on_back_clicked,
+ )
+ add_hover_cursor(self.back_button)
+
+ # Create header
+ header_box = CenterBox(
+ name="network-header",
+ start_children=[self.back_button],
+ center_children=[Label(name="network-title", label="Wi-Fi Networks")],
+ end_children=[self.refresh_button],
+ )
+
+ # Create access points list
+ self.ap_list_box = Box(orientation="v", spacing=4)
+ scrolled_window = ScrolledWindow(
+ name="network-ap-scrolled-window",
+ child=self.ap_list_box,
+ h_expand=True,
+ v_expand=True,
+ propagate_width=False,
+ propagate_height=False,
+ )
+
+ # Main container
+ main_container = Box(
+ name="network-content",
+ orientation="v",
+ spacing=8,
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ style_classes=["tile-content"],
+ children=[
+ header_box,
+ self.status_label,
+ scrolled_window,
+ ],
+ )
+
+ # Initialize button states
+ self.refresh_button.set_sensitive(False)
+
+ # Mark content as created
+ self._content_created = True
+
+ # Now update UI state since content is ready
+ if self.network_client.wifi_device:
+ self._update_wifi_status_ui()
+
+ return main_container
+
+ def _on_wifi_device_added(self, *_):
+ """Handle when WiFi device is added."""
+ self._setup_wifi_device()
+
+ def _setup_wifi_device(self):
+ """Setup WiFi device connections and initial state."""
+ if self.network_client.wifi_device:
+ self.network_client.wifi_device.connect("changed", self._load_access_points)
+ self.network_client.wifi_device.connect(
+ "notify::wireless-enabled", self._update_wifi_status_ui
+ )
+ self.network_client.wifi_device.connect(
+ "notify::active-access-point", self._update_tile_state
+ )
+
+ # Only update UI if content has been created
+ if self._content_created and hasattr(self, "refresh_button"):
+ self._update_wifi_status_ui()
+
+ # Update tile icon immediately
+ self._update_tile_icon()
+ else:
+ # Only show error if content has been created
+ if self._content_created and hasattr(self, "status_label"):
+ self.status_label.set_label("Wi-Fi device not available.")
+ self.status_label.set_visible(True)
+ if hasattr(self, "refresh_button"):
+ self.refresh_button.set_sensitive(False)
+
+ def _update_wifi_status_ui(self, *_):
+ """Update WiFi status UI elements."""
+ if not hasattr(self, "refresh_button") or not self._content_created:
+ return # Content not created yet
+
+ if self.network_client.wifi_device:
+ enabled = self.network_client.wifi_device.wireless_enabled
+ self.refresh_button.set_sensitive(enabled)
+
+ if not enabled:
+ self.status_label.set_label("Wi-Fi disabled.")
+ self.status_label.set_visible(True)
+ self._clear_ap_list()
+ else:
+ self.refresh_button.set_sensitive(False)
+
+ def _on_back_clicked(self, *_):
+ """Handle back button click - return to default (notifications) view."""
+ if self.dashboard_instance:
+ self.dashboard_instance.reset_to_default()
+
+ def _refresh_access_points(self, *_):
+ """Refresh access points list."""
+ if (
+ self.network_client.wifi_device
+ and self.network_client.wifi_device.wireless_enabled
+ ):
+ self.status_label.set_label("Scanning for Wi-Fi networks...")
+ self.status_label.set_visible(True)
+ self._clear_ap_list()
+ self.network_client.wifi_device.scan()
+ return False
+
+ def _clear_ap_list(self):
+ """Clear the access points list."""
+ for child in self.ap_list_box.get_children():
+ child.destroy()
+
+ def _load_access_points(self, *_):
+ """Load and display access points."""
+ if not hasattr(self, "ap_list_box"):
+ return # Content not created yet
+
+ if (
+ not self.network_client.wifi_device
+ or not self.network_client.wifi_device.wireless_enabled
+ ):
+ self._clear_ap_list()
+ self.status_label.set_label("Wi-Fi disabled.")
+ self.status_label.set_visible(True)
+ return
+
+ self._clear_ap_list()
+
+ access_points = self.network_client.wifi_device.access_points
+
+ if not access_points:
+ self.status_label.set_label("No Wi-Fi networks found.")
+ self.status_label.set_visible(True)
+ else:
+ self.status_label.set_visible(False)
+ # Sort access points by connection status (connected first) then by signal strength
+ sorted_aps = sorted(
+ access_points, key=lambda x: (not x.is_active, -x.strength)
+ )
+ for access_point in sorted_aps:
+ slot = WifiAccessPointSlot(
+ access_point, self.network_client, self.network_client.wifi_device
+ )
+ self.ap_list_box.add(slot)
+ self.ap_list_box.show_all()
diff --git a/modules/dashboard/notifications.py b/modules/dashboard/notifications.py
new file mode 100644
index 0000000..ed17e16
--- /dev/null
+++ b/modules/dashboard/notifications.py
@@ -0,0 +1,394 @@
+"""
+Dashboard notification components for Modus.
+Provides notification display functionality for the dashboard.
+"""
+
+import json
+import os
+from datetime import datetime
+
+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.scrolledwindow import ScrolledWindow
+from gi.repository import GLib, Gtk
+
+import utils.icons as icons
+from utils.notification_utils import (
+ PERSISTENT_HISTORY_FILE,
+ compute_time_label,
+ create_historical_notification_from_data,
+ get_shared_notification_history,
+)
+
+
+class DashboardNotificationItem(Box):
+ """Individual notification item for dashboard"""
+
+ def __init__(self, notification_data, **kwargs):
+ super().__init__(
+ name="notification-container",
+ orientation="v",
+ h_align="fill",
+ h_expand=True,
+ **kwargs,
+ )
+
+ self.notification_data = notification_data
+
+ # Create historical notification from data
+ hist_notif = create_historical_notification_from_data(notification_data)
+
+ # Create timestamp
+ try:
+ arrival_time = datetime.fromisoformat(hist_notif.timestamp)
+ except Exception:
+ arrival_time = datetime.now()
+
+ # Create time label
+ self.time_label = Label(
+ name="notification-timestamp",
+ markup=compute_time_label(arrival_time),
+ h_align="start",
+ ellipsization="end",
+ )
+
+ # Create notification image box
+ # For dashboard, we'll use a simple icon instead of trying to load cached images
+ self.notif_image_box = Box(
+ name="notification-image",
+ orientation="v",
+ children=[
+ Label(name="notification-icon", markup=icons.notifications),
+ Box(v_expand=True),
+ ],
+ )
+
+ # Create summary label
+ self.notif_summary_label = Label(
+ name="notification-summary",
+ markup=hist_notif.summary,
+ h_align="start",
+ ellipsization="end",
+ )
+
+ # Create app name label
+ self.notif_app_name_label = Label(
+ name="notification-app-name",
+ markup=f"{hist_notif.app_name}",
+ h_align="start",
+ ellipsization="end",
+ )
+
+ # Create body label
+ self.notif_body_label = (
+ Label(
+ name="notification-body",
+ markup=hist_notif.body,
+ h_align="start",
+ ellipsization="end",
+ line_wrap="word-char",
+ )
+ if hist_notif.body
+ else Box()
+ )
+ if hist_notif.body:
+ self.notif_body_label.set_single_line_mode(True)
+
+ # Create summary box with separators
+ self.notif_summary_box = Box(
+ name="notification-summary-box",
+ orientation="h",
+ children=[
+ self.notif_summary_label,
+ Box(
+ name="notif-sep",
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ ),
+ self.notif_app_name_label,
+ Box(
+ name="notif-sep",
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ ),
+ self.time_label,
+ ],
+ )
+
+ # Create text box
+ self.notif_text_box = Box(
+ name="notification-text",
+ orientation="v",
+ v_align="center",
+ h_expand=True,
+ children=[
+ self.notif_summary_box,
+ self.notif_body_label,
+ ],
+ )
+
+ # Create close button
+ self.notif_close_button = Button(
+ name="notif-close-button",
+ child=Label(name="notif-close-label", markup=icons.cancel),
+ on_clicked=lambda *_: self.remove_notification(),
+ )
+
+ self.notif_close_button_box = Box(
+ orientation="v",
+ children=[
+ self.notif_close_button,
+ Box(v_expand=True),
+ ],
+ )
+
+ # Create main content box
+ content_box = Box(
+ name="notification-box-hist",
+ spacing=8,
+ children=[
+ self.notif_image_box,
+ self.notif_text_box,
+ self.notif_close_button_box,
+ ],
+ )
+
+ self.add(content_box)
+
+ def remove_notification(self):
+ """Remove this notification from the dashboard"""
+ if self.get_parent():
+ self.get_parent().remove(self)
+ self.destroy()
+
+
+class DashboardNotifications(Box):
+ """Dashboard notifications container that shows real notifications"""
+
+ def __init__(self, **kwargs):
+ super().__init__(name="notification-history", orientation="v", **kwargs)
+
+ self.notification_items = []
+ self.max_notifications = 5 # Limit to 5 notifications in dashboard
+
+ # Create header components (matching dock notifications)
+ self.header_label = Label(
+ name="nhh",
+ label="Notifications",
+ h_align="start",
+ h_expand=True,
+ )
+ self.header_switch = Gtk.Switch(name="dnd-switch")
+ self.header_switch.set_vexpand(False)
+ self.header_switch.set_valign(Gtk.Align.CENTER)
+ self.header_switch.set_active(False)
+ self.header_clean = Button(
+ name="nhh-button",
+ child=Label(name="nhh-button-label", markup=icons.trash),
+ on_clicked=self.clear_history,
+ )
+ self.do_not_disturb_enabled = False
+ self.header_switch.connect("notify::active", self.on_do_not_disturb_changed)
+ self.dnd_label = Label(name="dnd-label", markup=icons.notifications_off)
+
+ self.history_header = CenterBox(
+ name="notification-history-header",
+ spacing=8,
+ start_children=[self.header_switch, self.dnd_label],
+ center_children=[self.header_label],
+ end_children=[self.header_clean],
+ )
+
+ # Create notifications list
+ self.notifications_list = Box(
+ name="notifications-list",
+ orientation="v",
+ spacing=4,
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ v_align="fill",
+ )
+
+ # Create "no notifications" label
+ self.no_notifications_label = Label(
+ name="no-notif",
+ markup=icons.notifications_clear,
+ v_align="fill",
+ h_align="fill",
+ v_expand=True,
+ h_expand=True,
+ justification="center",
+ )
+ self.no_notifications_box = Box(
+ name="no-notifications-box",
+ v_align="fill",
+ h_align="fill",
+ v_expand=True,
+ h_expand=True,
+ children=[self.no_notifications_label],
+ )
+
+ # Create scrolled window for notifications list only
+ self.notifications_scrolled = ScrolledWindow(
+ name="notification-history-scrolled-window",
+ child=self.notifications_list,
+ h_scrollbar_policy="never",
+ v_scrollbar_policy="automatic",
+ min_content_size=(400, 200),
+ max_content_size=(450, 300),
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ v_align="fill",
+ propagate_width=False,
+ propagate_height=False,
+ )
+
+ # Add components to main container - header outside, scrolled list inside
+ self.add(self.history_header)
+ self.add(self.notifications_scrolled)
+
+ # Add the no notifications box initially to the notifications list
+ self.notifications_list.add(self.no_notifications_box)
+
+ # Connect to shared notification history
+ self._connect_to_notifications()
+
+ # Load and sync DND state
+ self._load_and_sync_dnd_state()
+
+ # Load initial notifications
+ self._load_recent_notifications()
+
+ def _connect_to_notifications(self):
+ """Connect to the shared notification history for real-time updates"""
+ try:
+ self.shared_history = get_shared_notification_history()
+ self.shared_history.connect(
+ "notification-added", self._on_notification_added
+ )
+ self.shared_history.connect("dnd-state-changed", self._on_dnd_state_changed)
+ except Exception as e:
+ print(f"Could not connect to notification history: {e}")
+
+ def _on_notification_added(self, shared_history):
+ """Handle new notification added"""
+ GLib.idle_add(self._refresh_notifications)
+
+ def _on_dnd_state_changed(self, notification_history, dnd_enabled):
+ """Handle DND state change"""
+ self.do_not_disturb_enabled = dnd_enabled
+ self.header_switch.set_active(dnd_enabled)
+
+ def _load_and_sync_dnd_state(self):
+ """Load and sync DND state from shared history"""
+ try:
+ if hasattr(self, "shared_history"):
+ dnd_enabled = self.shared_history.do_not_disturb_enabled
+ self.header_switch.set_active(dnd_enabled)
+ self.do_not_disturb_enabled = dnd_enabled
+ except Exception as e:
+ print(f"Could not sync DND state: {e}")
+
+ def on_do_not_disturb_changed(self, switch, pspec):
+ """Handle DND switch toggle"""
+ self.do_not_disturb_enabled = switch.get_active()
+
+ try:
+ if hasattr(self, "shared_history"):
+ self.shared_history.set_do_not_disturb_enabled(
+ self.do_not_disturb_enabled
+ )
+ except Exception as e:
+ print(f"Could not update shared DND state: {e}")
+
+ def clear_history(self, *args):
+ """Clear all notifications"""
+ # Clear notification items
+ for item in self.notification_items[:]:
+ self.notifications_list.remove(item)
+ item.destroy()
+ self.notification_items.clear()
+
+ # Clear persistent history
+ try:
+ if hasattr(self, "shared_history"):
+ self.shared_history.clear_history()
+ except Exception as e:
+ print(f"Could not clear shared history: {e}")
+
+ # Update visibility
+ self._update_no_notifications_visibility()
+
+ def _load_recent_notifications(self):
+ """Load recent notifications from persistent storage"""
+ try:
+ if os.path.exists(PERSISTENT_HISTORY_FILE):
+ with open(PERSISTENT_HISTORY_FILE, "r") as f:
+ notifications = json.load(f)
+
+ # Get the most recent notifications (up to max_notifications)
+ recent_notifications = notifications[-self.max_notifications :]
+
+ for notif_data in reversed(recent_notifications):
+ self._add_notification_item(notif_data)
+
+ except Exception as e:
+ print(f"Error loading recent notifications: {e}")
+
+ def _refresh_notifications(self):
+ """Refresh the notification list"""
+ # Clear existing notifications
+ for item in self.notification_items[:]:
+ self.notifications_list.remove(item)
+ item.destroy()
+ self.notification_items.clear()
+
+ # Reload recent notifications
+ self._load_recent_notifications()
+
+ # Update visibility
+ self._update_no_notifications_visibility()
+
+ def _add_notification_item(self, notification_data):
+ """Add a notification item to the dashboard"""
+ # Remove no notifications box if it's showing
+ if self.no_notifications_box in self.notifications_list.get_children():
+ self.notifications_list.remove(self.no_notifications_box)
+
+ if len(self.notification_items) >= self.max_notifications:
+ # Remove oldest notification
+ oldest = self.notification_items.pop(0)
+ self.notifications_list.remove(oldest)
+ oldest.destroy()
+
+ # Create new notification item
+ item = DashboardNotificationItem(notification_data)
+ self.notification_items.append(item)
+ self.notifications_list.add(item)
+
+ # Update visibility
+ self._update_no_notifications_visibility()
+
+ # Show all to ensure visibility
+ self.show_all()
+
+ def _update_no_notifications_visibility(self):
+ """Update visibility of no notifications label"""
+ has_notifications = bool(self.notification_items)
+
+ if has_notifications:
+ # Hide no notifications box
+ if self.no_notifications_box in self.notifications_list.get_children():
+ self.notifications_list.remove(self.no_notifications_box)
+ else:
+ # Show no notifications box
+ if self.no_notifications_box not in self.notifications_list.get_children():
+ self.notifications_list.add(self.no_notifications_box)
diff --git a/modules/dashboard/player_mini.py b/modules/dashboard/player_mini.py
new file mode 100644
index 0000000..f842b44
--- /dev/null
+++ b/modules/dashboard/player_mini.py
@@ -0,0 +1,462 @@
+import gi
+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.label import Label
+from fabric.widgets.stack import Stack
+from loguru import logger
+
+import config.data as data
+import utils.icons as icons
+from services.mpris import PlayerManager, PlayerService
+from utils.wiggle_bar import WigglyWidget
+
+gi.require_version("Gtk", "3.0")
+
+
+class PlayerMini(Box):
+ def __init__(self, player, **kwargs):
+ super().__init__(style_classes="player-mini", **kwargs)
+
+ if not data.VERTICAL:
+ self.remove_style_class(
+ "vertical"
+ ) # vertical class binding from unknown source
+
+ self._player = PlayerService(player=player)
+
+ self.duration = 0.0
+
+ self._player.connect("pause", self.on_pause)
+ self._player.connect("play", self.on_play)
+ self._player.connect("meta-change", self.on_metadata)
+ self._player.connect("shuffle-toggle", self.on_shuffle)
+ self._player.connect("track-position", self.on_update_track_position)
+
+ self.player_name = Label(
+ name=player.props.player_name,
+ style_classes=["player-icon", "mini"],
+ markup=getattr(icons, player.props.player_name, icons.disc),
+ )
+
+ self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
+
+ self.song = Label(
+ name="song",
+ label="song",
+ style_classes="mini",
+ justification="left",
+ h_align="start",
+ ellipsization="end",
+ max_chars_width=15,
+ )
+ self.artist = Label(
+ name="artist",
+ label="artist",
+ style_classes="mini",
+ justification="left",
+ h_align="start",
+ )
+ self.music = Box(
+ name="music",
+ h_expand=True,
+ v_expand=True,
+ children=[self.song],
+ )
+
+ self.play_pause_button = Button(
+ name="play-pause-button",
+ child=Label(name="play-pause-label", markup=icons.play),
+ style_classes="mini",
+ tooltip_text="Play/Pause",
+ on_clicked=lambda b, *_: self.handle_play_pause(player),
+ )
+
+ self.shuffle_button = Button(
+ name="shuffle-button",
+ child=Label(name="shuffle", markup=icons.shuffle),
+ on_clicked=lambda b, *_: self.handle_shuffle(b, player),
+ )
+
+ self.wiggly = WigglyWidget()
+ self.wiggly.connect("on-seek", self.on_seek)
+ self.wiggly_bar = Box(
+ orientation="v",
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ v_align="fill",
+ children=self.wiggly,
+ )
+
+ self.album_cover = Box(style_classes="album-image")
+ self.album_cover.set_style(
+ f"background-image:url('{data.HOME_DIR}/.current.wall')"
+ )
+
+ self.children = [
+ self.album_cover,
+ Box(
+ name="source",
+ style_classes="mini",
+ v_align="end",
+ children=self.player_name,
+ ),
+ # CenterBox(
+ # name="progress-container",
+ # h_expand=True,
+ # v_expand=True,
+ # orientation="v",
+ # center_children=[self.wiggly_bar],
+ # ),
+ Box(
+ orientation="v",
+ v_expand=True,
+ h_expand=True,
+ style="padding-left:10px;",
+ children=[
+ CenterBox(
+ name="details",
+ center_children=self.music,
+ ),
+ Box(
+ name="controls",
+ style_classes="horizontal" if not data.VERTICAL else "vertical",
+ spacing=5,
+ h_expand=True,
+ children=[
+ CenterBox(
+ h_expand=True,
+ v_expand=True,
+ center_children=[
+ Button(
+ name="prev-button",
+ child=Label(
+ name="play-previous",
+ style_classes="mini",
+ markup=icons.previous_fill,
+ ),
+ on_clicked=lambda b, *_: self.handle_prev(
+ player
+ ),
+ ),
+ self.play_pause_button,
+ Button(
+ name="next-button",
+ child=Label(
+ name="play-next",
+ style_classes="mini",
+ markup=icons.next_fill,
+ ),
+ on_clicked=lambda b, *_: self.handle_next(
+ player
+ ),
+ ),
+ # self.shuffle_button,
+ ],
+ )
+ ],
+ ),
+ ],
+ ),
+ ]
+
+ self.on_metadata(self._player, metadata=player.props.metadata, player=player)
+
+ def on_update_track_position(self, sender, pos, dur):
+ if dur == 0:
+ return
+ self.duration = dur
+ self.wiggly.update_value_from_signal(pos / dur)
+
+ def on_seek(self, sender, ratio):
+ pos = ratio * self.duration # duration in seconds
+ print(f"Seeking to {pos:.2f}s")
+ self._player.set_position(int(pos))
+
+ def skip_forward(self, seconds=10):
+ self._player._player.seek(seconds * 1000000)
+
+ def skip_backward(self, seconds=10):
+ self._player._player.seek(-1 * seconds * 1000000)
+
+ def on_metadata(self, sender, metadata, player):
+ keys = metadata.keys()
+ if "xesam:artist" in keys and "xesam:title" in keys:
+ # _max_chars = 20 if not data.VERTICAL else 30
+ song_title = metadata["xesam:title"]
+ # if len(song_title) > _max_chars:
+ # song_title = song_title[: _max_chars - 1] + "…"
+ self.song.set_label(song_title)
+
+ artist_list = metadata["xesam:artist"]
+ artist_name = artist_list[0] if artist_list else "Unknown Artist"
+ # if len(artist_name) > _max_chars:
+ # artist_name = artist_name[: _max_chars - 1] + "…"
+ self.artist.set_label(artist_name)
+ if "mpris:artUrl" in keys:
+ self.set_style(f"background-image:url('{metadata['mpris:artUrl']}')")
+ self.album_cover.set_style(
+ f"background-image:url('{metadata['mpris:artUrl']}')"
+ )
+
+ if (
+ player.props.playback_status.value_name
+ == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
+ ):
+ self.on_play(self._player)
+
+ if player.props.shuffle == True:
+ self.shuffle_button.get_child().set_markup(icons.disable_shuffle)
+ self.shuffle_button.get_child().set_name("disable-shuffle")
+ else:
+ self.shuffle_button.get_child().set_markup(icons.shuffle)
+ self.shuffle_button.get_child().set_name("shuffle")
+
+ def on_pause(self, sender):
+ self.play_pause_button.get_child().set_markup(icons.play)
+ self.wiggly.dragging = True
+ self.wiggly.update_amplitude(True)
+ self.wiggly.pause = True
+ # self.play_pause_button.add_style_class("pause-track")
+
+ def on_play(self, sender):
+ self.play_pause_button.get_child().set_markup(icons.pause)
+ self.wiggly.pause = False
+ self.wiggly.dragging = False
+ self.wiggly.update_amplitude(False)
+ # self.play_pause_button.remove_style_class("pause-track")
+
+ def on_shuffle(self, sender, player, status):
+ print("callback status", status)
+ if status == False:
+ self.shuffle_button.get_child().set_markup(icons.shuffle)
+ self.shuffle_button.get_child().set_name("shuffle")
+ else:
+ self.shuffle_button.get_child().set_markup(icons.disable_shuffle)
+ self.shuffle_button.get_child().set_name("disable-shuffle")
+
+ self.shuffle_button.get_child().set_style("color: white")
+
+ def handle_next(self, player):
+ self._player._player.next()
+
+ def handle_prev(self, player):
+ self._player._player.previous()
+
+ def handle_play_pause(self, player):
+ is_playing = (
+ self._player._player.props.playback_status.value_name
+ == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
+ )
+
+ def _set_play_ui():
+ self.play_pause_button.get_child().set_markup(icons.pause)
+ # self.play_pause_button.remove_style_class("pause-track")
+ # self.play_pause_button.get_child().set_name("pause-label")
+
+ def _set_pause_ui():
+ self.play_pause_button.get_child().set_markup(icons.play)
+ # self.play_pause_button.add_style_class("pause-track")
+ # self.play_pause_button.get_child().set_name("play-label")
+
+ if is_playing:
+ _set_pause_ui()
+ else:
+ _set_play_ui()
+
+ try:
+ self._player._player.play_pause()
+ except Exception as e:
+ # revert if signal failed
+ if is_playing:
+ _set_pause_ui()
+ else:
+ _set_play_ui()
+ logger.warning("Failed to toggle playback:", e)
+
+ def handle_shuffle(self, shuffle_button, player):
+ print("shuffle", player.props.shuffle)
+ if player.props.shuffle == False:
+ player.set_shuffle(True)
+ print("setting to true", player.props.player_name)
+ else:
+ player.set_shuffle(False)
+ shuffle_button.get_child().set_style("color: var(--outline)")
+
+
+class PlacheholderMini(Box):
+ def __init__(self, **kwargs):
+ super().__init__(style_classes="player-mini", **kwargs)
+
+ self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
+ self.album_cover = Box(style_classes="album-image")
+ self.album_cover.set_style(
+ f"background-image:url('{data.HOME_DIR}/.current.wall')"
+ )
+ self.player_name = Label(
+ style_classes=["player-icon", "mini"], markup=icons.disc
+ )
+
+ self.children = [
+ self.album_cover,
+ Box(
+ name="source",
+ style_classes="mini",
+ v_align="end",
+ children=self.player_name,
+ ),
+ Box(
+ orientation="v",
+ v_expand=True,
+ h_expand=True,
+ style="padding-left:10px;",
+ v_align="center",
+ children=[
+ CenterBox(
+ name="details",
+ center_children=Label(
+ label="Nothing Playing", style="color:black;"
+ ),
+ )
+ ],
+ ),
+ ]
+
+
+class PlayerContainerMini(Box):
+ def __init__(self, **kwargs):
+ super().__init__(name="player-container", style_classes="mini", **kwargs)
+
+ self.manager = PlayerManager()
+ self.manager.connect("new-player", self.new_player)
+ self.manager.connect("player-vanish", self.on_player_vanish)
+ self.placeholder = PlacheholderMini()
+ self.stack = Stack(
+ # name="player-container",
+ transition_type="crossfade",
+ transition_duration=100,
+ children=[self.placeholder],
+ )
+
+ self.player_stack = Stack(
+ name="player-stack",
+ transition_type="crossfade",
+ transition_duration=100,
+ children=[],
+ )
+
+ self.player_switch_container = CenterBox(
+ name="player-switch-container",
+ orientation="v",
+ style_classes=(
+ ["horizontal-player", "mini"]
+ if not data.VERTICAL
+ else "vertical-player"
+ ),
+ center_children=[],
+ )
+ self.mini_tile_icon = Label(
+ name="disc",
+ style_classes=["tile-icon"],
+ style="font-size:30px; margin:0px; padding:0px;",
+ markup=icons.disc,
+ justification="center",
+ )
+
+ self.mini_tile_view = CenterBox(
+ style_classes=["tile", "mini", "on"],
+ v_expand=False,
+ center_children=self.mini_tile_icon,
+ )
+ self.event_box = EventBox(
+ events=["scroll"],
+ child=Box(children=[self.stack, self.player_switch_container]),
+ )
+ self.children = self.event_box
+ self.players = []
+ self.manager.init_all_players()
+ self.event_box.connect("scroll-event", self.on_scroll)
+ self.stack.connect("notify::visible-child", self.on_visible_child_changed)
+
+ def new_player(self, manager, player):
+ print(player.props.player_name, "new player")
+ print(player)
+ new_player = PlayerMini(player=player)
+ new_player.wiggly_bar.queue_draw()
+ new_player.set_name(player.props.player_name)
+ self.players.append(new_player)
+ print("stacking mini", player.props.player_name)
+ self.stack.add_named(new_player, player.props.player_name)
+ if len(self.players) == 1:
+ self.stack.remove(self.placeholder)
+
+ self.player_switch_container.add_center(
+ Button(
+ name=player.props.player_name,
+ style_classes=["player-button", "mini"],
+ on_clicked=lambda b: self.switch_player(player.props.player_name, b),
+ )
+ )
+ self.update_player_list()
+
+ def switch_player(self, player_name, button):
+ self.stack.set_visible_child_name(player_name)
+
+ for btn in self.player_switch_container.center_children:
+ btn.remove_style_class("active")
+ button.add_style_class("active")
+
+ def on_player_vanish(self, manager, player):
+ for player_instance in self.players:
+ if player_instance.get_name() == player.props.player_name:
+ self.stack.remove(player_instance)
+ self.players.remove(player_instance)
+ for btn in self.player_switch_container.center_children:
+ if btn.get_name() == player_instance.get_name():
+ self.player_switch_container.remove_center(btn)
+ self.update_player_list()
+ break
+
+ if len(self.players) == 0:
+ self.stack.add_named(self.placeholder, "placeholder")
+
+ def update_player_list(self):
+ curr = self.stack.get_visible_child()
+ for btn in self.player_switch_container.center_children:
+ print(btn.get_name())
+ if btn.get_name() == curr.get_name():
+ btn.add_style_class("active")
+ else:
+ btn.remove_style_class("active")
+
+ def on_scroll(self, widget, event):
+ match event.direction:
+ case 0:
+ self.switch_relative_player(forward=False)
+ case 1:
+ self.switch_relative_player(forward=True)
+
+ def switch_relative_player(self, forward=True):
+ if not self.players:
+ return
+
+ current_player = self.stack.get_visible_child()
+ current_index = self.players.index(current_player)
+
+ next_index = (current_index + (1 if forward else -1)) % len(self.players)
+ next_player = self.players[next_index]
+
+ for btn in self.player_switch_container.center_children:
+ if btn.get_name() == next_player.get_name():
+ self.switch_player(next_player.get_name(), btn)
+ break
+
+ def on_visible_child_changed(self, *args):
+ curr_player = self.stack.get_visible_child().get_name()
+ self.mini_tile_icon.set_name(curr_player)
+ self.mini_tile_icon.set_markup(getattr(icons, curr_player, icons.disc))
+
+ def get_mini_view(self):
+ return self.mini_tile_view
diff --git a/modules/dashboard/tile.py b/modules/dashboard/tile.py
new file mode 100644
index 0000000..f04e2f1
--- /dev/null
+++ b/modules/dashboard/tile.py
@@ -0,0 +1,216 @@
+"""
+Dashboard Tile Components
+
+This module provides tile widgets for the dashboard interface.
+
+Overview:
+- Tiles are interactive components that display an icon, label, and status
+- When clicked, tiles show their detailed content in the dashboard's content area
+- Tiles can switch between mini (compact) and maxi (full) views
+- Two tile types are available: Tile (standard) and TileSpecial (custom layouts)
+
+Architecture:
+- Tile: Standard tile with icon, label, properties, and optional menu button
+- TileSpecial: Custom tile that can switch between different view layouts
+- Both tiles communicate with the parent dashboard to show content
+
+Usage:
+- Create tiles with appropriate properties and add them to the dashboard
+- The dashboard handles content switching and tile state management
+- Tiles automatically notify the dashboard when clicked
+"""
+
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.stack import Stack
+from gi.repository import Gdk
+
+import utils.icons as icons
+
+
+def add_hover_cursor(widget):
+ """Add hover cursor functionality to a widget."""
+ widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
+ widget.connect(
+ "enter-notify-event",
+ lambda w, e: w.get_window().set_cursor(
+ Gdk.Cursor.new_from_name(w.get_display(), "pointer")
+ )
+ if w.get_window()
+ else None,
+ )
+ widget.connect(
+ "leave-notify-event",
+ lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None,
+ )
+
+
+class Tile(Box):
+ """
+ A dashboard tile widget that displays an icon, label, and properties.
+
+ When clicked, the tile notifies the dashboard to show its detailed content
+ in the dashboard's content area instead of expanding inline.
+
+ Args:
+ menu (bool): Whether this tile has a menu button
+ markup (str): Icon markup to display
+ label (str): Text label for the tile
+ props (Label): Properties widget to show tile status
+ **kwargs: Additional widget arguments
+ """
+
+ def __init__(self, *, menu: bool, markup: str, label: str, props: Label, **kwargs):
+ # Setup CSS classes
+ default_classes = ["tile"]
+ extra_classes = kwargs.pop("style_classes", [])
+ all_classes = default_classes + extra_classes
+
+ super().__init__(style_classes=all_classes, v_align="start", **kwargs)
+
+ # Store tile properties
+ self.props = props
+ self.toggle = False # Track if tile is active
+ self.dashboard_instance = None # Reference to parent dashboard
+
+ # Create tile components
+ self._create_tile_icon(markup)
+ self._create_tile_content(label)
+ self._create_tile_layout(menu)
+
+ def _create_tile_icon(self, markup: str):
+ """Create the tile's icon label."""
+ self.icon = Label(style_classes="tile-icon", markup=markup)
+
+ def _create_tile_content(self, label: str):
+ """Create the tile's text content area."""
+ self.tile_label = Label(
+ style_classes="tile-label", label=label, h_align="start"
+ )
+
+ # Container for label and properties
+ content_box = Box(
+ style_classes="tile-type",
+ orientation="v",
+ v_expand=True,
+ h_expand=True,
+ v_align="center",
+ children=[self.tile_label, self.props],
+ )
+
+ # Make the type_box clickable using a button
+ self.type_box = Button(
+ style_classes="tile-content-button",
+ h_expand=True,
+ v_expand=True,
+ child=content_box,
+ on_clicked=self.handle_click,
+ )
+ # Add hover cursor to the content area
+ add_hover_cursor(self.type_box)
+
+ def _create_tile_layout(self, has_menu: bool):
+ """Create the tile's layout structure."""
+ # Create menu button if needed
+ if has_menu:
+ self.menu_button = Button(
+ style_classes="tile-button",
+ h_expand=True,
+ child=Label(style_classes="tile-icon", markup=icons.chevron_right),
+ on_clicked=self.handle_click,
+ )
+ # Add hover cursor to menu button
+ add_hover_cursor(self.menu_button)
+ content_children = [self.type_box, self.menu_button]
+ else:
+ content_children = [self.type_box]
+
+ # Create revealer for smooth transitions
+ self.content_button = Revealer(
+ transition_duration=150,
+ transition_type="slide-left",
+ h_expand=True,
+ child=Box(children=content_children),
+ child_revealed=True,
+ )
+
+ # Create the main tile view
+ self.normal_view = Box(children=[self.icon, self.content_button])
+
+ # Create placeholder menu (content is shown in dashboard content area)
+ self.menu = Box(
+ style_classes="tile-menu",
+ children=[
+ Label(label="Tile content will be shown in dashboard content area")
+ ],
+ )
+
+ # Stack to switch between normal and menu views
+ self.stack = Stack(
+ transition_type="crossfade",
+ transition_duration=150,
+ h_expand=True,
+ children=[self.normal_view, self.menu],
+ )
+
+ self.children = self.stack
+
+ def handle_click(self, *_):
+ """
+ Handle tile click events.
+
+ When clicked, the tile toggles its active state and notifies the dashboard
+ to show/hide its content in the dashboard's content area.
+ """
+ self.toggle = not self.toggle
+ print(f"Tile {self.get_name()} clicked, toggle: {self.toggle}")
+
+ # Notify dashboard to handle content switching
+ if self.dashboard_instance:
+ self.dashboard_instance.handle_tile_menu_expand(
+ self.get_name(), self.toggle
+ )
+
+
+class TileSpecial(Box):
+ """
+ A special tile widget that can switch between normal and mini views.
+
+ This tile type is used for components that need different layouts
+ in normal vs compact modes (e.g., media player).
+
+ Args:
+ mini_props: Widget(s) to show in mini view
+ props: Widget(s) to show in normal view
+ **kwargs: Additional widget arguments
+ """
+
+ def __init__(self, *, mini_props, props, **kwargs):
+ super().__init__(**kwargs)
+
+ # Store view components
+ self.props = props
+ self.mini_props = mini_props
+ self.toggle = False
+
+ # Create view containers
+ self._create_views()
+ self._setup_layout()
+
+ def _create_views(self):
+ """Create the normal and collapsed view containers."""
+ self.normal_view = Box(children=self.props)
+ self.collapsed_view = Box(children=self.mini_props)
+
+ def _setup_layout(self):
+ """Setup the stack layout for switching between views."""
+ self.stack = Stack(
+ transition_type="crossfade",
+ transition_duration=150,
+ h_expand=True,
+ v_align="start",
+ children=[self.normal_view, self.collapsed_view],
+ )
+ self.children = self.stack
diff --git a/modules/dock.py b/modules/dock.py
deleted file mode 100644
index fb89eb7..0000000
--- a/modules/dock.py
+++ /dev/null
@@ -1,1133 +0,0 @@
-import json
-from gi.repository import GLib, Gtk, Gdk
-import cairo
-from utils.icon_resolver import IconResolver
-from utils.occlusion import check_occlusion
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.widgets.image import Image
-from fabric.widgets.eventbox import EventBox
-from fabric.widgets.wayland import WaylandWindow as Window
-from fabric.hyprland.widgets import get_hyprland_connection
-from fabric.utils import (
- exec_shell_command,
- exec_shell_command_async,
- idle_add,
- remove_handler,
- get_relative_path,
-)
-from fabric.utils.helpers import get_desktop_applications
-from modules.bar.bar import MyCorner # Add this import for corners
-
-import config.data as data
-import logging
-
-OCCLUSION = 36 + data.DOCK_ICON_SIZE
-
-
-# Credit to Aylur for the createSurfaceFromWidget code
-def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
- alloc = widget.get_allocation()
- surface = cairo.ImageSurface(
- cairo.Format.ARGB32,
- alloc.width,
- alloc.height,
- )
- cr = cairo.Context(surface)
- cr.set_source_rgba(255, 255, 255, 0)
- cr.rectangle(0, 0, alloc.width, alloc.height)
- cr.fill()
- widget.draw(cr)
- return surface
-
-
-class Dock(Window):
- # Static registry to track all active dock instances
- _instances = []
-
- def __init__(self, **kwargs):
- super().__init__(
- name="dock-window",
- layer="top",
- anchor="bottom center"
- if not data.VERTICAL and not data.BOTTOM_BAR
- else "left center"
- if data.VERTICAL_RIGHT_ALIGN
- else "right center",
- margin="-8px 0 -8px 0"
- if not data.VERTICAL and not data.BOTTOM_BAR
- else "-8px 0 -80px 0"
- if data.VERTICAL_RIGHT_ALIGN
- else "0 -80px 0 -8px",
- exclusivity="none",
- **kwargs,
- )
- # Add this instance to the registry
- Dock._instances.append(self)
-
- # Initialize configuration
- self.config = data.read_config()
- self.conn = get_hyprland_connection()
- self.icon = IconResolver()
- self.pinned = self.config.get("pinned_apps", [])
- self.config_path = get_relative_path("../config/assets/modus.json")
- self.app_map = {} # Initialize the app map
- self._all_apps = get_desktop_applications() # Get all apps for lookup
- # Create app identifiers mapping
- self.app_identifiers = self._build_app_identifiers_map()
- self.is_hidden = False
- self.hide_id = None
- self._arranger_handler = None
- self._drag_in_progress = False # Drag lock flag
- self.is_hovered = False
- self.always_occluded = (
- data.DOCK_ALWAYS_OCCLUDED
- ) # Track the always occluded setting
-
- # Set up UI containers
- self.view = Box(
- name="viewport",
- orientation="h"
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else "v",
- spacing=4,
- )
- self.wrapper = Box(name="dock", orientation="v", children=[self.view])
-
- # Main dock container with hover handling
- self.dock_eventbox = EventBox()
- self.dock_eventbox.add(self.wrapper)
- self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter)
- self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave)
-
- # Helper to create corner boxes
- def create_corner_box(name, orientation, align, corner_position):
- return Box(
- name=name,
- orientation=orientation,
- **align,
- children=[
- Box(
- **(
- {"v_expand": True, "v_align": "fill"}
- if orientation == "v"
- else {"h_expand": True, "h_align": "fill"}
- )
- ),
- MyCorner(corner_position),
- ],
- )
-
- # Determine dock layout based on orientation
- if not data.VERTICAL and not data.BOTTOM_BAR:
- # Horizontal layout: corners at the bottom
- self.corner_left = create_corner_box(
- name="dock-corner-left",
- orientation="v",
- align={"h_align": "start"},
- corner_position="bottom-right",
- )
-
- self.corner_right = create_corner_box(
- name="dock-corner-right",
- orientation="v",
- align={"h_align": "end"},
- corner_position="bottom-left",
- )
-
- self.dock_full = Box(
- name="dock-full",
- orientation="h",
- h_expand=True,
- h_align="fill",
- children=[
- self.corner_left,
- self.dock_eventbox,
- self.corner_right,
- ],
- )
-
- elif data.VERTICAL_RIGHT_ALIGN:
- # Vertical right-aligned layout: corners at top and bottom (left-angled corners)
- self.corner_top = create_corner_box(
- name="dock-corner-top",
- orientation="h",
- align={"v_align": "start"},
- corner_position="bottom-left",
- )
-
- self.corner_bottom = create_corner_box(
- name="dock-corner-bottom",
- orientation="h",
- align={"v_align": "end"},
- corner_position="top-left",
- )
-
- self.dock_full = Box(
- name="dock-full",
- orientation="v",
- v_expand=True,
- v_align="fill",
- children=[
- self.corner_top,
- self.dock_eventbox,
- self.corner_bottom,
- ],
- )
-
- else:
- # Vertical left/default alignment: corners at top and bottom (right-angled corners)
- self.corner_top = create_corner_box(
- name="dock-corner-top",
- orientation="h",
- align={"v_align": "start"},
- corner_position="bottom-right",
- )
-
- self.corner_bottom = create_corner_box(
- name="dock-corner-bottom",
- orientation="h",
- align={"v_align": "end"},
- corner_position="top-right",
- )
-
- self.dock_full = Box(
- name="dock-full",
- orientation="v",
- v_expand=True,
- v_align="fill",
- children=[
- self.corner_top,
- self.dock_eventbox,
- self.corner_bottom,
- ],
- )
- # Bottom/side hover activation area
- self.hover_activator = EventBox()
- self.hover_activator.set_size_request(
- -1
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else 1,
- 1
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else -1,
- )
- self.hover_activator.connect("enter-notify-event", self._on_hover_enter)
- self.hover_activator.connect("leave-notify-event", self._on_hover_leave)
-
- # Update main_box to include the dock_full and activator
- self.main_box = Box(
- orientation="v"
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else "h",
- children=[self.dock_full, self.hover_activator],
- )
- self.add(self.main_box)
-
- # Drag-and-drop setup for the viewport
- self.view.drag_source_set(
- Gdk.ModifierType.BUTTON1_MASK,
- [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
- Gdk.DragAction.MOVE,
- )
- self.view.drag_dest_set(
- Gtk.DestDefaults.ALL,
- [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
- Gdk.DragAction.MOVE,
- )
- self.view.connect("drag-data-get", self.on_drag_data_get)
- self.view.connect("drag-data-received", self.on_drag_data_received)
- self.view.connect("drag-begin", self.on_drag_begin)
- self.view.connect("drag-end", self.on_drag_end)
-
- # Initialization
- if self.conn.ready:
- self.update_dock()
- GLib.timeout_add(500, self.check_hide)
- else:
- self.conn.connect("event::ready", self.update_dock)
- self.conn.connect("event::ready", self.check_hide)
-
- for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"):
- self.conn.connect(f"event::{ev}", self.update_dock)
- self.conn.connect("event::workspace", self.check_hide)
-
- if data.VERTICAL:
- self.wrapper.add_style_class("vertical")
- else:
- self.wrapper.remove_style_class("vertical")
-
- if data.BOTTOM_BAR:
- self.wrapper.add_style_class("vertical-right")
- else:
- self.wrapper.remove_style_class("vertical-right")
-
- if data.VERTICAL_RIGHT_ALIGN:
- self.wrapper.add_style_class("vertical-left")
- else:
- self.wrapper.remove_style_class("vertical-left")
-
- GLib.timeout_add(250, self.check_occlusion_state)
- # Monitor modus.json for changes
- GLib.timeout_add_seconds(1, self.check_config_change)
-
- # Check if dock should be visible based on configuration
- if not data.DOCK_ENABLED:
- self.set_visible(False)
-
- # Apply always occluded state if enabled in settings
- if self.always_occluded:
- self.dock_full.add_style_class("occluded")
-
- def _build_app_identifiers_map(self):
- """Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects"""
- identifiers = {}
- for app in self._all_apps:
- # Map by name (lowercase)
- if app.name:
- identifiers[app.name.lower()] = app
-
- # Map by display name
- if app.display_name:
- identifiers[app.display_name.lower()] = app
-
- # Map by window class if available
- if app.window_class:
- identifiers[app.window_class.lower()] = app
-
- # Map by executable name if available
- if app.executable:
- exe_basename = app.executable.split("/")[-1].lower()
- identifiers[exe_basename] = app
-
- # Map by command line if available (without parameters)
- if app.command_line:
- cmd_base = app.command_line.split()[0].split("/")[-1].lower()
- identifiers[cmd_base] = app
-
- return identifiers
-
- def _normalize_window_class(self, class_name):
- """Normalize window class by removing common suffixes and lowercase."""
- if not class_name:
- return ""
-
- normalized = class_name.lower()
-
- # Remove common suffixes
- suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
- for suffix in suffixes:
- if normalized.endswith(suffix):
- normalized = normalized[: -len(suffix)]
-
- return normalized
-
- def _classes_match(self, class1, class2):
- """Check if two window class names match with stricter comparison."""
- if not class1 or not class2:
- return False
-
- # Normalize both classes
- norm1 = self._normalize_window_class(class1)
- norm2 = self._normalize_window_class(class2)
-
- # Direct match after normalization
- if norm1 == norm2:
- return True
-
- # Don't do substring matching as it's too error-prone
- # This avoids incorrectly matching flatpak apps and others
- return False
-
- def on_drag_begin(self, widget, drag_context):
- """Handle drag begin event by setting the drag lock flag."""
- self._drag_in_progress = True
- # Set custom drag icon using the widget surface
- Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget))
-
- def _on_hover_enter(self, *args):
- """Handle hover over bottom activation area"""
- self.toggle_dock(show=True)
-
- def _on_hover_leave(self, *args):
- """Handle leave from bottom activation area"""
- self.delay_hide()
-
- def _on_dock_enter(self, widget, event):
- """Handle hover over dock content"""
- self.is_hovered = True
- self.dock_full.remove_style_class("occluded")
- # Cancel any pending hide operations
- if self.hide_id:
- GLib.source_remove(self.hide_id)
- self.hide_id = None
- self.toggle_dock(show=True)
- return True # Important: Stop event propagation
-
- def _on_dock_leave(self, widget, event):
- """Handle leave from dock content"""
- # Only trigger if mouse actually left the entire dock area
- if event.detail == Gdk.NotifyType.INFERIOR:
- return False # Ignore child-to-child mouse movements
-
- self.is_hovered = False
- self.delay_hide()
- # Immediate occlusion check on true leave using simplified format
- occlusion_region = (
- ("bottom", OCCLUSION)
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else ("left", OCCLUSION)
- if data.VERTICAL_RIGHT_ALIGN
- else ("right", OCCLUSION)
- )
- # Add occlusion style if not dragging an icon or if always_occluded is enabled
- if self.always_occluded or (
- not self._drag_in_progress
- and (check_occlusion(occlusion_region) or not self.view.get_children())
- ):
- self.dock_full.add_style_class("occluded")
- return True
-
- # Enhanced app lookup methods
- def find_app(self, app_identifier):
- """Return the DesktopApp object by matching any app identifier.
-
- If app_identifier is a dict, it will use all available keys for matching.
- """
- if not app_identifier:
- return None
-
- # If we got a dict object with app data (new format)
- if isinstance(app_identifier, dict):
- # Try to find by all available identifiers in priority order
- for key in [
- "window_class",
- "executable",
- "command_line",
- "name",
- "display_name",
- ]:
- if key in app_identifier and app_identifier[key]:
- app = self.find_app_by_key(app_identifier[key])
- if app:
- return app
- return None
-
- # Simple string identifier (backward compatibility)
- return self.find_app_by_key(app_identifier)
-
- def find_app_by_key(self, key_value):
- """Find app by a single identifier value"""
- if not key_value:
- return None
-
- # Try direct lookup in our identifiers map
- normalized_id = str(key_value).lower()
- if normalized_id in self.app_identifiers:
- return self.app_identifiers[normalized_id]
-
- # Try fuzzy matching - find apps where the identifier is part of their names
- for app in self._all_apps:
- if app.name and normalized_id in app.name.lower():
- return app
- if app.display_name and normalized_id in app.display_name.lower():
- return app
- if app.window_class and normalized_id in app.window_class.lower():
- return app
- if app.executable and normalized_id in app.executable.lower():
- return app
- if app.command_line and normalized_id in app.command_line.lower():
- return app
-
- return None
-
- # Update the dock's app map using DesktopApp objects from the system.
- def update_app_map(self):
- """Updates the mapping of commands to DesktopApp objects."""
- self._all_apps = get_desktop_applications() # Refresh all apps
- self.app_map = {
- app.name: app for app in self._all_apps if app.name
- } # Map app names to DesktopApp objects
- self.app_identifiers = (
- self._build_app_identifiers_map()
- ) # Rebuild identifiers map
-
- def create_button(self, app_identifier, instances):
- """Create dock application button"""
- desktop_app = self.find_app(app_identifier) # Find app by identifier
- icon_img = None
- display_name = None
-
- if desktop_app:
- icon_img = desktop_app.get_icon_pixbuf(size=data.DOCK_ICON_SIZE)
- display_name = desktop_app.display_name or desktop_app.name
-
- # Extract identifier for fallback
- id_value = (
- app_identifier["name"]
- if isinstance(app_identifier, dict)
- else app_identifier
- )
-
- if not icon_img:
- # Fallback to IconResolver with the app command
- icon_img = self.icon.get_icon_pixbuf(
- id_value, data.DOCK_ICON_SIZE
- ) # Use identifier for fallback
-
- if not icon_img: # Double check after exec path try
- # Fallback icon if no DesktopApp is found
- icon_img = self.icon.get_icon_pixbuf(
- "application-x-executable-symbolic", data.DOCK_ICON_SIZE
- )
- # Final fallback
- if not icon_img:
- icon_img = self.icon.get_icon_pixbuf(
- "image-missing", data.DOCK_ICON_SIZE
- )
-
- items = [Image(pixbuf=icon_img)]
-
- tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown")
- if not display_name and instances and instances[0].get("title"):
- tooltip = instances[0]["title"]
-
- button = Button(
- child=Box(
- name="dock-icon",
- orientation="v",
- h_align="center",
- children=items,
- ),
- on_clicked=lambda *a: self.handle_app(
- app_identifier, instances, desktop_app
- ), # Pass desktop_app as well
- tooltip_text=tooltip,
- name="dock-app-button",
- )
-
- # Store app data with the button for future reference
- button.app_identifier = app_identifier
- button.desktop_app = desktop_app
- button.instances = instances
-
- if instances:
- button.add_style_class("instance") # Style running apps
-
- # Enable DnD for ALL apps
- button.drag_source_set(
- Gdk.ModifierType.BUTTON1_MASK,
- [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
- Gdk.DragAction.MOVE,
- )
- button.connect("drag-begin", self.on_drag_begin)
- button.connect("drag-end", self.on_drag_end) # Connect drag-end to ALL buttons
-
- # Make all buttons drop targets for better reordering
- button.drag_dest_set(
- Gtk.DestDefaults.ALL,
- [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
- Gdk.DragAction.MOVE,
- )
- button.connect("drag-data-get", self.on_drag_data_get)
- button.connect("drag-data-received", self.on_drag_data_received)
-
- button.connect("enter-notify-event", self._on_child_enter)
- return button
-
- # Enhanced app launching with multiple fallbacks
- def handle_app(self, app_identifier, instances, desktop_app=None):
- """Handle application button clicks with improved fallbacks"""
- if not instances:
- # Try to launch the app with multiple fallback methods
- if not desktop_app:
- desktop_app = self.find_app(app_identifier)
-
- if desktop_app:
- # Try the standard launch method first
- launch_success = desktop_app.launch()
-
- # If that fails, try command line or executable
- if not launch_success:
- if desktop_app.command_line:
- exec_shell_command_async(f"nohup {desktop_app.command_line}")
- elif desktop_app.executable:
- exec_shell_command_async(f"nohup {desktop_app.executable}")
- else:
- # No desktop entry found, try direct execution
- if isinstance(app_identifier, dict):
- # Try command_line first, then executable, then name as last resort
- if (
- "command_line" in app_identifier
- and app_identifier["command_line"]
- ):
- exec_shell_command_async(
- f"nohup {app_identifier['command_line']}"
- )
- elif (
- "executable" in app_identifier and app_identifier["executable"]
- ):
- exec_shell_command_async(
- f"nohup {app_identifier['executable']}"
- )
- elif "name" in app_identifier and app_identifier["name"]:
- exec_shell_command_async(f"nohup {app_identifier['name']}")
- elif isinstance(app_identifier, str):
- # Try direct execution with the identifier as fallback
- exec_shell_command_async(f"nohup {app_identifier}")
- else:
- # Handle window switching for running instances
- focused = self.get_focused()
- idx = next(
- (i for i, inst in enumerate(instances) if inst["address"] == focused),
- -1,
- )
- next_inst = instances[(idx + 1) % len(instances)]
- exec_shell_command(
- f"hyprctl dispatch focuswindow address:{next_inst['address']}"
- )
-
- def _on_child_enter(self, widget, event):
- """Maintain hover state when entering child widgets"""
- self.is_hovered = True
- if self.hide_id:
- GLib.source_remove(self.hide_id)
- self.hide_id = None
- return False # Continue event propagation
-
- def toggle_dock(self, show):
- """Show or hide the dock immediately"""
- if show:
- if self.is_hidden:
- self.is_hidden = False
- self.dock_full.add_style_class("show-dock")
- self.dock_full.remove_style_class("hide-dock")
- if self.hide_id:
- GLib.source_remove(self.hide_id)
- self.hide_id = None
- else:
- if not self.is_hidden:
- self.is_hidden = True
- self.dock_full.add_style_class("hide-dock")
- self.dock_full.remove_style_class("show-dock")
-
- def delay_hide(self):
- """Schedule hiding after short delay"""
- if self.hide_id:
- GLib.source_remove(self.hide_id)
- self.hide_id = GLib.timeout_add(1000, self.hide_dock)
-
- def hide_dock(self):
- """Finalize hiding procedure"""
- self.toggle_dock(show=False)
- self.hide_id = None
- return False
-
- def check_hide(self, *args):
- """Determine if dock should auto-hide"""
- clients = self.get_clients()
- current_ws = self.get_workspace()
- ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws]
-
- if not ws_clients:
- self.toggle_dock(show=True)
- elif any(not w.get("floating") and not w.get("fullscreen") for w in ws_clients):
- self.delay_hide()
- else:
- self.toggle_dock(show=True)
-
- def update_dock(self, *args):
- """Refresh dock contents and clear drag lock."""
- self.update_app_map() # Update app map before creating buttons
- arranger_handler = getattr(self, "_arranger_handler", None)
- if arranger_handler:
- remove_handler(arranger_handler)
- clients = self.get_clients()
-
- # Create a mapping of window class to instances
- running_windows = {}
- for c in clients:
- # Try multiple identification methods in order of reliability
- window_id = None
-
- # Try initialClass first (most reliable)
- if class_name := c.get("initialClass", "").lower():
- window_id = class_name
-
- # Try class second (fallback)
- elif class_name := c.get("class", "").lower():
- window_id = class_name
-
- # Use title as last resort if both class identifiers are missing
- elif title := c.get("title", "").lower():
- # Extract app name from title (common format: "App Name - Document")
- possible_name = title.split(" - ")[0].strip()
- if (
- possible_name and len(possible_name) > 1
- ): # Avoid single letter app names
- window_id = possible_name
- else:
- window_id = title # Use full title if we can't extract a good name
-
- # Use generic identifier as absolute fallback
- if not window_id:
- window_id = "unknown-app"
-
- # Log window for debugging purposes
- logging.debug(
- f"Window detected: {window_id} (from {c.get('initialClass', '')}/{c.get('class', '')}/{c.get('title', '')})"
- )
-
- # Add to running windows - store with both original and normalized keys
- running_windows.setdefault(window_id, []).append(c)
-
- # Also store with normalized key for more flexible matching
- normalized_id = self._normalize_window_class(window_id)
- if normalized_id != window_id:
- running_windows.setdefault(normalized_id, []).extend(
- running_windows[window_id]
- )
-
- # Map pinned apps to their running instances
- pinned_buttons = []
- used_window_classes = set() # Track which window classes we've already assigned
-
- for app_data in self.pinned:
- app = self.find_app(app_data)
-
- # Try to find running instances for this pinned app
- instances = []
- matched_class = None
-
- # Extract all possible identifiers from app_data for matching
- possible_identifiers = []
-
- if isinstance(app_data, dict):
- for key in [
- "window_class",
- "executable",
- "command_line",
- "name",
- "display_name",
- ]:
- if key in app_data and app_data[key]:
- possible_identifiers.append(app_data[key].lower())
- elif isinstance(app_data, str):
- possible_identifiers.append(app_data.lower())
-
- # Add identifiers from DesktopApp if available
- if app:
- if app.window_class:
- possible_identifiers.append(app.window_class.lower())
- if app.executable:
- possible_identifiers.append(app.executable.split("/")[-1].lower())
- if app.command_line:
- cmd_parts = app.command_line.split()
- if cmd_parts:
- possible_identifiers.append(cmd_parts[0].split("/")[-1].lower())
- if app.name:
- possible_identifiers.append(app.name.lower())
- if app.display_name:
- possible_identifiers.append(app.display_name.lower())
-
- # Remove duplicates
- possible_identifiers = list(set(possible_identifiers))
-
- # Try each identifier for matching
- for identifier in possible_identifiers:
- # Try direct match
- if identifier in running_windows:
- instances = running_windows[identifier]
- matched_class = identifier
- break
-
- # Try normalized version
- normalized = self._normalize_window_class(identifier)
- if normalized in running_windows:
- instances = running_windows[normalized]
- matched_class = normalized
- break
-
- # Try substring matching for window classes (less reliable but helpful)
- for window_class in running_windows:
- # For substring matching, ensure the identifier is at least 3 chars
- # to avoid too broad matches
- if len(identifier) >= 3 and identifier in window_class:
- instances = running_windows[window_class]
- matched_class = window_class
- logging.debug(
- f"Substring match: {identifier} in {window_class}"
- )
- break
-
- if matched_class:
- break
-
- # Mark the matched class as used
- if matched_class:
- used_window_classes.add(matched_class)
- # Also mark the normalized version as used
- used_window_classes.add(self._normalize_window_class(matched_class))
- logging.debug(
- f"Matched pinned app {app_data} to running instances via {matched_class}"
- )
-
- # Create button for this pinned app with any found instances
- pinned_buttons.append(self.create_button(app_data, instances))
-
- # For any remaining window classes that aren't assigned to pinned apps
- open_buttons = []
- for class_name, instances in running_windows.items():
- if class_name not in used_window_classes:
- # Enhanced app identification for running windows
- app = None
-
- # Try multiple methods to find the correct app
- # 1. Direct lookup by class name
- app = self.app_identifiers.get(class_name)
-
- # 2. Try with normalized class name
- if not app:
- norm_class = self._normalize_window_class(class_name)
- app = self.app_identifiers.get(norm_class)
-
- # 3. Try with our more robust find_app method
- if not app:
- app = self.find_app_by_key(class_name)
-
- # 4. Try using window title which often contains app name
- if not app and instances and instances[0].get("title"):
- title = instances[0].get("title", "")
- # Extract potential app name from title (common format: "App Name - Document")
- possible_name = title.split(" - ")[0].strip()
- if len(possible_name) > 2: # Avoid very short names
- app = self.find_app_by_key(possible_name)
-
- # Create comprehensive app data if app was found
- if app:
- app_data = {
- "name": app.name,
- "display_name": app.display_name,
- "window_class": app.window_class,
- "executable": app.executable,
- "command_line": app.command_line,
- }
- identifier = app_data
- else:
- # Fallback to just class name
- identifier = class_name
-
- open_buttons.append(self.create_button(identifier, instances))
-
- # Assemble dock layout
- children = pinned_buttons
- # Only add separator if both pinned and open buttons exist
- if pinned_buttons and open_buttons:
- children += [
- Box(
- orientation="v"
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else "h",
- v_expand=not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR,
- h_expand=data.VERTICAL
- and data.VERTICAL_RIGHT_ALIGN
- and data.BOTTOM_BAR,
- name="dock-separator",
- )
- ]
- children += open_buttons
-
- self.view.children = children
- idle_add(self._update_size)
- self._drag_in_progress = False # Clear the drag lock
-
- def _update_size(self):
- """Update window size based on content"""
- width, _ = self.view.get_preferred_width()
- self.set_size_request(width, -1)
- return False
-
- def get_clients(self):
- """Get current client list"""
- try:
- return json.loads(self.conn.send_command("j/clients").reply.decode())
- except json.JSONDecodeError:
- return []
-
- def get_focused(self):
- """Get focused window address"""
- try:
- return json.loads(
- self.conn.send_command("j/activewindow").reply.decode()
- ).get("address", "")
- except json.JSONDecodeError:
- return ""
-
- def get_workspace(self):
- """Get current workspace ID"""
- try:
- return json.loads(
- self.conn.send_command("j/activeworkspace").reply.decode()
- ).get("id", 0)
- except json.JSONDecodeError:
- return 0
-
- def check_occlusion_state(self):
- """Periodic occlusion check"""
- # Skip occlusion check if hovered or dragging an icon
- if self.is_hovered or self._drag_in_progress:
- self.dock_full.remove_style_class("occluded")
- return True
-
- # If always_occluded is enabled, keep the dock occluded
- if self.always_occluded:
- self.dock_full.add_style_class("occluded")
- return True
-
- occlusion_region = (
- ("bottom", OCCLUSION)
- if not data.VERTICAL
- and not data.VERTICAL_RIGHT_ALIGN
- and not data.BOTTOM_BAR
- else ("left", OCCLUSION)
- if data.VERTICAL_RIGHT_ALIGN
- else ("right", OCCLUSION)
- )
- if check_occlusion(occlusion_region) or not self.view.get_children():
- self.dock_full.add_style_class("occluded")
- else:
- self.dock_full.remove_style_class("occluded")
- return True
-
- def _find_drag_target(self, widget):
- """Find valid drag target in viewport"""
- children = self.view.get_children()
- while widget is not None and widget not in children:
- widget = widget.get_parent() if hasattr(widget, "get_parent") else None
- return widget
-
- def on_drag_data_get(self, widget, drag_context, data, info, time):
- """Handle drag start"""
- target = self._find_drag_target(
- widget.get_parent() if isinstance(widget, Box) else widget
- )
- if target is not None:
- index = self.view.get_children().index(target)
- data.set_text(str(index), -1)
-
- def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
- """Handle drop event"""
- target = self._find_drag_target(
- widget.get_parent() if isinstance(widget, Box) else widget
- )
- if target is None:
- return
- try:
- source_index = int(data.get_text())
- except (TypeError, ValueError):
- return
-
- children = self.view.get_children()
- try:
- target_index = children.index(target)
- except ValueError:
- return
-
- if source_index != target_index:
- # Check for separator to detect cross-section drag
- separator_index = -1
- for i, child in enumerate(children):
- if child.get_name() == "dock-separator":
- separator_index = i
- break
-
- # Detect if we're dragging across the separator (between pinned/unpinned)
- cross_section_drag = separator_index != -1 and (
- (source_index < separator_index and target_index > separator_index)
- or (source_index > separator_index and target_index < separator_index)
- )
-
- # Move the item in the children list
- child = children.pop(source_index)
- children.insert(target_index, child)
- self.view.children = children # Update view immediately
-
- # Update pinned apps configuration
- self.update_pinned_apps(skip_update=not cross_section_drag)
-
- # Force immediate update if we dragged across sections to update separator visibility
- if cross_section_drag:
- GLib.idle_add(self.update_dock)
-
- def on_drag_end(self, widget, drag_context):
- """Handles drag end, for unpinning and closing apps."""
- # If a drag is already in progress, ignore duplicate calls.
- if not self._drag_in_progress:
- return
-
- def process_drag_end():
- """Inner function to handle drag end, safe to call with idle_add."""
- if self.get_mapped(): # Check if the window is mapped
- # Get window geometry *before* any modifications
- display = Gdk.Display.get_default()
- _, x, y, _ = display.get_pointer() # Get pointer relative to the screen
- window = self.get_window()
- if window: # Check if we got a window
- win_x, win_y, width, height = window.get_geometry()
- if not (
- win_x <= x <= win_x + width and win_y <= y <= win_y + height
- ):
- # Drag ended outside the dock - get app data from the button
- app_id = widget.app_identifier
- instances = widget.instances
-
- # Find this app in pinned list
- app_index = -1
- for i, pinned_app in enumerate(self.pinned):
- # Match by identifier
- if pinned_app == app_id:
- app_index = i
- break
- # For dict format, match by name
- elif isinstance(pinned_app, dict) and isinstance(
- app_id, dict
- ):
- if pinned_app.get("name") == app_id.get("name"):
- app_index = i
- break
-
- # If it's a pinned app, remove from pinned
- if app_index >= 0:
- self.pinned.pop(app_index)
- self.config["pinned_apps"] = self.pinned
- self.update_pinned_apps_file()
- self.update_dock() # Update dock after file save
- elif instances: # Close if running and unpinned
- # Close running app (if not pinned)
- address = instances[0].get("address")
- if address:
- exec_shell_command(
- f"hyprctl dispatch closewindow address:{address}"
- )
- self.update_dock() # Update dock after closing window
- self._drag_in_progress = False # Clear the drag lock
-
- GLib.idle_add(process_drag_end) # Deferred execution with idle_add
-
- def check_config_change(self):
- """Check if the config file has been modified."""
- new_config = data.read_config()
- if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
- self.config = new_config
- self.pinned = self.config.get("pinned_apps", [])
- self.update_app_map() # Update app_map when config changes
- self.update_dock()
- return True # Continue the timeout
-
- def update_pinned_apps_file(self):
- """Writes the updated pinned apps list to modus.json"""
- config_path = get_relative_path("../config/assets/modus.json")
- try:
- with open(config_path, "w") as file:
- json.dump(self.config, file, indent=4)
- return True
- except Exception as e:
- logging.error(f"Failed to write dock config: {e}")
- return False
-
- def update_pinned_apps(self, skip_update=False):
- """Update pinned apps configuration by collecting all items before the separator"""
- pinned_children = []
- for child in self.view.get_children():
- if child.get_name() == "dock-separator":
- break # Stop at separator
-
- # Use stored app identifier from the button
- if hasattr(child, "app_identifier"):
- # If the button has a desktop_app, use it to create comprehensive data
- if hasattr(child, "desktop_app") and child.desktop_app:
- app = child.desktop_app
- app_data = {
- "name": app.name,
- "display_name": app.display_name,
- "window_class": app.window_class,
- "executable": app.executable,
- "command_line": app.command_line,
- }
- pinned_children.append(app_data)
- else:
- # Keep existing app_identifier (might be dict or string)
- pinned_children.append(child.app_identifier)
-
- # Directly update both config and local pinned list
- self.config["pinned_apps"] = pinned_children
- self.pinned = pinned_children # Update local state immediately
-
- # Write to file first to ensure persistence
- file_updated = self.update_pinned_apps_file()
-
- # Only update the dock if needed and file was successfully written
- if file_updated and not skip_update:
- self.update_dock() # Refresh dock only after file is saved
-
- # Static method to update all dock instances
- @staticmethod
- def notify_config_change():
- """Notify all dock instances to reload their configuration immediately."""
- for dock in Dock._instances:
- # Use idle_add to avoid potential issues with direct calls
- GLib.idle_add(dock.check_config_change_immediate)
-
- def check_config_change_immediate(self):
- """Immediately check for configuration changes and update if needed."""
- new_config = data.read_config()
-
- # Check if always_occluded setting has changed
- previous_always_occluded = self.always_occluded
- self.always_occluded = data.DOCK_ALWAYS_OCCLUDED
-
- # If always_occluded changed, update the dock appearance
- if previous_always_occluded != self.always_occluded:
- if self.always_occluded and not self.is_hovered:
- self.dock_full.add_style_class("occluded")
- elif not self.always_occluded and not self.is_hovered:
- # Perform a normal occlusion check
- occlusion_region = (
- ("bottom", OCCLUSION)
- if not data.VERTICAL and not data.BOTTOM_BAR
- else ("left", OCCLUSION)
- if data.VERTICAL_RIGHT_ALIGN
- else ("right", OCCLUSION)
- )
- if check_occlusion(occlusion_region) or not self.view.get_children():
- self.dock_full.add_style_class("occluded")
- else:
- self.dock_full.remove_style_class("occluded")
-
- if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
- self.config = new_config
- self.pinned = self.config.get("pinned_apps", [])
- self.update_app_map()
- self.update_dock()
- return False # Don't repeat
-
- # Add a static method to handle dock visibility changes
- @staticmethod
- def update_visibility(visible):
- """Update visibility for all dock instances."""
- for dock in Dock._instances:
- dock.set_visible(visible)
diff --git a/modules/dock/components/__init__.py b/modules/dock/components/__init__.py
new file mode 100644
index 0000000..9410f86
--- /dev/null
+++ b/modules/dock/components/__init__.py
@@ -0,0 +1,184 @@
+from gi.repository import Gtk
+from fabric.system_tray.widgets import SystemTrayItem
+import json
+import os
+
+import gi
+from fabric.hyprland.service import HyprlandEvent
+from fabric.hyprland.widgets import HyprlandLanguage, get_hyprland_connection
+from fabric.utils import get_relative_path
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.label import Label
+
+import config.data as data
+import utils.icons as icons
+
+from .applications import Applications
+from .battery import Battery
+from .controls import Controls
+from .indicators import Indicators
+from .metrics import Metrics
+from .workspaces import workspace
+from .systemtray import SystemTray
+
+gi.require_version("Gtk", "3.0")
+# Monkey patch SystemTray to use GTK theme icons
+
+
+class DockComponents(Box):
+ def __init__(self, orientation_val="h", dock_instance=None, **kwargs):
+ super().__init__(
+ name="dock-components", orientation=orientation_val, spacing=8, **kwargs
+ )
+
+ self.dock_instance = dock_instance
+
+ # Create applications with dock reference
+ self.applications = Applications(
+ orientation_val=orientation_val, dock_instance=dock_instance
+ )
+
+ self.connection = get_hyprland_connection()
+ self.lang_label = Label(name="lang-label")
+ self.language = Button(
+ name="language", h_align="center", v_align="center", child=self.lang_label
+ )
+ self.on_language_switch()
+ self.connection.connect("event::activelayout", self.on_language_switch)
+
+ # Initialize component visibility from data
+ self.component_visibility = data.DOCK_COMPONENTS_VISIBILITY
+
+ # Create components
+ self.workspaces = workspace
+ self.metrics = Metrics()
+ self.battery = Battery()
+ self.date_time = DateTime(
+ name="date-time",
+ formatters=["%I:%M"] if not data.VERTICAL else ["%I\n%M"],
+ h_align="center" if not data.VERTICAL else "fill",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.controls = Controls()
+ self.indicators = Indicators()
+ self.systray = SystemTray()
+
+ # Create list of components to add
+ self.themed_children = [
+ self.workspaces,
+ self.metrics,
+ self.controls,
+ self.applications,
+ self.indicators,
+ self.battery,
+ self.date_time,
+ self.language,
+ self.systray,
+ ]
+
+ # Add each child individually
+ for child in self.themed_children:
+ self.add(child)
+
+ # Apply initial visibility
+ self.apply_component_props()
+
+ # Apply invert style for specific themes and positions
+ should_invert = data.DOCK_THEME in ["Dense", "Edge"] or (
+ data.DOCK_THEME == "Pills"
+ and data.DOCK_POSITION in ["Left", "Right", "Top"]
+ )
+
+ if should_invert:
+ for child in self.themed_children:
+ if hasattr(child, "add_style_class"):
+ child.add_style_class("invert")
+
+ def apply_component_props(self):
+ components = {
+ "workspace": self.workspaces,
+ "metrics": self.metrics,
+ "battery": self.battery,
+ "date_time": self.date_time,
+ "controls": self.controls,
+ "indicators": self.indicators,
+ "systray": self.systray,
+ "applications": self.applications,
+ "language": self.language,
+ }
+
+ for component_name, widget in components.items():
+ if component_name in self.component_visibility:
+ visibility = self.component_visibility[component_name]
+ # Special handling for applications component - let it manage its own visibility
+ if component_name == "applications":
+ # Only apply config visibility if the component has content
+ if hasattr(widget, "_update_visibility_based_on_content"):
+ widget._update_visibility_based_on_content()
+ else:
+ widget.set_visible(visibility)
+ # Special handling for battery - let it manage its own visibility based on config and battery state
+ elif component_name == "battery":
+ # Battery component manages its own visibility based on config and battery presence
+ # Don't override its visibility here, let it handle it internally
+ pass
+ else:
+ widget.set_visible(visibility)
+
+ def toggle_component_visibility(self, component_name):
+ components = {
+ "workspace": self.workspaces,
+ "metrics": self.metrics,
+ "battery": self.battery,
+ "date_time": self.date_time,
+ "controls": self.controls,
+ "indicators": self.indicators,
+ "systray": self.systray,
+ "applications": self.applications,
+ "language": self.language,
+ }
+
+ if component_name in components and component_name in self.component_visibility:
+ self.component_visibility[component_name] = not self.component_visibility[
+ component_name
+ ]
+
+ components[component_name].set_visible(
+ self.component_visibility[component_name]
+ )
+
+ config_file = get_relative_path("../../../config/assets/config.json")
+ if os.path.exists(config_file):
+ try:
+ with open(config_file, "r") as f:
+ config = json.load(f)
+
+ config[f"dock_{component_name}_visible"] = (
+ self.component_visibility[component_name]
+ )
+
+ with open(config_file, "w") as f:
+ json.dump(config, f, indent=4)
+ except Exception as e:
+ print(f"Error updating config file: {e}")
+
+ return self.component_visibility[component_name]
+
+ return None
+
+ def on_language_switch(self, _=None, event: HyprlandEvent = None):
+ lang_data = (
+ event.data[1]
+ if event and event.data and len(event.data) > 1
+ else HyprlandLanguage().get_label()
+ )
+ self.language.set_tooltip_text(lang_data)
+ if not data.VERTICAL:
+ self.lang_label.set_label(lang_data[:2].lower())
+ else:
+ self.lang_label.add_style_class("icon")
+ self.lang_label.set_markup(icons.keyboard)
diff --git a/modules/dock/components/applications.py b/modules/dock/components/applications.py
new file mode 100644
index 0000000..ffde4c8
--- /dev/null
+++ b/modules/dock/components/applications.py
@@ -0,0 +1,717 @@
+import json
+import logging
+
+import cairo
+from fabric.hyprland.widgets import get_hyprland_connection
+from fabric.utils import exec_shell_command, exec_shell_command_async, get_relative_path
+from fabric.utils.helpers import get_desktop_applications
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from gi.repository import Gdk, GLib, Gtk
+
+import config.data as data
+from utils.icon_resolver import IconResolver
+
+
+def read_config():
+ config_path = get_relative_path("../../../config/assets/dock.json")
+ try:
+ with open(config_path, "r") as file:
+ config_data = json.load(file)
+
+ if (
+ "pinned_apps" in config_data
+ and config_data["pinned_apps"]
+ and isinstance(config_data["pinned_apps"][0], str)
+ ):
+ all_apps = get_desktop_applications()
+ app_map = {app.name: app for app in all_apps if app.name}
+
+ old_pinned = config_data["pinned_apps"]
+ config_data["pinned_apps"] = []
+
+ for app_id in old_pinned:
+ app = app_map.get(app_id)
+ if app:
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ config_data["pinned_apps"].append(app_data_obj)
+ else:
+ config_data["pinned_apps"].append({"name": app_id})
+
+ except (FileNotFoundError, json.JSONDecodeError):
+ config_data = {"pinned_apps": []}
+ return config_data
+
+
+def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
+ alloc = widget.get_allocation()
+ surface = cairo.ImageSurface(
+ cairo.Format.ARGB32,
+ alloc.width,
+ alloc.height,
+ )
+ cr = cairo.Context(surface)
+ cr.set_source_rgba(255, 255, 255, 0)
+ cr.rectangle(0, 0, alloc.width, alloc.height)
+ cr.fill()
+ widget.draw(cr)
+ return surface
+
+
+class Applications(Box):
+ def __init__(self, orientation_val="h", dock_instance=None, **kwargs):
+ super().__init__(
+ name="applications-dock", orientation=orientation_val, spacing=4, **kwargs
+ )
+
+ self.hide()
+
+ self.dock_instance = dock_instance
+
+ self.config = read_config()
+ self.conn = get_hyprland_connection()
+ self.icon_resolver = IconResolver()
+ self.pinned = self.config.get("pinned_apps", [])
+ self.config_path = get_relative_path("../../../config/assets/dock.json")
+ self.app_map = {}
+ self._all_apps = get_desktop_applications()
+ self.app_identifiers = self._build_app_identifiers_map()
+ self._drag_in_progress = False
+
+ self.update_applications()
+
+ if self.conn.ready:
+ pass
+ else:
+ self.conn.connect("event::ready", lambda *args: self.update_applications())
+
+ for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"):
+ self.conn.connect(f"event::{ev}", lambda *args: self.update_applications())
+
+ GLib.timeout_add_seconds(1, self.check_config_change)
+
+ def _build_app_identifiers_map(self):
+ identifiers = {}
+ for app in self._all_apps:
+ if app.name:
+ identifiers[app.name.lower()] = app
+ if app.display_name:
+ identifiers[app.display_name.lower()] = app
+ if app.window_class:
+ identifiers[app.window_class.lower()] = app
+ if app.executable:
+ identifiers[app.executable.split("/")[-1].lower()] = app
+ if app.command_line:
+ identifiers[app.command_line.split()[0].split("/")[-1].lower()] = app
+ return identifiers
+
+ def find_app_by_key(self, key):
+ if not key:
+ return None
+ key_lower = key.lower() if isinstance(key, str) else ""
+ return self.app_identifiers.get(key_lower)
+
+ def find_app(self, app_identifier):
+ if not app_identifier:
+ return None
+ if isinstance(app_identifier, dict):
+ for key in [
+ "window_class",
+ "executable",
+ "command_line",
+ "name",
+ "display_name",
+ ]:
+ if key in app_identifier and app_identifier[key]:
+ app = self.find_app_by_key(app_identifier[key])
+ if app:
+ return app
+ return None
+ return self.find_app_by_key(app_identifier)
+
+ def create_button(self, app_identifier, instances):
+ desktop_app = self.find_app(app_identifier)
+ icon_img = None
+ display_name = None
+ icon_size = data.DOCK_ICON_SIZE
+
+ if desktop_app:
+ icon_img = desktop_app.get_icon_pixbuf(size=icon_size)
+ display_name = desktop_app.display_name or desktop_app.name
+
+ id_value = (
+ app_identifier["name"]
+ if isinstance(app_identifier, dict)
+ else app_identifier
+ )
+
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(id_value, icon_size)
+
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(
+ "application-x-executable-symbolic", icon_size
+ )
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(
+ "image-missing", icon_size
+ )
+
+ items = [Image(pixbuf=icon_img)]
+ tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown")
+ if not display_name and instances and instances[0].get("title"):
+ tooltip = instances[0]["title"]
+
+ button = Button(
+ child=Box(
+ name="dock-icon", orientation="v", h_align="center", children=items
+ ),
+ on_clicked=lambda *a: self.handle_app(
+ app_identifier, instances, desktop_app
+ ),
+ tooltip_text=tooltip,
+ name="dock-app-button",
+ )
+ button.app_identifier = app_identifier
+ button.desktop_app = desktop_app
+ button.instances = instances
+ if instances:
+ button.add_style_class("instance")
+
+ button.drag_source_set(
+ Gdk.ModifierType.BUTTON1_MASK,
+ [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+
+ button.drag_dest_set(
+ Gtk.DestDefaults.ALL,
+ [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+
+ button.connect("drag-begin", self.on_drag_begin)
+ button.connect("drag-data-get", self.on_drag_data_get)
+ button.connect("drag-data-received", self.on_drag_data_received)
+ button.connect("drag-end", self.on_drag_end)
+ button.connect("button-press-event", self.on_button_press)
+
+ return button
+
+ def handle_app(self, app_identifier, instances, desktop_app=None):
+ if not instances:
+ if not desktop_app:
+ desktop_app = self.find_app(app_identifier)
+ if desktop_app:
+ launch_success = desktop_app.launch()
+ if not launch_success:
+ if desktop_app.command_line:
+ exec_shell_command_async(f"nohup {desktop_app.command_line} &")
+ elif desktop_app.executable:
+ exec_shell_command_async(f"nohup {desktop_app.executable} &")
+ else:
+ cmd_to_run = None
+ if isinstance(app_identifier, dict):
+ if (
+ "command_line" in app_identifier
+ and app_identifier["command_line"]
+ ):
+ cmd_to_run = app_identifier["command_line"]
+ elif (
+ "executable" in app_identifier and app_identifier["executable"]
+ ):
+ cmd_to_run = app_identifier["executable"]
+ elif "name" in app_identifier and app_identifier["name"]:
+ cmd_to_run = app_identifier["name"]
+ elif isinstance(app_identifier, str):
+ cmd_to_run = app_identifier
+ if cmd_to_run:
+ exec_shell_command_async(f"nohup {cmd_to_run} &")
+ else:
+ focused = self.get_focused()
+ idx = next(
+ (i for i, inst in enumerate(instances) if inst["address"] == focused),
+ -1,
+ )
+ next_inst = instances[(idx + 1) % len(instances)]
+ exec_shell_command(
+ f"hyprctl dispatch focuswindow address:{next_inst['address']}"
+ )
+
+ def update_pinned_apps_file(self):
+ config_path = get_relative_path("../../../config/assets/dock.json")
+ try:
+ with open(config_path, "w") as file:
+ json.dump(self.config, file, indent=4)
+ return True
+ except Exception as e:
+ logging.error(f"Failed to write dock config: {e}")
+ return False
+
+ def check_config_change(self):
+ new_config = read_config()
+ if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
+ self.config = new_config
+ self.pinned = self.config.get("pinned_apps", [])
+ self.update_app_map()
+ self.update_applications()
+ return True
+
+ def update_app_map(self):
+ self._all_apps = get_desktop_applications()
+ self.app_identifiers = self._build_app_identifiers_map()
+
+ def _normalize_window_class(self, class_name):
+ if not class_name:
+ return ""
+ normalized = class_name.lower()
+ suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
+ for suffix in suffixes:
+ if normalized.endswith(suffix):
+ normalized = normalized[: -len(suffix)]
+ return normalized
+
+ def _is_special_workspace(self, client):
+ """Check if a client is in a special workspace"""
+ if "workspace" not in client:
+ return False
+
+ workspace = client["workspace"]
+ if "name" in workspace:
+ workspace_name = str(workspace["name"])
+ # Special workspaces typically start with "special:" or have negative IDs
+ if workspace_name.startswith("special:"):
+ return True
+
+ if "id" in workspace:
+ workspace_id = workspace["id"]
+ # Special workspaces have negative IDs
+ if workspace_id < 0:
+ return True
+
+ return False
+
+ def update_applications(self):
+ for child in self.get_children():
+ self.remove(child)
+
+ clients = []
+ try:
+ clients_data = self.conn.send_command("j/clients").reply
+ if clients_data:
+ clients = json.loads(clients_data.decode("utf-8"))
+ except Exception as e:
+ logging.error(f"Failed to get clients: {e}")
+
+ running_windows = {}
+ for client in clients:
+ # Skip clients in special workspaces if the setting is enabled
+ if data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS and self._is_special_workspace(client):
+ continue
+
+ class_name = client.get("class", "").lower()
+ if class_name:
+ if class_name not in running_windows:
+ running_windows[class_name] = []
+ running_windows[class_name].append(client)
+
+ pinned_buttons = []
+ used_window_classes = set()
+
+ for app_data_item in self.pinned:
+ app = self.find_app(app_data_item)
+ instances = []
+ matched_class = None
+ possible_identifiers = []
+
+ if isinstance(app_data_item, dict):
+ for key in [
+ "window_class",
+ "executable",
+ "command_line",
+ "name",
+ "display_name",
+ ]:
+ if key in app_data_item and app_data_item[key]:
+ possible_identifiers.append(app_data_item[key].lower())
+ elif isinstance(app_data_item, str):
+ possible_identifiers.append(app_data_item.lower())
+
+ for class_name, window_instances in running_windows.items():
+ for identifier in possible_identifiers:
+ if identifier in class_name or class_name in identifier:
+ instances = window_instances
+ matched_class = class_name
+ break
+ if matched_class:
+ break
+
+ if matched_class:
+ used_window_classes.add(matched_class)
+
+ pinned_buttons.append(self.create_button(app_data_item, instances))
+
+ open_buttons = []
+ for class_name, instances in running_windows.items():
+ if class_name not in used_window_classes:
+ app = self.find_app_by_key(class_name)
+
+ if app:
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ identifier = app_data_obj
+ else:
+ identifier = class_name
+
+ open_buttons.append(self.create_button(identifier, instances))
+
+ children = pinned_buttons
+
+ if pinned_buttons and open_buttons:
+ separator_orientation = (
+ Gtk.Orientation.VERTICAL
+ if self.get_orientation() == Gtk.Orientation.HORIZONTAL
+ else Gtk.Orientation.HORIZONTAL
+ )
+
+ children.append(
+ Box(
+ orientation=separator_orientation,
+ v_expand=False,
+ h_expand=False,
+ h_align="center",
+ v_align="center",
+ name="dock-separator",
+ )
+ )
+
+ children += open_buttons
+
+ for child in children:
+ self.add(child)
+
+ if len(children) > 0:
+ self.show_all()
+ else:
+ self.hide()
+
+ self._update_visibility_based_on_content()
+
+ return True
+
+ def _update_visibility_based_on_content(self):
+ has_content = len(self.get_children()) > 0
+
+ if not has_content:
+ self.hide()
+ else:
+ if data.DOCK_COMPONENTS_VISIBILITY.get("applications", True):
+ self.show_all()
+ else:
+ self.hide()
+
+ def update_pinned_apps(self, app_identifier, index=None):
+ is_pinned = False
+ for pinned_app in self.pinned:
+ if (
+ isinstance(app_identifier, dict)
+ and isinstance(pinned_app, dict)
+ and app_identifier.get("name") == pinned_app.get("name")
+ ):
+ is_pinned = True
+ break
+ elif app_identifier == pinned_app:
+ is_pinned = True
+ break
+
+ if not is_pinned:
+ if hasattr(app_identifier, "desktop_app") and app_identifier.desktop_app:
+ app = app_identifier.desktop_app
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ if index is not None:
+ self.pinned.insert(index, app_data_obj)
+ else:
+ self.pinned.append(app_data_obj)
+ else:
+ if index is not None:
+ self.pinned.insert(index, app_identifier)
+ else:
+ self.pinned.append(app_identifier)
+
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_applications()
+
+ def create_context_menu(self, widget, event):
+ """Create and show context menu for application"""
+ menu = Gtk.Menu()
+
+ app_identifier = widget.app_identifier
+ instances = widget.instances
+ is_pinned = self.is_app_pinned(app_identifier)
+
+ # Send to workspace submenu
+ workspace_menu = Gtk.Menu()
+ workspace_item = Gtk.MenuItem(label="Send to workspace")
+ workspace_item.set_submenu(workspace_menu)
+
+ # Get available workspaces (1-10)
+ for i in range(1, 11):
+ workspace_menu_item = Gtk.MenuItem(label=f"Workspace {i}")
+ workspace_menu_item.connect("activate", lambda item, ws=i: self.send_to_workspace(instances, ws))
+ workspace_menu.append(workspace_menu_item)
+
+ menu.append(workspace_item)
+
+ # Pin/Unpin app
+ pin_label = "Unpin app" if is_pinned else "Pin app"
+ pin_item = Gtk.MenuItem(label=pin_label)
+ pin_item.connect("activate", lambda item: self.toggle_pin_app(app_identifier))
+ menu.append(pin_item)
+
+ # Close app (only if instances exist)
+ if instances:
+ close_item = Gtk.MenuItem(label="Close app")
+ close_item.connect("activate", lambda item: self.close_app(instances))
+ menu.append(close_item)
+
+ # Float window
+ float_item = Gtk.MenuItem(label="Float window")
+ float_item.connect("activate", lambda item: self.float_window(instances))
+ menu.append(float_item)
+
+ # Fullscreen
+ fullscreen_item = Gtk.MenuItem(label="Fullscreen")
+ fullscreen_item.connect("activate", lambda item: self.fullscreen_window(instances))
+ menu.append(fullscreen_item)
+
+ menu.show_all()
+
+ # Attach menu to widget for proper positioning
+ menu.attach_to_widget(widget, None)
+
+ # Use popup_at_pointer for reliable positioning near the widget
+ try:
+ # Modern GTK approach - popup at the event location
+ menu.popup_at_pointer(event)
+ except AttributeError:
+ # Fallback for older GTK versions
+ menu.popup(None, None, None, None, event.button, event.time)
+
+ return True
+
+ def on_button_press(self, widget, event):
+ if event.button == 3: # Right mouse button
+ return self.create_context_menu(widget, event)
+ return False
+
+ def on_drag_begin(self, widget, drag_context):
+ self._drag_in_progress = True
+ Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget))
+
+ # Prevent dock from hiding during drag
+ if self.dock_instance:
+ self.dock_instance.prevent_hiding(True)
+
+ # Store the source widget for later use
+ self._dragged_widget = widget
+
+ def on_drag_data_get(self, widget, drag_context, data_obj, info, time):
+ children = self.get_children()
+ index = children.index(widget)
+ data_obj.set_text(str(index), -1)
+
+ def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time):
+ try:
+ source_index = int(data_obj.get_text())
+ except (TypeError, ValueError):
+ return
+
+ children = self.get_children()
+ target_index = children.index(widget)
+
+ if source_index != target_index:
+ separator_index = -1
+ for i, child in enumerate(children):
+ if child.get_name() == "dock-separator":
+ separator_index = i
+ break
+
+ cross_section_drag_to_pin = separator_index != -1 and (
+ source_index > separator_index and target_index < separator_index
+ )
+
+ cross_section_drag_to_unpin = separator_index != -1 and (
+ source_index < separator_index and target_index > separator_index
+ )
+
+ if cross_section_drag_to_pin:
+ source_widget = children[source_index]
+ app_identifier = source_widget.app_identifier
+
+ if hasattr(source_widget, "desktop_app") and source_widget.desktop_app:
+ app = source_widget.desktop_app
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ self.pinned.insert(target_index, app_data_obj)
+ else:
+ self.pinned.insert(target_index, app_identifier)
+
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_applications()
+ elif cross_section_drag_to_unpin:
+ if source_index < len(self.pinned):
+ self.pinned.pop(source_index)
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_applications()
+ else:
+ if source_index < separator_index and target_index < separator_index:
+ item = self.pinned.pop(source_index)
+ self.pinned.insert(target_index, item)
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_applications()
+ elif source_index > separator_index and target_index > separator_index:
+ child_to_move = children.pop(source_index)
+ children.insert(target_index, child_to_move)
+
+ for child in self.get_children():
+ self.remove(child)
+ for child in children:
+ self.add(child)
+ self.show_all()
+
+ def on_drag_end(self, widget, drag_context):
+ if not self._drag_in_progress:
+ return
+
+ self._drag_in_progress = False
+ if self.dock_instance:
+ self.dock_instance.prevent_hiding(False)
+
+ if hasattr(self, "_dragged_widget") and self._dragged_widget:
+ children = self.get_children()
+ source_index = children.index(self._dragged_widget)
+
+ separator_index = -1
+ for i, child in enumerate(children):
+ if child.get_name() == "dock-separator":
+ separator_index = i
+ break
+
+ if separator_index != -1 and source_index < separator_index:
+ if source_index < len(self.pinned):
+ self.pinned.pop(source_index)
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_applications()
+
+ if hasattr(self, "_dragged_widget"):
+ delattr(self, "_dragged_widget")
+
+ def get_focused(self):
+ try:
+ return json.loads(
+ self.conn.send_command("j/activewindow").reply.decode()
+ ).get("address", "")
+ except json.JSONDecodeError:
+ return ""
+
+ def is_app_pinned(self, app_identifier):
+ """Check if an app is pinned"""
+ for pinned_app in self.pinned:
+ if (
+ isinstance(app_identifier, dict)
+ and isinstance(pinned_app, dict)
+ and app_identifier.get("name") == pinned_app.get("name")
+ ):
+ return True
+ elif app_identifier == pinned_app:
+ return True
+ return False
+
+ def toggle_pin_app(self, app_identifier):
+ """Toggle pin state of an app"""
+ if self.is_app_pinned(app_identifier):
+ self.unpin_app(app_identifier)
+ else:
+ self.update_pinned_apps(app_identifier)
+
+ def unpin_app(self, app_identifier):
+ """Remove app from pinned apps"""
+ for i, pinned_app in enumerate(self.pinned):
+ if (
+ isinstance(app_identifier, dict)
+ and isinstance(pinned_app, dict)
+ and app_identifier.get("name") == pinned_app.get("name")
+ ):
+ self.pinned.pop(i)
+ break
+ elif app_identifier == pinned_app:
+ self.pinned.pop(i)
+ break
+
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_applications()
+
+ def send_to_workspace(self, instances, workspace):
+ """Send window(s) to specified workspace"""
+ if not instances:
+ return
+
+ # Send the first instance (focused window) to the workspace
+ address = instances[0]["address"]
+ exec_shell_command_async(f"hyprctl dispatch movetoworkspace {workspace},address:{address}")
+
+ def close_app(self, instances):
+ """Close all instances of the app"""
+ if not instances:
+ return
+
+ for instance in instances:
+ address = instance["address"]
+ exec_shell_command_async(f"hyprctl dispatch closewindow address:{address}")
+
+ def float_window(self, instances):
+ """Toggle float state of the window"""
+ if not instances:
+ return
+
+ # Toggle float for the first instance
+ address = instances[0]["address"]
+ exec_shell_command_async(f"hyprctl dispatch togglefloating address:{address}")
+
+ def fullscreen_window(self, instances):
+ """Toggle fullscreen state of the window"""
+ if not instances:
+ return
+
+ # Toggle fullscreen for the first instance
+ address = instances[0]["address"]
+ exec_shell_command_async(f"hyprctl dispatch fullscreen address:{address}")
diff --git a/modules/dock/components/battery.py b/modules/dock/components/battery.py
new file mode 100644
index 0000000..ec2ad50
--- /dev/null
+++ b/modules/dock/components/battery.py
@@ -0,0 +1,182 @@
+from gi.repository import GLib
+
+from fabric.widgets.label import Label
+from fabric.widgets.box import Box
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.button import Button
+from services.battery import Battery as BatteryService
+import utils.icons as icons
+import config.data as data
+
+
+class Battery(Box):
+ def __init__(self, **kwargs):
+ orientation = "v" if data.VERTICAL else "h"
+ super().__init__(
+ name="battery", spacing=0, orientation=orientation, visible=True, **kwargs
+ )
+
+ # Initialize battery service
+ self._battery = BatteryService()
+ self._battery.changed.connect(self.update_battery)
+
+ # Create the battery icon and circular progress
+ self.icon = Label(name="battery-icon", markup=icons.battery)
+ self.circle = CircularProgressBar(
+ name="battery-circle",
+ value=0,
+ size=26,
+ line_width=2,
+ start_angle=150,
+ end_angle=390,
+ style_classes="battery",
+ child=self.icon,
+ )
+
+ # Create button to hold the circle
+ self.circle_button = Button(
+ name="battery-button",
+ child=self.circle,
+ )
+
+ # Create percentage label
+ self.level = Label(
+ name="battery-level",
+ style_classes="battery",
+ label="0%",
+ )
+
+ # Create the container box
+ self.battery_box = Box(
+ name="battery-box",
+ orientation=orientation,
+ spacing=0,
+ children=[self.circle_button],
+ )
+
+ # Only add level label in horizontal mode
+ if not data.VERTICAL:
+ self.battery_box.add(self.level)
+
+ # Enable tooltips
+ self.circle_button.set_has_tooltip(True)
+ self.battery_box.set_has_tooltip(True)
+ self.level.set_has_tooltip(True)
+
+ # Connect tooltip signals
+ self.circle_button.connect("query-tooltip", self.on_query_tooltip)
+ self.battery_box.connect("query-tooltip", self.on_query_tooltip)
+ self.level.connect("query-tooltip", self.on_query_tooltip)
+
+ # Add the battery box to self
+ self.add(self.battery_box)
+
+ # Initial update
+ GLib.idle_add(self.update_battery)
+
+ def on_query_tooltip(self, _widget, _x, _y, _keyboard_mode, tooltip):
+ """Handle tooltip query"""
+ tooltip.set_markup(self.get_tooltip_text())
+ return True
+
+ def get_tooltip_text(self):
+ """Get formatted tooltip text in bullet points"""
+ if not self._battery.is_present:
+ return "No battery detected"
+
+ percentage = self._battery.percentage
+ state = self._battery.state
+ # Get capacity as integer by converting from float
+ capacity = int(float(self._battery.capacity.rstrip("%")))
+ tooltip_points = []
+
+ # Status and percentage
+ if state == "FULLY_CHARGED":
+ status = f"{icons.bat_full} Fully Charged"
+ elif state == "CHARGING":
+ status = f"{icons.bat_charging} Charging"
+ elif percentage <= 15 and state == "DISCHARGING":
+ status = f"{icons.bat_low} Low Battery"
+ elif state == "DISCHARGING":
+ status = f"{icons.bat_discharging} Discharging"
+ else:
+ status = "Battery"
+ tooltip_points.append(f"• Status: {status}")
+
+ # Battery level and capacity
+ tooltip_points.append(f"• Level: {int(percentage)}%")
+ tooltip_points.append(f"• Battery Health: {capacity}%")
+
+ # Add power profile if available
+ power_profile = self._battery.power_profile
+ if power_profile:
+ # Add icon based on profile
+ if power_profile == "power-saver":
+ profile_icon = icons.power_saving
+ elif power_profile == "performance":
+ profile_icon = icons.power_performance
+ else: # balanced
+ profile_icon = icons.power_balanced
+ tooltip_points.append(f"• Profile: {profile_icon} {power_profile}")
+
+ # Add temperature if available
+ temp = self._battery.temperature
+ if temp != "N/A":
+ tooltip_points.append(f"• Temperature: {temp}")
+
+ # Add time estimates
+ if state == "CHARGING":
+ time_to_full = self._battery.time_to_full
+ if time_to_full:
+ tooltip_points.append(f"• Time until full: {time_to_full}")
+ elif state == "DISCHARGING":
+ time_to_empty = self._battery.time_to_empty
+ if time_to_empty:
+ tooltip_points.append(f"• Time remaining: {time_to_empty}")
+
+ return "\n".join(tooltip_points)
+
+ def update_battery(self, *args):
+ """Update battery status and UI"""
+ if not self._battery.is_present:
+ self.set_visible(False)
+ return True
+
+ percentage = self._battery.percentage
+ state = self._battery.state
+ charging = state in ["CHARGING", "FULLY_CHARGED"]
+ power_profile = self._battery.power_profile
+
+ # Update circle progress
+ self.circle.set_value(percentage / 100.0)
+
+ # Only show percentage label if not at 100%
+ if percentage < 100:
+ self.level.set_label(f"{int(percentage)}%")
+ self.level.set_visible(True)
+ else:
+ self.level.set_visible(False)
+
+ # Apply alert styling if battery is low AND not charging
+ if percentage <= 15 and not charging:
+ self.icon.add_style_class("alert")
+ self.circle.add_style_class("alert")
+ else:
+ self.icon.remove_style_class("alert")
+ self.circle.remove_style_class("alert")
+
+ # Update icon based on battery state if not in power profile mode
+ if not power_profile:
+ if state == "FULLY_CHARGED":
+ self.icon.set_markup(icons.battery)
+ elif state == "CHARGING":
+ self.icon.set_markup(icons.charging)
+ elif percentage <= 15 and state == "DISCHARGING":
+ self.icon.set_markup(icons.alert)
+ elif state == "DISCHARGING":
+ self.icon.set_markup(icons.discharging)
+ else:
+ self.icon.set_markup(icons.battery)
+
+ self.set_visible(True)
+ return True
diff --git a/modules/dock/components/controls.py b/modules/dock/components/controls.py
new file mode 100644
index 0000000..53f0db2
--- /dev/null
+++ b/modules/dock/components/controls.py
@@ -0,0 +1,300 @@
+from fabric.audio.service import Audio
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from gi.repository import Gdk, GLib
+
+import config.data as data
+import utils.icons as icons
+from services.brightness import Brightness as BrightnessService
+
+
+class Brightness(Box):
+ def __init__(self, **kwargs):
+ super().__init__(name="button-bar-brightness", **kwargs)
+ self.brightness = BrightnessService.get_initial()
+ if self.brightness.screen_brightness == -1:
+ self.destroy()
+ return
+
+ self.progress_bar = CircularProgressBar(
+ name="button-brightness",
+ size=28,
+ line_width=2,
+ start_angle=150,
+ end_angle=390,
+ )
+ self.brightness_label = Label(
+ name="brightness-label", markup=icons.brightness_high
+ )
+ self.brightness_button = Button(child=self.brightness_label)
+ self.event_box = EventBox(
+ events=["scroll", "smooth-scroll"],
+ child=Overlay(child=self.progress_bar, overlays=self.brightness_button),
+ )
+ self.event_box.connect("scroll-event", self.on_scroll)
+ self.add(self.event_box)
+ self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
+
+ self._updating_from_brightness = False
+ self._pending_value = None
+ self._update_source_id = None
+
+ self.progress_bar.connect("notify::value", self.on_progress_value_changed)
+ self.brightness.connect("screen", self.on_brightness_changed)
+ self.on_brightness_changed()
+
+ def on_scroll(self, _, event):
+ if self.brightness.max_screen == -1:
+ return
+
+ current_brightness = self.brightness.screen_brightness
+ max_brightness = self.brightness.max_screen
+ step = max(1, int(max_brightness * 0.01)) # 1% of max brightness
+
+ if event.direction == Gdk.ScrollDirection.SMOOTH:
+ if hasattr(event, "delta_y") and abs(event.delta_y) > 0:
+ new_brightness = current_brightness - int(event.delta_y * step)
+ self.brightness.screen_brightness = new_brightness
+ elif event.direction == Gdk.ScrollDirection.UP:
+ self.brightness.screen_brightness = current_brightness + step
+ elif event.direction == Gdk.ScrollDirection.DOWN:
+ self.brightness.screen_brightness = current_brightness - step
+
+ def on_progress_value_changed(self, widget, pspec):
+ if self._updating_from_brightness:
+ return
+ new_norm = widget.value
+ new_brightness = int(new_norm * self.brightness.max_screen)
+ self._pending_value = new_brightness
+ if self._update_source_id is None:
+ self._update_source_id = GLib.timeout_add(
+ 50, self._update_brightness_callback
+ )
+
+ def _update_brightness_callback(self):
+ if (
+ self._pending_value is not None
+ and self._pending_value != self.brightness.screen_brightness
+ ):
+ self.brightness.screen_brightness = self._pending_value
+ self._pending_value = None
+ return True
+ else:
+ self._update_source_id = None
+ return False
+
+ def on_brightness_changed(self, *args):
+ if self.brightness.max_screen == -1:
+ return
+ normalized = self.brightness.screen_brightness / self.brightness.max_screen
+ self._updating_from_brightness = True
+ self.progress_bar.value = normalized
+ self._updating_from_brightness = False
+
+ brightness_percentage = int(normalized * 100)
+ if brightness_percentage >= 75:
+ self.brightness_label.set_markup(icons.brightness_high)
+ elif brightness_percentage >= 24:
+ self.brightness_label.set_markup(icons.brightness_medium)
+ else:
+ self.brightness_label.set_markup(icons.brightness_low)
+ self.set_tooltip_text(f"{brightness_percentage}%")
+
+ def destroy(self):
+ if self._update_source_id is not None:
+ GLib.source_remove(self._update_source_id)
+ super().destroy()
+
+
+class Volume(Box):
+ def __init__(self, **kwargs):
+ super().__init__(name="button-bar-vol", **kwargs)
+ self.audio = Audio()
+ self.progress_bar = CircularProgressBar(
+ name="button-volume",
+ size=28,
+ line_width=2,
+ start_angle=150,
+ end_angle=390,
+ )
+ self.vol_label = Label(name="vol-label", markup=icons.vol_high)
+ self.vol_button = Button(on_clicked=self.toggle_mute, child=self.vol_label)
+ self.event_box = EventBox(
+ events=["scroll", "smooth-scroll"],
+ child=Overlay(child=self.progress_bar, overlays=self.vol_button),
+ )
+ self.audio.connect("notify::speaker", self.on_new_speaker)
+ if self.audio.speaker:
+ self.audio.speaker.connect("changed", self.on_speaker_changed)
+ self.event_box.connect("scroll-event", self.on_scroll)
+ self.add(self.event_box)
+ self.on_speaker_changed()
+ self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
+
+ def on_new_speaker(self, *args):
+ if self.audio.speaker:
+ self.audio.speaker.connect("changed", self.on_speaker_changed)
+ self.on_speaker_changed()
+
+ def toggle_mute(self, event):
+ current_stream = self.audio.speaker
+ if current_stream:
+ current_stream.muted = not current_stream.muted
+ if current_stream.muted:
+ self.on_speaker_changed()
+ self.progress_bar.add_style_class("muted")
+ self.vol_label.add_style_class("muted")
+ else:
+ self.on_speaker_changed()
+ self.progress_bar.remove_style_class("muted")
+ self.vol_label.remove_style_class("muted")
+
+ def on_scroll(self, _, event):
+ if not self.audio.speaker:
+ return
+
+ current_volume = self.audio.speaker.volume
+ step = 1
+
+ if event.direction == Gdk.ScrollDirection.SMOOTH:
+ if hasattr(event, "delta_y") and abs(event.delta_y) > 0:
+ new_volume = current_volume - int(event.delta_y * step)
+ self.audio.speaker.volume = new_volume
+ # Remove delta_x handling
+ elif event.direction == Gdk.ScrollDirection.UP:
+ self.audio.speaker.volume = current_volume + step
+ elif event.direction == Gdk.ScrollDirection.DOWN:
+ self.audio.speaker.volume = current_volume - step
+
+ def on_speaker_changed(self, *_):
+ if not self.audio.speaker:
+ return
+
+ vol_high_icon = icons.vol_high
+ vol_medium_icon = icons.vol_medium
+ vol_mute_icon = icons.vol_off
+ vol_off_icon = icons.vol_mute
+
+ if "bluetooth" in self.audio.speaker.icon_name:
+ vol_high_icon = icons.bluetooth_connected
+ vol_medium_icon = icons.bluetooth
+ vol_mute_icon = icons.bluetooth_off
+ vol_off_icon = icons.bluetooth_disconnected
+
+ self.progress_bar.value = self.audio.speaker.volume / 100
+
+ if self.audio.speaker.muted:
+ self.vol_label.set_markup(vol_mute_icon)
+ self.progress_bar.add_style_class("muted")
+ self.vol_label.add_style_class("muted")
+ self.set_tooltip_text("Muted")
+ return
+ else:
+ self.progress_bar.remove_style_class("muted")
+ self.vol_label.remove_style_class("muted")
+ self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%")
+ if self.audio.speaker.volume > 74:
+ self.vol_label.set_markup(vol_high_icon)
+ elif self.audio.speaker.volume > 0:
+ self.vol_label.set_markup(vol_medium_icon)
+ else:
+ self.vol_label.set_markup(vol_off_icon)
+
+
+class MicroPhone(Box):
+ def __init__(self, **kwargs):
+ super().__init__(name="button-bar-mic", **kwargs)
+ self.audio = Audio()
+ self.progress_bar = CircularProgressBar(
+ name="button-mic",
+ size=28,
+ line_width=2,
+ start_angle=150,
+ end_angle=390,
+ )
+ self.mic_label = Label(name="mic-label", markup=icons.mic)
+ self.mic_button = Button(on_clicked=self.toggle_mute, child=self.mic_label)
+ self.event_box = EventBox(
+ events=["scroll", "smooth-scroll"],
+ child=Overlay(child=self.progress_bar, overlays=self.mic_button),
+ )
+ self.audio.connect("notify::microphone", self.on_new_microphone)
+ if self.audio.microphone:
+ self.audio.microphone.connect("changed", self.on_microphone_changed)
+ self.event_box.connect("scroll-event", self.on_scroll)
+ self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
+ self.add(self.event_box)
+ self.on_microphone_changed()
+
+ def on_new_microphone(self, *args):
+ if self.audio.microphone:
+ self.audio.microphone.connect("changed", self.on_microphone_changed)
+ self.on_microphone_changed()
+
+ def toggle_mute(self, event):
+ current_stream = self.audio.microphone
+ if current_stream:
+ current_stream.muted = not current_stream.muted
+ if current_stream.muted:
+ self.mic_label.set_markup(icons.mic_mute)
+ self.progress_bar.add_style_class("muted")
+ self.mic_label.add_style_class("muted")
+ else:
+ self.on_microphone_changed()
+ self.progress_bar.remove_style_class("muted")
+ self.mic_label.remove_style_class("muted")
+
+ def on_scroll(self, _, event):
+ if not self.audio.microphone:
+ return
+ if event.direction == Gdk.ScrollDirection.SMOOTH:
+ if hasattr(event, "delta_y") and abs(event.delta_y) > 0:
+ self.audio.microphone.volume -= event.delta_y
+ elif event.direction == Gdk.ScrollDirection.UP:
+ self.audio.microphone.volume += 1
+ elif event.direction == Gdk.ScrollDirection.DOWN:
+ self.audio.microphone.volume -= 1
+
+ def on_microphone_changed(self, *_):
+ if not self.audio.microphone:
+ return
+
+ self.progress_bar.value = self.audio.microphone.volume / 100
+
+ if self.audio.microphone.muted:
+ self.mic_label.set_markup(icons.mic_mute)
+ self.progress_bar.add_style_class("muted")
+ self.mic_label.add_style_class("muted")
+ self.set_tooltip_text("Muted")
+ return
+ else:
+ self.progress_bar.remove_style_class("muted")
+ self.mic_label.remove_style_class("muted")
+ self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%")
+ if self.audio.microphone.volume > 74:
+ self.mic_label.set_markup(icons.mic)
+ elif self.audio.microphone.volume > 0:
+ self.mic_label.set_markup(icons.mic)
+ else:
+ self.mic_label.set_markup(icons.mic_mute)
+
+
+class Controls(Box):
+ def __init__(self, **kwargs):
+ brightness = BrightnessService.get_initial()
+ children = []
+ if brightness.screen_brightness != -1:
+ children.append(Brightness())
+ children.extend([Volume(), MicroPhone()])
+ super().__init__(
+ name="control",
+ orientation="h" if not data.VERTICAL else "v",
+ spacing=4,
+ children=children,
+ **kwargs,
+ )
+ self.show_all()
diff --git a/modules/dock/components/indicators.py b/modules/dock/components/indicators.py
new file mode 100644
index 0000000..3874ce7
--- /dev/null
+++ b/modules/dock/components/indicators.py
@@ -0,0 +1,289 @@
+import subprocess
+import time
+
+from fabric.bluetooth import BluetoothClient
+from fabric.utils import get_relative_path
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from gi.repository import GLib
+
+import config.data as data
+import utils.icons as icons
+from services.network import NetworkClient
+
+
+class WifiIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(name="button-bar-network", **kwargs)
+
+ self.network = NetworkClient()
+ self.progress_bar = CircularProgressBar(
+ name="button-network",
+ size=28,
+ line_width=2,
+ start_angle=150,
+ end_angle=390,
+ )
+ self.wifi_label = Label(name="network-label", markup=icons.loader)
+
+ self.add(Overlay(child=self.progress_bar, overlays=self.wifi_label))
+
+ self.network.connect("ready", self.on_network_ready)
+
+ if self.network.is_ready:
+ self.setup_signals()
+
+ def on_network_ready(self, *args):
+ self.setup_signals()
+
+ def setup_signals(self):
+ self.network.connect("changed", self.on_network_changed)
+ self.network.connect("wifi-device-added", self.on_wifi_device_added)
+ self.network.connect("wifi-device-removed", self.on_wifi_device_removed)
+ self.network.connect("ethernet-device-added", self.on_ethernet_device_added)
+ self.network.connect("ethernet-device-removed", self.on_ethernet_device_removed)
+
+ if self.network.wifi_device:
+ self.network.wifi_device.connect("changed", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-added", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-removed", self.on_wifi_changed)
+
+ if self.network.ethernet_device:
+ self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
+
+ self.update_state()
+
+ def update_state(self):
+ if (
+ self.network.ethernet_device
+ and self.network.ethernet_device.state == "activated"
+ ):
+ self.wifi_label.set_markup(icons.world)
+ self.progress_bar.value = 1.0 # Full circle for ethernet
+ tooltip = f"Connected to Ethernet ({self.network.ethernet_device.speed})"
+ self.set_tooltip_text(tooltip)
+ return
+
+ if not self.network.wifi_device:
+ self.wifi_label.set_markup(icons.cloud_off)
+ self.progress_bar.value = 0
+ tooltip = "No WiFi device found"
+ elif not self.network.wifi_device.wireless_enabled:
+ self.wifi_label.set_markup(icons.cloud_off)
+ self.progress_bar.value = 0
+ tooltip = "WiFi disabled"
+ else:
+ active_ap = self.network.wifi_device.active_access_point
+ if active_ap:
+ strength = active_ap.strength
+ self.progress_bar.value = strength / 100
+ if strength >= 75:
+ self.wifi_label.set_markup(icons.wifi_3)
+ elif strength >= 50:
+ self.wifi_label.set_markup(icons.wifi_2)
+ elif strength >= 25:
+ self.wifi_label.set_markup(icons.wifi_1)
+ else:
+ self.wifi_label.set_markup(icons.wifi_0)
+ tooltip = f"Connected to {active_ap.ssid} ({strength}%)"
+ else:
+ self.wifi_label.set_markup(icons.world_off)
+ self.progress_bar.value = 0
+ tooltip = "Not connected"
+
+ self.set_tooltip_text(tooltip)
+
+ def on_wifi_device_added(self, *args):
+ self.network.wifi_device.connect("changed", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-added", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-removed", self.on_wifi_changed)
+ self.update_state()
+
+ def on_wifi_device_removed(self, *args):
+ self.update_state()
+
+ def on_network_changed(self, *args):
+ self.update_state()
+
+ def on_wifi_changed(self, *args):
+ self.update_state()
+
+ def on_ethernet_device_added(self, *args):
+ if self.network.ethernet_device:
+ self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
+ self.update_state()
+
+ def on_ethernet_device_removed(self, *args):
+ self.update_state()
+
+ def on_ethernet_changed(self, *args):
+ self.update_state()
+
+
+class BluetoothIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(name="button-bar-bluetooth", **kwargs)
+
+ self.bluetooth = BluetoothClient()
+
+ self.bt_label = Label(name="bluetooth-label", markup=icons.loader)
+ self.add(self.bt_label)
+
+ self.bluetooth.connect("changed", self.on_bluetooth_changed)
+ self.bluetooth.connect("device-added", self.on_device_added)
+ self.bluetooth.connect("device-removed", self.on_device_removed)
+
+ self.update_state()
+
+ def update_state(self):
+ if not self.bluetooth.enabled:
+ self.bt_label.set_markup(icons.bluetooth_off)
+ tooltip = "Bluetooth disabled"
+ else:
+ connected_devices = self.bluetooth.connected_devices
+ if connected_devices:
+ self.bt_label.set_markup(icons.bluetooth_connected)
+ if len(connected_devices) == 1:
+ 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:
+ if self.bluetooth.scanning:
+ self.bt_label.set_markup(icons.loader)
+ tooltip = "Scanning for devices..."
+ else:
+ self.bt_label.set_markup(icons.bluetooth)
+ tooltip = "No devices connected"
+
+ self.set_tooltip_text(tooltip)
+
+ def on_bluetooth_changed(self, *args):
+ self.update_state()
+
+ def on_device_added(self, _, address):
+ self.update_state()
+
+ def on_device_removed(self, _, address):
+ self.update_state()
+
+
+class RecordingIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(name="button-bar-recording", visible=False, **kwargs)
+
+ self.script_path = get_relative_path("../../../scripts/screen-capture.sh")
+ self.recording_start_time = None
+
+ self.recording_label = Label(name="recording-label", markup=icons.screenrecord)
+ self.time_label = Label(name="recording-time-label", markup="00:00")
+
+ # Adapt layout based on dock position
+ if data.VERTICAL:
+ # Vertical dock: show only icon
+ self.recording_box = Box(
+ orientation="v", spacing=2, children=[self.recording_label]
+ )
+ else:
+ # Horizontal dock: place icon and time side by side
+ self.recording_box = Box(
+ orientation="h",
+ spacing=4,
+ children=[self.recording_label, self.time_label],
+ )
+ self.add(self.recording_box)
+
+ self.connect("clicked", self.on_stop_recording)
+
+ self.hide()
+
+ # Delay initial check to prevent showing during dock initialization
+ GLib.timeout_add(1, self._delayed_init)
+
+ def check_recording_status(self):
+ try:
+ result = subprocess.run(
+ [self.script_path, "status"], capture_output=True, text=True, timeout=2
+ )
+ is_recording = result.stdout.strip() == "true"
+
+ if is_recording:
+ if not self.get_visible():
+ self.show()
+
+ # Get the recording start time if we don't have it
+ if self.recording_start_time is None:
+ self.recording_start_time = self.get_recording_start_time()
+
+ # Update the recording time display
+ if self.recording_start_time:
+ elapsed_seconds = int(time.time() - self.recording_start_time)
+ minutes = elapsed_seconds // 60
+ seconds = elapsed_seconds % 60
+ # Only update time label in horizontal mode
+ if not data.VERTICAL:
+ time_text = f"{minutes:02d}:{seconds:02d}"
+ self.time_label.set_markup(time_text)
+ self.set_tooltip_text(
+ f"Recording in progress ({minutes:02d}:{
+ seconds:02d}) - Click to stop"
+ )
+ else:
+ self.set_tooltip_text("Recording in progress - Click to stop")
+ else:
+ if self.get_visible():
+ self.hide()
+ self.recording_start_time = None
+
+ except Exception:
+ # If we can't check status, hide the indicator
+ if self.get_visible():
+ self.hide()
+ self.recording_start_time = None
+
+ return True # Continue the timeout
+
+ def get_recording_start_time(self):
+ """Get the recording start time from the file"""
+ try:
+ with open("/tmp/recording_start_time.txt", "r") as f:
+ return float(f.read().strip())
+ except Exception:
+ return None
+
+ def on_stop_recording(self, *args):
+ try:
+ subprocess.Popen([self.script_path, "record", "stop"])
+ except Exception as e:
+ print(f"Error stopping recording: {e}")
+
+ def _delayed_init(self):
+ try:
+ self.check_recording_status()
+ self.timeout_id = GLib.timeout_add(1000, self.check_recording_status)
+ except Exception as e:
+ print(f"[DEBUG] Error in delayed recording indicator init: {e}")
+ return False # Don't repeat this timeout
+
+
+class Indicators(Box):
+ def __init__(self, **kwargs):
+ self.recording_indicator = RecordingIndicator()
+
+ super().__init__(
+ name="indicator",
+ orientation="h" if not data.VERTICAL else "v",
+ spacing=4,
+ children=[
+ WifiIndicator(),
+ BluetoothIndicator(),
+ self.recording_indicator,
+ ],
+ **kwargs,
+ )
+ self.show_all()
diff --git a/modules/dock/components/metrics.py b/modules/dock/components/metrics.py
new file mode 100644
index 0000000..fa62818
--- /dev/null
+++ b/modules/dock/components/metrics.py
@@ -0,0 +1,328 @@
+import json
+import subprocess
+
+import psutil
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from gi.repository import GLib
+
+import config.data as data
+import utils.icons as icons
+
+
+class MetricsProvider:
+ def __init__(self):
+ self.gpu = []
+ self.cpu = 0.0
+ self.mem = 0.0
+ self.swap = 0.0
+ self.disk = []
+
+ self.bat_percent = 0.0
+ self.bat_charging = None
+ self.bat_time = 0
+
+ self._gpu_update_running = False
+
+ GLib.timeout_add_seconds(1, self._update)
+
+ def _update(self):
+ self.cpu = psutil.cpu_percent(interval=0)
+ self.mem = psutil.virtual_memory().percent
+ self.swap = psutil.swap_memory().percent
+ self.disk = [psutil.disk_usage(path).percent for path in data.METRICS_DISKS]
+
+ if not self._gpu_update_running:
+ self._start_gpu_update_async()
+
+ return True
+
+ def _start_gpu_update_async(self):
+ self._gpu_update_running = True
+
+ GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None)
+
+ def _run_nvtop_in_thread(self):
+ output = None
+ error_message = None
+ try:
+ result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10)
+ output = result
+ except FileNotFoundError:
+ error_message = "nvtop command not found."
+ except subprocess.CalledProcessError as e:
+ error_message = (
+ f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}"
+ )
+ except subprocess.TimeoutExpired:
+ error_message = "nvtop command timed out."
+ except Exception as e:
+ error_message = f"Unexpected error running nvtop: {e}"
+
+ GLib.idle_add(self._process_gpu_output, output, error_message)
+ self._gpu_update_running = False
+
+ def _process_gpu_output(self, output, error_message):
+ try:
+ if error_message:
+ self.gpu = []
+ elif output:
+ try:
+ info = json.loads(output)
+ if not info or not isinstance(info, list):
+ self.gpu = []
+ return False
+
+ gpu_utils = []
+ for gpu in info:
+ if not isinstance(gpu, dict):
+ continue
+ util = gpu.get("gpu_util", "0%")
+ if not isinstance(util, str):
+ continue
+ try:
+ # Remove % and convert to int, default to 0 if fails
+ util_value = int(util.rstrip("%") or 0)
+ gpu_utils.append(util_value)
+ except (ValueError, TypeError):
+ continue
+
+ self.gpu = gpu_utils if gpu_utils else []
+ except (KeyError, ValueError, TypeError, AttributeError):
+ self.gpu = []
+ else:
+ self.gpu = []
+ except json.JSONDecodeError:
+ self.gpu = []
+ except Exception:
+ self.gpu = []
+
+ return False
+
+ def get_metrics(self):
+ return (self.cpu, self.mem, self.swap, self.disk, self.gpu)
+
+ def get_battery(self):
+ return (self.bat_percent, self.bat_charging, self.bat_time)
+
+ def get_gpu_info(self):
+ try:
+ result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5)
+ if not result.strip():
+ return []
+
+ info = json.loads(result)
+ if not info or not isinstance(info, list):
+ return []
+
+ valid_gpus = []
+ for gpu in info:
+ if not isinstance(gpu, dict):
+ continue
+
+ gpu_data = {
+ "device_name": gpu.get("device_name", "GPU"),
+ "gpu_util": gpu.get("gpu_util", "0%"),
+ "mem_util": gpu.get("mem_util", "0%"),
+ "temperature": gpu.get("temperature", "0 C"),
+ "power": gpu.get("power", "0 W"),
+ }
+ valid_gpus.append(gpu_data)
+
+ return valid_gpus
+
+ except FileNotFoundError:
+ return []
+ except subprocess.CalledProcessError:
+ return []
+ except subprocess.TimeoutExpired:
+ return []
+ except json.JSONDecodeError:
+ return []
+ except Exception:
+ return []
+
+
+shared_provider = MetricsProvider()
+
+
+class SingularMetric:
+ def __init__(self, id, name, icon):
+ self.name_markup = name
+ self.icon_markup = icon
+
+ self.icon = Label(name="metrics-icon", markup=icon)
+ self.circle = CircularProgressBar(
+ name="metrics-circle",
+ value=0,
+ size=28,
+ line_width=2,
+ start_angle=150,
+ end_angle=390,
+ style_classes=id,
+ child=self.icon,
+ )
+
+ self.level = Label(name="metrics-level", style_classes=id, label="0%")
+ self.revealer = Revealer(
+ name=f"metrics-{id}-revealer",
+ transition_duration=250,
+ transition_type="slide-left",
+ child=self.level,
+ child_revealed=False,
+ )
+
+ self.box = Box(
+ name=f"metrics-{id}-box",
+ orientation="h",
+ spacing=0,
+ children=[self.circle, self.revealer],
+ )
+
+ def markup(self):
+ return f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
+
+
+class Metrics(Button):
+ def __init__(self, **kwargs):
+ super().__init__(name="metrics", **kwargs)
+
+ main_box = Box(
+ spacing=0,
+ orientation="h" if not data.VERTICAL else "v",
+ visible=True,
+ all_visible=True,
+ )
+
+ visible = getattr(
+ data,
+ "METRICS_VISIBLE",
+ {"cpu": True, "ram": True, "swap": True, "disk": True, "gpu": True},
+ )
+ disks = (
+ [
+ SingularMetric(
+ "disk",
+ f"DISK ({path})" if len(data.METRICS_DISKS) != 1 else "DISK",
+ icons.disk,
+ )
+ for path in data.METRICS_DISKS
+ ]
+ if visible.get("disk", True)
+ else []
+ )
+
+ gpu_info = shared_provider.get_gpu_info()
+ gpus = (
+ [
+ SingularMetric(
+ "gpu",
+ f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU",
+ icons.gpu,
+ )
+ for v in gpu_info
+ ]
+ if visible.get("gpu", True)
+ else []
+ )
+
+ self.cpu = (
+ SingularMetric("cpu", "CPU", icons.cpu)
+ if visible.get("cpu", True)
+ else None
+ )
+ self.ram = (
+ SingularMetric("ram", "RAM", icons.memory)
+ if visible.get("ram", True)
+ else None
+ )
+ self.swap = (
+ SingularMetric("swap", "SWAP", icons.swap)
+ if visible.get("swap", True)
+ else None
+ )
+ self.disk = disks
+ self.gpu = gpus
+
+ for disk in self.disk:
+ main_box.add(disk.box)
+ main_box.add(Box(name="metrics-sep"))
+ if self.ram:
+ main_box.add(self.ram.box)
+ main_box.add(Box(name="metrics-sep"))
+ if self.swap:
+ main_box.add(self.swap.box)
+ main_box.add(Box(name="metrics-sep"))
+ if self.cpu:
+ main_box.add(self.cpu.box)
+ for gpu in self.gpu:
+ main_box.add(Box(name="metrics-sep"))
+ main_box.add(gpu.box)
+
+ self.add(main_box)
+
+ self.connect("clicked", self.on_click)
+ self.labels_visible = False
+
+ GLib.timeout_add_seconds(1, self.update_metrics)
+
+ def _format_percentage(self, value: int) -> str:
+ """Formato natural del porcentaje sin forzar ancho fijo."""
+ return f"{value}%"
+
+ def on_click(self, widget):
+ if not data.VERTICAL:
+ self.labels_visible = not self.labels_visible
+ if self.cpu:
+ self.cpu.revealer.set_reveal_child(self.labels_visible)
+ if self.ram:
+ self.ram.revealer.set_reveal_child(self.labels_visible)
+ if self.swap:
+ self.swap.revealer.set_reveal_child(self.labels_visible)
+ for disk in self.disk:
+ disk.revealer.set_reveal_child(self.labels_visible)
+ for gpu in self.gpu:
+ gpu.revealer.set_reveal_child(self.labels_visible)
+
+ def update_metrics(self):
+ cpu, mem, swap, disks, gpus = shared_provider.get_metrics()
+
+ if self.cpu:
+ self.cpu.circle.set_value(cpu / 100.0)
+ self.cpu.level.set_label(self._format_percentage(int(cpu)))
+ if self.ram:
+ self.ram.circle.set_value(mem / 100.0)
+ self.ram.level.set_label(self._format_percentage(int(mem)))
+ if self.swap:
+ self.swap.circle.set_value(swap / 100.0)
+ self.swap.level.set_label(self._format_percentage(int(swap)))
+ for i, disk in enumerate(self.disk):
+ if i < len(disks):
+ disk.circle.set_value(disks[i] / 100.0)
+ disk.level.set_label(self._format_percentage(int(disks[i])))
+ for i, gpu in enumerate(self.gpu):
+ if i < len(gpus):
+ gpu.circle.set_value(gpus[i] / 100.0)
+ gpu.level.set_label(self._format_percentage(int(gpus[i])))
+
+ tooltip_metrics = []
+ if self.disk:
+ tooltip_metrics.extend(self.disk)
+ if self.ram:
+ tooltip_metrics.append(self.ram)
+ if self.swap:
+ tooltip_metrics.append(self.swap)
+ if self.cpu:
+ tooltip_metrics.append(self.cpu)
+ if self.gpu:
+ tooltip_metrics.extend(self.gpu)
+ self.set_tooltip_markup(
+ (" - " if not data.VERTICAL else "\n").join(
+ [v.markup() for v in tooltip_metrics]
+ )
+ )
+
+ return True
diff --git a/modules/dock/components/player.py b/modules/dock/components/player.py
new file mode 100644
index 0000000..9e4b55f
--- /dev/null
+++ b/modules/dock/components/player.py
@@ -0,0 +1,447 @@
+import re
+
+from gi.repository import Playerctl
+from fabric.core.service import Signal
+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.stack import Stack
+from loguru import logger
+
+import config.data as data
+import utils.icons as icons
+from services.mpris import PlayerManager, PlayerService
+from utils.wiggle_bar import WigglyWidget
+
+
+class Player(Box):
+ def __init__(self, player, **kwargs):
+ super().__init__(style_classes="player", orientation="v", **kwargs)
+
+ # Check if we're in vertical mode based on dock position
+ vertical_mode = (
+ data.DOCK_POSITION in ["Left", "Right"]
+ if hasattr(data, "DOCK_POSITION")
+ else False
+ )
+
+ if not vertical_mode:
+ # vertical class binding from unknown source
+ self.remove_style_class("vertical")
+
+ self._player = PlayerService(player=player)
+
+ self.duration = 0.0
+
+ self._player.connect("pause", self.on_pause)
+ self._player.connect("play", self.on_play)
+ self._player.connect("meta-change", self.on_metadata)
+ self._player.connect("shuffle-toggle", self.on_shuffle)
+ self._player.connect("track-position", self.on_update_track_position)
+
+ self.player_name = Label(
+ name=player.props.player_name,
+ style_classes="player-icon",
+ markup=getattr(icons, player.props.player_name, icons.disc),
+ )
+
+ # Use a fallback background image
+ self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
+
+ self.song = Label(
+ name="song",
+ label="song",
+ justification="left",
+ h_align="start",
+ max_chars_width=10,
+ )
+ self.artist = Label(
+ name="artist", label="artist", justification="left", h_align="start"
+ )
+ self.music = Box(
+ name="music",
+ orientation="v",
+ h_expand=True,
+ v_expand=True,
+ children=[self.song, self.artist],
+ )
+
+ self.play_pause_button = Button(
+ name="pause-button",
+ child=Label(name="pause-label", markup=icons.play),
+ style_classes="pause-track",
+ tooltip_text="Play/Pause",
+ on_clicked=lambda b, *_: self.handle_play_pause(player),
+ )
+
+ self.shuffle_button = Button(
+ name="shuffle-button",
+ child=Label(name="shuffle", markup=icons.shuffle),
+ on_clicked=lambda b, *_: self.handle_shuffle(b, player),
+ )
+
+ self.wiggly = WigglyWidget()
+ self.wiggly.connect("on-seek", self.on_seek)
+ self.gtk_wrapper = Box(
+ orientation="v",
+ h_expand=True,
+ v_expand=True,
+ h_align="fill",
+ v_align="fill",
+ children=self.wiggly,
+ )
+
+ self.children = [
+ Box(name="source", h_expand=True, v_expand=True, children=self.player_name),
+ CenterBox(
+ name="details",
+ start_children=self.music,
+ end_children=self.play_pause_button if not vertical_mode else [],
+ ),
+ Box(
+ name="controls",
+ style_classes="horizontal" if not vertical_mode else "vertical",
+ spacing=5,
+ children=[
+ Button(
+ name="prev-button",
+ child=Label(name="play-previous", markup=icons.prev),
+ on_clicked=lambda b, *_: self.handle_prev(player),
+ ),
+ CenterBox(
+ name="progress-container",
+ h_expand=True,
+ v_expand=True,
+ orientation="v",
+ center_children=[self.gtk_wrapper],
+ ),
+ Button(
+ name="next-button",
+ child=Label(name="play-next", markup=icons.next),
+ on_clicked=lambda b, *_: self.handle_next(player),
+ ),
+ self.shuffle_button,
+ ]
+ if not vertical_mode
+ else [
+ CenterBox(
+ orientation="v",
+ h_expand=True,
+ start_children=[
+ CenterBox(
+ h_expand=True,
+ v_expand=True,
+ v_align="end",
+ start_children=[
+ Button(
+ name="prev-button",
+ child=Label(
+ name="play-previous", markup=icons.prev
+ ),
+ on_clicked=lambda b, *_: self.handle_prev(
+ player
+ ),
+ ),
+ Button(
+ name="next-button",
+ child=Label(
+ name="play-next", markup=icons.next
+ ),
+ on_clicked=lambda b, *_: self.handle_next(
+ player
+ ),
+ ),
+ self.shuffle_button,
+ ],
+ end_children=self.play_pause_button,
+ )
+ ],
+ end_children=CenterBox(
+ name="progress-container",
+ h_expand=True,
+ v_expand=True,
+ orientation="v",
+ center_children=[self.gtk_wrapper],
+ ),
+ )
+ ],
+ ),
+ ]
+
+ self.on_metadata(self._player, metadata=player.props.metadata, player=player)
+
+ def on_update_track_position(self, sender, pos, dur):
+ self.duration = dur
+ if dur > 0:
+ self.wiggly.update_value_from_signal(pos / dur)
+ else:
+ # Duration is 0 or unknown - show some activity but don't update progress
+ # Keep the current progress or set to a small value to indicate activity
+ if pos > 0:
+ # Show minimal progress to indicate playback is active
+ self.wiggly.update_value_from_signal(0.1)
+ else:
+ self.wiggly.update_value_from_signal(0.0)
+
+ def on_seek(self, sender, ratio):
+ pos = ratio * self.duration # duration in seconds
+ self._player.set_position(int(pos))
+
+ def on_metadata(self, sender, metadata, player):
+ keys = metadata.keys()
+ if "xesam:artist" in keys and "xesam:title" in keys:
+ # Check if we're in vertical mode
+ vertical_mode = (
+ data.DOCK_POSITION in ["Left", "Right"]
+ if hasattr(data, "DOCK_POSITION")
+ else False
+ )
+ _max_chars = 43 if not vertical_mode else 30
+
+ song_title = metadata["xesam:title"]
+ if len(song_title) > _max_chars:
+ song_title = song_title[: _max_chars - 1] + "…"
+ self.song.set_label(song_title)
+
+ artist_list = metadata["xesam:artist"]
+ artist_name = artist_list[0] if artist_list else "Unknown Artist"
+ if len(artist_name) > _max_chars:
+ artist_name = artist_name[: _max_chars - 1] + "…"
+ self.artist.set_label(artist_name)
+
+ if "mpris:artUrl" in keys:
+ self.set_style(f"background-image:url('{metadata['mpris:artUrl']}')")
+ elif "xesam:url" in keys and "youtube.com" in metadata["xesam:url"]:
+ # Simple YouTube thumbnail extraction for Firefox
+ url = metadata["xesam:url"]
+ match = re.search(r"(?:v=|/)([a-zA-Z0-9_-]{11})", url)
+ if match:
+ video_id = match.group(1)
+ thumbnail_url = (
+ f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
+ )
+ self.set_style(f"background-image:url('{thumbnail_url}')")
+
+ else:
+ # Fallback to default wallpaper
+ self.set_style(
+ f"background-image:url('{data.HOME_DIR}/.current.wall')"
+ )
+ else:
+ # Fallback to default wallpaper when no artwork is available
+ self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
+
+ if (
+ player.props.playback_status.value_name
+ == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
+ ):
+ self.on_play(self._player)
+
+ if player.props.shuffle == True:
+ self.shuffle_button.get_child().set_markup(
+ icons.shuffle
+ ) # Use same icon for now
+ self.shuffle_button.get_child().set_name("disable-shuffle")
+ else:
+ self.shuffle_button.get_child().set_markup(icons.shuffle)
+ self.shuffle_button.get_child().set_name("shuffle")
+
+ def on_pause(self, sender):
+ self.play_pause_button.get_child().set_markup(icons.play)
+ self.play_pause_button.get_child().set_name("pause-label")
+ self.wiggly.dragging = True
+ self.wiggly.update_amplitude(True)
+ self.wiggly.pause = True
+ self.play_pause_button.add_style_class("pause-track")
+
+ def on_play(self, sender):
+ self.play_pause_button.get_child().set_markup(icons.pause)
+ self.play_pause_button.get_child().set_name("play-label")
+ self.wiggly.pause = False
+ self.wiggly.dragging = False
+ self.wiggly.update_amplitude(False)
+ self.play_pause_button.remove_style_class("pause-track")
+
+ def on_shuffle(self, sender, player, status):
+ if status == False:
+ self.shuffle_button.get_child().set_markup(icons.shuffle)
+ self.shuffle_button.get_child().set_name("shuffle")
+ else:
+ self.shuffle_button.get_child().set_markup(
+ icons.shuffle
+ ) # Use same icon for now
+ self.shuffle_button.get_child().set_name("disable-shuffle")
+
+ self.shuffle_button.get_child().set_style("color: white")
+
+ def handle_next(self, player):
+ try:
+ player.next()
+ except Exception:
+ # Next is not available for this player (common with browsers)
+ pass
+
+ def handle_prev(self, player):
+ try:
+ player.previous()
+ except Exception:
+ # Previous is not available for this player (common with browsers)
+ pass
+
+ def handle_play_pause(self, player):
+ is_playing = self.play_pause_button.get_child().get_name() == "play-label"
+
+ def _set_play_ui():
+ self.play_pause_button.get_child().set_markup(icons.pause)
+ self.play_pause_button.remove_style_class("pause-track")
+ self.play_pause_button.get_child().set_name("pause-label")
+
+ def _set_pause_ui():
+ self.play_pause_button.get_child().set_markup(icons.play)
+ self.play_pause_button.add_style_class("pause-track")
+ self.play_pause_button.get_child().set_name("play-label")
+
+ if is_playing:
+ _set_pause_ui()
+ else:
+ _set_play_ui()
+
+ try:
+ player.play_pause()
+ except Exception as e:
+ # revert if signal failed
+ if is_playing:
+ _set_pause_ui()
+ else:
+ _set_play_ui()
+ logger.warning("Failed to toggle playback:", e)
+
+ def handle_shuffle(self, shuffle_button, player):
+ try:
+ if player.props.shuffle == False:
+ player.set_shuffle(True)
+ else:
+ player.set_shuffle(False)
+ shuffle_button.get_child().set_style("color: var(--outline)")
+ except Exception:
+ # Shuffle is not available for this player (common with browsers)
+ pass
+
+
+class PlayerContainer(Box):
+ @Signal
+ def active_player_changed(self, player: Playerctl.Player) -> None: ...
+
+ def __init__(self, **kwargs):
+ super().__init__(name="player-container", orientation="v", **kwargs)
+
+ self.manager = PlayerManager()
+ self.manager.connect("new-player", self.new_player)
+ self.manager.connect("player-vanish", self.on_player_vanish)
+ self.stack = Stack(
+ name="player-container",
+ transition_type="crossfade",
+ transition_duration=100,
+ children=[],
+ )
+
+ self.player_stack = Stack(
+ name="player-stack",
+ transition_type="crossfade",
+ transition_duration=100,
+ children=[],
+ )
+
+ # Check if we're in vertical mode
+ vertical_mode = (
+ data.DOCK_POSITION in ["Left", "Right"]
+ if hasattr(data, "DOCK_POSITION")
+ else False
+ )
+
+ self.player_switch_container = CenterBox(
+ name="player-switch-container",
+ orientation="h",
+ style_classes="horizontal-player"
+ if not vertical_mode
+ else "vertical-player",
+ center_children=[],
+ )
+ self.children = [self.stack, self.player_switch_container]
+ self.player = []
+ self.player_objects = {} # Map player names to player objects
+ self.active_player = None # Track the currently active player
+ self.manager.init_all_players()
+
+ def new_player(self, manager, player):
+ new_player = Player(player=player)
+ new_player.gtk_wrapper.queue_draw()
+ new_player.set_name(player.props.player_name)
+ self.player.append(new_player)
+ # Store the player object
+ self.player_objects[player.props.player_name] = player
+ self.stack.add_named(new_player, player.props.player_name)
+
+ self.player_switch_container.add_center(
+ Button(
+ name=player.props.player_name,
+ style_classes="player-button",
+ on_clicked=lambda b: self.switch_player(player.props.player_name, b),
+ )
+ )
+
+ # If this is the first player, make it active
+ if not self.active_player:
+ self.active_player = player
+ self.active_player_changed(player)
+
+ self.update_player_list()
+
+ def switch_player(self, player_name, button):
+ self.stack.set_visible_child_name(player_name)
+
+ for btn in self.player_switch_container.center_children:
+ btn.remove_style_class("active")
+ button.add_style_class("active")
+
+ # Get the player object and emit signal
+ if player_name in self.player_objects:
+ self.active_player = self.player_objects[player_name]
+ self.active_player_changed(self.active_player)
+
+ def on_player_vanish(self, manager, player):
+ player_name = player.props.player_name
+
+ for player_instance in self.player:
+ if player_instance.get_name() == player_name:
+ self.stack.remove(player_instance)
+ self.player.remove(player_instance)
+ for btn in self.player_switch_container.center_children:
+ if btn.get_name() == player_name:
+ self.player_switch_container.remove_center(btn)
+ break
+
+ # Clean up player objects dictionary
+ if player_name in self.player_objects:
+ del self.player_objects[player_name]
+
+ # If the active player vanished, switch to another one
+ if self.active_player and self.active_player.props.player_name == player_name:
+ self.active_player = None
+ # Try to find another player to make active
+ if self.player_objects:
+ first_player_name = next(iter(self.player_objects))
+ self.active_player = self.player_objects[first_player_name]
+ self.active_player_changed(self.active_player)
+
+ self.update_player_list()
+
+ def update_player_list(self):
+ curr = self.stack.get_visible_child()
+ if curr:
+ for btn in self.player_switch_container.center_children:
+ if btn.get_name() == curr.get_name():
+ btn.add_style_class("active")
+ else:
+ btn.remove_style_class("active")
\ No newline at end of file
diff --git a/modules/dock/components/systemtray.py b/modules/dock/components/systemtray.py
new file mode 100644
index 0000000..bdc3052
--- /dev/null
+++ b/modules/dock/components/systemtray.py
@@ -0,0 +1,170 @@
+import config.data as data
+from gi.repository import Gdk, GdkPixbuf, GLib, Gray, Gtk
+from fabric.widgets.box import Box
+import logging
+import gi
+
+gi.require_version("Gray", "0.1")
+
+
+logger = logging.getLogger(__name__)
+
+
+class SystemTray(Box):
+ def __init__(
+ self, pixel_size: int = 16, refresh_interval: int = 1, **kwargs
+ ) -> None:
+ orientation = (
+ Gtk.Orientation.HORIZONTAL
+ if not data.VERTICAL
+ else Gtk.Orientation.VERTICAL
+ )
+ super().__init__(name="tray", orientation=orientation, spacing=4, **kwargs)
+ self.enabled = True
+ super().set_visible(False)
+ self.pixel_size = pixel_size
+ self.refresh_interval = refresh_interval
+
+ self.buttons_by_id = {}
+ self.items_by_id = {}
+
+ self.watcher = Gray.Watcher()
+ self.watcher.connect("item-added", self.on_watcher_item_added)
+
+ GLib.timeout_add_seconds(self.refresh_interval, self._refresh_all_items)
+
+ def set_visible(self, visible: bool):
+ self.enabled = visible
+ self._update_visibility()
+
+ def _update_visibility(self):
+ has = len(self.get_children()) > 0
+ super().set_visible(self.enabled and has)
+
+ def _get_item_pixbuf(self, item: Gray.Item) -> GdkPixbuf.Pixbuf:
+ try:
+ pm = Gray.get_pixmap_for_pixmaps(item.get_icon_pixmaps(), self.pixel_size)
+ if pm:
+ return pm.as_pixbuf(self.pixel_size, GdkPixbuf.InterpType.HYPER)
+
+ name = item.get_icon_name()
+ theme = Gtk.IconTheme.get_default()
+ path = item.get_icon_theme_path()
+ if path:
+ theme.prepend_search_path(path)
+ return theme.load_icon(
+ name, self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE
+ )
+ except GLib.Error as e:
+ logger.debug(f"Icon load error {e}")
+ return Gtk.IconTheme.get_default().load_icon(
+ "image-missing", self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE
+ )
+
+ def _refresh_item_ui(self, identifier: str, item: Gray.Item, button: Gtk.Button):
+ pixbuf = self._get_item_pixbuf(item)
+ img = button.get_image()
+ if isinstance(img, Gtk.Image):
+ img.set_from_pixbuf(pixbuf)
+ else:
+ new = Gtk.Image.new_from_pixbuf(pixbuf)
+ button.set_image(new)
+ new.show()
+ tip = None
+ if hasattr(item, "get_tooltip_text"):
+ tip = item.get_tooltip_text()
+ elif hasattr(item, "get_title"):
+ tip = item.get_title()
+ if tip:
+ button.set_tooltip_text(tip)
+ else:
+ button.set_has_tooltip(False)
+
+ def _refresh_all_items(self) -> bool:
+ for ident, item in self.items_by_id.items():
+ btn = self.buttons_by_id.get(ident)
+ if btn:
+ self._refresh_item_ui(ident, item, btn)
+ return True
+
+ def on_watcher_item_added(self, _, identifier: str):
+ item = self.watcher.get_item_for_identifier(identifier)
+ if not item:
+ return
+
+ if identifier in self.buttons_by_id:
+ self.buttons_by_id[identifier].destroy()
+ del self.buttons_by_id[identifier]
+ del self.items_by_id[identifier]
+
+ btn = self.do_bake_item_button(item)
+ self.buttons_by_id[identifier] = btn
+ self.items_by_id[identifier] = item
+
+ item.connect(
+ "notify::icon-pixmaps",
+ lambda itm, pspec: self._refresh_item_ui(identifier, itm, btn),
+ )
+ item.connect(
+ "notify::icon-name",
+ lambda itm, pspec: self._refresh_item_ui(identifier, itm, btn),
+ )
+
+ try:
+ item.connect(
+ "updated", lambda itm: self._refresh_item_ui(identifier, itm, btn)
+ )
+ except TypeError:
+ pass
+
+ item.connect(
+ "removed", lambda itm: self.on_item_instance_removed(identifier, itm)
+ )
+
+ self.add(btn)
+ btn.show_all()
+ self._update_visibility()
+
+ def do_bake_item_button(self, item: Gray.Item) -> Gtk.Button:
+ btn = Gtk.Button()
+ btn.connect("button-press-event", lambda b, e: self.on_button_click(b, item, e))
+ img = Gtk.Image.new_from_pixbuf(self._get_item_pixbuf(item))
+ btn.set_image(img)
+ tip = (
+ item.get_tooltip_text()
+ if hasattr(item, "get_tooltip_text")
+ else getattr(item, "get_title", lambda: None)()
+ )
+ if tip:
+ btn.set_tooltip_text(tip)
+ return btn
+
+ def on_item_instance_removed(self, identifier: str, removed_item: Gray.Item):
+ if self.items_by_id.get(identifier) is removed_item:
+ btn = self.buttons_by_id.pop(identifier, None)
+ self.items_by_id.pop(identifier, None)
+ if btn:
+ btn.destroy()
+ self._update_visibility()
+
+ def on_button_click(
+ self, button: Gtk.Button, item: Gray.Item, event: Gdk.EventButton
+ ):
+ if event.button == Gdk.BUTTON_PRIMARY:
+ try:
+ item.activate(int(event.x_root), int(event.y_root))
+ except Exception as e:
+ logger.error(f"Activate error: {e}")
+ elif event.button == Gdk.BUTTON_SECONDARY:
+ menu = getattr(item, "get_menu", lambda: None)()
+ if isinstance(menu, Gtk.Menu):
+ menu.popup_at_widget(
+ button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event
+ )
+ else:
+ cm = getattr(item, "context_menu", None)
+ if cm:
+ try:
+ cm(int(event.x_root), int(event.y_root))
+ except Exception as e:
+ logger.error(f"ContextMenu error: {e}")
diff --git a/modules/dock/components/workspaces.py b/modules/dock/components/workspaces.py
new file mode 100644
index 0000000..0ba3de3
--- /dev/null
+++ b/modules/dock/components/workspaces.py
@@ -0,0 +1,147 @@
+import json
+import subprocess
+
+from fabric.hyprland.service import Hyprland
+from fabric.hyprland.widgets import WorkspaceButton, HyprlandWorkspaces
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+
+import config.data as data
+
+CHINESE_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "〇"]
+
+
+class Workspace(Button):
+ def __init__(self):
+ should_invert = data.DOCK_THEME in ["Dense", "Edge"] or (
+ data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"]
+ )
+
+ super().__init__(
+ name="workspace-single",
+ can_focus=True,
+ receives_default=True,
+ relief="none",
+ style_classes="invert" if should_invert else None,
+ )
+
+ self.connection = Hyprland()
+ self.activeWorkspace = str(self._get_active_workspace())
+
+ self.set_label(str(self.activeWorkspace))
+ self.connect("clicked", self._on_click)
+ self.connection.connect("event::workspace", self._on_workspace_changed)
+
+ self.set_sensitive(True)
+ self.show_all()
+
+ def _get_active_workspace(self):
+ active = self.connection.send_command("j/activeworkspace").reply
+ return json.loads(active.decode("utf-8"))["name"]
+
+ def _get_active_workspaces(self):
+ clients_data = self.connection.send_command("j/clients").reply
+ clients = json.loads(clients_data.decode("utf-8"))
+
+ active_workspaces = set()
+ for client in clients:
+ if "workspace" in client and "name" in client["workspace"]:
+ workspace_name = str(client["workspace"]["name"])
+ active_workspaces.add(workspace_name)
+
+ return sorted(
+ list(active_workspaces), key=lambda x: int(x) if x.isdigit() else x
+ )
+
+ def _on_click(self, _widget):
+ self._cycle_workspace()
+
+ def _cycle_workspace(self):
+ active_workspaces = self._get_active_workspaces()
+
+ if not active_workspaces:
+ return
+
+ if str(self.activeWorkspace) not in active_workspaces:
+ active_workspaces.append(str(self.activeWorkspace))
+ active_workspaces.sort(key=lambda x: int(x) if x.isdigit() else x)
+
+ current_index = active_workspaces.index(self.activeWorkspace)
+ next_index = (current_index + 1) % len(active_workspaces)
+ next_workspace = active_workspaces[next_index]
+
+ cmd = ["hyprctl", "dispatch", "workspace", str(next_workspace)]
+ subprocess.run(cmd, capture_output=True, text=True, timeout=2)
+
+ self.activeWorkspace = str(next_workspace)
+ self.set_label(str(self.activeWorkspace))
+
+ def _on_workspace_changed(self, _obj, signal):
+ """Handle workspace change events from Hyprland"""
+ workspace_data = json.loads(signal.data[0])
+ self.activeWorkspace = str(workspace_data)
+ self.set_label(str(self.activeWorkspace))
+
+
+workspaces = HyprlandWorkspaces(
+ name="workspaces",
+ invert_scroll=True,
+ empty_scroll=True,
+ v_align="fill",
+ orientation="h" if not data.VERTICAL else "v",
+ spacing=8,
+ buttons=[
+ WorkspaceButton(
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ id=i,
+ label=None,
+ style_classes=["vertical"] if data.VERTICAL else None,
+ )
+ for i in range(1, 11)
+ ],
+ buttons_factory=None
+ if data.DOCK_HIDE_SPECIAL_WORKSPACE
+ else HyprlandWorkspaces.default_buttons_factory,
+)
+
+workspaces_num = HyprlandWorkspaces(
+ name="workspaces-num",
+ invert_scroll=True,
+ empty_scroll=True,
+ v_align="fill",
+ orientation="h" if not data.VERTICAL else "v",
+ spacing=0 if not data.WORKSPACE_USE_CHINESE_NUMERAL else 4,
+ buttons=[
+ WorkspaceButton(
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ id=i,
+ label=CHINESE_NUMERALS[i - 1]
+ if data.WORKSPACE_USE_CHINESE_NUMERAL and 1 <= i <= len(CHINESE_NUMERALS)
+ else str(i),
+ )
+ for i in range(1, 11)
+ ],
+ buttons_factory=None
+ if data.DOCK_HIDE_SPECIAL_WORKSPACE
+ else HyprlandWorkspaces.default_buttons_factory,
+)
+
+
+workspaceactive = Workspace()
+
+workspace = Box(
+ name="workspaces-container",
+ children=[
+ workspaces
+ if data.WORKSPACE_DOTS
+ else workspaces_num
+ if data.WORKSPACE_NUMS
+ else workspaceactive
+ ],
+)
diff --git a/modules/dock/main.py b/modules/dock/main.py
new file mode 100644
index 0000000..9aae8a2
--- /dev/null
+++ b/modules/dock/main.py
@@ -0,0 +1,511 @@
+import json
+
+import config.data as data
+from fabric.hyprland.widgets import get_hyprland_connection
+from fabric.widgets.box import Box
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.revealer import Revealer
+from gi.repository import Gdk, GLib, Gtk
+from modules.corners import MyCorner
+from modules.dock.components import DockComponents
+from utils.occlusion import check_occlusion
+from utils.wayland import WaylandWindow as Window
+
+
+class Dock(Window):
+ _instances = []
+
+ def __init__(self, **kwargs):
+ self.icon_size = 28
+ self.effective_occlusion_size = 36 + self.icon_size
+
+ anchor_to_set: str
+ revealer_transition_type: str
+
+ self.actual_dock_is_horizontal: bool
+ main_box_orientation_val: Gtk.Orientation
+ main_box_h_align_val: str
+ dock_wrapper_orientation_val: Gtk.Orientation
+
+ self.actual_dock_is_horizontal = not data.VERTICAL
+
+ if self.actual_dock_is_horizontal:
+ if data.DOCK_POSITION == "Top":
+ anchor_to_set = "top"
+ revealer_transition_type = "slide-down"
+ else: # Bottom
+ anchor_to_set = "bottom"
+ revealer_transition_type = "slide-up"
+ main_box_orientation_val = Gtk.Orientation.VERTICAL
+ main_box_h_align_val = "center"
+ dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
+ else:
+ if data.DOCK_POSITION == "Left":
+ anchor_to_set = "left"
+ revealer_transition_type = "slide-right"
+ elif data.DOCK_POSITION == "Right":
+ anchor_to_set = "right"
+ revealer_transition_type = "slide-left"
+ else:
+ anchor_to_set = "right"
+ revealer_transition_type = "slide-left"
+
+ main_box_orientation_val = Gtk.Orientation.HORIZONTAL
+ main_box_h_align_val = "end" if anchor_to_set == "right" else "start"
+ dock_wrapper_orientation_val = Gtk.Orientation.VERTICAL
+
+ super().__init__(
+ name="dock-window",
+ layer="top",
+ anchor=anchor_to_set,
+ margin="0px 0px 0px 0px",
+ exclusivity="auto" if not data.DOCK_AUTO_HIDE else "none",
+ **kwargs,
+ )
+ Dock._instances.append(self)
+
+ self.conn = get_hyprland_connection()
+
+ self.hide_id = None
+ self._arranger_handler = None
+ self._drag_in_progress = False
+ self.always_occluded = data.DOCK_ALWAYS_OCCLUDED
+ self.is_mouse_over_dock_area = False
+ self._prevent_occlusion = False
+
+ self.view = Box(name="viewport", spacing=4)
+ self.wrapper = Box(
+ name="dock",
+ children=[self.view],
+ style_classes=["left"]
+ if data.DOCK_POSITION == "Left"
+ else ["right"]
+ if data.DOCK_POSITION == "Right"
+ else ["top"]
+ if data.DOCK_POSITION == "Top"
+ else [],
+ )
+
+ self.wrapper.set_orientation(dock_wrapper_orientation_val)
+ self.view.set_orientation(dock_wrapper_orientation_val)
+
+ if dock_wrapper_orientation_val == Gtk.Orientation.VERTICAL:
+ self.wrapper.add_style_class("vertical")
+ else:
+ self.wrapper.remove_style_class("vertical")
+
+ match data.DOCK_THEME:
+ case "Pills":
+ self.wrapper.add_style_class("pills")
+ case "Dense":
+ self.wrapper.add_style_class("dense")
+ case "Edge":
+ self.wrapper.add_style_class("edge")
+ case _:
+ self.wrapper.add_style_class("pills")
+
+ self.dock_eventbox = EventBox()
+ self.dock_eventbox.add(self.wrapper)
+ self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter)
+ self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave)
+
+ self.components = DockComponents(
+ orientation_val="h" if not data.VERTICAL else "v", dock_instance=self
+ )
+
+ if self.actual_dock_is_horizontal:
+ self.view.add(self.components)
+ elif data.DOCK_POSITION == "Left":
+ self.view.add(self.components)
+ else: # Right position
+ self.view.add(self.components)
+
+ self.corner_left = Box()
+ self.corner_right = Box()
+ self.corner_top = Box()
+ self.corner_bottom = Box()
+
+ if self.actual_dock_is_horizontal:
+ if data.DOCK_POSITION == "Top":
+ self.corner_left = Box(
+ name="dock-corner-left",
+ orientation=Gtk.Orientation.VERTICAL,
+ h_align="start",
+ children=[MyCorner("top-right"), Box(v_expand=True, v_align="fill")],
+ )
+ self.corner_right = Box(
+ name="dock-corner-right",
+ orientation=Gtk.Orientation.VERTICAL,
+ h_align="end",
+ children=[MyCorner("top-left"), Box(v_expand=True, v_align="fill")],
+ )
+ else: # Bottom
+ self.corner_left = Box(
+ name="dock-corner-left",
+ orientation=Gtk.Orientation.VERTICAL,
+ h_align="start",
+ children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-right")],
+ )
+ self.corner_right = Box(
+ name="dock-corner-right",
+ orientation=Gtk.Orientation.VERTICAL,
+ h_align="end",
+ children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-left")],
+ )
+ self.dock_full = Box(
+ name="dock-full",
+ orientation=Gtk.Orientation.HORIZONTAL,
+ h_expand=True,
+ h_align="fill",
+ children=[self.corner_left, self.dock_eventbox, self.corner_right],
+ )
+ else:
+ if anchor_to_set == "right":
+ self.corner_top = Box(
+ name="dock-corner-top",
+ orientation=Gtk.Orientation.HORIZONTAL,
+ v_align="start",
+ children=[
+ Box(h_expand=True, h_align="fill"),
+ MyCorner("bottom-right"),
+ ],
+ )
+ self.corner_bottom = Box(
+ name="dock-corner-bottom",
+ orientation=Gtk.Orientation.HORIZONTAL,
+ v_align="end",
+ children=[
+ Box(h_expand=True, h_align="fill"),
+ MyCorner("top-right"),
+ ],
+ )
+ else:
+ self.corner_top = Box(
+ name="dock-corner-top",
+ orientation=Gtk.Orientation.HORIZONTAL,
+ v_align="start",
+ children=[
+ MyCorner("bottom-left"),
+ Box(h_expand=True, h_align="fill"),
+ ],
+ )
+ self.corner_bottom = Box(
+ name="dock-corner-bottom",
+ orientation=Gtk.Orientation.HORIZONTAL,
+ v_align="end",
+ children=[MyCorner("top-left"), Box(h_expand=True, h_align="fill")],
+ )
+
+ self.dock_full = Box(
+ name="dock-full",
+ orientation=Gtk.Orientation.VERTICAL,
+ v_expand=True,
+ v_align="fill",
+ margin=0,
+ children=[self.corner_top, self.dock_eventbox, self.corner_bottom],
+ )
+
+ self.dock_revealer = Revealer(
+ name="dock-revealer",
+ transition_type=revealer_transition_type,
+ transition_duration=250,
+ child_revealed=False,
+ child=self.dock_full,
+ )
+
+ self.hover_activator = EventBox()
+
+ if self.actual_dock_is_horizontal:
+ # Bottom dock
+ self.hover_activator.set_size_request(-1, 1)
+ else:
+ # Vertical dock (Left or Right)
+ self.hover_activator.set_size_request(1, -1)
+
+ self.hover_activator.connect("enter-notify-event", self._on_hover_enter)
+ self.hover_activator.connect("leave-notify-event", self._on_hover_leave)
+
+ if self.actual_dock_is_horizontal:
+ if data.DOCK_POSITION == "Top":
+ # Top dock - revealer first, then hover activator
+ self.main_box = Box(
+ orientation=main_box_orientation_val,
+ children=[self.dock_revealer, self.hover_activator],
+ h_align=main_box_h_align_val,
+ )
+ else:
+ # Bottom dock - hover activator first, then revealer
+ self.main_box = Box(
+ orientation=main_box_orientation_val,
+ children=[self.hover_activator, self.dock_revealer],
+ h_align=main_box_h_align_val,
+ )
+ else:
+ # Vertical dock
+ if data.DOCK_POSITION == "Left":
+ # Left dock - revealer first, then hover activator
+ self.main_box = Box(
+ orientation=main_box_orientation_val,
+ children=[self.dock_revealer, self.hover_activator],
+ h_align=main_box_h_align_val,
+ )
+ else:
+ # Right dock - hover activator first, then revealer
+ self.main_box = Box(
+ orientation=main_box_orientation_val,
+ children=[self.hover_activator, self.dock_revealer],
+ h_align=main_box_h_align_val,
+ )
+ self.add(self.main_box)
+
+ if data.DOCK_THEME in ["Edge", "Dense"]:
+ for corner in [
+ self.corner_left,
+ self.corner_right,
+ self.corner_top,
+ self.corner_bottom,
+ ]:
+ corner.set_visible(False)
+
+ if not data.DOCK_ENABLED:
+ self.set_visible(False)
+
+ if self.always_occluded:
+ self.dock_full.add_style_class("occluded")
+
+ if self.conn.ready:
+ GLib.timeout_add(250, self.check_occlusion_state)
+ else:
+ self.conn.connect(
+ "event::ready",
+ lambda *args: GLib.timeout_add(250, self.check_occlusion_state),
+ )
+
+ self.conn.connect("event::workspace", self.check_hide)
+
+ GLib.timeout_add_seconds(1, self.check_config_change)
+
+ def _on_hover_enter(self, *args):
+ self.is_mouse_over_dock_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+
+ def _on_hover_leave(self, *args):
+ self.is_mouse_over_dock_area = False
+ self.delay_hide()
+
+ def _on_dock_enter(self, widget, event):
+ self.is_mouse_over_dock_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+ return True
+
+ def _on_dock_leave(self, widget, event):
+ if event.detail == Gdk.NotifyType.INFERIOR:
+ return False
+
+ self.is_mouse_over_dock_area = False
+ self.delay_hide()
+
+ if self.always_occluded:
+ self.dock_full.add_style_class("occluded")
+ return True
+
+ def delay_hide(self):
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered)
+
+ def hide_dock_if_not_hovered(self):
+ self.hide_id = None
+ if (
+ not self.is_mouse_over_dock_area
+ and not self._drag_in_progress
+ and not self._prevent_occlusion
+ ):
+ if self.always_occluded:
+ self.dock_revealer.set_reveal_child(False)
+ else:
+ if self.actual_dock_is_horizontal:
+ occlusion_region = (
+ ("top", self.effective_occlusion_size)
+ if data.DOCK_POSITION == "Top"
+ else ("bottom", self.effective_occlusion_size)
+ )
+ else:
+ occlusion_region = (
+ ("left", self.effective_occlusion_size)
+ if data.DOCK_POSITION == "Left"
+ else ("right", self.effective_occlusion_size)
+ )
+ if check_occlusion(occlusion_region) or not self.view.get_children():
+ self.dock_revealer.set_reveal_child(False)
+ return False
+
+ def check_hide(self, *args):
+ if (
+ self.is_mouse_over_dock_area
+ or self._drag_in_progress
+ or self._prevent_occlusion
+ ):
+ return
+
+ clients = self.get_clients()
+ current_ws = self.get_workspace()
+ ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws]
+
+ if not self.always_occluded:
+ if not ws_clients:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ self.dock_full.remove_style_class("occluded")
+ elif any(
+ not w.get("floating") and not w.get("fullscreen") for w in ws_clients
+ ):
+ self.check_occlusion_state()
+ else:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ self.dock_full.remove_style_class("occluded")
+ else:
+ if self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(False)
+ self.dock_full.add_style_class("occluded")
+
+ def get_clients(self):
+ try:
+ return json.loads(self.conn.send_command("j/clients").reply.decode())
+ except json.JSONDecodeError:
+ return []
+
+ def get_focused(self):
+ try:
+ return json.loads(
+ self.conn.send_command("j/activewindow").reply.decode()
+ ).get("address", "")
+ except json.JSONDecodeError:
+ return ""
+
+ def get_workspace(self):
+ try:
+ return json.loads(
+ self.conn.send_command("j/activeworkspace").reply.decode()
+ ).get("id", 0)
+ except json.JSONDecodeError:
+ return 0
+
+ def check_occlusion_state(self):
+ if (
+ self.is_mouse_over_dock_area
+ or self._drag_in_progress
+ or self._prevent_occlusion
+ ):
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+ return True
+
+ if not data.DOCK_AUTO_HIDE:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+ return True
+
+ if self.always_occluded:
+ if self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(False)
+ self.dock_full.add_style_class("occluded")
+ return True
+
+ if self.actual_dock_is_horizontal:
+ occlusion_region = (
+ ("top", self.effective_occlusion_size)
+ if data.DOCK_POSITION == "Top"
+ else ("bottom", self.effective_occlusion_size)
+ )
+ else:
+ occlusion_region = (
+ ("left", self.effective_occlusion_size)
+ if data.DOCK_POSITION == "Left"
+ else ("right", self.effective_occlusion_size)
+ )
+ is_occluded_by_window = check_occlusion(occlusion_region)
+ is_empty = not self.view.get_children()
+
+ if is_occluded_by_window or is_empty:
+ if self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(False)
+ self.dock_full.add_style_class("occluded")
+ else:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ self.dock_full.remove_style_class("occluded")
+
+ return True
+
+ def _update_size(self):
+ width, _ = self.view.get_preferred_width()
+ self.set_size_request(width, -1)
+ return False
+
+ def check_config_change(self):
+ new_always_occluded = data.DOCK_ALWAYS_OCCLUDED
+ if self.always_occluded != new_always_occluded:
+ self.always_occluded = new_always_occluded
+ self.check_occlusion_state()
+ return True
+
+ def check_config_change_immediate(self):
+ previous_always_occluded = self.always_occluded
+ self.always_occluded = data.DOCK_ALWAYS_OCCLUDED
+
+ if previous_always_occluded != self.always_occluded:
+ self.check_occlusion_state()
+ return False
+
+ @staticmethod
+ def notify_config_change():
+ for dock_instance in Dock._instances:
+ GLib.idle_add(dock_instance.check_config_change_immediate)
+
+ @staticmethod
+ def update_visibility(visible):
+ for dock in Dock._instances:
+ dock.set_visible(visible)
+ if visible:
+ GLib.idle_add(dock.check_occlusion_state)
+ else:
+ if (
+ hasattr(dock, "dock_revealer")
+ and dock.dock_revealer.get_reveal_child()
+ ):
+ dock.dock_revealer.set_reveal_child(False)
+
+ def on_app_drag_begin(self):
+ self._drag_in_progress = True
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+
+ def on_app_drag_end(self):
+ self._drag_in_progress = False
+ GLib.idle_add(self.check_occlusion_state)
+
+ def prevent_hiding(self, prevent=True):
+ self._prevent_occlusion = prevent
+ if prevent:
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+ else:
+ GLib.idle_add(self.check_occlusion_state)
diff --git a/modules/launcher/__init__.py b/modules/launcher/__init__.py
new file mode 100644
index 0000000..547fa73
--- /dev/null
+++ b/modules/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/modules/launcher/components/__init__.py b/modules/launcher/components/__init__.py
deleted file mode 100644
index da2ef07..0000000
--- a/modules/launcher/components/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from .applauncher import *
-from .bluetooth import *
-from .cliphist import *
-from .dashboard import *
-from .emoji import *
-from .power import *
-from .sh import *
-from .wallpapers import *
-from .wifi import *
-from .calendar import *
-from .dashboard import *
-from .tools import *
-from .kanban import *
-from .tmux import *
-from .window_switcher import *
-from .notification_center import *
diff --git a/modules/launcher/components/applauncher.py b/modules/launcher/components/applauncher.py
deleted file mode 100644
index 83380de..0000000
--- a/modules/launcher/components/applauncher.py
+++ /dev/null
@@ -1,630 +0,0 @@
-import operator
-from collections.abc import Iterator
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.entry import Entry
-from fabric.widgets.image import Image
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.utils import (
- DesktopApp,
- get_desktop_applications,
- idle_add,
- remove_handler,
- exec_shell_command_async,
-)
-from fabric.utils.helpers import get_relative_path
-from gi.repository import GLib, Gdk
-import utils.icons as icons
-import config.data as data
-import json
-import os
-import re
-import math
-import numpy as np
-import subprocess
-from modules.dock import Dock # Import the Dock class
-
-
-class AppLauncher(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="app-launcher",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
- self.selected_index = -1 # Track the selected item index
-
- self._arranger_handler: int = 0
- self._all_apps = get_desktop_applications()
- self.config = data.read_config()
-
- # Calculator history initialization
- self.calc_history_path = f"{data.CACHE_DIR}/calc.json"
- if os.path.exists(self.calc_history_path):
- with open(self.calc_history_path, "r") as f:
- self.calc_history = json.load(f)
- else:
- self.calc_history = []
-
- self.viewport = Box(name="viewport", spacing=4, orientation="v")
- self.search_entry = Entry(
- name="search-entry",
- placeholder="Search Applications...",
- h_expand=True,
- notify_text=self.notify_text, # Use the method instead of lambda
- on_activate=lambda entry, *_: self.on_search_entry_activate(
- entry.get_text()
- ),
- on_key_press_event=self.on_search_entry_key_press, # Handle key presses
- )
- self.search_entry.props.xalign = 0.5
- self.scrolled_window = ScrolledWindow(
- name="scrolled-window",
- spacing=10,
- min_content_size=(450, 105),
- max_content_size=(450, 105),
- child=self.viewport,
- )
-
- self.header_box = Box(
- name="header_box",
- spacing=10,
- orientation="h",
- children=[
- Button(
- name="config-button",
- child=Label(name="config-label", markup=icons.config),
- on_clicked=lambda *_: (
- exec_shell_command_async(
- f"python {get_relative_path('../../../config/config.py')}"
- ),
- self.close_launcher(),
- ),
- ),
- self.search_entry,
- ],
- )
-
- self.launcher_box = Box(
- name="launcher-box",
- spacing=10,
- h_expand=True,
- orientation="v",
- children=[
- self.header_box,
- self.scrolled_window,
- ],
- )
-
- self.resize_viewport()
-
- self.add(self.launcher_box)
- self.show_all()
-
- def close_launcher(self):
- self.viewport.children = []
- self.selected_index = -1 # Reset selection
- self.launcher.close()
-
- def open_launcher(self):
- self._all_apps = get_desktop_applications()
- self.arrange_viewport()
-
- def ensure_initialized(self):
- """Make sure the launcher is initialized with apps list before opening"""
- if not hasattr(self, "_initialized"):
- # Force pre-loading apps
- self._all_apps = get_desktop_applications()
- self._initialized = True
- return True # Was initialized for first time
- return False # Was already initialized
-
- def arrange_viewport(self, query: str = ""):
- if query.startswith("="):
- # In calculator mode, update history view once (not per keystroke)
- self.show_scrolled_window()
- self.launcher.dashboard.hide()
- self.update_calculator_viewport()
- return
- remove_handler(self._arranger_handler) if self._arranger_handler else None
- self.viewport.children = []
- self.selected_index = -1 # Clear selection when viewport changes
-
- if query.startswith("/"):
- self.launcher.dashboard.hide()
- return
-
- if query.strip():
- self.launcher.dashboard.hide()
- self.show_scrolled_window()
- else:
- self.launcher.dashboard.show()
- self.hide_scrolled_window()
- return False
-
- filtered_apps_iter = iter(
- sorted(
- [
- app
- for app in self._all_apps
- if query.casefold()
- in (
- (app.display_name or "")
- + (" " + app.name + " ")
- + (app.generic_name or "")
- ).casefold()
- ],
- key=lambda app: (app.display_name or "").casefold(),
- )
- )
- should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps)
-
- self._arranger_handler = idle_add(
- lambda apps_iter: self.add_next_application(apps_iter)
- or self.handle_arrange_complete(should_resize, query),
- filtered_apps_iter,
- pin=True,
- )
-
- def handle_arrange_complete(self, should_resize, query):
- if should_resize:
- self.resize_viewport()
- # Only auto-select first item if query exists
- if query.strip() != "" and self.viewport.get_children():
- self.update_selection(0)
- return False
-
- def add_next_application(self, apps_iter: Iterator[DesktopApp]):
- if not (app := next(apps_iter, None)):
- return False
- self.viewport.add(self.bake_application_slot(app))
- return True
-
- def show_scrolled_window(self):
- if hasattr(self, "scrolled_window") and self.scrolled_window:
- self.scrolled_window.show_all()
-
- def hide_scrolled_window(self):
- if hasattr(self, "scrolled_window") and self.scrolled_window:
- self.scrolled_window.hide()
-
- def resize_viewport(self):
- self.scrolled_window.set_min_content_width(
- self.viewport.get_allocation().width # type: ignore
- )
- return False
-
- def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button:
- button = Button(
- name="slot-button",
- child=Box(
- name="slot-box",
- orientation="h",
- spacing=10,
- children=[
- Image(
- name="app-icon",
- pixbuf=app.get_icon_pixbuf(size=24),
- h_align="start",
- ),
- Label(
- name="app-label",
- label=app.display_name or "Unknown",
- ellipsization="end",
- v_align="center",
- h_align="center",
- ),
- Label(
- name="app-desc",
- label=app.description or "",
- ellipsization="end",
- v_align="center",
- h_align="start",
- h_expand=True,
- ),
- ],
- ),
- tooltip_text=app.description,
- on_clicked=lambda *_: (app.launch(), self.close_launcher()),
- **kwargs,
- )
- return button
-
- def update_selection(self, new_index: int):
- # Unselect current
- if self.selected_index != -1 and self.selected_index < len(
- self.viewport.get_children()
- ):
- current_button = self.viewport.get_children()[self.selected_index]
- current_button.get_style_context().remove_class("selected")
- # Select new
- if new_index != -1 and new_index < len(self.viewport.get_children()):
- new_button = self.viewport.get_children()[new_index]
- new_button.get_style_context().add_class("selected")
- self.selected_index = new_index
- self.scroll_to_selected(new_button)
- else:
- self.selected_index = -1
-
- def scroll_to_selected(self, button):
- def scroll():
- adj = self.scrolled_window.get_vadjustment()
- alloc = button.get_allocation()
- if alloc.height == 0:
- return False # Retry if allocation isn't ready
-
- y = alloc.y
- height = alloc.height
- page_size = adj.get_page_size()
- current_value = adj.get_value()
-
- # Calculate visible boundaries
- visible_top = current_value
- visible_bottom = current_value + page_size
-
- if y < visible_top:
- # Item above viewport - align to top
- adj.set_value(y)
- elif y + height > visible_bottom:
- # Item below viewport - align to bottom
- new_value = y + height - page_size
- adj.set_value(new_value)
- # No action if already fully visible
- return False
-
- GLib.idle_add(scroll)
-
- def on_search_entry_activate(self, text):
- # This method is now only used for non-Enter key activations
- commands = self.config.get("commands", {})
-
- # Check if text is a mathematical expression
- if text and (text[0].isdigit()):
- self.evaluate_calculator_expression(text)
- return
-
- if text in commands:
- command = commands[text]
- eval(command, {"launcher": self.launcher})
- else:
- children = self.viewport.get_children()
- if children:
- # Only activate if we have selection or non-empty query
- if text.strip() == "" and self.selected_index == -1:
- return # Prevent accidental activation when empty
- selected_index = self.selected_index if self.selected_index != -1 else 0
- if 0 <= selected_index < len(children):
- children[selected_index].clicked()
-
- def on_search_entry_key_press(self, widget, event):
- text = widget.get_text()
-
- # Check if we're in calculator mode
- if text.startswith("="):
- if event.keyval == Gdk.KEY_Down:
- self.move_selection(1)
- return True
- elif event.keyval == Gdk.KEY_Up:
- self.move_selection(-1)
- return True
- elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
- # In calculator mode, if a history item is highlighted:
- if self.selected_index != -1 and self.selected_index < len(
- self.calc_history
- ):
- if event.state & Gdk.ModifierType.SHIFT_MASK:
- # Shift+Enter deletes the selected calculator history item
- self.delete_selected_calc_history()
- else:
- # Normal Enter copies the result
- selected_text = self.calc_history[self.selected_index]
- self.copy_text_to_clipboard(selected_text)
- # Clear selection so new expressions are evaluated on further Return presses
- self.selected_index = -1
- else:
- # Force reset selection index
- self.selected_index = -1
- # No item selected, evaluate the expression
- self.evaluate_calculator_expression(text)
- return True
- elif event.keyval == Gdk.KEY_Escape:
- self.close_launcher()
- return True
- return False
- else:
- # Normal app mode behavior
- if event.keyval == Gdk.KEY_Down:
- self.move_selection(1)
- return True
- elif event.keyval == Gdk.KEY_Up:
- self.move_selection(-1)
- return True
- elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and (
- event.state & Gdk.ModifierType.SHIFT_MASK
- ):
- # Only add to dock when in app mode (not calculator mode)
- self.add_selected_app_to_dock()
- return True
- elif event.keyval == Gdk.KEY_Escape:
- self.close_launcher()
- return True
- return False
-
- def notify_text(self, entry, *_):
- """Handle text changes in the search entry"""
- text = entry.get_text()
- if text.startswith("="):
- # In calculator mode, show scrolled window and update viewport
- self.show_scrolled_window()
- self.launcher.dashboard.hide()
- self.update_calculator_viewport()
- # Always reset selection when typing a new expression
- self.selected_index = -1
- else:
- self.arrange_viewport(text)
-
- def add_selected_app_to_dock(self):
- """Adds the currently selected application to the modus.json file with comprehensive metadata."""
- children = self.viewport.get_children()
- if (
- not children
- or self.selected_index == -1
- or self.selected_index >= len(children)
- ):
- return # No app selected
-
- selected_button = children[self.selected_index]
- # Extract the app's display name from the label to find the DesktopApp object
- selected_app = next(
- (
- app
- for app in self._all_apps
- if app.display_name
- == selected_button.get_child().get_children()[1].props.label
- ),
- None,
- )
- if not selected_app:
- return
-
- # Create comprehensive app data dictionary - Include all available properties
- # Filter out None values to keep the JSON clean
- app_data = {
- k: v
- for k, v in {
- "name": selected_app.name,
- "display_name": selected_app.display_name,
- "window_class": selected_app.window_class,
- "executable": selected_app.executable,
- "command_line": selected_app.command_line,
- "icon_name": selected_app.icon_name,
- }.items()
- if v is not None
- }
-
- config_path = get_relative_path("../../../config/assets/modus.json")
- try:
- with open(config_path, "r") as file:
- data = json.load(file)
- except (FileNotFoundError, json.JSONDecodeError):
- data = {"pinned_apps": []}
-
- # Check if app is already pinned (by name)
- already_pinned = False
- for pinned_app in data.get("pinned_apps", []):
- if (
- isinstance(pinned_app, dict)
- and pinned_app.get("name") == app_data["name"]
- ):
- already_pinned = True
- # Update existing entry with latest app data
- pinned_app.update(app_data)
- break
- elif isinstance(pinned_app, str) and pinned_app == app_data["name"]:
- already_pinned = True
- # Replace string format with new comprehensive format
- data["pinned_apps"].remove(pinned_app)
- data["pinned_apps"].append(app_data)
- break
-
- # Add to pinned apps if not already there
- if not already_pinned:
- data.setdefault("pinned_apps", []).append(app_data)
-
- # Write the updated config
- with open(config_path, "w") as file:
- json.dump(data, file, indent=4)
-
- # Notify dock instances of the configuration change immediately
- Dock.notify_config_change()
-
- def move_selection(self, delta: int):
- children = self.viewport.get_children()
- if not children:
- return
- # Allow starting selection from nothing when empty
- if self.selected_index == -1 and delta == 1:
- new_index = 0
- else:
- new_index = self.selected_index + delta
- new_index = max(0, min(new_index, len(children) - 1))
- self.update_selection(new_index)
-
- def save_calc_history(self):
- with open(self.calc_history_path, "w") as f:
- json.dump(self.calc_history, f)
-
- def evaluate_calculator_expression(self, text: str):
- # Add debug print
- print(f"Evaluating calculator expression: {text}")
-
- # Remove the '=' prefix and extra spaces
- expr = text.lstrip("=").strip()
- if not expr:
- return
-
- # Common replacements to make expressions more natural
- replacements = {
- "^": "**", # Power operator
- "×": "*", # Multiplication
- "÷": "/", # Division
- "π": "np.pi", # Pi constant
- "pi": "np.pi", # Pi constant (text)
- "e": "np.e", # Euler's number
- "sin(": "np.sin(", # Sine
- "cos(": "np.cos(", # Cosine
- "tan(": "np.tan(", # Tangent
- "log(": "np.log10(", # Log base 10
- "ln(": "np.log(", # Natural log
- "sqrt(": "np.sqrt(", # Square root
- "abs(": "np.abs(", # Absolute value
- "exp(": "np.exp(", # Exponential
- }
-
- # Apply all replacements
- for old, new in replacements.items():
- expr = expr.replace(old, new)
-
- # Replace factorial: e.g. 5! -> np.factorial(5)
- expr = re.sub(r"(\d+)!", r"np.factorial(\1)", expr)
-
- # Replace brackets: allow [] and {} as ()
- for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]:
- expr = expr.replace(old, new)
-
- # Define the safe execution environment
- safe_dict = {
- "np": np,
- "math": math,
- "arange": np.arange,
- "linspace": np.linspace,
- "array": np.array,
- }
-
- try:
- # Evaluate the expression in the safe environment
- result = eval(expr, {"__builtins__": None}, safe_dict)
-
- # Format the result based on its type
- if isinstance(result, np.ndarray):
- if result.size > 10: # Truncate large arrays
- result_str = f"Array of shape {result.shape}"
- else:
- result_str = str(result)
- elif isinstance(result, (int, float, np.number)):
- # Format numbers nicely - integers as integers, floats with limited precision
- if isinstance(result, (int, np.integer)) or result.is_integer():
- result_str = str(int(result))
- else:
- result_str = f"{float(result):.10g}" # Remove trailing zeros
- else:
- result_str = str(result)
-
- except Exception as e:
- result_str = f"Error: {str(e)}"
-
- # Prepend to history (newest first)
- self.calc_history.insert(0, f"{text} => {result_str}")
- self.save_calc_history()
- self.update_calculator_viewport()
-
- def update_calculator_viewport(self):
- self.viewport.children = []
- for item in self.calc_history:
- btn = self.create_calc_history_button(item)
- self.viewport.add(btn)
- # Don't reset selection index here automatically
- # Ensure selection state stays valid
- if self.selected_index >= len(self.calc_history):
- self.selected_index = -1
-
- def create_calc_history_button(self, text: str) -> Button:
- # Parse the result to create a more readable display
- if "=>" in text:
- parts = text.split("=>")
- expression = parts[0].strip()
- result = parts[1].strip()
-
- # For very long results, truncate for display but keep full in tooltip
- display_text = text
- if len(result) > 50: # Truncate long results
- display_text = f"{expression} => {result[:47]}..."
-
- btn = Button(
- name="slot-button", # reuse existing CSS styling
- child=Box(
- name="calc-slot-box",
- orientation="h",
- spacing=10,
- children=[
- Label(
- name="calc-label",
- label=display_text,
- ellipsization="end",
- v_align="center",
- h_align="center",
- ),
- ],
- ),
- tooltip_text=text,
- on_clicked=lambda *_: self.copy_text_to_clipboard(text),
- )
- else:
- # Fallback for non-calculation entries
- btn = Button(
- name="slot-button",
- child=Box(
- name="calc-slot-box",
- orientation="h",
- spacing=10,
- children=[
- Label(
- name="calc-label",
- label=text,
- ellipsization="end",
- v_align="center",
- h_align="center",
- ),
- ],
- ),
- tooltip_text=text,
- on_clicked=lambda *_: self.copy_text_to_clipboard(text),
- )
- return btn
-
- def copy_text_to_clipboard(self, text: str):
- # Split the text on "=>" and copy only the result part if available
- parts = text.split("=>", 1)
- copy_text = parts[1].strip() if len(parts) > 1 else text
- try:
- subprocess.run(["wl-copy"], input=copy_text.encode(), check=True)
- except subprocess.CalledProcessError as e:
- print(f"Clipboard copy failed: {e}")
-
- def delete_selected_calc_history(self):
- if self.selected_index != -1 and self.selected_index < len(self.calc_history):
- # Store the current index before deletion
- current_index = self.selected_index
-
- # Delete the item
- del self.calc_history[current_index]
- self.save_calc_history()
-
- # Determine the new selection index
- # If we deleted the first item, stay at index 0
- # Otherwise, move to the previous item
- new_index = 0 if current_index == 0 else current_index - 1
-
- # Reset selection before updating viewport
- self.selected_index = -1
-
- # Update the viewport
- self.update_calculator_viewport()
-
- # If we still have items, select the determined index
- if len(self.calc_history) > 0:
- self.update_selection(min(new_index, len(self.calc_history) - 1))
diff --git a/modules/launcher/components/bluetooth.py b/modules/launcher/components/bluetooth.py
deleted file mode 100644
index c3f6930..0000000
--- a/modules/launcher/components/bluetooth.py
+++ /dev/null
@@ -1,170 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.image import Image
-from fabric.widgets.button import Button
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.bluetooth import BluetoothClient, BluetoothDevice
-import utils.icons as icons
-
-
-class BluetoothDeviceSlot(CenterBox):
- def __init__(self, device: BluetoothDevice, **kwargs):
- super().__init__(name="bluetooth-device", **kwargs)
- self.device = device
- self.device.connect("changed", self.on_changed)
- self.device.connect(
- "notify::closed", lambda *_: self.device.closed and self.destroy()
- )
-
- self.connection_label = Label(
- name="bluetooth-connection", markup=icons.bluetooth_disconnected
- )
- self.connect_button = Button(
- name="bluetooth-connect",
- label="Connect",
- on_clicked=lambda *_: self.device.set_connecting(not self.device.connected),
- )
-
- self.start_children = [
- Box(
- spacing=8,
- children=[
- Image(icon_name=device.icon_name + "-symbolic", size=32),
- Label(label=device.name),
- self.connection_label,
- ],
- )
- ]
- self.end_children = self.connect_button
-
- self.device.emit("changed") # to update display status
-
- def on_changed(self, *_):
- self.connection_label.set_markup(
- icons.bluetooth_connected
- if self.device.connected
- else icons.bluetooth_disconnected
- )
- if self.device.connecting:
- self.connect_button.set_label(
- "Connecting..." if not self.device.connecting else "Disconnecting..."
- )
- else:
- self.connect_button.set_label(
- "Connect" if not self.device.connected else "Disconnect"
- )
- return
-
-
-class BluetoothConnections(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="bluetooth",
- spacing=4,
- orientation="vertical",
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
-
- self.buttons = self.launcher.dashboard.bluetooth_button
- self.bt_status_text = self.buttons.bluetooth_status_text
- self.bt_status_button = self.buttons.bluetooth_status_button
- self.bt_icon = self.buttons.bluetooth_icon
- self.bt_label = self.buttons.bluetooth_label
- self.bt_menu_button = self.buttons.bluetooth_menu_button
- self.bt_menu_label = self.buttons.bluetooth_menu_label
-
- self.client = BluetoothClient(on_device_added=self.on_device_added)
- self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar)
- self.scan_button = Button(
- name="bluetooth-scan",
- child=self.scan_label,
- tooltip_text="Scan for Bluetooth devices",
- on_clicked=lambda *_: self.client.toggle_scan(),
- )
- self.back_button = Button(
- name="bluetooth-back",
- child=Label(name="bluetooth-back-label", markup=icons.chevron_left),
- on_clicked=lambda *_: self.launcher.open("launcher"),
- )
-
- self.client.connect("notify::enabled", lambda *_: self.status_label())
- self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
-
- self.paired_box = Box(spacing=2, orientation="vertical")
- self.available_box = Box(spacing=2, orientation="vertical")
-
- # Create a single content container with the required structure:
- # [Paired label] [Paired devices list] [Available label] [Available devices list]
- content_box = Box(spacing=4, orientation="vertical")
- content_box.add(self.paired_box)
- content_box.add(Label(name="bluetooth-section", label="Available"))
- content_box.add(self.available_box)
-
- self.children = [
- CenterBox(
- name="bluetooth-header",
- start_children=self.back_button,
- center_children=Label(name="bluetooth-text", label="Bluetooth Devices"),
- end_children=self.scan_button,
- ),
- ScrolledWindow(
- name="bluetooth-devices",
- min_content_size=(-1, -1),
- child=content_box,
- v_expand=True,
- ),
- ]
-
- # Trigger initial notifications to update status without delay.
- self.client.notify("scanning")
- self.client.notify("enabled")
-
- def status_label(self):
- print(self.client.enabled)
- if self.client.enabled:
- self.bt_status_text.set_label("Enabled")
- for i in [
- self.bt_status_button,
- self.bt_status_text,
- self.bt_icon,
- self.bt_label,
- self.bt_menu_button,
- self.bt_menu_label,
- ]:
- i.remove_style_class("disabled")
- self.bt_icon.set_markup(icons.bluetooth)
- else:
- self.bt_status_text.set_label("Disabled")
- for i in [
- self.bt_status_button,
- self.bt_status_text,
- self.bt_icon,
- self.bt_label,
- self.bt_menu_button,
- self.bt_menu_label,
- ]:
- i.add_style_class("disabled")
- self.bt_icon.set_markup(icons.bluetooth_off)
-
- def on_device_added(self, client: BluetoothClient, address: str):
- if not (device := client.get_device(address)):
- return
- slot = BluetoothDeviceSlot(device)
-
- if device.paired:
- return self.paired_box.add(slot)
- return self.available_box.add(slot)
-
- def update_scan_label(self):
- if self.client.scanning:
- self.scan_label.add_style_class("scanning")
- self.scan_button.add_style_class("scanning")
- self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices")
- else:
- self.scan_label.remove_style_class("scanning")
- self.scan_button.remove_style_class("scanning")
- self.scan_button.set_tooltip_text("Scan for Bluetooth devices")
-
diff --git a/modules/launcher/components/calendar.py b/modules/launcher/components/calendar.py
deleted file mode 100644
index 263e572..0000000
--- a/modules/launcher/components/calendar.py
+++ /dev/null
@@ -1,217 +0,0 @@
-import calendar
-from datetime import datetime
-import gi
-import utils.icons as icons
-from fabric.widgets.label import Label
-from fabric.widgets.box import Box
-from fabric.widgets.centerbox import CenterBox
-
-gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk, GLib
-
-
-class Calendar(Gtk.Box):
- def __init__(self):
- super().__init__(
- orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar"
- )
-
- self.current_year = datetime.now().year
- self.current_month = datetime.now().month
- self.current_day = datetime.now().day
- # Store previous month/year to determine transition direction.
- self.previous_key = (self.current_year, self.current_month)
-
- # Cache threshold in number of months away from the current view.
- self.cache_threshold = 1 # Allow current month +/- 1 month
-
- # Dictionary to store built views for each month.
- self.month_views = {}
-
- self.prev_month_button = Gtk.Button(
- name="prev-month-button",
- child=Label(name="month-button-label", markup=icons.chevron_left),
- )
- self.prev_month_button.connect("clicked", self.on_prev_month_clicked)
-
- self.month_label = Gtk.Label(name="month-label")
-
- self.next_month_button = Gtk.Button(
- name="next-month-button",
- child=Label(name="month-button-label", markup=icons.chevron_right),
- )
- self.next_month_button.connect("clicked", self.on_next_month_clicked)
-
- self.header = CenterBox(
- spacing=4,
- name="header",
- start_children=[self.prev_month_button],
- center_children=[
- self.month_label,
- ],
- end_children=[self.next_month_button],
- )
-
- self.add(self.header)
-
- self.weekday_row = Gtk.Box(spacing=4, name="weekday-row")
- self.pack_start(self.weekday_row, False, False, 0)
-
- # Create a stack to hold month days views.
- self.stack = Gtk.Stack(name="calendar-stack")
- self.stack.set_transition_duration(250)
- self.pack_start(self.stack, True, True, 0)
-
- self.update_header()
- self.update_calendar()
- GLib.timeout_add_seconds(60, self.check_date_change)
-
- def update_header(self):
- # Update header month label and weekday row.
- self.month_label.set_text(
- datetime(self.current_year, self.current_month, 1)
- .strftime("%B %Y")
- .capitalize()
- )
- # Clear existing children from weekday_row.
- for child in self.weekday_row.get_children():
- self.weekday_row.remove(child)
- days = self.get_weekday_initials()
- for day in days:
- label = Gtk.Label(label=day.upper(), name="weekday-label")
- self.weekday_row.pack_start(label, True, True, 0)
- self.weekday_row.show_all()
-
- def update_calendar(self):
- new_key = (self.current_year, self.current_month)
- # Set the transition type based on whether we're moving to a later or earlier month.
- if new_key > self.previous_key:
- self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
- elif new_key < self.previous_key:
- self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
- # Update previous key for next comparison.
- self.previous_key = new_key
-
- # Create the month view if it doesn't exist.
- if new_key not in self.month_views:
- month_view = self.create_month_view(self.current_year, self.current_month)
- self.month_views[new_key] = month_view
- self.stack.add_titled(
- month_view,
- f"{self.current_year}_{self.current_month}",
- f"{self.current_year}_{self.current_month}",
- )
- # Switch the visible child in the stack.
- self.stack.set_visible_child_name(f"{self.current_year}_{self.current_month}")
- self.update_header()
- self.stack.show_all()
-
- # Purge any cached month views that are too far away.
- self.prune_cache()
-
- def prune_cache(self):
- # Compute a numerical value for a (year, month) key.
- def month_index(key):
- year, month = key
- return year * 12 + (month - 1)
-
- current_index = month_index((self.current_year, self.current_month))
- keys_to_remove = []
- for key in self.month_views:
- if abs(month_index(key) - current_index) > self.cache_threshold:
- keys_to_remove.append(key)
- for key in keys_to_remove:
- widget = self.month_views.pop(key)
- self.stack.remove(widget)
-
- def create_month_view(self, year, month):
- grid = Gtk.Grid(
- column_homogeneous=True, row_homogeneous=False, name="calendar-grid"
- )
-
- cal = calendar.Calendar(firstweekday=6)
- month_days = cal.monthdayscalendar(year, month)
-
- prev_month = month - 1 if month > 1 else 12
- next_month = month + 1 if month < 12 else 1
- prev_year = year if month > 1 else year - 1
- next_year = year if month < 12 else year + 1
-
- prev_month_days_count = calendar.monthrange(prev_year, prev_month)[1]
- next_month_day = 1
-
- for row, week in enumerate(month_days):
- for col, day in enumerate(week):
- day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box")
- top_spacer = Gtk.Box(hexpand=True, vexpand=True)
- middle_box = Gtk.Box(hexpand=True, vexpand=True)
- bottom_spacer = Gtk.Box(hexpand=True, vexpand=True)
-
- if day == 0:
- if row == 0:
- day_num = prev_month_days_count - week[:col].count(0)
- label = Gtk.Label(label=str(day_num), name="prev-month-day")
- else:
- label = Gtk.Label(
- label=str(next_month_day), name="next-month-day"
- )
- next_month_day += 1
- else:
- label = Gtk.Label(label=str(day), name="day-label")
- if (
- day == self.current_day
- and month == datetime.now().month
- and year == datetime.now().year
- ):
- label.get_style_context().add_class("current-day")
-
- middle_box.pack_start(
- Gtk.Box(hexpand=True, vexpand=True), True, True, 0
- )
- middle_box.pack_start(label, False, False, 0)
- middle_box.pack_start(
- Gtk.Box(hexpand=True, vexpand=True), True, True, 0
- )
-
- day_box.pack_start(top_spacer, True, True, 0)
- day_box.pack_start(middle_box, True, True, 0)
- day_box.pack_start(bottom_spacer, True, True, 0)
-
- grid.attach(day_box, col, row, 1, 1)
-
- grid.show_all()
- return grid
-
- def get_weekday_initials(self):
- # Returns localized weekday initials.
- return [datetime(2023, 1, i + 1).strftime("%a")[:1] for i in range(7)]
-
- def on_prev_month_clicked(self, widget):
- if self.current_month == 1:
- self.current_month = 12
- self.current_year -= 1
- else:
- self.current_month -= 1
- self.update_calendar()
-
- def on_next_month_clicked(self, widget):
- if self.current_month == 12:
- self.current_month = 1
- self.current_year += 1
- else:
- self.current_month += 1
- self.update_calendar()
-
- def check_date_change(self):
- now = datetime.now()
- if (
- now.day != self.current_day
- or now.month != self.current_month
- or now.year != self.current_year
- ):
- self.current_day = now.day
- self.current_month = now.month
- self.current_year = now.year
- self.update_calendar()
- return True
-
diff --git a/modules/launcher/components/cliphist.py b/modules/launcher/components/cliphist.py
deleted file mode 100644
index 5747747..0000000
--- a/modules/launcher/components/cliphist.py
+++ /dev/null
@@ -1,504 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.entry import Entry
-from fabric.widgets.image import Image
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.utils import idle_add, remove_handler
-from fabric.utils.helpers import get_relative_path
-from gi.repository import GLib, Gdk, GdkPixbuf
-import subprocess
-import os
-import re
-import sys
-import tempfile
-
-import utils.icons as icons
-
-
-class ClipHistory(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="clip-history",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- # Create a temporary directory for image icons
- self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
- self.image_cache = {} # Cache for image previews
-
- self.launcher = kwargs["launcher"]
- self.selected_index = -1 # Track the selected item index
- self._arranger_handler = 0
- self.clipboard_items = []
- self._loading = False
- self._pending_updates = False
-
- self.viewport = Box(name="viewport", spacing=4, orientation="v")
- self.search_entry = Entry(
- name="search-entry",
- placeholder="Search Clipboard History...",
- h_expand=True,
- notify_text=self.filter_items,
- on_activate=lambda entry, *_: self.use_selected_item(),
- on_key_press_event=self.on_search_entry_key_press,
- )
- self.search_entry.props.xalign = 0.5
-
- self.scrolled_window = ScrolledWindow(
- name="scrolled-window",
- spacing=10,
- min_content_size=(450, 105),
- max_content_size=(450, 105),
- child=self.viewport,
- )
-
- self.header_box = Box(
- name="header_box",
- spacing=10,
- orientation="h",
- children=[
- Button(
- name="clear-button",
- child=Label(name="clear-label", markup=icons.trash),
- on_clicked=lambda *_: self.clear_history(),
- ),
- self.search_entry,
- ],
- )
-
- self.history_box = Box(
- name="launcher-box",
- spacing=10,
- h_expand=True,
- orientation="v",
- children=[
- self.header_box,
- self.scrolled_window,
- ],
- )
-
- self.add(self.history_box)
- self.show_all()
-
- def close(self):
- """Close the clipboard history panel"""
- self.viewport.children = []
- self.selected_index = -1 # Reset selection
- self.launcher.close()
-
- def open(self):
- """Open the clipboard history panel and load items"""
- self.viewport.set_visible(True)
- if self._loading:
- return
- self._loading = True
- self.search_entry.set_text("") # Clear search
- self.search_entry.grab_focus()
- # Start loading in background using GLib.idle_add
- GLib.idle_add(self._load_clipboard_items_thread)
-
- def _load_clipboard_items_thread(self):
- """Worker for loading clipboard items, now runs in main loop via idle_add"""
- try:
- result = subprocess.run(
- ["cliphist", "list"], capture_output=True, text=True, check=True
- )
- lines = result.stdout.strip().split("\n")
- new_items = []
- for line in lines:
- if not line or " 1 else "0"
- content = parts[1] if len(parts) > 1 else item
-
- # Truncate content for display
- display_text = content.strip()
- if len(display_text) > 100:
- display_text = display_text[:97] + "..."
-
- # Check if this is an image by examining the content
- is_image = self.is_image_data(content)
-
- if is_image:
- # For images, create item with image preview
- button = Button(
- name="slot-button",
- child=Box(
- name="slot-box",
- orientation="h",
- spacing=10,
- children=[
- Image(name="clip-icon", h_align="start"), # Placeholder
- Label(
- name="clip-label",
- label="[Image]",
- ellipsization="end",
- v_align="center",
- h_align="start",
- h_expand=True,
- ),
- ],
- ),
- tooltip_text="Image in clipboard",
- on_clicked=lambda *_, id=item_id: self.paste_item(id),
- )
- # Load image preview in background
- self._load_image_preview_async(item_id, button)
- else:
- # For text, create regular item
- button = self.create_text_item_button(item_id, display_text)
-
- # Add key press event handler for Enter key
- button.connect(
- "key-press-event",
- lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id),
- )
-
- # Make sure button can receive focus and key events
- button.set_can_focus(True)
- button.add_events(Gdk.EventMask.KEY_PRESS_MASK)
-
- return button
-
- def _load_image_preview_async(self, item_id, button):
- """Load image preview using GLib.idle_add instead of threads"""
-
- def load_image():
- try:
- if item_id in self.image_cache:
- pixbuf = self.image_cache[item_id]
- else:
- result = subprocess.run(
- ["cliphist", "decode", item_id], capture_output=True, check=True
- )
- loader = GdkPixbuf.PixbufLoader()
- loader.write(result.stdout)
- loader.close()
- pixbuf = loader.get_pixbuf()
- width, height = pixbuf.get_width(), pixbuf.get_height()
- max_size = 72
- 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))
- pixbuf = pixbuf.scale_simple(
- new_width, new_height, GdkPixbuf.InterpType.BILINEAR
- )
- self.image_cache[item_id] = pixbuf
- self._update_image_button(button, pixbuf)
- except Exception as e:
- print(f"Error loading image preview: {e}", file=sys.stderr)
- return False
-
- GLib.idle_add(load_image)
-
- def _update_image_button(self, button, pixbuf):
- """Update the button with the loaded image preview"""
- box = button.get_child()
- if box and len(box.get_children()) > 0:
- image_widget = box.get_children()[0]
- if isinstance(image_widget, Image):
- image_widget.set_from_pixbuf(pixbuf)
-
- def create_text_item_button(self, item_id, display_text):
- """Create a button for a text clipboard item"""
- return Button(
- name="slot-button",
- child=Box(
- name="slot-box",
- orientation="h",
- spacing=10,
- children=[
- Label(
- name="clip-icon",
- markup=icons.clip_text,
- h_align="start",
- ),
- Label(
- name="clip-label",
- label=display_text,
- ellipsization="end",
- v_align="center",
- h_align="start",
- h_expand=True,
- ),
- ],
- ),
- tooltip_text=display_text,
- on_clicked=lambda *_: self.paste_item(item_id),
- )
-
- def is_image_data(self, content):
- """Determine if clipboard content is likely an image"""
- # Check for common image data patterns
- return (
- content.startswith("data:image/")
- or content.startswith("\x89PNG")
- or content.startswith("GIF8")
- or content.startswith("\xff\xd8\xff") # JPEG
- or re.match(r"^\s* visible_bottom:
- # Item below viewport - align to bottom
- new_value = y + height - page_size
- adj.set_value(new_value)
- return False
-
- GLib.idle_add(scroll)
-
- def use_selected_item(self):
- """Use (paste) the selected clipboard item"""
- children = self.viewport.get_children()
- if (
- not children
- or self.selected_index == -1
- or self.selected_index >= len(self.clipboard_items)
- ):
- return
-
- # Get the item ID from the first part before the tab
- item_line = self.clipboard_items[self.selected_index]
- item_id = item_line.split("\t", 1)[0]
- self.paste_item(item_id)
-
- def delete_selected_item(self):
- """Delete the selected clipboard item"""
- children = self.viewport.get_children()
- if not children or self.selected_index == -1:
- return
-
- # Get the item ID from the first part before the tab
- item_line = self.clipboard_items[self.selected_index]
- item_id = item_line.split("\t", 1)[0]
- self.delete_item(item_id)
-
- def on_item_key_press(self, widget, event, item_id):
- """Handle key press events on clipboard items"""
- if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
- # Copy item to clipboard and close
- self.paste_item(item_id)
- return True
- return False
-
- def __del__(self):
- """Clean up temporary files on destruction"""
- try:
- if hasattr(self, "tmp_dir") and os.path.exists(self.tmp_dir):
- import shutil
-
- shutil.rmtree(self.tmp_dir)
- self.image_cache.clear()
- except Exception as e:
- print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
diff --git a/modules/launcher/components/dashboard.py b/modules/launcher/components/dashboard.py
deleted file mode 100644
index 49b8ad8..0000000
--- a/modules/launcher/components/dashboard.py
+++ /dev/null
@@ -1,605 +0,0 @@
-import subprocess
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.utils.helpers import exec_shell_command_async
-import gi
-from gi.repository import Gtk, Gdk, GLib
-from services import network_client
-from services.powerprofile import PowerProfiles
-from fabric import Fabricator
-
-gi.require_version("Gtk", "3.0")
-import utils.icons as icons
-
-
-def add_hover_cursor(widget):
- # Add enter/leave events to change the cursor
- widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
- widget.connect(
- "enter-notify-event",
- lambda w, e: w.get_window().set_cursor(
- Gdk.Cursor.new_from_name(w.get_display(), "pointer")
- )
- if w.get_window()
- else None,
- )
- widget.connect(
- "leave-notify-event",
- lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None,
- )
-
-
-class NetworkButton(Box):
- def __init__(self):
- self.network_client = network_client
- self._animation_timeout_id = None
- self._animation_step = 0
- self._animation_direction = 1
-
- self.network_icon = Label(
- name="network-icon",
- markup=None,
- )
- self.network_label = Label(
- name="network-label",
- label="Wi-Fi",
- justification="left",
- )
- self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)])
- self.network_ssid = Label(
- name="network-ssid",
- justification="left",
- )
- self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)])
- self.network_text = Box(
- name="network-text",
- orientation="v",
- h_align="start",
- v_align="center",
- children=[self.network_label_box, self.network_ssid_box],
- )
- self.network_status_box = Box(
- h_align="start",
- v_align="center",
- spacing=10,
- children=[self.network_icon, self.network_text],
- )
- self.network_status_button = Button(
- name="network-status-button",
- h_expand=True,
- child=self.network_status_box,
- on_clicked=lambda *_: self.network_client.wifi_device.toggle_wifi()
- if self.network_client.wifi_device
- else None,
- )
- add_hover_cursor(self.network_status_button) # <-- Added hover
-
- self.network_menu_label = Label(
- name="network-menu-label",
- markup=icons.chevron_right,
- )
- self.network_menu_button = Button(
- name="network-menu-button",
- child=self.network_menu_label,
- )
- add_hover_cursor(self.network_menu_button) # <-- Added hover
-
- super().__init__(
- name="network-button",
- orientation="h",
- h_align="fill",
- v_align="fill",
- h_expand=True,
- v_expand=True,
- spacing=0,
- children=[self.network_status_button, self.network_menu_button],
- )
-
- self.widgets = [
- self,
- self.network_icon,
- self.network_label,
- self.network_ssid,
- self.network_status_button,
- self.network_menu_button,
- self.network_menu_label,
- ]
-
- # Connect to wifi device signals when ready
- self.network_client.connect("device-ready", self._on_wifi_ready)
-
- # Check initial state using idle_add to defer until GTK loop is running
- GLib.idle_add(self._initial_update)
-
- def _initial_update(self):
- self.update_state()
- return False # Run only once
-
- def _on_wifi_ready(self, *args):
- if self.network_client.wifi_device:
- self.network_client.wifi_device.connect(
- "notify::enabled", self.update_state
- )
- self.network_client.wifi_device.connect("notify::ssid", self.update_state)
- self.update_state()
-
- def _animate_searching(self):
- """Animate wifi icon when searching for networks"""
- wifi_icons = [
- icons.wifi_0,
- icons.wifi_1,
- icons.wifi_2,
- icons.wifi_3,
- icons.wifi_2,
- icons.wifi_1,
- ]
-
- # Si el widget no existe o el WiFi está desactivado, detener la animación
- wifi = self.network_client.wifi_device
- if not self.network_icon or not wifi or not wifi.enabled:
- self._stop_animation()
- return False
-
- # Si estamos conectados, detener la animación
- if wifi.state == "activated" and wifi.ssid != "Disconnected":
- self._stop_animation()
- return False
-
- GLib.idle_add(self.network_icon.set_markup, wifi_icons[self._animation_step])
-
- # Reiniciar al principio cuando llegamos al final
- self._animation_step = (self._animation_step + 1) % len(wifi_icons)
-
- return True # Mantener la animación activa
-
- def _start_animation(self):
- if self._animation_timeout_id is None:
- self._animation_step = 0
- self._animation_direction = 1
- # Ejecuta la animación cada 500ms sin usar idle_add
- self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching)
-
- def _stop_animation(self):
- if self._animation_timeout_id is not None:
- GLib.source_remove(self._animation_timeout_id)
- self._animation_timeout_id = None
-
- def update_state(self, *args):
- """Update the button state based on network status"""
- wifi = self.network_client.wifi_device
- ethernet = self.network_client.ethernet_device
-
- # Update enabled/disabled state
- if wifi and not wifi.enabled:
- self._stop_animation()
- self.network_icon.set_markup(icons.wifi_off)
- for widget in self.widgets:
- widget.add_style_class("disabled")
- self.network_ssid.set_label("Disabled")
- return
-
- # Remove disabled class if we got here
- for widget in self.widgets:
- widget.remove_style_class("disabled")
-
- # Update text and animation based on state
- if wifi and wifi.enabled:
- if wifi.state == "activated" and wifi.ssid != "Disconnected":
- self._stop_animation()
- self.network_ssid.set_label(wifi.ssid)
- # Update icon based on signal strength
- if wifi.strength > 0:
- strength = wifi.strength
- if strength < 25:
- self.network_icon.set_markup(icons.wifi_0)
- elif strength < 50:
- self.network_icon.set_markup(icons.wifi_1)
- elif strength < 75:
- self.network_icon.set_markup(icons.wifi_2)
- else:
- self.network_icon.set_markup(icons.wifi_3)
- else:
- self.network_ssid.set_label("Enabled")
- self._start_animation()
-
- # Handle primary device check safely
- try:
- primary_device = self.network_client.primary_device
- except AttributeError:
- primary_device = "wireless" # Default to wireless if error occurs
-
- # Handle wired connection case
- if primary_device == "wired":
- self._stop_animation()
- if ethernet and ethernet.internet == "activated":
- self.network_icon.set_markup(icons.world)
- else:
- self.network_icon.set_markup(icons.world_off)
- else:
- if not wifi:
- self._stop_animation()
- self.network_icon.set_markup(icons.wifi_off)
- elif (
- wifi.state == "activated"
- and wifi.ssid != "Disconnected"
- and wifi.strength > 0
- ):
- self._stop_animation()
- strength = wifi.strength
- if strength < 25:
- self.network_icon.set_markup(icons.wifi_0)
- elif strength < 50:
- self.network_icon.set_markup(icons.wifi_1)
- elif strength < 75:
- self.network_icon.set_markup(icons.wifi_2)
- else:
- self.network_icon.set_markup(icons.wifi_3)
- else:
- self._start_animation()
-
-
-class BluetoothButton(Box):
- def __init__(self, launcher):
- super().__init__(
- name="bluetooth-button",
- orientation="h",
- h_align="fill",
- v_align="fill",
- h_expand=True,
- v_expand=True,
- )
- self.launcher = launcher
-
- self.bluetooth_icon = Label(
- name="bluetooth-icon",
- markup=icons.bluetooth,
- )
- self.bluetooth_label = Label(
- name="bluetooth-label",
- label="Bluetooth",
- justification="left",
- )
- self.bluetooth_label_box = Box(
- children=[self.bluetooth_label, Box(h_expand=True)]
- )
- self.bluetooth_status_text = Label(
- name="bluetooth-status",
- label="Disabled",
- justification="left",
- )
- self.bluetooth_status_box = Box(
- children=[self.bluetooth_status_text, Box(h_expand=True)]
- )
- self.bluetooth_text = Box(
- orientation="v",
- h_align="start",
- v_align="center",
- children=[self.bluetooth_label_box, self.bluetooth_status_box],
- )
- self.bluetooth_status_container = Box(
- h_align="start",
- v_align="center",
- spacing=10,
- children=[self.bluetooth_icon, self.bluetooth_text],
- )
- self.bluetooth_status_button = Button(
- name="bluetooth-status-button",
- h_expand=True,
- child=self.bluetooth_status_container,
- on_clicked=lambda *_: self.launcher.bluetooth.client.toggle_power(),
- )
- add_hover_cursor(self.bluetooth_status_button) # <-- Added hover
- self.bluetooth_menu_label = Label(
- name="bluetooth-menu-label",
- markup=icons.chevron_right,
- )
- self.bluetooth_menu_button = Button(
- name="bluetooth-menu-button",
- on_clicked=lambda *_: self.launcher.open("bluetooth"),
- child=self.bluetooth_menu_label,
- )
- add_hover_cursor(self.bluetooth_menu_button) # <-- Added hover
-
- self.add(self.bluetooth_status_button)
- self.add(self.bluetooth_menu_button)
-
-
-class NightModeButton(Button):
- def __init__(self):
- self.night_mode_icon = Label(
- name="night-mode-icon",
- markup=icons.night,
- )
- self.night_mode_label = Label(
- name="night-mode-label",
- label="Night Mode",
- justification="left",
- )
- self.night_mode_label_box = Box(
- children=[self.night_mode_label, Box(h_expand=True)]
- )
- self.night_mode_status = Label(
- name="night-mode-status",
- label="Enabled",
- justification="left",
- )
- self.night_mode_status_box = Box(
- children=[self.night_mode_status, Box(h_expand=True)]
- )
- self.night_mode_text = Box(
- name="night-mode-text",
- orientation="v",
- h_align="start",
- v_align="center",
- children=[self.night_mode_label_box, self.night_mode_status_box],
- )
- self.night_mode_box = Box(
- h_align="start",
- v_align="center",
- spacing=10,
- children=[self.night_mode_icon, self.night_mode_text],
- )
-
- super().__init__(
- name="night-mode-button",
- h_expand=True,
- child=self.night_mode_box,
- on_clicked=self.toggle_hyprsunset,
- )
- add_hover_cursor(self) # <-- Added hover
-
- self.widgets = [
- self,
- self.night_mode_label,
- self.night_mode_status,
- self.night_mode_icon,
- ]
- # self.check_hyprsunset()
- # **Monitor external changes every 3 seconds**
- self.hyprsunset_watcher = Fabricator(
- self.is_hyprsunset_running,
- interval=3000,
- stream=False,
- default_value=False,
- )
- self.hyprsunset_watcher.changed.connect(self.update_night_mode_status)
- self.update_night_mode_status(None, self.is_hyprsunset_running())
-
- def toggle_hyprsunset(self, *args):
- """
- Toggle the 'hyprsunset' process:
- - If running, kill it and mark as 'Disabled'.
- - If not running, start it and mark as 'Enabled'.
- """
- try:
- subprocess.check_output(["pgrep", "hyprsunset"])
- exec_shell_command_async("pkill hyprsunset")
- self.night_mode_status.set_label("Disabled")
- for widget in self.widgets:
- widget.add_style_class("disabled")
- except subprocess.CalledProcessError:
- exec_shell_command_async("hyprsunset -t 3500")
- self.night_mode_status.set_label("Enabled")
- for widget in self.widgets:
- widget.remove_style_class("disabled")
-
- def is_hyprsunset_running(self, *args):
- """Returns True if hyprsunset is running."""
- try:
- subprocess.check_output(["pgrep", "hyprsunset"])
- return True
- except subprocess.CalledProcessError:
- return False
-
- def update_night_mode_status(self, _, is_running):
- """Update UI based on hyprsunset status."""
- if is_running:
- self.night_mode_status.set_label("Enabled")
- for widget in self.widgets:
- widget.remove_style_class("disabled")
- else:
- self.night_mode_status.set_label("Disabled")
- for widget in self.widgets:
- widget.add_style_class("disabled")
-
-
-class CaffeineButton(Button):
- def __init__(self):
- self.caffeine_icon = Label(
- name="caffeine-icon",
- markup=icons.coffee,
- )
- self.caffeine_label = Label(
- name="caffeine-label",
- label="Caffeine",
- justification="left",
- )
- self.caffeine_label_box = Box(
- children=[self.caffeine_label, Box(h_expand=True)]
- )
- self.caffeine_status = Label(
- name="caffeine-status",
- label="Enabled",
- justification="left",
- )
- self.caffeine_status_box = Box(
- children=[self.caffeine_status, Box(h_expand=True)]
- )
- self.caffeine_text = Box(
- name="caffeine-text",
- orientation="v",
- h_align="start",
- v_align="center",
- children=[self.caffeine_label_box, self.caffeine_status_box],
- )
- self.caffeine_box = Box(
- h_align="start",
- v_align="center",
- spacing=10,
- children=[self.caffeine_icon, self.caffeine_text],
- )
- super().__init__(
- name="caffeine-button",
- h_expand=True,
- child=self.caffeine_box,
- on_clicked=self.toggle_wlinhibit,
- )
- add_hover_cursor(self) # <-- Added hover
-
- self.widgets = [
- self,
- self.caffeine_label,
- self.caffeine_status,
- self.caffeine_icon,
- ]
- # **Monitor external changes every 3 seconds**
- self.wlinhibit_watcher = Fabricator(
- self.is_wlinhibit_running,
- interval=3000,
- stream=False,
- default_value=False,
- )
- self.wlinhibit_watcher.changed.connect(self.update_wlinhibit_status)
- self.update_wlinhibit_status(None, self.is_wlinhibit_running())
-
- def toggle_wlinhibit(self, *args):
- """Toggle the 'wlinhibit' process."""
- if self.is_wlinhibit_running():
- exec_shell_command_async("pkill wlinhibit")
- else:
- exec_shell_command_async("wlinhibit")
-
- def is_wlinhibit_running(self, *args):
- """Returns True if wlinhibit is running. Accepts extra arguments to avoid Fabricator errors."""
- try:
- subprocess.check_output(["pgrep", "wlinhibit"])
- return True
- except subprocess.CalledProcessError:
- return False
-
- def update_wlinhibit_status(self, _, is_running):
- """Update UI based on wlinhibit status."""
- if is_running:
- self.caffeine_status.set_label("Enabled")
- for widget in self.widgets:
- widget.remove_style_class("disabled")
- else:
- self.caffeine_status.set_label("Disabled")
- for widget in self.widgets:
- widget.add_style_class("disabled")
-
-
-class PowerProfileButton(Button):
- def __init__(self):
- self.power_profile_service = PowerProfiles.get_default()
-
- self.power_profile_icon = Label(
- name="power-profile-icon",
- markup=icons.power_balanced, # Default icon
- )
- self.power_profile_label = Label(
- name="power-profile-label",
- label="Power Profile",
- justification="left",
- )
- self.power_profile_label_box = Box(
- children=[self.power_profile_label, Box(h_expand=True)]
- )
- self.power_profile_status = Label(
- name="power-profile-status",
- label="Balanced",
- justification="left",
- )
- self.power_profile_status_box = Box(
- children=[self.power_profile_status, Box(h_expand=True)]
- )
- self.power_profile_text = Box(
- name="power-profile-text",
- orientation="v",
- h_align="start",
- v_align="center",
- children=[self.power_profile_label_box, self.power_profile_status_box],
- )
- self.power_profile_box = Box(
- h_align="start",
- v_align="center",
- spacing=10,
- children=[self.power_profile_icon, self.power_profile_text],
- )
-
- super().__init__(
- name="power-profile-button",
- h_expand=True,
- child=self.power_profile_box,
- on_clicked=self.toggle_power_profile,
- )
- add_hover_cursor(self)
-
- self.widgets = [
- self,
- self.power_profile_label,
- self.power_profile_status,
- self.power_profile_icon,
- ]
-
- # Connect to power profile service signals
- self.power_profile_service.connect("profile", self.update_power_profile_status)
-
- # Initial update
- self.update_power_profile_status(None, self.power_profile_service.get_current_profile())
-
- def toggle_power_profile(self, *args):
- current_profile = self.power_profile_service.get_current_profile()
- profiles = ["power-saver", "balanced", "performance"]
- current_index = profiles.index(current_profile)
- next_profile = profiles[(current_index + 1) % len(profiles)]
- self.power_profile_service.set_power_profile(next_profile)
-
- def update_power_profile_status(self, _, profile):
- # Map profiles to their display names and icons
- profile_map = {
- "power-saver": {"name": "Power Saver", "icon": icons.power_saving},
- "balanced": {"name": "Balanced", "icon": icons.power_balanced},
- "performance": {"name": "Performance", "icon": icons.power_performance},
- }
-
- profile_info = profile_map.get(profile, profile_map["balanced"])
- self.power_profile_status.set_label(profile_info["name"])
- self.power_profile_icon.set_markup(profile_info["icon"])
-
- if profile == "balanced":
- for widget in self.widgets:
- widget.add_style_class("disabled")
- else:
- for widget in self.widgets:
- widget.remove_style_class("disabled")
-
-
-
-class Dashboard(Gtk.Grid):
- def __init__(self, **kwargs):
- super().__init__(name="buttons-grid")
- self.set_row_homogeneous(True)
- self.set_column_homogeneous(True)
- self.set_row_spacing(8)
- self.set_column_spacing(8)
- self.set_vexpand(True) # Prevent vertical expansion
-
- self.launcher = kwargs["launcher"]
-
- # Instantiate each button
- self.network_button = NetworkButton()
- self.bluetooth_button = BluetoothButton(self.launcher)
- self.night_mode_button = NightModeButton()
- self.caffeine_button = CaffeineButton()
- self.power_profile_button = PowerProfileButton()
-
- # Attach buttons into the grid (two rows, three columns)
- self.attach(self.network_button, 0, 0, 1, 1)
- self.attach(self.bluetooth_button, 1, 0, 1, 1)
- self.attach(self.power_profile_button, 2, 0, 1, 1)
- self.attach(self.night_mode_button, 0, 1, 1, 1)
- self.attach(self.caffeine_button, 1, 1, 1, 1)
-
- self.show_all()
diff --git a/modules/launcher/components/emoji.py b/modules/launcher/components/emoji.py
deleted file mode 100644
index b69355e..0000000
--- a/modules/launcher/components/emoji.py
+++ /dev/null
@@ -1,330 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.entry import Entry
-from fabric.widgets.stack import Stack
-from fabric.utils import remove_handler, get_relative_path
-from gi.repository import Gdk
-import utils.icons as icons
-import os
-import subprocess
-import ijson
-
-
-class EmojiPicker(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="emoji",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
- self.selected_index = -1
- self.emojis_per_page = 9 * 3
- self.current_page_index = 0
- self.filtered_emojis = []
- self.total_pages = 0
-
- self._arranger_handler: int = 0
- self._all_emojis = self._load_emoji_data()
-
- self.stack = Stack(
- name="viewport",
- spacing=4,
- orientation="v",
- transition_type="slide-up-down",
- transition_duration=200,
- )
- self.search_entry = Entry(
- name="search-entry",
- placeholder="Search Emojis...",
- h_expand=True,
- notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
- on_activate=lambda entry, *_: self.on_search_entry_activate(
- entry.get_text()
- ),
- on_key_press_event=self.on_search_entry_key_press,
- )
- self.search_entry.props.xalign = 0.5
- self.header_box = Box(
- name="header_box",
- spacing=10,
- orientation="h",
- children=[
- self.search_entry,
- # Button(
- # name="close-button",
- # child=Label(name="close-label", markup=icons.cancel),
- # tooltip_text="Exit",
- # on_clicked=lambda *_: self.close_picker(),
- # ),
- ],
- )
-
- self.picker_box = Box(
- name="picker-box",
- spacing=10,
- h_expand=True,
- orientation="v",
- children=[
- self.header_box,
- self.stack,
- ],
- )
-
- self.resize_viewport()
-
- self.add(self.picker_box)
- self.show_all()
-
- def _load_emoji_data(self):
- emoji_data = {}
- emoji_file_path = get_relative_path("../../../config/assets/emoji.json")
- if not os.path.exists(emoji_file_path):
- print(f"Emoji JSON file not found at: {emoji_file_path}")
- return {}
-
- with open(emoji_file_path, "r") as f:
- for emoji_char, emoji_info in ijson.kvitems(f, ""):
- emoji_data[emoji_char] = emoji_info
- return emoji_data
-
- def close_picker(self):
- self.stack.children = []
- self.selected_index = -1
- self.launcher.close()
-
- def open_picker(self):
- self.search_entry.set_text("")
- self.current_page_index = 0
- self.arrange_viewport()
- self.search_entry.grab_focus()
-
- def arrange_viewport(self, query: str = ""):
- remove_handler(self._arranger_handler) if self._arranger_handler else None
- self.stack.children = []
- self.selected_index = -1
- self.current_page_index = 0
-
- self.filtered_emojis = [
- (emoji_char, emoji_info)
- for emoji_char, emoji_info in self._all_emojis.items()
- if query.casefold()
- in (
- emoji_info.get("name", "") + " " + emoji_info.get("group", "")
- ).casefold()
- ]
- self.total_pages = (
- (len(self.filtered_emojis) + self.emojis_per_page - 1)
- // self.emojis_per_page
- if self.filtered_emojis
- else 0
- )
-
- self.load_page(self.current_page_index)
-
- should_resize = not query
-
- if should_resize:
- self.resize_viewport()
- if query.strip() != "" and self.get_all_emoji_buttons():
- self.update_selection(0)
-
- def load_page(self, page_index):
- self.update_selection(-1)
- page_box = Box(name=f"page-box-{page_index}", orientation="v", spacing=4)
- start_index = page_index * self.emojis_per_page
- end_index = min(
- (page_index + 1) * self.emojis_per_page, len(self.filtered_emojis)
- )
- page_emojis = self.filtered_emojis[start_index:end_index]
-
- grid_box = Box(name="emoji-grid-box", orientation="v", spacing=2)
-
- row_box = None
- for i, (emoji_char, emoji_info) in enumerate(page_emojis):
- if i % 9 == 0:
- row_box = Box(name="emoji-row-box", orientation="h", spacing=2)
- grid_box.add(row_box)
- if row_box is not None:
- row_box.add(self.bake_emoji_slot(emoji_char, emoji_info))
- page_box.add(grid_box)
- self.stack.add_named(page_box, f"page-{page_index}")
- self.stack.set_visible_child_name(f"page-{page_index}")
- page_box.show_all()
-
- buttons = self.get_all_emoji_buttons()
- if buttons and self.selected_index != -1:
- page_relative_index = self.selected_index % self.emojis_per_page
- if page_relative_index < len(buttons):
- self.update_selection(page_relative_index)
- else:
- self.update_selection(len(buttons) - 1)
-
- def resize_viewport(self):
- return False
-
- def bake_emoji_slot(self, emoji_char: str, emoji_info: dict, **kwargs) -> Button:
- button = Button(
- name="emoji-slot-button",
- child=Box(
- name="emoji-slot-box",
- orientation="horizontal",
- halign="center",
- valign="center",
- children=[
- Label(
- name="emoji-char-label",
- label=emoji_char,
- use_markup=True,
- v_align="center",
- h_align="center",
- css_name="emoji-char-label",
- ),
- ],
- ),
- tooltip_text=emoji_info.get("name", "Unknown"),
- on_clicked=lambda *_: (
- self.copy_emoji_to_clipboard(emoji_char),
- self.close_picker(),
- ),
- **kwargs,
- )
- return button
-
- def update_selection(self, new_index: int):
- buttons = self.get_all_emoji_buttons()
- if not buttons:
- self.selected_index = -1
- return
-
- if self.selected_index != -1 and self.selected_index < len(buttons):
- current_button = buttons[self.selected_index]
- current_button.get_style_context().remove_class("selected")
- if not buttons or current_button not in buttons:
- self.selected_index = -1
-
- if 0 <= new_index < len(buttons):
- new_button = buttons[new_index]
- new_button.get_style_context().add_class("selected")
- self.selected_index = new_index
- else:
- self.selected_index = -1
-
- def get_all_emoji_buttons(self):
- buttons = []
- current_page = self.stack.get_visible_child()
- if current_page and current_page.get_children():
- if current_page.get_children()[0].get_children():
- for row_box in current_page.get_children()[0].get_children():
- buttons.extend(row_box.get_children())
- return buttons
-
- def on_search_entry_activate(self, text):
- buttons = self.get_all_emoji_buttons()
- if buttons:
- if self.selected_index != -1:
- buttons[self.selected_index].clicked()
- elif buttons and text.strip() != "":
- buttons[0].clicked()
-
- def on_search_entry_key_press(self, widget, event):
- if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
- self.move_selection_2d(event.keyval)
- return True
- elif event.keyval == Gdk.KEY_Escape:
- self.close_picker()
- return True
- return False
-
- def move_selection_2d(self, keyval):
- buttons = self.get_all_emoji_buttons()
- total_items_current_page = len(buttons)
- if total_items_current_page == 0:
- return
-
- rows = 3
- columns = 9
-
- if self.selected_index == -1:
- if keyval in (Gdk.KEY_Down, Gdk.KEY_Right):
- new_index = 0
- elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left):
- new_index = total_items_current_page - 1
- else:
- return
- else:
- current_index_page = self.selected_index
- row = current_index_page // columns
- col = current_index_page % columns
-
- if keyval == Gdk.KEY_Right:
- new_col = (col + 1) % columns
- new_row = row
- if new_col == 0:
- new_row = row + 1
- elif keyval == Gdk.KEY_Left:
- new_col = (col - 1) % columns
- new_row = row
- if new_col == (columns - 1):
- new_row = row - 1
- elif keyval == Gdk.KEY_Down:
- new_row = row + 1
- new_col = col
- elif keyval == Gdk.KEY_Up:
- new_row = row - 1
- new_col = col
- else:
- return
-
- if new_row >= rows:
- if self.current_page_index < self.total_pages - 1:
- current_col = col # Keep track of current column
- self.current_page_index += 1
- self.load_page(self.current_page_index)
- new_index = current_col # Try to keep the same column
- if (
- new_index >= total_items_current_page
- ): # if column is out of bound, select last
- new_index = total_items_current_page - 1
- self.selected_index = -1
- self.update_selection(new_index)
- return
- else:
- new_index = total_items_current_page - 1
- elif new_row < 0:
- if self.current_page_index > 0:
- current_col = col # Keep track of current column
- self.current_page_index -= 1
- self.load_page(self.current_page_index)
- new_index = (
- rows - 1
- ) * columns + current_col # Select last row, same column
- if (
- new_index >= total_items_current_page
- ): # if column is out of bound, select last
- new_index = total_items_current_page - 1
- self.selected_index = -1
- self.update_selection(new_index)
- return
- else:
- new_index = 0
- else:
- new_index = new_row * columns + new_col
- if new_index >= total_items_current_page:
- new_index = total_items_current_page - 1
-
- if new_index < 0:
- new_index = 0
- elif new_index >= total_items_current_page:
- new_index = total_items_current_page - 1
-
- self.update_selection(new_index)
-
- def copy_emoji_to_clipboard(self, emoji_char: str):
- try:
- subprocess.run(["wl-copy"], input=emoji_char.encode("utf-8"), check=True)
- except subprocess.CalledProcessError as e:
- print(f"Clipboard copy failed: {e}")
diff --git a/modules/launcher/components/kanban.py b/modules/launcher/components/kanban.py
deleted file mode 100644
index 8c2bf8a..0000000
--- a/modules/launcher/components/kanban.py
+++ /dev/null
@@ -1,365 +0,0 @@
-import gi
-import json
-import os
-from pathlib import Path
-import cairo # For rendering the drag preview
-
-from fabric.widgets.label import Label
-from fabric.widgets.box import Box
-from fabric.widgets.centerbox import CenterBox
-import utils.icons as icons
-
-gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk, Gdk, GObject, GLib
-
-
-def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
- alloc = widget.get_allocation()
- surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
- cr = cairo.Context(surface)
- # Use a transparent background.
- cr.set_source_rgba(0, 0, 0, 0)
- cr.rectangle(0, 0, alloc.width, alloc.height)
- cr.fill()
- widget.draw(cr)
- return surface
-
-
-class InlineEditor(Gtk.Box):
- __gsignals__ = {
- "confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
- "canceled": (GObject.SignalFlags.RUN_LAST, None, ()),
- }
-
- def __init__(self, initial_text=""):
- super().__init__(name="inline-editor", spacing=4)
- # Replace Gtk.Entry with a Gtk.TextView for multiline editing.
- self.text_view = Gtk.TextView()
- self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)
- buffer = self.text_view.get_buffer()
- buffer.set_text(initial_text)
- # Connect key press events to handle Return and SHIFT+Return.
- self.text_view.connect("key-press-event", self.on_key_press)
-
- confirm_btn = Gtk.Button(
- name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept)
- )
- confirm_btn.connect("clicked", self.on_confirm)
- confirm_btn.get_style_context().add_class("flat")
-
- cancel_btn = Gtk.Button(
- name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel)
- )
- cancel_btn.connect("clicked", self.on_cancel)
- cancel_btn.get_style_context().add_class("flat")
-
- # Pack the TextView inside a ScrolledWindow for better appearance.
- sw = Gtk.ScrolledWindow()
- sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
- sw.set_min_content_height(50)
- sw.add(self.text_view)
-
- self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4)
- self.center_box = CenterBox(center_children=[self.button_box], orientation="v")
-
- self.pack_start(sw, True, True, 0)
- self.pack_start(self.center_box, False, False, 0)
- self.show_all()
-
- def on_confirm(self, widget):
- buffer = self.text_view.get_buffer()
- start, end = buffer.get_bounds()
- text = buffer.get_text(start, end, True).strip()
- if text:
- self.emit("confirmed", text)
- else:
- self.emit("canceled")
-
- def on_cancel(self, widget):
- self.emit("canceled")
-
- def on_key_press(self, widget, event):
- # Check for Escape to cancel.
- if event.keyval == Gdk.KEY_Escape:
- self.emit("canceled")
- return True
-
- # If Return is pressed...
- if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
- state = event.get_state()
- if state & Gdk.ModifierType.SHIFT_MASK:
- # SHIFT+Return: insert a newline.
- buffer = self.text_view.get_buffer()
- cursor_iter = buffer.get_iter_at_mark(buffer.get_insert())
- buffer.insert(cursor_iter, "\n")
- return True # Prevent further handling.
- else:
- # Plain Return: confirm the edit.
- self.on_confirm(widget)
- return True
- return False
-
-
-class KanbanNote(Gtk.EventBox):
- __gsignals__ = {
- "changed": (GObject.SignalFlags.RUN_LAST, None, ()),
- }
-
- def __init__(self, text):
- super().__init__()
- self.text = text
- # Variables to store the click offset for drag preview.
- self.setup_ui()
- self.setup_dnd()
- self.connect("button-press-event", self.on_button_press)
-
- def setup_ui(self):
- self.box = Gtk.Box(name="kanban-note", spacing=4)
- self.label = Gtk.Label(label=self.text)
- self.label.set_line_wrap(True)
- # Wrap long lines.
- self.label.set_line_wrap_mode(Gtk.WrapMode.WORD)
-
- self.delete_btn = Gtk.Button(
- name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash)
- )
- self.delete_btn.connect("clicked", self.on_delete_clicked)
-
- self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn])
-
- self.box.pack_start(self.label, True, True, 0)
- self.box.pack_start(self.center_btn, False, False, 0)
- self.add(self.box)
- self.show_all()
-
- def setup_dnd(self):
- self.drag_source_set(
- Gdk.ModifierType.BUTTON1_MASK,
- [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)],
- Gdk.DragAction.MOVE,
- )
- self.connect("drag-data-get", self.on_drag_data_get)
- self.connect("drag-data-delete", self.on_drag_data_delete)
- # Set up drag-begin to display a preview of the card.
- self.connect("drag-begin", self.on_drag_begin)
-
- def on_button_press(self, widget, event):
- if event.type != Gdk.EventType._2BUTTON_PRESS:
- return True
- self.start_edit()
- return False
-
- def on_drag_begin(self, widget, context):
- surface = createSurfaceFromWidget(self)
- Gtk.drag_set_icon_surface(context, surface)
-
- def on_drag_data_get(self, widget, drag_context, data, info, time):
- data.set_text(self.label.get_text(), -1)
-
- def on_drag_data_delete(self, widget, drag_context):
- self.get_parent().destroy()
-
- def on_delete_clicked(self, button):
- self.get_parent().destroy()
-
- def start_edit(self):
- row = self.get_parent()
- editor = InlineEditor(self.label.get_text())
-
- def on_confirmed(editor, text):
- self.label.set_text(text)
- row.remove(editor)
- row.add(self)
- row.show_all()
- self.emit("changed")
-
- def on_canceled(editor):
- row.remove(editor)
- row.add(self)
- row.show_all()
-
- editor.connect("confirmed", on_confirmed)
- editor.connect("canceled", on_canceled)
-
- row.remove(self)
- row.add(editor)
- row.show_all()
- # Usamos un retardo de 50 milisegundos:
- GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False))
-
-
-class KanbanColumn(Gtk.Frame):
- __gsignals__ = {
- "changed": (GObject.SignalFlags.RUN_LAST, None, ()),
- }
-
- def __init__(self, title):
- super().__init__(name="kanban-column")
- self.title = title
- self.setup_ui()
- self.setup_dnd()
- self.set_hexpand(True)
- self.set_vexpand(True)
-
- def setup_ui(self):
- self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
- self.listbox = Gtk.ListBox()
- self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
-
- self.add_btn = Gtk.Button(
- name="kanban-btn-add",
- child=Label(name="kanban-btn-label", markup=icons.add),
- )
- header = CenterBox(
- name="kanban-header",
- center_children=[Label(name="column-header", label=self.title)],
- end_children=[self.add_btn],
- )
- self.box.pack_start(header, False, False, 0)
-
- self.add_btn.connect("clicked", self.on_add_clicked)
-
- scrolled = Gtk.ScrolledWindow(name="kanban-scroll")
- scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
- scrolled.add(self.listbox)
- scrolled.set_vexpand(True)
-
- self.box.pack_start(scrolled, True, True, 0)
- self.box.pack_start(self.add_btn, False, False, 0)
- self.add(self.box)
- self.show_all()
-
- def setup_dnd(self):
- self.listbox.drag_dest_set(
- Gtk.DestDefaults.ALL,
- [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)],
- Gdk.DragAction.MOVE,
- )
-
- self.listbox.connect("drag-data-received", self.on_drag_data_received)
- self.listbox.connect("drag-motion", self.on_drag_motion)
- self.listbox.connect("drag-leave", self.on_drag_leave)
-
- def on_add_clicked(self, button):
- editor = InlineEditor()
- row = Gtk.ListBoxRow(name="kanban-row")
- row.add(editor)
- self.listbox.add(row)
- self.listbox.show_all()
- editor.text_view.grab_focus()
-
- def on_confirmed(editor, text):
- note = KanbanNote(text)
- note.connect("changed", lambda x: self.emit("changed"))
- row.remove(editor)
- row.add(note)
- self.listbox.show_all()
- self.emit("changed") # Emit on add
-
- def on_canceled(editor):
- row.destroy()
-
- editor.connect("confirmed", on_confirmed)
- editor.connect("canceled", on_canceled)
-
- def add_note(self, text, suppress_signal=False):
- note = KanbanNote(text)
- note.connect("changed", lambda x: self.emit("changed"))
- row = Gtk.ListBoxRow(name="kanban-row")
- row.add(note)
- row.connect("destroy", lambda x: self.emit("changed"))
- self.listbox.add(row)
- self.listbox.show_all()
- if not suppress_signal:
- self.emit("changed")
-
- def get_notes(self):
- return [
- row.get_children()[0].label.get_text()
- for row in self.listbox.get_children()
- if isinstance(row.get_children()[0], KanbanNote)
- ]
-
- def clear_notes(self, suppress_signal=False):
- for row in self.listbox.get_children():
- row.destroy()
- if not suppress_signal:
- self.emit("changed")
-
- def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
- text = data.get_text()
- if text:
- row = self.listbox.get_row_at_y(y)
- new_note = KanbanNote(text)
- new_note.connect("changed", lambda x: self.emit("changed"))
- new_row = Gtk.ListBoxRow(name="kanban-row")
- new_row.add(new_note)
- new_row.connect("destroy", lambda x: self.emit("changed"))
-
- if row:
- self.listbox.insert(new_row, row.get_index())
- else:
- self.listbox.add(new_row)
-
- self.listbox.show_all()
- drag_context.finish(True, False, time)
- self.emit("changed") # Emit on move
-
- def on_drag_motion(self, widget, drag_context, x, y, time):
- Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time)
- return True
-
- def on_drag_leave(self, widget, drag_context, time):
- widget.get_parent().get_parent().drag_unhighlight()
-
-
-class Kanban(Gtk.Box):
- STATE_FILE = Path(os.path.expanduser("~/.local/state/modus/kanban.json"))
-
- def __init__(self):
- super().__init__(name="kanban")
-
- self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True)
- self.grid.set_vexpand(True)
- self.add(self.grid)
-
- self.columns = [
- KanbanColumn("To Do"),
- KanbanColumn("In Progress"),
- KanbanColumn("Done"),
- ]
-
- for i, column in enumerate(self.columns):
- self.grid.attach(column, i, 0, 1, 1)
- column.connect("changed", lambda x: self.save_state())
-
- self.load_state()
- self.show_all()
-
- def save_state(self):
- state = {
- "columns": [
- {"title": col.title, "notes": col.get_notes()} for col in self.columns
- ]
- }
- try:
- with open(self.STATE_FILE, "w") as f:
- json.dump(state, f, indent=2)
- except Exception as e:
- print(f"Error saving state: {e}")
-
- def load_state(self):
- try:
- with open(self.STATE_FILE, "r") as f:
- state = json.load(f)
- for col_data in state["columns"]:
- for column in self.columns:
- if column.title == col_data["title"]:
- column.clear_notes(suppress_signal=True)
- for note_text in col_data["notes"]:
- column.add_note(note_text, suppress_signal=True)
- break
- except FileNotFoundError:
- pass
- except Exception as e:
- print(f"Error loading state: {e}")
diff --git a/modules/launcher/components/notification_center.py b/modules/launcher/components/notification_center.py
deleted file mode 100644
index 86ade48..0000000
--- a/modules/launcher/components/notification_center.py
+++ /dev/null
@@ -1,409 +0,0 @@
-from fabric.notifications import Notification
-from services import notification_service
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.widgets.label import Label
-from fabric.widgets.image import Image
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.revealer import Revealer
-from loguru import logger
-import utils.icons as icons
-from gi.repository import GdkPixbuf, GLib
-from utils import CustomImage
-import os
-from gi.repository import Gtk
-from fabric.widgets.centerbox import CenterBox
-
-
-class NotificationCenter(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="notification-center",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
- self.notification_service = notification_service
-
- # Main container for notifications
- self.notifications = Box(
- v_expand=True,
- h_expand=True,
- orientation="v",
- spacing=8,
- )
- self.header_switch = Gtk.Switch(name="matugen-switcher")
- self.header_switch.set_vexpand(False)
- self.header_switch.set_valign(Gtk.Align.CENTER)
- self.header_switch.set_active(self.notification_service.dont_disturb)
- self.do_not_disturb_enabled = self.notification_service.dont_disturb
- self.header_switch.connect("notify::active", self.on_do_not_disturb_changed)
- self.dnd_label = Label(name="dnd-label", markup=icons.notifications_off)
- # Header with clear all button
- header = CenterBox(
- h_expand=True,
- start_children=[self.header_switch, self.dnd_label],
- center_children=[
- Label(
- label="Notifications",
- name="nhh",
- )
- ],
- end_children=[
- Button(
- name="nhh-button",
- child=Label(name="nhh-button-label", markup=icons.trash),
- on_clicked=lambda *args: self.clear_all_notifications(),
- )
- ],
- )
-
- self.scrolledwindow = ScrolledWindow(
- name="scrolled-window",
- child=self.notifications,
- min_content_size=(400, 300),
- max_content_size=(400, 300),
- v_expand=True,
- h_expand=True,
- )
-
- # Main container
- main_container = Box(
- v_expand=True,
- h_expand=True,
- orientation="v",
- children=[header, self.scrolledwindow],
- )
-
- self.add(main_container)
-
- # Connect to notification service signals
- self.notification_service.connect(
- "notification-added", self.on_new_notification
- )
- self.notification_service.connect("clear_all", self.on_clear_all)
-
- # Load existing notifications
- self.load_notifications()
-
- def load_notifications(self):
- """Load existing notifications from the service."""
- notifications = self.notification_service.get_deserialized()
- if not notifications:
- # Create a container box to better center the message
- container = Box(
- name="no-notifications-container",
- orientation="v",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True,
- )
-
- # Show a message if no notifications
- label = Label(
- name="no-notifications-label",
- markup=icons.notifications_clear,
- v_align="fill",
- h_align="fill",
- v_expand=True,
- h_expand=True,
- justification="center",
- )
-
- container.add(label)
- self.notifications.add(container)
- return
-
- for notification in notifications:
- self.add_notification(notification)
-
- def toggle_notification_body(self, revealer, chevron_label):
- """Toggle the visibility of notification body."""
- is_revealed = revealer.get_reveal_child()
- revealer.set_reveal_child(not is_revealed)
- # Update chevron icon
- chevron_label.set_markup(
- icons.chevron_up if not is_revealed else icons.chevron_down
- )
-
- def add_notification(self, notification: Notification):
- """Add a notification to the center."""
- # Remove empty state container if it exists
- for child in self.notifications.get_children():
- if child.get_name() == "no-notifications-container":
- self.notifications.remove(child)
- break
-
- # Get timestamp from notification object
- timestamp = getattr(notification, 'timestamp', GLib.get_real_time() / 1000000)
- time_str = GLib.DateTime.new_from_unix_local(timestamp).format("%I:%M %p")
-
- # Create the notification header (summary) box
- header_box = Box(
- name="notification-header",
- # h_expand=True,
- spacing=8,
- children=[
- Box(
- orientation="h",
- h_expand=True,
- spacing=8,
- style="min-width: 0;",
- children=[
- # Summary
- Label(
- name="notification-app-name",
- markup=notification.app_name + " | ",
- h_align="start",
- ellipsization="end",
- ),
- Label(
- markup=notification.summary.replace("\n", " "),
- h_align="start",
- ellipsization="end",
- ),
- Label(
- name="notification-time",
- markup=f" | {time_str}",
- h_align="end",
- style="color: var(--outline);",
- ),
- ],
- ),
- # Add chevron button if there's content to show
- *(
- [
- Button(
- name="chevron-button",
- child=Label(
- name="chevron-label", markup=icons.chevron_down
- ),
- )
- ]
- if notification.body or notification.image_pixbuf
- else []
- ),
- Button(
- name="notif-close-button",
- child=Label(name="notif-close-label", markup=icons.cancel),
- on_clicked=lambda *args: self.remove_notification(notification),
- ),
- ],
- )
-
- # Create content box that will be revealed
- # Create content box in vertical layout for body + actions
- content_box = Box(
- name="notification-content",
- spacing=8,
- orientation="v",
- )
-
- # Create horizontal box for image and body
- body_box = Box(
- name="notification-body-box",
- spacing=8,
- orientation="h",
- )
-
- # Add image if available
- if notification.image_pixbuf:
- image_box = Box(
- name="notification-image",
- children=[
- CustomImage(
- pixbuf=notification.image_pixbuf.scale_simple(
- 48, 48, GdkPixbuf.InterpType.BILINEAR
- )
- if notification.image_pixbuf
- else self.get_pixbuf(notification.app_icon, 48, 48)
- ),
- ],
- )
- body_box.add(image_box)
-
- # Add body text if available
- if notification.body:
- body_box.add(
- Label(
- markup=notification.body.replace("\n", " "),
- name="notification-body",
- h_align="start",
- ellipsization="end",
- )
- )
-
- # Add body box to content
- content_box.add(body_box)
-
- # Add action buttons if available
- if notification.actions:
- action_box = Box(
- name="notification-action-buttons",
- spacing=4,
- h_expand=True,
- )
- for i, action in enumerate(notification.actions):
- style_class = (
- "start-action"
- if i == 0
- else "end-action"
- if i == len(notification.actions) - 1
- else "middle-action"
- )
- button = Button(
- name="action-button",
- h_expand=True,
- child=Label(name="button-label", label=action.label),
- on_clicked=lambda btn, act=action, notif=notification: [
- act.invoke(),
- self.notification_service.remove_notification(notif.id),
- self.remove_notification(notif)
- ],
- )
- button.add_style_class(style_class)
- action_box.add(button)
- content_box.add(action_box)
-
- # Create revealer for content
- body_revealer = None
- if notification.body or notification.image_pixbuf:
- body_revealer = Revealer(
- name="body-revealer",
- transition_type="slide-down",
- reveal_child=False,
- child=content_box,
- )
-
- # Create main notification box
- notification_box = Box(
- name="notification-box",
- orientation="v",
- h_expand=True,
- style="margin: 4px; padding: 16px; border-radius: 20px; background-color: var(--surface-container-low); min-width: 200px;",
- children=[header_box],
- )
-
- # Add body revealer if exists
- if body_revealer:
- notification_box.add(body_revealer)
- # Get the chevron button from header_box children
- chevron_button = [
- c for c in header_box.get_children() if c.get_name() == "chevron-button"
- ][0]
- chevron_label = chevron_button.get_child()
- chevron_button.connect(
- "clicked",
- lambda *args: self.toggle_notification_body(
- body_revealer, chevron_label
- ),
- )
-
- self.notifications.add(notification_box)
-
- def on_do_not_disturb_changed(self, switch, pspec):
- """Handle Do Not Disturb toggle."""
- self.do_not_disturb_enabled = switch.get_active()
- self.notification_service.dont_disturb = self.do_not_disturb_enabled
-
- def on_new_notification(self, fabric_notif, id):
- """Handle new notification event."""
- notification = fabric_notif.get_notification_from_id(id)
- self.add_notification(notification)
-
- def remove_notification(self, notification: Notification):
- """Remove a notification from the center."""
- self.notification_service.remove_notification(notification.id)
- # Find and remove the notification box
- for child in self.notifications.get_children():
- if child.get_name() == "notification-box":
- # Get the header box
- header_box = child.get_children()[0]
- # Get the text container box
- text_box = header_box.get_children()[0]
- # Get the summary label (second child of text box)
- summary_label = text_box.get_children()[1]
- if summary_label.get_text() == notification.summary:
- self.notifications.remove(child)
- break
-
- def clear_all_notifications(self, *args):
- """Clear all notifications."""
- self.notification_service.clear_all_notifications()
- # Remove all child widgets
- for child in self.notifications.get_children():
- self.notifications.remove(child)
-
- # Add empty state
- container = Box(
- name="no-notifications-container",
- orientation="v",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True,
- )
-
- label = Label(
- name="no-notifications-label",
- markup=icons.notifications_clear,
- v_align="fill",
- h_align="fill",
- v_expand=True,
- h_expand=True,
- justification="center",
- )
-
- container.add(label)
- self.notifications.add(container)
-
- def on_clear_all(self, *args):
- """Handle clear all event from service."""
- # Remove all child widgets
- for child in self.notifications.get_children():
- self.notifications.remove(child)
-
- # Add empty state
- container = Box(
- name="no-notifications-container",
- orientation="v",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True,
- )
-
- label = Label(
- name="no-notifications-label",
- markup=icons.notifications_clear,
- v_align="fill",
- h_align="fill",
- v_expand=True,
- h_expand=True,
- justification="center",
- )
-
- container.add(label)
- self.notifications.add(container)
-
- def get_pixbuf(self, icon_path, width, height):
- if icon_path.startswith("file://"):
- icon_path = icon_path[7:]
-
- if not os.path.exists(icon_path):
- logger.warning(f"Icon path does not exist: {icon_path}")
- return None
-
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
- return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
- except Exception as e:
- logger.error(f"Failed to load or scale icon: {e}")
- return None
-
- def open_center(self):
- """Open the notification center."""
- self.show_all()
diff --git a/modules/launcher/components/power.py b/modules/launcher/components/power.py
deleted file mode 100644
index b441e25..0000000
--- a/modules/launcher/components/power.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.utils.helpers import exec_shell_command_async
-import utils.icons as icons
-
-
-class PowerMenu(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="power-menu",
- orientation="h",
- spacing=10,
- v_align="center",
- h_align="center",
- v_expand=True,
- h_expand=True,
- visible=True,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
-
- self.btn_lock = Button(
- name="power-menu-button",
- child=Label(name="button-label", markup=icons.lock),
- on_clicked=self.lock,
- )
-
- self.btn_suspend = Button(
- name="power-menu-button",
- child=Label(name="button-label", markup=icons.suspend),
- on_clicked=self.suspend,
- )
-
- self.btn_logout = Button(
- name="power-menu-button",
- child=Label(name="button-label", markup=icons.logout),
- on_clicked=self.logout,
- )
-
- self.btn_reboot = Button(
- name="power-menu-button",
- child=Label(name="button-label", markup=icons.reboot),
- on_clicked=self.reboot,
- )
-
- self.btn_shutdown = Button(
- name="power-menu-button",
- child=Label(name="button-label", markup=icons.shutdown),
- on_clicked=self.poweroff,
- )
-
- self.buttons = [
- self.btn_lock,
- self.btn_suspend,
- self.btn_logout,
- self.btn_reboot,
- self.btn_shutdown,
- ]
-
- for button in self.buttons:
- self.add(button)
-
- self.show_all()
-
- def close_menu(self):
- self.launcher.close()
-
- def lock(self, *args):
- exec_shell_command_async("hyprlock --immediate")
- self.close_menu()
-
- def suspend(self, *args):
- exec_shell_command_async("systemctl suspend")
- self.close_menu()
-
- def logout(self, *args):
- exec_shell_command_async("hyprctl dispatch exit")
- self.close_menu()
-
- def reboot(self, *args):
- exec_shell_command_async("systemctl reboot")
- self.close_menu()
-
- def poweroff(self, *args):
- exec_shell_command_async("systemctl poweroff")
- self.close_menu()
diff --git a/modules/launcher/components/sh.py b/modules/launcher/components/sh.py
deleted file mode 100644
index 6fef79a..0000000
--- a/modules/launcher/components/sh.py
+++ /dev/null
@@ -1,139 +0,0 @@
-import os
-import subprocess
-from typing import List
-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
-import config.data as data
-
-BINS = f"{data.CACHE_DIR}/binaries"
-
-
-class Sh(Box):
- def __init__(self, **kwargs):
- super().__init__(
- all_visible=False,
- visible=False,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
- self._arranger_handler: int = 0
- self.shell_command_manager = ShellCommandManager(self)
- self.viewport = None
-
- self.search_entry = Entry(
- name="search-entry",
- placeholder="Search Shell Commands...",
- h_expand=True,
- notify_text=lambda entry, *_: self.handle_search_input(entry.get_text()),
- on_activate=lambda entry, *_: self.handle_search_input(entry.get_text()),
- )
- self.search_entry.props.xalign = 0.5
-
- self.header_box = Box(
- spacing=10,
- orientation="h",
- children=[self.search_entry],
- )
-
- self.launcher_box = Box(
- spacing=10,
- orientation="v",
- h_expand=True,
- children=[self.header_box],
- )
-
- self.add(self.launcher_box)
-
- def open_sh(self):
- if not self.viewport:
- self.viewport = Box(name="viewport", spacing=10, orientation="v")
- self.scrolled_window = ScrolledWindow(
- name="scrolled-window",
- spacing=10,
- h_scrollbar_policy="never",
- v_scrollbar_policy="never",
- # min_content_size=(-1, -1),
- child=self.viewport,
- )
- self.launcher_box.add(self.scrolled_window)
-
- self.viewport.children = []
- self.shell_command_manager.show_shell_commands(self.viewport, "")
-
- self.viewport.show()
- self.search_entry.grab_focus()
-
- def handle_search_input(self, text: str):
- self.shell_command_manager.show_shell_commands(self.viewport, text)
-
-
-class ShellCommandManager:
- def __init__(self, launcher):
- self.launcher = launcher
- self.binaries = set()
- self.reload_binaries()
-
- def reload_binaries(self):
- paths = os.getenv("PATH", "").split(":")
- self.binaries = set()
-
- for path in paths:
- self.binaries.update(self.list_binaries_in_path(path))
-
- with open(BINS, "w") as f:
- f.write("\n".join(self.binaries))
-
- def list_binaries_in_path(self, path):
- if not os.path.exists(path):
- return []
- try:
- return [
- entry
- for entry in os.listdir(path)
- if os.access(os.path.join(path, entry), os.X_OK)
- ]
- except OSError:
- return []
-
- def show_shell_commands(self, viewport, search_query: str):
- if not search_query:
- return
-
- viewport.children = []
-
- results = self.query_binaries(search_query)
- self.display_results(viewport, results)
-
- def query_binaries(self, filter_str: str) -> List[str]:
- filter_str = filter_str.lower()
- filtered_bins = (cmd for cmd in self.binaries if filter_str in cmd)
- return list(dict.fromkeys(filtered_bins))[:8]
-
- def display_results(self, viewport, results: List[str]):
- for command in results:
- button = Button(
- child=Label(
- label=command,
- v_align="center",
- h_align="start",
- ),
- on_clicked=lambda _, cmd=command: self.execute_command(cmd),
- name="app-slot-button",
- )
- viewport.add(button)
-
- def close_menu(self):
- self.launcher.close()
-
- def execute_command(self, cmd: str):
- try:
- output = subprocess.check_output(cmd, shell=True, text=True)
- print(output)
- except subprocess.CalledProcessError as e:
- print(e.output)
- finally:
- self.close_menu()
diff --git a/modules/launcher/components/tmux.py b/modules/launcher/components/tmux.py
deleted file mode 100644
index 583e477..0000000
--- a/modules/launcher/components/tmux.py
+++ /dev/null
@@ -1,540 +0,0 @@
-import subprocess
-import os
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.entry import Entry
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.utils import idle_add, remove_handler, exec_shell_command_async
-from gi.repository import GLib, Gdk, Gtk
-import utils.icons as icons
-import config.data as data
-
-class TmuxManager(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="tmux-manager",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
- self.selected_index = -1 # Track the selected item index
-
- self._arranger_handler: int = 0
-
- self.viewport = Box(name="viewport", spacing=4, orientation="v")
- self.session_name_entry = Entry(
- name="session-name-entry",
- placeholder="Create Tmux Session...",
- h_expand=True,
- on_activate=lambda entry, *_: self.create_session(entry.get_text()),
- on_key_press_event=self.on_entry_key_press,
- )
- self.session_name_entry.props.xalign = 0.5
- self.scrolled_window = ScrolledWindow(
- name="scrolled-window",
- spacing=10,
- min_content_size=(450, 105),
- max_content_size=(450, 105),
- child=self.viewport,
- )
-
- self.header_box = Box(
- name="header_box",
- spacing=10,
- orientation="h",
- children=[
- Button(
- name="new-session-button",
- child=Label(name="new-session-label", markup=icons.add),
- tooltip_text="Create New Session",
- on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()),
- ),
- self.session_name_entry,
- ],
- )
-
- self.tmux_box = Box(
- name="tmux-box",
- spacing=10,
- h_expand=True,
- orientation="v",
- children=[
- self.header_box,
- self.scrolled_window,
- ],
- )
-
- self.add(self.tmux_box)
- self.show_all()
-
- def close_manager(self):
- """Close the tmux manager"""
- self.viewport.children = []
- self.selected_index = -1 # Reset selection
- self.launcher.close()
-
- def open_manager(self):
- """Open the tmux manager and refresh sessions"""
- self.viewport.set_visible(True)
- self.refresh_sessions()
- self.session_name_entry.set_text("")
- self.session_name_entry.grab_focus()
-
- def refresh_sessions(self):
- """Get tmux sessions and populate the viewport"""
- remove_handler(self._arranger_handler) if self._arranger_handler else None
- self.viewport.children = []
- self.selected_index = -1 # Clear selection when viewport changes
-
- # Get tmux sessions
- sessions = self.get_tmux_sessions()
- if not sessions:
- # Create a container box to better center the message
- container = Box(
- name="no-tmux-container",
- orientation="v",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True
- )
-
- # Show a message if no sessions
- label = Label(
- name="no-tmux",
- markup=icons.terminal,
- h_align="center",
- v_align="center",
- )
-
- container.add(label)
-
- self.viewport.add(container)
- return
-
- # Add session slots to viewport
- for session in sessions:
- self.viewport.add(self.create_session_slot(session))
-
- 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
- )
- if result.returncode == 0:
- return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()]
- return []
- except Exception as e:
- print(f"Error getting tmux sessions: {e}")
- return []
-
- def create_session_slot(self, session_name):
- """Create a button for a tmux session"""
- # Create an entry for inline editing (initially hidden)
- name_entry = Entry(
- name="session-name-entry",
- text=session_name,
- visible=False,
- on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry),
- on_key_press_event=self.on_rename_key_press,
- )
-
- # Create the label showing the session name
- name_label = Label(
- name="app-label",
- label=session_name,
- ellipsization="end",
- v_align="center",
- h_align="center",
- )
-
- # Session slot content box
- slot_box = Box(
- name="slot-box",
- orientation="h",
- spacing=10,
- children=[
- Label(
- name="tmux-icon",
- markup=icons.terminal, # Use existing terminal icon
- ),
- name_label,
- name_entry,
- ],
- )
-
- button = Button(
- name="slot-button", # reuse existing CSS styling
- child=slot_box,
- tooltip_text=f"Attach to session: {session_name}",
- on_clicked=lambda *_: self.attach_to_session(session_name),
- can_focus=True, # Ensure the button can receive focus
- )
-
- # Add double-click handler to start renaming
- button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry)
-
- # Add key press handler for 'r' to rename
- button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry)
-
- # Store reference to entry and label in button for later access
- button.name_entry = name_entry
- button.name_label = name_label
- button.session_name = session_name
-
- return button
-
- def on_session_click(self, button, event, session_name, label, entry):
- """Handle clicks on session buttons"""
- # Handle double-click to rename
- if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1:
- self.start_rename(button, session_name, label, entry)
- return True
- # Handle right click for context menu
- elif event.button == 3:
- menu = Gtk.Menu()
-
- # Rename option
- rename_item = Gtk.MenuItem(label="Rename")
- rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry))
- menu.append(rename_item)
-
- # Kill option
- kill_item = Gtk.MenuItem(label="Kill Session")
- kill_item.connect("activate", lambda _: self.kill_session(session_name))
- menu.append(kill_item)
-
- menu.show_all()
- menu.popup_at_pointer(event)
- return True
-
- return False
-
- def start_rename(self, button, session_name, label, entry):
- """Start inline renaming of a session"""
- # Hide label, show entry
- label.set_visible(False)
- entry.set_visible(True)
-
- # Focus entry and select all text
- entry.grab_focus()
- entry.select_region(0, -1)
-
- # Mark button as being edited
- button.get_style_context().add_class("editing")
-
- def finish_rename(self, button, old_name, entry):
- """Finish renaming a session"""
- new_name = entry.get_text().strip()
-
- # Only rename if the name changed and isn't empty
- if new_name and new_name != old_name:
- self.rename_session(old_name, new_name)
-
- # Reset UI state
- self.cancel_rename(button)
-
- def cancel_rename(self, button):
- """Cancel renaming operation"""
- # Restore original view
- button.name_entry.set_visible(False)
- button.name_label.set_visible(True)
-
- # Remove editing style
- button.get_style_context().remove_class("editing")
-
- # Return focus to session name entry
- self.session_name_entry.grab_focus()
-
- def on_rename_key_press(self, entry, event):
- """Handle key presses in the rename entry"""
- if event.keyval == Gdk.KEY_Escape:
- # Find the parent button
- parent = entry.get_parent()
- while parent and not isinstance(parent, Button):
- parent = parent.get_parent()
-
- if parent:
- self.cancel_rename(parent)
- return True
-
- return False
-
- def on_session_right_click(self, button, event, session_name):
- """Handle right-click on a session button to show context menu"""
- if event.button == 3: # Right click
- menu = Gtk.Menu()
-
- # Rename option
- rename_item = Gtk.MenuItem(label="Rename")
- rename_item.connect("activate", lambda _: self.start_rename(
- button,
- session_name,
- button.name_label,
- button.name_entry
- ))
- menu.append(rename_item)
-
- # Kill option
- kill_item = Gtk.MenuItem(label="Kill Session")
- kill_item.connect("activate", lambda _: self.kill_session(session_name))
- menu.append(kill_item)
-
- menu.show_all()
- menu.popup_at_pointer(event)
- return True
-
- return False
-
- def on_entry_key_press(self, widget, event):
- """Handle key press events in the entry"""
- if event.keyval == Gdk.KEY_Escape:
- self.close_manager()
- return True
-
- # Custom navigation with UP/DOWN keys removed
- return False
-
- def scroll_to_selected(self, button):
- """Scroll to ensure the selected button is visible"""
- def scroll():
- adj = self.scrolled_window.get_vadjustment()
- alloc = button.get_allocation()
- if alloc.height == 0:
- return False # Retry if allocation isn't ready
-
- y = alloc.y
- height = alloc.height
- page_size = adj.get_page_size()
- current_value = adj.get_value()
-
- # Calculate visible boundaries
- visible_top = current_value
- visible_bottom = current_value + page_size
-
- if y < visible_top:
- # Item above viewport - align to top
- adj.set_value(y)
- elif y + height > visible_bottom:
- # Item below viewport - align to bottom
- new_value = y + height - page_size
- adj.set_value(new_value)
- # No action if already fully visible
- return False
- GLib.idle_add(scroll)
-
- def create_session(self, session_name):
- """Create a new tmux session"""
- if not session_name:
- # Get existing session names
- existing_sessions = self.get_tmux_sessions()
-
- # Find the next available number
- counter = 0
- while str(counter) in existing_sessions:
- counter += 1
-
- session_name = str(counter)
-
- try:
- # Clean the session name (replace spaces with underscores)
- clean_name = session_name.strip().replace(" ", "_")
-
- # Create session
- subprocess.run(
- ["tmux", "new-session", "-d", "-s", clean_name],
- check=True
- )
-
- # Refresh the session list
- self.refresh_sessions()
-
- # Clear entry
- self.session_name_entry.set_text("")
-
- # Launch a terminal and attach to this session
- terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}")
- exec_shell_command_async(terminal_cmd)
-
- # Close manager
- self.close_manager()
-
- except Exception as e:
- print(f"Error creating tmux session: {e}")
-
- def attach_to_session(self, session_name):
- """Attach to an existing tmux session"""
- try:
- # Launch a terminal and attach to this session
- terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}")
- exec_shell_command_async(terminal_cmd)
- self.close_manager()
- except Exception as e:
- print(f"Error attaching to tmux session: {e}")
-
- def get_terminal_command(self, cmd):
- """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}"
-
- def rename_session_dialog(self, old_name):
- """Show dialog to rename a session"""
- dialog = Gtk.Dialog(
- title="Rename Session",
- transient_for=None,
- flags=0
- )
-
- dialog.add_buttons(
- Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
- Gtk.STOCK_OK, Gtk.ResponseType.OK
- )
-
- content_area = dialog.get_content_area()
- entry = Gtk.Entry()
- entry.set_text(old_name)
- entry.set_activates_default(True)
- content_area.add(entry)
-
- dialog.set_default_response(Gtk.ResponseType.OK)
- dialog.show_all()
-
- response = dialog.run()
- if response == Gtk.ResponseType.OK:
- new_name = entry.get_text()
- if new_name and new_name != old_name:
- self.rename_session(old_name, new_name)
-
- dialog.destroy()
-
- def rename_session(self, old_name, new_name):
- """Rename a tmux session"""
- try:
- # Clean the session name (replace spaces with underscores)
- clean_name = new_name.strip().replace(" ", "_")
-
- # Rename session
- subprocess.run(
- ["tmux", "rename-session", "-t", old_name, clean_name],
- check=True
- )
-
- # Refresh the session list
- self.refresh_sessions()
-
- except Exception as e:
- print(f"Error renaming tmux session: {e}")
-
- def kill_session(self, session_name):
- """Kill a tmux session"""
- try:
- # Kill session
- subprocess.run(
- ["tmux", "kill-session", "-t", session_name],
- check=True
- )
-
- # Refresh the session list
- self.refresh_sessions()
-
- # Close the launcher after killing session
- self.close_manager()
-
- except Exception as e:
- print(f"Error killing tmux session: {e}")
-
- # Add new method to handle key presses on session slots
- def on_slot_key_press(self, button, event, session_name, label, entry):
- """Handle key presses on session buttons"""
- # Print debugging info
- print(f"Key pressed: {event.keyval}, State: {event.state}")
-
- # Check if 'r' key was pressed for renaming
- if event.keyval == Gdk.KEY_r:
- self.start_rename(button, session_name, label, entry)
- return True
- # Check for 'K' (capital K) which indicates Shift is pressed
- elif event.keyval == Gdk.KEY_K:
- print("Shift+K detected - killing session without confirmation")
- self.kill_session(session_name)
- return True
- # Check for lowercase 'k'
- elif event.keyval == Gdk.KEY_k:
- print("Regular k detected - showing confirmation")
- self.show_kill_confirmation_menu(button, session_name)
- return True
- # Check if Delete key was pressed for killing session
- elif event.keyval == Gdk.KEY_Delete:
- self.show_kill_confirmation_menu(button, session_name)
- return True
- return False
-
- def show_kill_confirmation_menu(self, button, session_name):
- """Show a confirmation menu for killing a session"""
- menu = Gtk.Menu()
-
- # Confirmation message as a disabled menu item
- msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?")
- msg_item.set_sensitive(False)
- menu.append(msg_item)
-
- # Separator
- menu.append(Gtk.SeparatorMenuItem())
-
- # Confirm option
- confirm_item = Gtk.MenuItem(label="Confirm")
- confirm_item.connect("activate", lambda _: self.kill_session(session_name))
- menu.append(confirm_item)
-
- # Cancel option
- cancel_item = Gtk.MenuItem(label="Cancel")
- # Close launcher on cancel
- cancel_item.connect("activate", lambda _: self.close_manager())
- menu.append(cancel_item)
-
- menu.show_all()
-
- # Show the menu positioned at the button
- menu.popup_at_widget(
- button,
- Gdk.Gravity.SOUTH_WEST,
- Gdk.Gravity.NORTH_WEST,
- None
- )
\ No newline at end of file
diff --git a/modules/launcher/components/tools.py b/modules/launcher/components/tools.py
deleted file mode 100644
index d04c39d..0000000
--- a/modules/launcher/components/tools.py
+++ /dev/null
@@ -1,370 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.utils.helpers import exec_shell_command_async, get_relative_path
-import utils.icons as icons
-from gi.repository import Gdk, GLib
-import os
-import subprocess
-import threading
-
-
-CAPTURE_SCRIPT = get_relative_path("../../../config/scripts/capture.sh")
-GAMEMODE_SCRIPT = get_relative_path("../../../config/scripts/gamemode.sh")
-
-
-class Toolbox(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="toolbox",
- orientation="h",
- spacing=4,
- v_align="center",
- h_align="center",
- visible=True,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
-
- self.btn_ssregion = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.ssregion),
- on_clicked=self.ssregion,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
- # Enable keyboard focus and connect events
- self.btn_ssregion.set_can_focus(True)
- self.btn_ssregion.connect("button-press-event", self.on_ssregion_click)
- self.btn_ssregion.connect("key-press-event", self.on_ssregion_key)
-
- self.btn_ssfull = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.ssfull),
- on_clicked=self.ssfull,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
- # Enable keyboard focus and connect events
- self.btn_ssfull.set_can_focus(True)
- self.btn_ssfull.connect("button-press-event", self.on_ssfull_click)
- self.btn_ssfull.connect("key-press-event", self.on_ssfull_key)
-
- self.btn_screenrecord = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.screenrecord),
- on_clicked=self.screenrecord,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- self.btn_ocr = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.ocr),
- on_clicked=self.ocr,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- self.btn_color = Button(
- name="toolbox-button",
- tooltip_text="Color Picker\nLeft Click: HEX\nMiddle Click: HSV\nRight Click: RGB\n\nKeyboard:\nEnter: HEX\nShift+Enter: RGB\nCtrl+Enter: HSV",
- child=Label(name="button-bar-label", markup=icons.colorpicker),
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- self.btn_gamemode = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.gamemode),
- on_clicked=self.gamemode,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- # Enable keyboard focus for the colorpicker button.
- self.btn_color.set_can_focus(True)
- # Connect both mouse and keyboard events.
- self.btn_color.connect("button-press-event", self.colorpicker)
- self.btn_color.connect("key_press_event", self.colorpicker_key)
-
- self.btn_emoji = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.emoji),
- on_clicked=self.emoji,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- self.btn_screenshots_folder = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.screenshots),
- on_clicked=self.open_screenshots_folder,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- self.btn_recordings_folder = Button(
- name="toolbox-button",
- child=Label(name="button-label", markup=icons.recordings),
- on_clicked=self.open_recordings_folder,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- )
-
- self.buttons = [
- self.btn_ssregion,
- self.btn_ssfull,
- self.btn_screenshots_folder,
- Box(
- name="tool-sep",
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- ),
- self.btn_screenrecord,
- self.btn_recordings_folder,
- Box(
- name="tool-sep",
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- ),
- self.btn_ocr,
- self.btn_color,
- Box(
- name="tool-sep",
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- ),
- self.btn_gamemode,
- self.btn_emoji,
- ]
-
- for button in self.buttons:
- self.add(button)
-
- self.show_all()
-
- # Start polling for process state every second.
- self.recorder_timer_id = GLib.timeout_add_seconds(
- 1, self.update_screenrecord_state
- )
-
- def close_menu(self):
- self.launcher.close()
-
- # Action methods
- def ssfull(self, *args):
- exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot p")
- self.close_menu()
-
- def on_ssfull_click(self, button, event):
- if event.type == Gdk.EventType.BUTTON_PRESS:
- if event.button == 1: # Left click
- self.ssfull()
- elif event.button == 3: # Right click
- exec_shell_command_async(
- f"bash {CAPTURE_SCRIPT} screenshot p mockup")
- self.close_menu()
- return True
- return False
-
- def on_ssfull_key(self, widget, event):
- if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
- modifiers = event.get_state()
- if modifiers & Gdk.ModifierType.SHIFT_MASK:
- exec_shell_command_async(
- f"bash {CAPTURE_SCRIPT} screenshot p mockup")
- self.close_menu()
- else:
- self.ssfull()
- return True
- return False
-
- def ssregion(self, *args):
- exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot sf")
- self.close_menu()
-
- def on_ssregion_click(self, button, event):
- if event.type == Gdk.EventType.BUTTON_PRESS:
- if event.button == 1: # Left click
- self.ssregion()
- elif event.button == 3: # Right click
- exec_shell_command_async(
- f"bash {CAPTURE_SCRIPT} screenshot sf mockup")
- self.close_menu()
- return True
- return False
-
- def on_ssregion_key(self, widget, event):
- if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
- modifiers = event.get_state()
- if modifiers & Gdk.ModifierType.SHIFT_MASK:
- exec_shell_command_async(
- f"bash {CAPTURE_SCRIPT} screenshot sf mockup")
- self.close_menu()
- else:
- self.ssregion()
- return True
- return False
-
- def screenrecord(self, *args):
- # Launch screenrecord script in detached mode so that it remains running independently of this program.
- exec_shell_command_async(
- f"bash -c 'nohup bash {CAPTURE_SCRIPT} record > /dev/null 2>&1 & disown'"
- )
- self.close_menu()
-
- def ocr(self, *args):
- exec_shell_command_async(f"bash {CAPTURE_SCRIPT} ocr sf")
- self.close_menu()
-
- def gamemode(self, *args):
- exec_shell_command_async(f"bash {GAMEMODE_SCRIPT}")
- self.gamemode_check()
- self.close_menu()
-
- def gamemode_check(self):
- def check():
- try:
- result = subprocess.run(
- f"bash {GAMEMODE_SCRIPT} check",
- shell=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- enabled = result.stdout == b"t\n"
- except Exception:
- enabled = False
-
- def update_ui():
- if enabled:
- self.btn_gamemode.get_child().set_markup(icons.gamemode_off)
- else:
- self.btn_gamemode.get_child().set_markup(icons.gamemode)
- return False
-
- GLib.idle_add(update_ui)
- return False
-
- GLib.idle_add(lambda: threading.Thread(target=check).start())
- return True
-
- def ssregion(self, *args):
- exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot sf")
- self.close_menu()
-
- def colorpicker(self, button, event):
- if event.type == Gdk.EventType.BUTTON_PRESS:
- cmd = {
- 1: "-hex", # Left click
- 2: "-hsv", # Middle click
- 3: "-rgb", # Right click
- }.get(event.button)
-
- if cmd:
- exec_shell_command_async(
- f"bash {
- get_relative_path(
- '../../../config/scripts/hyprpicker.sh')
- } {cmd}"
- )
- self.close_menu()
-
- def colorpicker_key(self, widget, event):
- if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
- modifiers = event.get_state()
- cmd = "-hex" # Default
-
- match modifiers & (
- Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK
- ):
- case Gdk.ModifierType.SHIFT_MASK:
- cmd = "-rgb"
- case Gdk.ModifierType.CONTROL_MASK:
- cmd = "-hsv"
-
- exec_shell_command_async(
- f"bash {get_relative_path('../../../config/scripts/hyprpicker.sh')} {
- cmd
- }"
- )
- self.close_menu()
- return True
- return False
-
- def update_screenrecord_state(self):
- def check():
- try:
- result = subprocess.run(
- "pgrep -f gpu-screen-recorder",
- shell=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- running = result.returncode == 0
- except Exception:
- running = False
-
- def update_ui():
- if running:
- self.btn_screenrecord.get_child().set_markup(icons.stop)
- self.btn_screenrecord.add_style_class("recording")
- else:
- self.btn_screenrecord.get_child().set_markup(icons.screenrecord)
- self.btn_screenrecord.remove_style_class("recording")
- return False
-
- GLib.idle_add(update_ui)
- return False
-
- GLib.idle_add(lambda: threading.Thread(target=check).start())
- return True
-
- def open_screenshots_folder(self, *args):
- screenshots_dir = os.path.join(
- os.environ.get("XDG_PICTURES_DIR",
- os.path.expanduser("~/Pictures")),
- "Screenshots",
- )
- # Create directory if it doesn't exist
- os.makedirs(screenshots_dir, exist_ok=True)
- exec_shell_command_async(f"xdg-open {screenshots_dir}")
- self.close_menu()
-
- def open_recordings_folder(self, *args):
- recordings_dir = os.path.join(
- os.environ.get("XDG_VIDEOS_DIR", os.path.expanduser("~/Videos")),
- "Recordings",
- )
- # Create directory if it doesn't exist
- os.makedirs(recordings_dir, exist_ok=True)
- exec_shell_command_async(f"xdg-open {recordings_dir}")
- self.close_menu()
-
- def emoji(self, *args):
- self.launcher.open("emoji")
diff --git a/modules/launcher/components/wallpapers.py b/modules/launcher/components/wallpapers.py
deleted file mode 100644
index 45c05b4..0000000
--- a/modules/launcher/components/wallpapers.py
+++ /dev/null
@@ -1,485 +0,0 @@
-import os
-import hashlib
-import shutil
-import colorsys
-from gi.repository import GdkPixbuf, Gtk, GLib, Gio, Gdk, Pango
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.entry import Entry
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.label import Label
-from fabric.utils.helpers import exec_shell_command_async
-import utils.icons as icons
-import config.data as data
-import config.config
-from PIL import Image
-import concurrent.futures
-from concurrent.futures import ThreadPoolExecutor
-
-class WallpaperSelector(Box):
- CACHE_DIR = f"{data.CACHE_DIR}/thumbnails" # Changed from wallpapers to thumbs
-
- def __init__(self, **kwargs):
-
- super().__init__(name="wallpapers", spacing=4, orientation="v", h_expand=False, v_expand=False, **kwargs)
- os.makedirs(self.CACHE_DIR, exist_ok=True)
-
- # Process old wallpapers: use os.scandir for efficiency and only loop
- # over image files that actually need renaming (they're not already lowercase
- # and with hyphens instead of spaces)
- with os.scandir(data.WALLPAPERS_DIR) as entries:
- for entry in entries:
- if entry.is_file() and self._is_image(entry.name):
- # Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces
- if entry.name != entry.name.lower() or " " in entry.name:
- new_name = entry.name.lower().replace(" ", "-")
- full_path = os.path.join(data.WALLPAPERS_DIR, entry.name)
- new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name)
- try:
- os.rename(full_path, new_full_path)
- print(f"Renamed old wallpaper '{full_path}' to '{new_full_path}'")
- except Exception as e:
- print(f"Error renaming file {full_path}: {e}")
-
- # Refresh the file list after potential renaming
- self.files = sorted([f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)])
- self.thumbnails = []
- self.thumbnail_queue = []
- self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor
-
- # Variable to control the selection (similar to AppLauncher)
- self.selected_index = -1
-
- # Initialize UI components
- self.viewport = Gtk.IconView(name="wallpaper-icons")
- self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str))
- self.viewport.set_pixbuf_column(0)
- # Hide text column so only the image is shown
- self.viewport.set_text_column(-1)
- self.viewport.set_item_width(0)
- self.viewport.connect("item-activated", self.on_wallpaper_selected)
- # self.viewport.connect("selection-changed", self._on_selection_changed) # Removed connection
-
- self.scrolled_window = ScrolledWindow(
- name="scrolled-window",
- spacing=10,
- h_expand=True,
- v_expand=True,
- child=self.viewport,
- )
-
- self.search_entry = Entry(
- name="search-entry-walls",
- placeholder="Search Wallpapers...",
- h_expand=True,
- notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
- on_key_press_event=self.on_search_entry_key_press,
- )
- self.search_entry.props.xalign = 0.5
- self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out)
-
- 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",
- }
-
- self.scheme_dropdown = Gtk.ComboBoxText()
- self.scheme_dropdown.set_name("scheme-dropdown")
- self.scheme_dropdown.set_tooltip_text("Select color scheme")
- for key, display_name in self.schemes.items():
- self.scheme_dropdown.append(key, display_name)
- self.scheme_dropdown.set_active_id("scheme-tonal-spot")
- self.scheme_dropdown.connect("changed", self.on_scheme_changed)
-
- # Load matugen state from the dedicated file
- self.matugen_enabled = True # Default to True
- try:
- with open(data.MATUGEN_STATE_FILE, 'r') as f:
- content = f.read().strip().lower()
- if content == "false":
- self.matugen_enabled = False
- elif content == "true":
- self.matugen_enabled = True
- # Any other content defaults to True
- except FileNotFoundError:
- # File doesn't exist, keep default True and create it on first toggle
- pass
- except Exception as e:
- print(f"Error reading matugen state file: {e}")
- # Keep default True on error
-
- # Create a switcher to enable/disable Matugen (enabled by default)
- self.matugen_switcher = Gtk.Switch(name="matugen-switcher")
- self.matugen_switcher.set_vexpand(False)
- self.matugen_switcher.set_hexpand(False)
- self.matugen_switcher.set_valign(Gtk.Align.CENTER)
- self.matugen_switcher.set_halign(Gtk.Align.CENTER)
- self.matugen_switcher.set_active(self.matugen_enabled)
- self.matugen_switcher.connect("notify::active", self.on_switch_toggled)
-
- self.mat_icon = Label(name="mat-label", markup=icons.palette)
-
- # Add the switcher to the header_box's start_children
- self.header_box = CenterBox(
- name="header-box",
- spacing=8,
- orientation="h",
- # Removed color button and label from here
- start_children=[self.matugen_switcher, self.mat_icon],
- center_children=[self.search_entry],
- end_children=[self.scheme_dropdown],
- )
-
- self.add(self.header_box)
-
- # Create the custom color selector components
- self.hue_slider = Gtk.Scale(
- orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL
- adjustment=Gtk.Adjustment(value=0, lower=0, upper=360, step_increment=1, page_increment=10),
- draw_value=False, # Hide the default value text
- digits=0,
- # inverted=True, # Removed inverted for horizontal
- name="hue-slider", # For CSS styling
- )
-
- # Changed expand/align for horizontal orientation
- self.hue_slider.set_hexpand(True)
- self.hue_slider.set_halign(Gtk.Align.FILL)
- self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically
- self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box
-
- self.apply_color_button = Button(name="apply-color-button", child=Label(name="apply-color-label", markup=icons.accept))
- self.apply_color_button.connect("clicked", self.on_apply_color_clicked)
- self.apply_color_button.set_vexpand(False) # Ensure button doesn't expand vertically
- self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically
-
- self.custom_color_selector_box = Box(
- orientation="h", spacing=5, name="custom-color-selector-box", # Changed orientation to horizontal
- h_align="center" # Center the horizontal box
- )
- self.custom_color_selector_box.add(self.hue_slider)
- self.custom_color_selector_box.add(self.apply_color_button)
- self.custom_color_selector_box.set_halign(Gtk.Align.FILL)
-
- # Add the scrolled window (grid) and the custom color selector box directly
- # to the main WallpaperSelector box (which is already vertical)
- self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand
- self.pack_start(self.custom_color_selector_box, False, False, 0) # Add custom selector, don't expand
-
- # Removed the old main_content_box and its add
-
- self._start_thumbnail_thread()
- self.connect("map", self.on_map) # Connect the map signal
- # Set initial sensitivity based on loaded state
- # self.scheme_dropdown.set_sensitive(self.matugen_enabled) # Ensure sensitivity is set correctly on load
- self.setup_file_monitor() # Initialize file monitoring
- self.show_all()
- # Ensure the search entry gets focus when starting
- self.search_entry.grab_focus()
-
- def setup_file_monitor(self):
- gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR)
- self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
- self.file_monitor.connect("changed", self.on_directory_changed)
-
- def on_directory_changed(self, monitor, file, other_file, event_type):
- file_name = file.get_basename()
- if event_type == Gio.FileMonitorEvent.DELETED:
- if file_name in self.files:
- self.files.remove(file_name)
- cache_path = self._get_cache_path(file_name)
- if os.path.exists(cache_path):
- try:
- os.remove(cache_path)
- except Exception as e:
- print(f"Error deleting cache {cache_path}: {e}")
- self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name]
- GLib.idle_add(self.arrange_viewport, self.search_entry.get_text())
- elif event_type == Gio.FileMonitorEvent.CREATED:
- if self._is_image(file_name):
- # Convert filename to lowercase and replace spaces with "-"
- new_name = file_name.lower().replace(" ", "-")
- full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
- new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name)
- if new_name != file_name:
- try:
- os.rename(full_path, new_full_path)
- file_name = new_name
- print(f"Renamed file '{full_path}' to '{new_full_path}')")
- except Exception as e:
- print(f"Error renaming file {full_path}: {e}")
- if file_name not in self.files:
- self.files.append(file_name)
- self.files.sort()
- self.executor.submit(self._process_file, file_name)
- elif event_type == Gio.FileMonitorEvent.CHANGED:
- if self._is_image(file_name) and file_name in self.files:
- cache_path = self._get_cache_path(file_name)
- if os.path.exists(cache_path):
- try:
- os.remove(cache_path)
- except Exception as e:
- print(f"Error deleting cache for changed file {file_name}: {e}")
- self.executor.submit(self._process_file, file_name)
-
- def arrange_viewport(self, query: str = ""):
- model = self.viewport.get_model()
- model.clear()
- filtered_thumbnails = [
- (thumb, name)
- for thumb, name in self.thumbnails
- if query.casefold() in name.casefold()
- ]
- filtered_thumbnails.sort(key=lambda x: x[1].lower())
- for pixbuf, file_name in filtered_thumbnails:
- model.append([pixbuf, file_name])
- # If the search entry is empty, no icon is selected; otherwise, select the first one.
- if query.strip() == "":
- self.viewport.unselect_all()
- self.selected_index = -1
- elif len(model) > 0:
- self.update_selection(0)
-
- def on_wallpaper_selected(self, iconview, path):
- model = iconview.get_model()
- file_name = model[path][1]
- full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
- selected_scheme = self.scheme_dropdown.get_active_id()
- current_wall = os.path.expanduser(f"~/.current.wall")
- if os.path.isfile(current_wall):
- os.remove(current_wall)
- os.symlink(full_path, current_wall)
- if self.matugen_switcher.get_active():
- # Matugen is enabled: run the normal command.
- exec_shell_command_async(f'matugen image {full_path} -t {selected_scheme}')
- else:
- # Matugen is disabled: run the alternative swww command.
- exec_shell_command_async(
- f'swww img {full_path} -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
- )
-
- def on_scheme_changed(self, combo):
- selected_scheme = combo.get_active_id()
- print(f"Color scheme selected: {selected_scheme}")
-
- def on_search_entry_key_press(self, widget, event):
- if event.state & Gdk.ModifierType.SHIFT_MASK:
- if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
- schemes_list = list(self.schemes.keys())
- current_id = self.scheme_dropdown.get_active_id()
- current_index = schemes_list.index(current_id) if current_id in schemes_list else 0
- new_index = (current_index - 1) % len(schemes_list) if event.keyval == Gdk.KEY_Up else (current_index + 1) % len(schemes_list)
- self.scheme_dropdown.set_active(new_index)
- return True
- elif event.keyval == Gdk.KEY_Right:
- self.scheme_dropdown.popup()
- return True
-
- if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
- self.move_selection_2d(event.keyval)
- return True
- elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
- if self.selected_index != -1:
- path = Gtk.TreePath.new_from_indices([self.selected_index])
- self.on_wallpaper_selected(self.viewport, path)
- return True
- return False
-
- # Removed _on_selection_changed method
-
- def move_selection_2d(self, keyval):
- model = self.viewport.get_model()
- total_items = len(model)
- if total_items == 0:
- return
-
- # --- Determine Column Count ---
- columns = self.viewport.get_columns()
-
- # If get_columns returns 0 or -1 (auto), try to estimate by checking item rows
- if columns <= 0 and total_items > 0:
- estimated_cols = 0
- try:
- # Check the row of the first item (should be 0)
- first_item_path = Gtk.TreePath.new_from_indices([0])
- base_row = self.viewport.get_item_row(first_item_path)
-
- # Find the index of the first item in the *next* row
- for i in range(1, total_items):
- path = Gtk.TreePath.new_from_indices([i])
- row = self.viewport.get_item_row(path)
- if row > base_row:
- estimated_cols = i # The number of items in the first row
- break
-
- # If loop finished without finding a new row, all items are in one row
- if estimated_cols == 0:
- estimated_cols = total_items
-
- columns = max(1, estimated_cols)
- except Exception:
- # Fallback if get_item_row fails (e.g., widget not realized)
- columns = 1
- elif columns <= 0 and total_items == 0:
- columns = 1 # Should not happen due to early return, but safe
-
- # Ensure columns is at least 1 after all checks
- columns = max(1, columns)
-
- # --- Navigation Logic ---
- current_index = self.selected_index
- new_index = current_index
-
- if current_index == -1:
- # If nothing is selected, select the first or last item based on direction
- if keyval in (Gdk.KEY_Down, Gdk.KEY_Right):
- new_index = 0
- elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left):
- new_index = total_items - 1
- if total_items == 0: new_index = -1 # Handle edge case
-
- else:
- # Calculate potential new index based on key press
- if keyval == Gdk.KEY_Up:
- potential_new_index = current_index - columns
- # Only update if the new index is valid (>= 0)
- if potential_new_index >= 0:
- new_index = potential_new_index
- elif keyval == Gdk.KEY_Down:
- potential_new_index = current_index + columns
- # Only update if the new index is valid (< total_items)
- if potential_new_index < total_items:
- new_index = potential_new_index
- elif keyval == Gdk.KEY_Left:
- # Only update if not already in the first column (index % columns != 0)
- # and the index is greater than 0
- if current_index > 0 and current_index % columns != 0:
- new_index = current_index - 1
- elif keyval == Gdk.KEY_Right:
- # Only update if not in the last column ((index + 1) % columns != 0)
- # and not the very last item (index < total_items - 1)
- if current_index < total_items - 1 and (current_index + 1) % columns != 0:
- new_index = current_index + 1
-
- # Only update if the index actually changed and is valid
- if new_index != self.selected_index and 0 <= new_index < total_items:
- self.update_selection(new_index)
- elif total_items > 0 and self.selected_index == -1 and 0 <= new_index < total_items:
- # Handle selecting the first item when starting from -1
- self.update_selection(new_index)
-
- def update_selection(self, new_index: int):
- self.viewport.unselect_all()
- path = Gtk.TreePath.new_from_indices([new_index])
- self.viewport.select_path(path)
- self.viewport.scroll_to_path(path, False, 0.5, 0.5) # Ensure the selected icon is visible
- self.selected_index = new_index
-
- def _start_thumbnail_thread(self):
- thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None)
-
- def _preload_thumbnails(self, _data):
- futures = [self.executor.submit(self._process_file, file_name) for file_name in self.files]
- concurrent.futures.wait(futures)
- GLib.idle_add(self._process_batch)
-
- def _process_file(self, file_name):
- full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
- cache_path = self._get_cache_path(file_name)
- if not os.path.exists(cache_path):
- try:
- with Image.open(full_path) as img:
- width, height = img.size
- side = min(width, height)
- left = (img.width - side) // 2
- top = (height - side) // 2
- right = left + side
- bottom = top + side
- img_cropped = img.crop((left, top, right, bottom))
- img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS)
- img_cropped.save(cache_path, "PNG")
- except Exception as e:
- print(f"Error processing {file_name}: {e}")
- return
- self.thumbnail_queue.append((cache_path, file_name))
- GLib.idle_add(self._process_batch)
-
- def _process_batch(self):
- batch = self.thumbnail_queue[:10]
- del self.thumbnail_queue[:10]
- for cache_path, file_name in batch:
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
- self.thumbnails.append((pixbuf, file_name))
- self.viewport.get_model().append([pixbuf, file_name])
- except Exception as e:
- print(f"Error loading thumbnail {cache_path}: {e}")
- if self.thumbnail_queue:
- GLib.idle_add(self._process_batch)
- return False
-
- def _get_cache_path(self, file_name: str) -> str:
- file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest()
- return os.path.join(self.CACHE_DIR, f"{file_hash}.png")
-
- @staticmethod
- def _is_image(file_name: str) -> bool:
- return file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp'))
-
- def on_search_entry_focus_out(self, widget, event):
- if self.get_mapped():
- widget.grab_focus()
- return False
-
- def on_map(self, widget):
- """Handles the map signal to set initial visibility of the color selector."""
- # Set visibility based on the loaded state when the widget becomes visible
- self.custom_color_selector_box.set_visible(not self.matugen_enabled)
-
- def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:
- """Converts 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, 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 rgba_to_hex(self, rgba: Gdk.RGBA) -> str:
- """Converts Gdk.RGBA to a HEX color string."""
- r = int(rgba.red * 255)
- g = int(rgba.green * 255)
- b = int(rgba.blue * 255)
- return f"#{r:02X}{g:02X}{b:02X}"
-
- def on_switch_toggled(self, switch, gparam):
- """Handles the toggling of the Matugen switch."""
- is_active = switch.get_active()
- self.matugen_enabled = is_active
- # self.scheme_dropdown.set_sensitive(is_active)
- self.custom_color_selector_box.set_visible(not is_active) # Toggle visibility
-
- # Save the state to the dedicated file
- try:
- with open(data.MATUGEN_STATE_FILE, 'w') as f:
- f.write(str(is_active))
- except Exception as e:
- print(f"Error writing matugen state file: {e}")
-
- def on_apply_color_clicked(self, button):
- """Applies the color selected by the hue slider via matugen."""
- hue_value = self.hue_slider.get_value() # Get value from 0-360
- hex_color = self.hsl_to_rgb_hex(hue_value) # Convert HSL(hue, 1.0, 0.5) to HEX
- print(f"Applying color from slider: H={hue_value}, HEX={hex_color}")
- selected_scheme = self.scheme_dropdown.get_active_id()
- # Run matugen with the chosen hex color and selected scheme
- exec_shell_command_async(f'matugen color hex "{hex_color}" -t {selected_scheme}')
- # Optionally save the chosen color to config if needed later
- # config.config.bind_vars["matugen_hex_color"] = hex_color
- # config.config.save_config() # Removed as save_config doesn't exist
diff --git a/modules/launcher/components/wifi.py b/modules/launcher/components/wifi.py
deleted file mode 100644
index 94e52d6..0000000
--- a/modules/launcher/components/wifi.py
+++ /dev/null
@@ -1,153 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.image import Image
-from fabric.widgets.button import Button
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from services import network_client
-import utils.icons as icons
-
-
-class WifiNetworkSlot(CenterBox):
- def __init__(self, network, client: network_client, **kwargs):
- super().__init__(name="wifi-device", **kwargs)
- self.network = network
- self.client = client
- self.connecting = False # Tracks if we are connecting/disconnecting
-
- self.connection_icon = Label(name="wifi-connection", markup=icons.wifi_off)
- self.connect_button = Button(
- name="wifi-connect",
- label="Connect",
- on_clicked=self.on_connect_clicked,
- )
-
- self.start_children = [
- Box(
- spacing=8,
- children=[
- Image(icon_name=network.get("icon-name"), size=32),
- Label(label=network.get("ssid")),
- self.connection_icon,
- ],
- )
- ]
- self.end_children = self.connect_button
-
- self.visible = True
- self.show_all()
-
- # Listen to WiFi state changes
- self.client.wifi_device.connect("changed", lambda *_: self.update_status())
-
- self.update_status()
-
- def on_connect_clicked(self, *_):
- connected = self.network.get("ssid") == self.client.wifi_device.ssid
- if connected:
- self.connect_button.set_label("Disconnecting...")
- self.client.wifi_device.enabled = False # Disables WiFi to disconnect
- else:
- self.connect_button.set_label("Connecting...")
- self.client.connect_wifi_bssid(self.network.get("bssid"))
-
- self.connecting = True
- self.connect_button.sensitive = False
-
- def update_status(self):
- state = self.client.wifi_device.state
- connected = self.network.get("ssid") == self.client.wifi_device.ssid
-
- if self.connecting:
- if state in ["activating"]:
- self.connect_button.set_label("Connecting...")
- elif state in ["deactivating"]:
- self.connect_button.set_label("Disconnecting...")
- else:
- self.connecting = False
- self.connect_button.sensitive = True
-
- if not self.connecting:
- self.connection_icon.set_markup(icons.wifi if connected else icons.wifi_off)
- self.connect_button.set_label("Disconnect" if connected else "Connect")
-
-
-class WifiManager(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="wifi",
- spacing=8,
- orientation="vertical",
- **kwargs,
- )
-
- self.client = network_client
- self.scan_icon = Label(name="wifi-scan-icon", markup=icons.scan)
- self.toggle_icon = Label(name="wifi-toggle-icon", markup=icons.wifi_off)
-
- self.scan_button = Button(
- name="wifi-scan",
- child=self.scan_icon,
- tooltip_text="Scan",
- on_clicked=lambda *_: self.client.wifi_device
- and self.client.wifi_device.scan(),
- )
-
- self.toggle_button = Button(
- name="wifi-toggle",
- tooltip_text="Toggle WiFi",
- child=self.toggle_icon,
- on_clicked=lambda *_: self.client.wifi_device
- and self.client.wifi_device.toggle_wifi(),
- )
-
- self.available_box = Box(spacing=2, orientation="vertical")
-
- self.children = [
- CenterBox(
- name="wifi-header",
- start_children=self.scan_button,
- center_children=Label(name="wifi-text", label="WiFi Networks"),
- end_children=self.toggle_button,
- ),
- ScrolledWindow(
- name="wifi-available",
- v_expand=True,
- h_scrollbar_policy="never",
- min_content_size=(-1, -1),
- child=self.available_box,
- ),
- ]
-
- self.client.connect("device-ready", lambda *_: self.on_device_ready())
-
- def on_device_ready(self):
- if not self.client.wifi_device:
- return
-
- self.update_toggle_icon()
-
- self.client.wifi_device.connect(
- "notify::enabled",
- lambda *_: self.update_toggle_icon(),
- )
-
- self.build_wifi_options()
-
- def update_toggle_icon(self):
- self.toggle_icon.set_markup(
- icons.wifi if self.client.wifi_device.enabled else icons.wifi_off
- )
-
- def build_wifi_options(self):
- self.available_box.children = []
- if not self.client.wifi_device:
- return
-
- aps = self.client.wifi_device.access_points
- seen_ssids = set()
- for ap in aps:
- ssid = ap.get("ssid")
- if ap.get("ssid") != "Unknown" and ssid not in seen_ssids:
- seen_ssids.add(ssid)
- self.available_box.add(WifiNetworkSlot(ap, self.client))
diff --git a/modules/launcher/components/window_switcher.py b/modules/launcher/components/window_switcher.py
deleted file mode 100644
index c2ed906..0000000
--- a/modules/launcher/components/window_switcher.py
+++ /dev/null
@@ -1,312 +0,0 @@
-import subprocess
-import json
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.button import Button
-from fabric.widgets.entry import Entry
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.image import Image
-from fabric.utils import idle_add, remove_handler, exec_shell_command_async
-from utils import IconResolver
-from gi.repository import GLib, Gdk, Gtk
-import utils.icons as icons
-import config.data as data
-
-class WindowSwitcher(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="window-switcher",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- self.launcher = kwargs["launcher"]
- self.selected_index = -1 # Track the selected item index
- self.icon_resolver = IconResolver()
-
- self.viewport = Box(name="viewport", spacing=4, orientation="v")
- self.search_entry = Entry(
- name="search-entry",
- placeholder="Search Windows...",
- h_expand=True,
- notify_text=lambda entry, *_: self.handle_search(entry.get_text()),
- on_activate=lambda entry, *_: self.handle_search(entry.get_text()),
- on_key_press_event=self.on_entry_key_press,
- )
- self.search_entry.props.xalign = 0.5
- self.scrolled_window = ScrolledWindow(
- name="scrolled-window",
- spacing=10,
- min_content_size=(450, 305),
- max_content_size=(450, 305),
- child=self.viewport,
- )
-
- self.header_box = Box(
- name="header_box",
- spacing=10,
- orientation="h",
- children=[
- self.search_entry,
- ],
- )
-
- self.window_switcher_box = Box(
- name="window-switcher-box",
- spacing=10,
- h_expand=True,
- orientation="v",
- children=[
- self.header_box,
- self.scrolled_window,
- ],
- )
-
- self.add(self.window_switcher_box)
- self.show_all()
-
- def close_switcher(self):
- """Close the window switcher"""
- self.viewport.hide()
- self.selected_index = -1 # Reset selection
- self.launcher.close()
-
- def open_switcher(self):
- """Open the window switcher and refresh windows"""
- self.refresh_windows()
- self.search_entry.set_text("")
- self.search_entry.grab_focus()
- self.viewport.show()
-
- def refresh_windows(self):
- """Get Hyprland windows and populate the viewport"""
- remove_handler(self._arranger_handler) if hasattr(self, '_arranger_handler') else None
- self.viewport.children = []
- self.selected_index = -1 # Clear selection when viewport changes
-
- # Get Hyprland windows
- windows = self.get_hyprland_windows()
- if not windows:
- # Create a container box to better center the message
- container = Box(
- name="no-windows-container",
- orientation="v",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True
- )
-
- # Show a message if no windows
- label = Label(
- name="no-windows",
- markup=icons.window,
- h_align="center",
- v_align="center",
- )
-
- container.add(label)
-
- self.viewport.add(container)
- return
-
- # Add window slots to viewport
- for window in windows:
- self.viewport.add(self.create_window_slot(window))
-
- def get_hyprland_windows(self):
- """Get list of windows from Hyprland"""
- try:
- # Get clients info from Hyprland
- result = subprocess.run(
- ["hyprctl", "clients", "-j"],
- capture_output=True,
- text=True,
- check=True
- )
- clients = json.loads(result.stdout)
-
- # Filter out special windows and sort by workspace
- windows = []
- for client in clients:
- if client.get("class") and not client.get("class").startswith("special"):
- windows.append(client)
-
- # Sort windows by workspace number
- windows.sort(key=lambda x: int(x.get("workspace", {}).get("id", 0)))
- return windows
-
- except Exception as e:
- print(f"Error getting Hyprland windows: {e}")
- return []
-
- def create_window_slot(self, window):
- """Create a button slot for a window"""
- # Get window title or class as fallback
- title = window.get("title", window.get("class", "Unknown"))
- workspace = window.get("workspace", {}).get("id", 0)
- window_class = window.get("class", "")
-
- # Get window icon using IconResolver
- icon = self.get_window_icon(window_class)
-
- # Create the button with window info
- button = Button(
- name="slot-button",
- child=Box(
- name="window-slot-box",
- orientation="h",
- spacing=10,
- children=[
- Image(
- name="window-icon",
- pixbuf=icon,
- h_align="start",
- ),
- Label(
- name="workspace-label",
- label=f"Workspace {workspace}",
- h_align="start",
- ),
- Label(
- name="window-title",
- label=title,
- ellipsization="end",
- v_align="center",
- h_align="center",
- ),
- ],
- ),
- on_clicked=lambda *_: self.focus_window(window.get("address")),
- on_key_press_event=lambda btn, event, addr=window.get("address"): self.on_slot_key_press(btn, event, addr),
- )
- return button
-
- def get_window_icon(self, window_class):
- """Get the appropriate icon for a window class using IconResolver"""
- try:
- # Try to get icon from IconResolver
- icon = self.icon_resolver.get_icon_pixbuf(window_class, size=24)
- if icon:
- return icon
- except Exception as e:
- print(f"Error resolving icon for {window_class}: {e}")
-
- # Fallback to default window icon if resolution fails
- return icons.window
-
- def focus_window(self, address):
- """Focus a window by its address"""
- try:
- subprocess.run(
- ["hyprctl", "dispatch", "focuswindow", f"address:{address}"],
- check=True
- )
- self.close_switcher()
- except Exception as e:
- print(f"Error focusing window: {e}")
-
- def handle_search(self, query):
- """Handle search input"""
- if not query:
- self.refresh_windows()
- return
-
- # Filter windows based on search query
- windows = self.get_hyprland_windows()
- filtered_windows = [
- window for window in windows
- if query.lower() in window.get("title", "").lower() or
- query.lower() in window.get("class", "").lower()
- ]
-
- # Update viewport with filtered results
- self.viewport.children = []
- self.selected_index = -1
-
- for window in filtered_windows:
- self.viewport.add(self.create_window_slot(window))
-
- def on_entry_key_press(self, widget, event):
- """Handle key presses on the search entry"""
- if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
- if self.selected_index != -1:
- children = self.viewport.get_children()
- if 0 <= self.selected_index < len(children):
- children[self.selected_index].clicked()
- return True
- elif event.keyval == Gdk.KEY_Down:
- self.move_selection(1)
- return True
- elif event.keyval == Gdk.KEY_Up:
- self.move_selection(-1)
- return True
- elif event.keyval == Gdk.KEY_Escape:
- self.close_switcher()
- return True
- return False
-
- def on_slot_key_press(self, button, event, address):
- """Handle key presses on window slots"""
- if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
- self.focus_window(address)
- return True
- elif event.keyval == Gdk.KEY_Escape:
- self.close_switcher()
- return True
- return False
-
- def move_selection(self, direction):
- """Move the selection up or down"""
- children = self.viewport.get_children()
- if not children:
- return
-
- new_index = self.selected_index + direction
- if 0 <= new_index < len(children):
- self.update_selection(new_index)
-
- def update_selection(self, new_index):
- """Update the selected item"""
- # Unselect current
- if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()):
- current_button = self.viewport.get_children()[self.selected_index]
- current_button.get_style_context().remove_class("selected")
-
- # Select new
- if new_index != -1 and new_index < len(self.viewport.get_children()):
- new_button = self.viewport.get_children()[new_index]
- new_button.get_style_context().add_class("selected")
- self.selected_index = new_index
- self.scroll_to_selected(new_button)
- else:
- self.selected_index = -1
-
- def scroll_to_selected(self, button):
- """Scroll to ensure the selected button is visible"""
- def scroll():
- adj = self.scrolled_window.get_vadjustment()
- alloc = button.get_allocation()
- if alloc.height == 0:
- return False # Retry if allocation isn't ready
-
- y = alloc.y
- height = alloc.height
- page_size = adj.get_page_size()
- current_value = adj.get_value()
-
- # Calculate visible boundaries
- visible_top = current_value
- visible_bottom = current_value + page_size
-
- if y < visible_top:
- # Item above viewport - align to top
- adj.set_value(y)
- elif y + height > visible_bottom:
- # Item below viewport - align to bottom
- new_value = y + height - page_size
- adj.set_value(new_value)
- # No action if already fully visible
- return False
- GLib.idle_add(scroll)
\ No newline at end of file
diff --git a/modules/launcher/launcher.py b/modules/launcher/launcher.py
deleted file mode 100644
index 4704e75..0000000
--- a/modules/launcher/launcher.py
+++ /dev/null
@@ -1,201 +0,0 @@
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.stack import Stack
-from fabric.widgets.wayland import WaylandWindow as Window
-from fabric.widgets.box import Box
-from modules.launcher.components import (
- AppLauncher,
- BluetoothConnections,
- EmojiPicker,
- PowerMenu,
- Sh,
- WallpaperSelector,
- WifiManager,
- Calendar,
- Dashboard,
- Toolbox,
- Kanban,
- TmuxManager,
- WindowSwitcher,
- NotificationCenter,
- ClipHistory,
-)
-
-
-class Launcher(Window):
- def __init__(self, **kwargs):
- super().__init__(
- layer="top",
- anchor="center",
- keyboard_mode="none",
- exclusivity="normal",
- visible=False,
- all_visible=False,
- **kwargs,
- )
-
- self.dashboard = Dashboard(launcher=self)
- self.wallpapers = WallpaperSelector(launcher=self)
- self.power = PowerMenu(launcher=self)
- self.emoji = EmojiPicker(launcher=self)
- self.bluetooth = BluetoothConnections(launcher=self)
- self.sh = Sh(launcher=self)
- self.wifi = WifiManager()
- self.calendar = Calendar()
- self.tools = Toolbox(launcher=self)
- self.kanban = Kanban()
- self.tmux = TmuxManager(launcher=self)
- self.window_switcher = WindowSwitcher(launcher=self)
- self.notification_center = NotificationCenter(launcher=self)
- self.cliphist = ClipHistory(launcher=self)
-
- # Wrap the dashboard in a Box container
- self.dashboard = Box(
- name="dashboard",
- orientation="h",
- spacing=10,
- children=[self.dashboard],
- )
- self.launcher = AppLauncher(launcher=self)
-
- self.stack = Stack(
- name="launcher-content",
- v_expand=True,
- h_expand=True,
- transition_type="crossfade",
- transition_duration=100,
- children=[
- self.launcher,
- self.wallpapers,
- self.power,
- self.emoji,
- self.cliphist,
- self.bluetooth,
- self.sh,
- self.wifi,
- self.calendar,
- self.tools,
- self.kanban,
- self.tmux,
- self.window_switcher,
- self.notification_center,
- ],
- )
-
- self.launcher_box = CenterBox(
- name="launcher",
- orientation="v",
- start_children=self.stack,
- end_children=self.dashboard,
- )
-
- self.add(self.launcher_box)
- self.show_all()
- self.hide()
- self.add_keybinding("Escape", lambda *_: self.close())
-
- def close(self):
- self.set_keyboard_mode("none")
- self.hide()
-
- for widget in [
- self.launcher,
- self.wallpapers,
- self.power,
- self.emoji,
- self.cliphist,
- self.bluetooth,
- self.sh,
- self.wifi,
- self.calendar,
- self.tools,
- self.kanban,
- self.tmux,
- self.window_switcher,
- self.notification_center,
- ]:
- if hasattr(widget, "viewport") and widget.viewport:
- widget.viewport.hide()
-
- for style in [
- "launcher",
- "wallpapers",
- "power",
- "emoji",
- "cliphist",
- "bluetooth",
- "sh",
- "wifi",
- "calendar",
- "tools",
- "kanban",
- "tmux",
- "window-switcher",
- "notification-center",
- ]:
- self.stack.remove_style_class(style)
-
- return True
-
- def open(self, widget):
- widgets = {
- "launcher": self.launcher,
- "wallpapers": self.wallpapers,
- "power": self.power,
- "emoji": self.emoji,
- "cliphist": self.cliphist,
- "bluetooth": self.bluetooth,
- "sh": self.sh,
- "wifi": self.wifi,
- "calendar": self.calendar,
- "tools": self.tools,
- "kanban": self.kanban,
- "tmux": self.tmux,
- "window-switcher": self.window_switcher,
- "notification-center": self.notification_center,
- }
- self.set_keyboard_mode("exclusive")
- self.show()
-
- for w in widgets.values():
- w.hide()
- self.dashboard.hide()
- for style in widgets.keys():
- self.stack.remove_style_class(style)
-
- if widget in widgets:
- self.stack.get_style_context().add_class(widget)
- self.stack.set_visible_child(widgets[widget])
- widgets[widget].show()
-
- if widget == "launcher":
- self.launcher.open_launcher()
- self.launcher.search_entry.set_text("")
- self.launcher.search_entry.grab_focus()
- self.dashboard.show()
-
- elif widget == "wallpapers":
- self.wallpapers.search_entry.set_text("")
- self.wallpapers.search_entry.grab_focus()
- self.wallpapers.viewport.show()
-
- elif widget == "tmux":
- self.tmux.open_manager()
- self.tmux.search_entry.set_text("")
- self.tmux.search_entry.grab_focus()
-
- elif widget == "emoji":
- self.emoji.open_picker()
- self.emoji.search_entry.set_text("")
- self.emoji.search_entry.grab_focus()
-
- elif widget == "cliphist":
- self.cliphist.open()
-
- elif widget == "sh":
- self.sh.open_sh()
-
- elif widget == "window-switcher":
- self.window_switcher.open_switcher()
-
- elif widget == "notification-center":
- self.notification_center.open_center()
diff --git a/modules/launcher/main.py b/modules/launcher/main.py
new file mode 100644
index 0000000..46a9dca
--- /dev/null
+++ b/modules/launcher/main.py
@@ -0,0 +1,1299 @@
+from typing import List, Optional, Tuple
+
+import utils.icons as icons
+from fabric.core.service import Property
+from fabric.utils import exec_shell_command_async, get_relative_path
+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 gi.repository import Gdk, GLib
+from modules.launcher.plugin_manager import PluginManager
+from modules.launcher.result import Result
+from modules.launcher.result_item import ResultItem
+from modules.launcher.trigger_config import TriggerConfig
+from utils.wayland import WaylandWindow as Window
+
+# Constants
+SEARCH_DEBOUNCE_MS = 150
+TRIGGER_SEARCH_DEBOUNCE_MS = 50
+CURSOR_POSITION_DELAY_MS = 10
+SCROLL_PADDING = 10
+DEFAULT_ITEM_HEIGHT = 60
+PAGE_NAVIGATION_STEP = 5
+LAUNCHER_WIDTH = 550
+LAUNCHER_HEIGHT = 260
+
+
+class Launcher(Window):
+ """
+ Main launcher window with search functionality and plugin system.
+ Similar to Albert Launcher 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",
+ layer="top",
+ anchor="center",
+ exclusivity="none",
+ keyboard_mode="exclusive",
+ **kwargs,
+ )
+
+ # Initialization flag to prevent callbacks during setup
+ self._initializing = True
+
+ # Flag to prevent recursion when automatically adding spaces
+ self._auto_adding_space = False
+
+ # Flag to prevent search change handler interference during backspace processing
+ self._processing_backspace = False
+
+ # Initialize plugin manager
+ self.plugin_manager = PluginManager()
+
+ # Initialize trigger configuration
+ self.trigger_config = TriggerConfig()
+
+ # Current results and selection
+ self.results: List[Result] = []
+ self.selected_index = 0
+ self.max_results = 10
+
+ # 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"
+
+ # Setup UI
+ main_box = Box(
+ name="launcher",
+ orientation="v",
+ spacing=0,
+ h_align="center",
+ v_align="center",
+ )
+ self.add(main_box)
+
+ # Search entry with overlay for trigger indication
+ self.search_entry = Entry(
+ name="launcher-search",
+ placeholder="Type to 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)
+
+ # Results container
+ 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)
+
+ # Initially hide the results container
+ self.results_scroll.hide()
+
+ self.connect("key-press-event", self._on_key_press)
+ 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
+ """
+ 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 - clear everything
+ self.opened_with_trigger = False
+ self.search_entry.set_text("")
+ self._clear_results()
+
+ # 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:
+ # Hide results when query is empty
+ self._clear_results()
+
+ 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 - reset trigger mode and hide results
+ self.triggered_plugin = None
+ self.active_trigger = ""
+ 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 - only show trigger suggestions
+ self.triggered_plugin = None
+ self.active_trigger = ""
+
+ # Show trigger suggestions if query matches trigger prefixes
+ trigger_suggestions = self._get_trigger_suggestions(query)
+ all_results = 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 or trigger suggestions - apply max_results limit
+ 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_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_markup=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 (Albert-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()
+
+ # Show/hide scroll container based on results
+ if self.results:
+ self.results_scroll.show()
+ else:
+ self.results_scroll.hide()
+
+ def _update_input_action_text(self):
+ """Update the input field with action text (Albert-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()
+
+ if self.triggered_plugin:
+ if self.results:
+ # Show the first result as action text
+ first_result = self.results[0]
+ action_text = first_result.title
+
+ # For calculator results, show the evaluation
+ if hasattr(first_result, "data") and "result" in first_result.data:
+ action_text = str(first_result.data["result"])
+
+ # Set placeholder to show the action text
+ if action_text and action_text != current_text:
+ self.search_entry.set_placeholder_text(
+ f"{current_text} → {action_text}"
+ )
+ else:
+ self.search_entry.set_placeholder_text(
+ f"[{self.active_trigger.strip()}] searching..."
+ )
+ 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"[{self.active_trigger.strip()}] ready - type to search"
+ )
+ else:
+ # Searching within trigger
+ self.search_entry.set_placeholder_text(
+ f"[{self.active_trigger.strip()}] searching..."
+ )
+ else:
+ # Not in trigger mode - show trigger help
+ if current_text == ":":
+ self.search_entry.set_placeholder_text(
+ "Showing all available triggers."
+ )
+ elif current_text:
+ self.search_entry.set_placeholder_text(
+ "Type trigger keyword (calc, app, file, system...)"
+ )
+ else:
+ self.search_entry.set_placeholder_text(
+ "Type trigger keyword: calc, app, file, system..."
+ )
+
+ 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)
+ self.results_scroll.hide()
+
+ 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
+ elif self.triggered_plugin:
+ # Exit trigger mode
+ self.triggered_plugin = None
+ self.active_trigger = ""
+ self.search_entry.set_text("")
+ self._clear_results()
+ 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."""
+ 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:
+ # 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."""
+ self.selected_index = index
+ self._activate_selected()
+
+ def _on_result_hovered(self, index):
+ """Handle result item hover."""
+ # Update selection when mouse hovers over a result (without scrolling)
+ 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 _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 60
+ item_y = allocation.y
+ except:
+ # 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/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py
new file mode 100644
index 0000000..13bd1fc
--- /dev/null
+++ b/modules/launcher/plugin_base.py
@@ -0,0 +1,172 @@
+from abc import ABC, abstractmethod
+from typing import List
+
+from modules.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/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py
new file mode 100644
index 0000000..3f9f9d0
--- /dev/null
+++ b/modules/launcher/plugin_manager.py
@@ -0,0 +1,186 @@
+import importlib
+import importlib.util
+import os
+from typing import Dict, List, Type
+
+from modules.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"modules.launcher.plugins.{plugin_name}", plugin_path
+ )
+
+ if spec and spec.loader:
+ module = importlib.util.module_from_spec(spec)
+
+ # Add the module to sys.modules to support relative imports
+ import sys
+
+ sys.modules[f"modules.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",
+ "kanban",
+ "calendar",
+ "reminders",
+ "otp",
+ "password",
+ "bookmarks",
+ "process",
+ "bash_scripts",
+ "tmux",
+ ]
+
+ 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/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py
new file mode 100644
index 0000000..666274b
--- /dev/null
+++ b/modules/launcher/plugins/__init__.py
@@ -0,0 +1,3 @@
+"""
+Built-in plugins for the launcher.
+"""
diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py
new file mode 100644
index 0000000..3f47137
--- /dev/null
+++ b/modules/launcher/plugins/applications.py
@@ -0,0 +1,163 @@
+import json
+import re
+from typing import List
+
+from fabric.utils import DesktopApp
+from fabric.utils.helpers import get_desktop_applications, get_relative_path
+
+from modules.dock.main import Dock
+from modules.launcher.plugin_base import PluginBase
+from modules.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):
+ # Set up triggers for applications - both with and without spaces
+ self.set_triggers(["app"])
+
+ def cleanup(self):
+ pass
+
+ def _pin_application(self, app):
+ """Pin an application to the dock."""
+ app_data = {
+ k: v
+ for k, v in {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ "icon_name": app.icon_name,
+ }.items()
+ if v is not None
+ }
+
+ config_path = get_relative_path("../../../config/assets/dock.json")
+ try:
+ with open(config_path, "r") as file:
+ data = json.load(file)
+ except (FileNotFoundError, json.JSONDecodeError):
+ data = {"pinned_apps": []}
+
+ already_pinned = False
+ for pinned_app in data.get("pinned_apps", []):
+ if (
+ isinstance(pinned_app, dict)
+ and pinned_app.get("name") == app_data["name"]
+ ):
+ already_pinned = True
+ pinned_app.update(app_data)
+ break
+ elif isinstance(pinned_app, str) and pinned_app == app_data["name"]:
+ already_pinned = True
+ data["pinned_apps"].remove(pinned_app)
+ data["pinned_apps"].append(app_data)
+ break
+
+ if not already_pinned:
+ data.setdefault("pinned_apps", []).append(app_data)
+
+ with open(config_path, "w") as file:
+ json.dump(data, file, indent=4)
+
+ Dock.notify_config_change()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search applications based on query."""
+ if not query_string.strip():
+ return []
+
+ # Get fresh applications list each time (like examples/app-launcher)
+ 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:
+ # Truncate description to prevent overflow beyond 550px window
+ description = app.description or app.generic_name or ""
+ if len(description) > 80: # Limit to ~80 characters to fit in 550px
+ 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):
+ app.launch()
diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py
new file mode 100644
index 0000000..7a242a6
--- /dev/null
+++ b/modules/launcher/plugins/bash_scripts.py
@@ -0,0 +1,552 @@
+import json
+import os
+import threading
+import time
+from typing import Dict, List
+
+from fabric.utils import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+
+class BashScriptsPlugin(PluginBase):
+ """
+ Plugin for managing and executing bash scripts.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Bash Scripts"
+ self.description = "Manage and execute bash scripts"
+
+ # Configuration
+ self.scripts_cache_file = os.path.join(data.CACHE_DIR, "bash_scripts.json")
+
+ # Default script directory to scan (only Modus scripts)
+ self.modus_scripts_dir = os.path.expanduser("~/.config/Modus/scripts")
+
+ # Scripts to exclude from discovery
+ self.excluded_scripts = {
+ "screen-capture.sh" # Exclude screen-capture.sh as it's handled by screencapture plugin
+ }
+
+ # Built-in power manager commands
+ self.power_manager_commands = {
+ "pm balanced": {
+ "description": "Switch to balanced power profile",
+ "profile": "balanced",
+ "icon": icons.power_balanced,
+ },
+ "pm performance": {
+ "description": "Switch to performance power profile",
+ "profile": "performance",
+ "icon": icons.power_performance,
+ },
+ "pm saver": {
+ "description": "Switch to power saver profile",
+ "profile": "power-saver",
+ "icon": icons.power_saving,
+ },
+ }
+
+ # In-memory cache
+ self._scripts_cache: Dict[str, Dict] = {}
+ self._last_cache_update = 0
+ self._cache_update_interval = 300 # 5 minutes
+
+ # Background cache building
+ self._cache_building = False
+ self._cache_thread = None
+
+ # Battery service reference
+ self._battery_service = None
+
+ def initialize(self):
+ """Initialize the bash scripts plugin."""
+ self.set_triggers(["sh"])
+ self._load_scripts_cache()
+ self._start_background_cache_update()
+ self._get_battery_service()
+
+ def cleanup(self):
+ """Cleanup the bash scripts plugin."""
+ self._scripts_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 _get_battery_service(self):
+ """Get reference to the battery service."""
+ try:
+ from services.battery import Battery
+ self._battery_service = Battery()
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error getting battery service: {e}")
+ self._battery_service = None
+
+ def _set_power_profile(self, profile: str) -> bool:
+ """Set the power profile using the battery service."""
+ if not self._battery_service:
+ print("BashScriptsPlugin: Battery service not available")
+ return False
+
+ try:
+ # Check if profile proxy is available
+ if not hasattr(self._battery_service, '_profile_proxy') or not self._battery_service._profile_proxy:
+ print("BashScriptsPlugin: Power profile proxy not available")
+ return False
+
+ # Get available profiles
+ profiles = self._battery_service._profile_proxy.Profiles
+ available_profiles = []
+ for p in profiles:
+ if isinstance(p, dict) and "Profile" in p:
+ available_profiles.append(p["Profile"])
+ elif hasattr(p, "Profile"):
+ available_profiles.append(p.Profile)
+ elif isinstance(p, str):
+ available_profiles.append(p)
+
+ # Map profile types to actual profile names
+ profile_mapping = {
+ "power-saver": ["power-saver", "powersave", "power_saver"],
+ "balanced": ["balanced", "balance"],
+ "performance": ["performance", "performance-mode"]
+ }
+
+ # Find the actual profile name
+ actual_profile = None
+ if profile in profile_mapping:
+ for candidate in profile_mapping[profile]:
+ if candidate in available_profiles:
+ actual_profile = candidate
+ break
+ elif profile in available_profiles:
+ actual_profile = profile
+
+ if actual_profile:
+ self._battery_service._profile_proxy.ActiveProfile = actual_profile
+ print(f"BashScriptsPlugin: Successfully set power profile to {actual_profile}")
+ return True
+ else:
+ print(f"BashScriptsPlugin: No matching profile found for '{profile}' in available profiles: {available_profiles}")
+ return False
+
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error setting power profile: {e}")
+ return False
+
+
+
+ def _load_scripts_cache(self):
+ """Load scripts cache from JSON file."""
+ try:
+ if os.path.exists(self.scripts_cache_file):
+ with open(self.scripts_cache_file, "r", encoding="utf-8") as f:
+ cache_data = json.load(f)
+ self._scripts_cache = cache_data.get("scripts", {})
+ self._last_cache_update = cache_data.get("last_update", 0)
+ else:
+ print("BashScriptsPlugin: No cache file found, will build cache in background")
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error loading scripts cache: {e}")
+ self._scripts_cache = {}
+ self._last_cache_update = 0
+
+ def _save_scripts_cache(self):
+ """Save scripts cache to JSON file."""
+ try:
+ os.makedirs(data.CACHE_DIR, exist_ok=True)
+ cache_data = {
+ "scripts": self._scripts_cache,
+ "last_update": self._last_cache_update,
+ }
+ with open(self.scripts_cache_file, "w", encoding="utf-8") as f:
+ json.dump(cache_data, f, indent=2)
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error saving scripts cache: {e}")
+
+ def _start_background_cache_update(self):
+ """Start background thread to update scripts cache."""
+ current_time = time.time()
+
+ # Check if cache needs updating
+ if (current_time - self._last_cache_update > self._cache_update_interval or
+ not self._scripts_cache):
+
+ if not self._cache_building:
+ self._cache_building = True
+ self._cache_thread = threading.Thread(
+ target=self._build_scripts_cache_background,
+ daemon=True
+ )
+ self._cache_thread.start()
+
+ def _build_scripts_cache_background(self):
+ """Build scripts cache in background thread."""
+ try:
+
+ new_cache = {}
+
+ # Scan Modus scripts directory for discovered scripts
+ if os.path.exists(self.modus_scripts_dir) and os.path.isdir(self.modus_scripts_dir):
+ try:
+ self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache)
+ except (PermissionError, FileNotFoundError, OSError) as e:
+ print(f"BashScriptsPlugin: Error scanning Modus scripts directory: {e}")
+
+ # Update cache atomically
+ self._scripts_cache = new_cache
+ self._last_cache_update = time.time()
+
+ # Save to disk
+ self._save_scripts_cache()
+
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error building scripts cache: {e}")
+ finally:
+ self._cache_building = False
+
+ def _scan_directory_for_scripts(self, directory: str, cache: Dict):
+ """Scan a directory for bash scripts and add them to cache."""
+ try:
+ with os.scandir(directory) as entries:
+ for entry in entries:
+ if entry.is_file(follow_symlinks=False):
+ script_path = entry.path
+ script_name = entry.name
+
+ # Skip excluded scripts
+ if script_name in self.excluded_scripts:
+ continue
+
+ # Check if it's a script file
+ if self._is_script_file(script_path):
+ cache[script_name] = {
+ "path": script_path,
+ "name": script_name,
+ "description": self._get_script_description(script_path),
+ "type": "discovered",
+ "executable": os.access(script_path, os.X_OK),
+ "args": [],
+ "category": os.path.basename(directory)
+ }
+
+ except (PermissionError, FileNotFoundError, OSError) as e:
+ print(f"BashScriptsPlugin: Error scanning directory {directory}: {e}")
+ except Exception as e:
+ print(f"BashScriptsPlugin: Unexpected error scanning {directory}: {e}")
+
+ def _is_script_file(self, file_path: str) -> bool:
+ """Check if a file is a bash script."""
+ try:
+ # Check file extension first (most common case)
+ if file_path.endswith(('.sh', '.bash')):
+ return True
+
+ # For files without extension, check shebang
+ try:
+ with open(file_path, 'rb') as f:
+ first_line = f.readline(100).decode('utf-8', errors='ignore')
+ if first_line.startswith('#!') and ('bash' in first_line or 'sh' in first_line):
+ return True
+ except (PermissionError, FileNotFoundError, UnicodeDecodeError):
+ pass
+
+ return False
+ except Exception:
+ return False
+
+ def _get_script_description(self, script_path: str) -> str:
+ """Extract description from script comments."""
+ try:
+ with open(script_path, 'r', encoding='utf-8', errors='ignore') as f:
+ lines = f.readlines()
+
+ # Look for description in first few comment lines
+ for line in lines[:10]:
+ line = line.strip()
+ if line.startswith('#') and not line.startswith('#!'):
+ # Remove leading # and whitespace
+ desc = line[1:].strip()
+ if desc and len(desc) > 5: # Meaningful description
+ return desc
+
+ return f"Script: {os.path.basename(script_path)}"
+ except (PermissionError, FileNotFoundError, UnicodeDecodeError):
+ return f"Script: {os.path.basename(script_path)}"
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search for bash scripts matching the query."""
+ query = query_string.strip()
+
+ # Start background update if needed (non-blocking)
+ if not self._scripts_cache or (time.time() - self._last_cache_update > self._cache_update_interval):
+ self._start_background_cache_update()
+
+ results = []
+
+ # Check for power manager commands first
+ power_results = self._search_power_commands(query)
+ if power_results:
+ results.extend(power_results)
+
+ # Handle special commands
+ if not query:
+ # Show all scripts when no query
+ results.extend(self._list_all_scripts())
+ # Also show power manager commands when no query
+ if not power_results:
+ results.extend(self._list_all_power_commands())
+ else:
+ # Search for scripts
+ results.extend(self._search_scripts(query))
+
+ return results
+
+ def _list_all_scripts(self) -> List[Result]:
+ """List all available scripts."""
+ results = []
+ max_results = 20
+
+ # Sort scripts by name for consistent ordering
+ sorted_scripts = sorted(self._scripts_cache.items(), key=lambda x: x[1].get("name", ""))
+
+ # Add scripts (limit to max_results)
+ for script_name, script_info in sorted_scripts:
+ script_results = self._create_script_results_with_args(script_name, script_info, 0.8)
+ for script_result in script_results:
+ if len(results) < max_results:
+ results.append(script_result)
+ else:
+ break
+ if len(results) >= max_results:
+ break
+
+ return results
+
+ def _search_scripts(self, query: str) -> List[Result]:
+ """Search for scripts matching the query."""
+ results = []
+ query_lower = query.lower()
+ max_results = 15
+
+ # Categorize matches for better sorting
+ exact_matches = []
+ prefix_matches = []
+ partial_matches = []
+ description_matches = []
+
+ for script_name, script_info in self._scripts_cache.items():
+ script_name_lower = script_name.lower()
+ description_lower = script_info.get("description", "").lower()
+
+ # Skip if no match at all
+ if query_lower not in script_name_lower and query_lower not in description_lower:
+ continue
+
+ # Categorize matches
+ if script_name_lower == query_lower:
+ exact_matches.append((script_name, script_info, 1.0))
+ elif script_name_lower.startswith(query_lower):
+ prefix_matches.append((script_name, script_info, 0.9))
+ elif query_lower in script_name_lower:
+ partial_matches.append((script_name, script_info, 0.7))
+ elif query_lower in description_lower:
+ description_matches.append((script_name, script_info, 0.5))
+
+ # Combine results in priority order
+ all_matches = exact_matches + prefix_matches + partial_matches + description_matches
+
+ # Convert to Result objects
+ for script_name, script_info, relevance in all_matches:
+ script_results = self._create_script_results_with_args(script_name, script_info, relevance)
+ for script_result in script_results:
+ if len(results) < max_results:
+ results.append(script_result)
+ else:
+ break
+ if len(results) >= max_results:
+ break
+
+ return results
+
+ def _search_power_commands(self, query: str) -> List[Result]:
+ """Search for power manager commands matching the query."""
+ if not query:
+ return []
+
+ results = []
+ query_lower = query.lower()
+
+ for cmd, info in self.power_manager_commands.items():
+ cmd_lower = cmd.lower()
+ description_lower = info["description"].lower()
+
+ # Check for exact match or partial match
+ if (query_lower == cmd_lower or
+ cmd_lower.startswith(query_lower) or
+ query_lower in cmd_lower or
+ query_lower in description_lower):
+
+ relevance = 1.0 if query_lower == cmd_lower else 0.9
+
+ result = Result(
+ title=cmd,
+ subtitle=info["description"],
+ icon_markup=info["icon"],
+ action=self._create_power_action(info["profile"]),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"command": cmd, "profile": info["profile"], "type": "power_manager"}
+ )
+ results.append(result)
+
+ return results
+
+ def _list_all_power_commands(self) -> List[Result]:
+ """List all power manager commands."""
+ results = []
+
+ for cmd, info in self.power_manager_commands.items():
+ result = Result(
+ title=cmd,
+ subtitle=info["description"],
+ icon_markup=info["icon"],
+ action=self._create_power_action(info["profile"]),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={"command": cmd, "profile": info["profile"], "type": "power_manager"}
+ )
+ results.append(result)
+
+ return results
+
+ def _create_power_action(self, profile: str):
+ """Create an action function for setting power profile."""
+ def action():
+ success = self._set_power_profile(profile)
+ if success:
+ # Clear the search query after successful execution
+ try:
+ from fabric import Application
+ from gi.repository import GLib
+
+ app = Application.get_default()
+ if app and hasattr(app, "launcher"):
+ launcher = app.launcher
+ if launcher and hasattr(launcher, "search_entry"):
+ def clear_search():
+ launcher.search_entry.set_text("")
+ return False
+ # Use a small delay to ensure the action completes first
+ GLib.timeout_add(50, clear_search)
+ except Exception as e:
+ print(f"BashScriptsPlugin: Could not clear search query: {e}")
+ return action
+
+ def _create_script_result(self, script_name: str, script_info: Dict, relevance: float) -> Result:
+ """Create a Result object for a script."""
+ script_path = script_info.get("path", "")
+ description = script_info.get("description", "")
+ script_type = script_info.get("type", "discovered")
+ executable = script_info.get("executable", False)
+ category = script_info.get("category", "")
+
+ # Create subtitle with additional info
+ subtitle_parts = []
+ if description:
+ subtitle_parts.append(description)
+ if category:
+ subtitle_parts.append(f"[{category}]")
+ if not executable:
+ subtitle_parts.append("(not executable)")
+
+ subtitle = " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}"
+
+ # Choose icon based on script type and status
+ if not executable:
+ icon_markup = icons.file
+ elif script_type == "custom":
+ icon_markup = icons.star
+ else:
+ icon_markup = icons.terminal
+
+ return Result(
+ title=script_name,
+ subtitle=subtitle,
+ icon_markup=icon_markup,
+ action=self._create_script_action(script_name, script_info),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"script_name": script_name, "script_path": script_path, "type": script_type}
+ )
+
+ def _create_script_results_with_args(self, script_name: str, script_info: Dict, relevance: float) -> List[Result]:
+ """Create multiple Result objects for scripts that support arguments."""
+ results = []
+
+ # Check for special scripts that need argument variants
+ if script_name == "hyprpicker.sh":
+ # For hyprpicker, only show the argument variants (skip basic version)
+ variants = [
+ ("-rgb", "Pick RGB color"),
+ ("-hex", "Pick HEX color"),
+ ("-hsv", "Pick HSV color")
+ ]
+
+ for arg, desc in variants:
+ variant_result = Result(
+ title=f"{script_name} {arg}",
+ subtitle=f"{desc} | [scripts]",
+ icon_markup=icons.terminal,
+ action=self._create_script_action_with_args(script_name, script_info, [arg]),
+ relevance=relevance + 0.1, # Slightly higher relevance for specific variants
+ plugin_name=self.display_name,
+ data={"script_name": script_name, "script_path": script_info.get("path", ""), "type": script_info.get("type", "discovered"), "args": [arg]}
+ )
+ results.append(variant_result)
+ else:
+ # For other scripts, create the basic result
+ basic_result = self._create_script_result(script_name, script_info, relevance)
+ results.append(basic_result)
+
+ return results
+
+ def _create_script_action(self, script_name: str, script_info: Dict):
+ """Create an action function for executing a script."""
+ def action():
+ self._execute_script(script_name, script_info)
+ return action
+
+ def _create_script_action_with_args(self, script_name: str, script_info: Dict, args: List[str]):
+ """Create an action function for executing a script with specific arguments."""
+ def action():
+ # Create a modified script_info with the specific arguments
+ modified_script_info = script_info.copy()
+ modified_script_info["args"] = args
+ self._execute_script(script_name, modified_script_info)
+ return action
+
+ def _execute_script(self, script_name: str, script_info: Dict):
+ """Execute a bash script."""
+ try:
+ script_path = script_info.get("path", "")
+ script_args = script_info.get("args", [])
+
+ if not os.path.exists(script_path):
+ return
+
+ if not script_info.get("executable", False):
+ return
+
+ # Build command
+ command = [script_path] + script_args
+ exec_shell_command_async(command)
+
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}")
+
diff --git a/modules/launcher/plugins/bookmarks.py b/modules/launcher/plugins/bookmarks.py
new file mode 100644
index 0000000..424a80e
--- /dev/null
+++ b/modules/launcher/plugins/bookmarks.py
@@ -0,0 +1,735 @@
+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.helpers import get_relative_path
+from thefuzz import fuzz
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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:
+ 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:
+ 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/assets/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 your first bookmark",
+ icon_markup=icons.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 your first bookmark",
+ icon_markup=icons.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_markup=icons.bulb,
+ 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_markup=icons.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_markup=icons.bulb,
+ 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_markup=icons.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:
+ return [
+ Result(
+ title="Bookmark Already Exists",
+ subtitle=f"A bookmark with URL '{normalized_url}' already exists",
+ icon_markup=icons.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)
+ subtitle = f"Click to add bookmark: {domain}"
+ if description:
+ subtitle += f" • {description}"
+
+ return [
+ Result(
+ title=f"Add bookmark for '{title}'",
+ subtitle=subtitle,
+ icon_markup=icons.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_markup=icons.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", ""))
+
+ return [
+ Result(
+ title=f"Remove bookmark '{title}'?",
+ subtitle=f"Click to confirm deletion: {domain}",
+ icon_markup=icons.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_markup=icons.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_markup=icons.bookmark,
+ 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", ""))
+ results.append(
+ Result(
+ title=f"remove {title}",
+ subtitle=f"Click to remove: {title} ({domain})",
+ icon_markup=icons.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
+ if len(title) > 60:
+ title = title[:57] + "..."
+
+ # Create subtitle with domain and description
+ domain = self._extract_domain(url)
+ if description:
+ subtitle = f"{domain} • {description}"
+ else:
+ subtitle = domain
+
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_markup=icons.bookmark,
+ 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:
+ 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:
+ from gi.repository import GLib
+
+ 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:
+ from gi.repository import GLib
+
+ 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/modules/launcher/plugins/caffeine.py b/modules/launcher/plugins/caffeine.py
new file mode 100644
index 0000000..433d0c1
--- /dev/null
+++ b/modules/launcher/plugins/caffeine.py
@@ -0,0 +1,209 @@
+import subprocess
+from threading import Timer
+from typing import List
+
+import gi
+from fabric.utils import get_relative_path
+from fabric.utils.helpers import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+gi.require_version("Gtk", "3.0")
+
+
+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_markup=icons.coffee,
+ 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_markup=icons.coffee,
+ 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/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py
new file mode 100644
index 0000000..1468154
--- /dev/null
+++ b/modules/launcher/plugins/calculator.py
@@ -0,0 +1,166 @@
+import math
+import re
+import subprocess
+import time
+from typing import List
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+from utils.conversion import Conversion
+
+
+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_markup=icons.calculator,
+ 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_markup=icons.calculator,
+ 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_markup=icons.calculator,
+ 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/modules/launcher/plugins/calendar.py b/modules/launcher/plugins/calendar.py
new file mode 100644
index 0000000..5d75ae5
--- /dev/null
+++ b/modules/launcher/plugins/calendar.py
@@ -0,0 +1,404 @@
+import calendar
+import datetime
+from typing import List
+
+import gi
+from fabric.widgets.label import Label
+from gi.repository import Gtk
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+gi.require_version("Gtk", "3.0")
+
+
+class CalendarWidget(Gtk.Box):
+ """Visual calendar widget showing month grid with dates and navigation buttons."""
+
+ def __init__(self, month: int, year: int, plugin_instance=None):
+ super().__init__(
+ name="calendar-widget", orientation=Gtk.Orientation.VERTICAL, spacing=2
+ )
+
+ # Set size constraints to fit within launcher (following CSS)
+ self.set_size_request(540, 240)
+ self.set_vexpand(False)
+ self.set_hexpand(True)
+
+ self.display_month = month
+ self.display_year = year
+ self.today = datetime.date.today()
+ self.plugin = plugin_instance
+
+ # Make widget focusable for keyboard events
+ self.set_can_focus(True)
+ self.connect("key-press-event", self._on_key_press)
+
+ self.setup_ui()
+
+ def setup_ui(self):
+ """Setup the calendar UI with navigation buttons."""
+ # Header with navigation buttons
+ header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ header_box.set_halign(Gtk.Align.CENTER)
+ header_box.set_name("calendar-header-box")
+
+ # Previous button
+ prev_button = Gtk.Button(
+ child=Label(markup=icons.chevron_left, name="calendar-nav-button")
+ )
+ prev_button.connect("clicked", self._on_previous_clicked)
+
+ # Month/Year label (following CSS naming)
+ month_name = calendar.month_name[self.display_month]
+ self.month_label = Label(
+ name="calendar-month-year",
+ label=f"{month_name} {self.display_year}",
+ h_align="center",
+ )
+ # Fixed width for consistent layout
+ self.month_label.set_size_request(150, -1)
+
+ # Next button
+ next_button = Gtk.Button(
+ child=Label(markup=icons.chevron_right, name="calendar-nav-button")
+ )
+ next_button.connect("clicked", self._on_next_clicked)
+
+ # Add to header box
+ header_box.pack_start(prev_button, False, False, 0)
+ header_box.pack_start(self.month_label, True, True, 0)
+ header_box.pack_start(next_button, False, False, 0)
+
+ # Calendar grid (following CSS naming)
+ grid = Gtk.Grid(
+ name="calendar-grid",
+ column_spacing=1,
+ row_spacing=1,
+ column_homogeneous=True,
+ row_homogeneous=True,
+ )
+
+ # Day headers (following CSS naming)
+ day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
+ for i, day_name in enumerate(day_names):
+ day_header = Label(
+ name="calendar-day-header", label=day_name, h_align="center"
+ )
+ grid.attach(day_header, i, 0, 1, 1)
+
+ # Calendar days (following CSS naming)
+ cal = calendar.monthcalendar(self.display_year, self.display_month)
+ for week_num, week in enumerate(cal, start=1):
+ for day_num, day in enumerate(week):
+ if day == 0:
+ # Empty cell (following CSS naming)
+ empty = Label(name="calendar-day-empty", label="")
+ grid.attach(empty, day_num, week_num, 1, 1)
+ else:
+ # Day label (following CSS naming)
+ day_label = Label(
+ name="calendar-day-label", label=str(day), h_align="center"
+ )
+
+ # Highlight today (CSS class)
+ if (
+ day == self.today.day
+ and self.display_month == self.today.month
+ and self.display_year == self.today.year
+ ):
+ day_label.get_style_context().add_class("today")
+
+ # Highlight weekends (CSS class)
+ if day_num in [6]: # Saturday
+ day_label.get_style_context().add_class("weekend")
+
+ grid.attach(day_label, day_num, week_num, 1, 1)
+
+ # Add to main box
+ self.pack_start(header_box, False, False, 0)
+ self.pack_start(grid, True, True, 0)
+ self.show_all()
+
+ def _on_previous_clicked(self, button):
+ """Handle previous month button click."""
+ if self.plugin:
+ self.plugin._navigate_to_previous()
+
+ def _on_next_clicked(self, button):
+ """Handle next month button click."""
+ if self.plugin:
+ self.plugin._navigate_to_next()
+
+ def _on_key_press(self, widget, event):
+ """Handle keyboard navigation for calendar."""
+ from gi.repository import Gdk
+
+ keyval = event.keyval
+
+ # Left arrow - previous month
+ if keyval == Gdk.KEY_Left:
+ if self.plugin:
+ self.plugin._navigate_to_previous()
+ return True
+
+ # Right arrow - next month
+ if keyval == Gdk.KEY_Right:
+ if self.plugin:
+ self.plugin._navigate_to_next()
+ return True
+
+ # Home - go to current month
+ if keyval == Gdk.KEY_Home:
+ if self.plugin:
+ self.plugin._reset_to_current_month()
+ self.plugin._force_launcher_refresh()
+ return True
+
+ return False
+
+ def update_display(self, month: int, year: int):
+ """Update the calendar to show a different month/year."""
+ self.display_month = month
+ self.display_year = year
+
+ # Update month label
+ month_name = calendar.month_name[month]
+ self.month_label.set_label(f"{month_name} {year}")
+
+ # Rebuild the calendar grid
+ # Find and remove the old grid
+ for child in self.get_children():
+ if isinstance(child, Gtk.Grid):
+ self.remove(child)
+ child.destroy()
+ break
+
+ # Create new grid (following CSS naming)
+ grid = Gtk.Grid(
+ name="calendar-grid",
+ column_spacing=1,
+ row_spacing=1,
+ column_homogeneous=True,
+ row_homogeneous=True,
+ )
+
+ # Day headers (following CSS naming)
+ day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
+ for i, day_name in enumerate(day_names):
+ day_header = Label(
+ name="calendar-day-header", label=day_name, h_align="center"
+ )
+ grid.attach(day_header, i, 0, 1, 1)
+
+ # Calendar days (following CSS naming)
+ cal = calendar.monthcalendar(year, month)
+ for week_num, week in enumerate(cal, start=1):
+ for day_num, day in enumerate(week):
+ if day == 0:
+ # Empty cell (following CSS naming)
+ empty = Label(name="calendar-day-empty", label="")
+ grid.attach(empty, day_num, week_num, 1, 1)
+ else:
+ # Day label (following CSS naming)
+ day_label = Label(
+ name="calendar-day-label", label=str(day), h_align="center"
+ )
+
+ # Highlight today (CSS class)
+ if (
+ day == self.today.day
+ and month == self.today.month
+ and year == self.today.year
+ ):
+ day_label.get_style_context().add_class("today")
+
+ # Highlight weekends (CSS class)
+ if day_num in [6]: # Saturday and Sunday
+ day_label.get_style_context().add_class("weekend")
+
+ grid.attach(day_label, day_num, week_num, 1, 1)
+
+ # Add new grid
+ self.pack_start(grid, True, True, 0)
+ grid.show_all()
+
+
+class CalendarPlugin(PluginBase):
+ """
+ Calendar plugin for the launcher.
+ Simple text-based calendar with working Enter key navigation.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Calendar"
+ self.description = "Calendar with visual dates and navigation buttons"
+ self._current_widget = None
+ # Current display state
+ self._current_month = None
+ self._current_year = None
+
+ def initialize(self):
+ """Initialize the Calendar plugin."""
+ self.set_triggers(["cal"])
+ self.description = "Calendar with visual dates and navigation. Use ← → arrows for months, Home for current month"
+
+ def cleanup(self):
+ """Cleanup the Calendar plugin."""
+ if self._current_widget:
+ if self._current_widget.get_parent():
+ self._current_widget.get_parent().remove(self._current_widget)
+ self._current_widget.destroy()
+ self._current_widget = None
+ # Reset to current month when cleaning up
+ self._reset_to_current_month()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process Calendar queries."""
+ results = []
+ query = query_string.strip().lower()
+ today = datetime.date.today()
+
+ # Handle date queries
+ if query in ["today", "now"]:
+ date_str = today.strftime("%A, %B %d, %Y")
+ results.append(
+ Result(
+ title=date_str,
+ subtitle="Today's date • Click to copy",
+ icon_markup=icons.calendar,
+ action=lambda: self._copy_to_clipboard(date_str),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ )
+ return results
+
+ elif query == "tomorrow":
+ tomorrow = today + datetime.timedelta(days=1)
+ date_str = tomorrow.strftime("%A, %B %d, %Y")
+ results.append(
+ Result(
+ title=date_str,
+ subtitle="Tomorrow's date • Click to copy",
+ icon_markup=icons.calendar,
+ action=lambda: self._copy_to_clipboard(date_str),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ )
+ return results
+
+ elif query == "yesterday":
+ yesterday = today - datetime.timedelta(days=1)
+ date_str = yesterday.strftime("%A, %B %d, %Y")
+ results.append(
+ Result(
+ title=date_str,
+ subtitle="Yesterday's date • Click to copy",
+ icon_markup=icons.calendar,
+ action=lambda: self._copy_to_clipboard(date_str),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ )
+ return results
+
+ # Clean up previous widget
+ self.cleanup()
+
+ # Check if we should reset to current month
+ # This happens when the query is empty or just the trigger
+ if not query or query in ["", "cal"]:
+ self._reset_to_current_month()
+
+ # Determine which month to show
+ if self._current_month is None:
+ # First time - show current month
+ display_month = today.month
+ display_year = today.year
+ else:
+ # Use stored month/year
+ display_month = self._current_month
+ display_year = self._current_year
+
+ # Update stored state
+ self._current_month = display_month
+ self._current_year = display_year
+
+ # Create calendar widget with navigation buttons
+ calendar_widget = CalendarWidget(display_month, display_year, self)
+ self._current_widget = calendar_widget
+
+ month_name = calendar.month_name[display_month]
+ results.append(
+ Result(
+ title=f"Calendar - {month_name} {display_year}",
+ subtitle="← → navigate months, Home for current month",
+ icon_markup=icons.calendar,
+ action=lambda: None,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ custom_widget=calendar_widget,
+ data={"type": "calendar_display"},
+ )
+ )
+
+ return results
+
+ def _navigate_to_next(self):
+ """Navigate to next month (called by button click)."""
+ if self._current_month and self._current_year:
+ next_month, next_year = self._advance_month(
+ self._current_month, self._current_year, 1
+ )
+ self._current_month = next_month
+ self._current_year = next_year
+
+ # Update the calendar widget display
+ if self._current_widget:
+ self._current_widget.update_display(next_month, next_year)
+
+ def _navigate_to_previous(self):
+ """Navigate to previous month (called by button click)."""
+ if self._current_month and self._current_year:
+ prev_month, prev_year = self._advance_month(
+ self._current_month, self._current_year, -1
+ )
+ self._current_month = prev_month
+ self._current_year = prev_year
+
+ # Update the calendar widget display
+ if self._current_widget:
+ self._current_widget.update_display(prev_month, prev_year)
+
+ def _advance_month(self, month: int, year: int, direction: int) -> tuple[int, int]:
+ """Advance month by direction (1 for next, -1 for previous)."""
+ if direction > 0: # Next
+ if month == 12:
+ return 1, year + 1
+ else:
+ return month + 1, year
+ else: # Previous
+ if month == 1:
+ return 12, year - 1
+ else:
+ return month - 1, year
+
+ def _reset_to_current_month(self):
+ """Reset calendar to current month."""
+ today = datetime.date.today()
+ self._current_month = today.month
+ self._current_year = today.year
+
+ def _copy_to_clipboard(self, text: str):
+ """Copy text to clipboard."""
+ try:
+ import subprocess
+
+ subprocess.run(["wl-copy"], input=text.encode(), check=True)
+ print(f"Copied to clipboard: {text}")
+ except subprocess.CalledProcessError:
+ print(f"Failed to copy to clipboard: {text}")
diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py
new file mode 100644
index 0000000..d86bd5a
--- /dev/null
+++ b/modules/launcher/plugins/clipboard.py
@@ -0,0 +1,438 @@
+import os
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from typing import Dict, List, Optional
+
+from gi.repository import GdkPixbuf, GLib
+
+from modules.launcher.plugin_base import PluginBase
+from modules.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:
+ from gi.repository import GLib
+
+ 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/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py
new file mode 100644
index 0000000..834d673
--- /dev/null
+++ b/modules/launcher/plugins/emoji.py
@@ -0,0 +1,188 @@
+import json
+import os
+import subprocess
+import time
+from collections import OrderedDict
+from typing import Dict, List
+
+from fabric.utils import get_relative_path
+
+import config.data as data
+from modules.launcher.plugin_base import PluginBase
+from modules.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 = {}
+ self.emoji_path = get_relative_path("../../../config/assets/emoji.json")
+
+ # Use cache directory for recent emojis (save directly in cache dir)
+ self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json")
+ self.recent_emojis = OrderedDict()
+ self.max_recent_emojis = 20 # Maximum number of recent emojis to track
+
+ def initialize(self):
+ """Initialize the emoji plugin."""
+ self.set_triggers(["em"])
+ self._load_emoji_data()
+ self._load_recent_emojis()
+
+ def cleanup(self):
+ """Cleanup the emoji plugin."""
+ pass
+
+ def _load_emoji_data(self):
+ """Load emoji data from JSON file."""
+ try:
+ if os.path.exists(self.emoji_path):
+ with open(self.emoji_path, "r", encoding="utf-8") as f:
+ self.emoji_data = json.load(f)
+ else:
+ print(f"Emoji file not found: {self.emoji_path}")
+ except Exception as e:
+ print(f"Error loading emoji data: {e}")
+
+ 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()
+
+ # If no query, show recently used emojis
+ if not query:
+ if self.recent_emojis:
+ # Show recent emojis in reverse order (most recent first)
+ for emoji in reversed(list(self.recent_emojis.keys())):
+ if emoji in self.emoji_data:
+ emoji_info = self.emoji_data[emoji]
+ results.append(
+ self._create_emoji_result(emoji, emoji_info, 1.0)
+ )
+ else:
+ # If no recent emojis, show some popular ones as fallback
+ popular_emojis = ["😀", "👍", "❤️", "🎉", "🔥", "✨", "🚀", "🌈"]
+ for emoji in popular_emojis:
+ if emoji in self.emoji_data:
+ emoji_info = self.emoji_data[emoji]
+ results.append(
+ self._create_emoji_result(emoji, emoji_info, 1.0)
+ )
+ return results
+
+ # Search by name, group, or the emoji itself
+ for emoji, info in self.emoji_data.items():
+ relevance = 0
+ name = info.get("name", "").lower()
+ group = info.get("group", "").lower()
+ slug = info.get("slug", "").lower()
+
+ # Exact match with emoji
+ if query == emoji:
+ relevance = 1.0
+ # Name contains query
+ elif query in name:
+ relevance = 0.9
+ # Slug contains query
+ elif query in slug:
+ relevance = 0.8
+ # Group contains query
+ elif query in group:
+ relevance = 0.7
+
+ if relevance > 0:
+ results.append(self._create_emoji_result(emoji, info, relevance))
+
+ # Sort by relevance
+ results.sort(key=lambda x: x.relevance, reverse=True)
+ return results[:20] # Limit to 20 results
+
+ def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result:
+ """Create a Result object for an emoji."""
+ name = info.get("name", "")
+ group = info.get("group", "")
+
+ # Check if this is a recently used emoji
+ is_recent = emoji in self.recent_emojis
+ subtitle = f"{group}" + (" • Recently used" if is_recent else "")
+
+ return Result(
+ title=name, # Show only the name, not the emoji
+ subtitle=subtitle,
+ icon_markup=emoji, # Use the emoji itself as the icon
+ action=lambda e=emoji: self._copy_to_clipboard(e),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"emoji": emoji, "name": name, "group": group, "recent": is_recent},
+ )
diff --git a/modules/launcher/plugins/kanban.py b/modules/launcher/plugins/kanban.py
new file mode 100644
index 0000000..fdaad8b
--- /dev/null
+++ b/modules/launcher/plugins/kanban.py
@@ -0,0 +1,959 @@
+"""
+Kanban Plugin for Modus Launcher
+
+A maintainable kanban board implementation with the following features:
+- Three columns: To Do, In Progress, Done
+- Drag and drop between columns
+- Inline editing of notes
+- Keyboard navigation and shortcuts
+- Persistent state storage
+- Command-based interaction (add, move, done)
+
+Architecture:
+- KanbanEditor: Handles inline text editing
+- KanbanNote: Individual note widget with editing and drag/drop
+- KanbanColumn: Column container with notes and drag/drop handling
+- KanbanBoard: Main board widget with keyboard handling and state management
+- KanbanPlugin: Plugin interface and command processing
+"""
+
+import json
+import os
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+import cairo
+import gi
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.label import Label
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from gi.repository import Gdk, GLib, GObject, Gtk
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+gi.require_version("Gtk", "3.0")
+
+# Constants
+KANBAN_STATE_FILE = Path(os.path.expanduser("~/.kanban.json"))
+BOARD_HEIGHT = 500
+COLUMN_HEIGHT = 150
+EDITOR_MIN_HEIGHT = 50
+
+
+def create_drag_surface(widget: Gtk.Widget) -> cairo.ImageSurface:
+ """Create a cairo surface from a widget for drag operations."""
+ alloc = widget.get_allocation()
+ surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
+ cr = cairo.Context(surface)
+ cr.set_source_rgba(0, 0, 0, 0)
+ cr.rectangle(0, 0, alloc.width, alloc.height)
+ cr.fill()
+ widget.draw(cr)
+ return surface
+
+
+class KanbanEditor(Gtk.Box):
+ """Inline text editor for kanban notes with confirm/cancel functionality."""
+
+ __gsignals__ = {
+ "confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
+ "canceled": (GObject.SignalFlags.RUN_LAST, None, ()),
+ }
+
+ def __init__(self, initial_text=""):
+ super().__init__(name="inline-editor", spacing=4)
+ self._setup_text_view(initial_text)
+ self._setup_buttons()
+ self._setup_layout()
+ self.show_all()
+
+ def _setup_text_view(self, initial_text: str):
+ """Setup the text view component."""
+ self.text_view = Gtk.TextView()
+ self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ self.text_view.connect("key-press-event", self._on_key_press)
+
+ buffer = self.text_view.get_buffer()
+ buffer.set_text(initial_text)
+
+ def _setup_buttons(self):
+ """Setup confirm and cancel buttons."""
+ # Confirm button
+ confirm_label = Label(name="kanban-btn-label", markup=icons.accept)
+ self.confirm_btn = Gtk.Button(name="kanban-btn")
+ self.confirm_btn.add(confirm_label)
+ self.confirm_btn.connect("clicked", self._on_confirm_clicked)
+ self.confirm_btn.get_style_context().add_class("flat")
+
+ # Cancel button
+ cancel_label = Label(name="kanban-btn-neg", markup=icons.cancel)
+ self.cancel_btn = Gtk.Button(name="kanban-btn")
+ self.cancel_btn.add(cancel_label)
+ self.cancel_btn.connect("clicked", self._on_cancel_clicked)
+ self.cancel_btn.get_style_context().add_class("flat")
+
+ def _setup_layout(self):
+ """Setup the widget layout."""
+ # Scrolled window for text view
+ scrolled_window = Gtk.ScrolledWindow(name="scrolled-window")
+ scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ scrolled_window.set_min_content_height(EDITOR_MIN_HEIGHT)
+ scrolled_window.add(self.text_view)
+
+ # Button container
+ button_box = Gtk.Box(spacing=4)
+ button_box.pack_start(self.confirm_btn, False, False, 0)
+ button_box.pack_start(self.cancel_btn, False, False, 0)
+
+ # Center the buttons
+ center_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ center_box.set_halign(Gtk.Align.CENTER)
+ center_box.pack_start(button_box, False, False, 0)
+
+ # Main layout
+ self.pack_start(scrolled_window, True, True, 0)
+ self.pack_start(center_box, False, False, 0)
+
+ def _get_text(self) -> str:
+ """Get the current text from the editor."""
+ buffer = self.text_view.get_buffer()
+ start, end = buffer.get_bounds()
+ return buffer.get_text(start, end, True).strip()
+
+ def _on_confirm_clicked(self, _widget):
+ """Handle confirm button click."""
+ text = self._get_text()
+ if text:
+ self.emit("confirmed", text)
+ else:
+ self.emit("canceled")
+
+ def _on_cancel_clicked(self, _widget):
+ """Handle cancel button click."""
+ self.emit("canceled")
+
+ def _on_key_press(self, _widget, event):
+ """Handle keyboard events."""
+ if event.keyval == Gdk.KEY_Escape:
+ self.emit("canceled")
+ return True
+
+ if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
+ # Shift+Enter: Insert newline
+ buffer = self.text_view.get_buffer()
+ cursor_iter = buffer.get_iter_at_mark(buffer.get_insert())
+ buffer.insert(cursor_iter, "\n")
+ return True
+ else:
+ # Enter: Confirm
+ self._on_confirm_clicked(None)
+ return True
+ return False
+
+
+class KanbanNote(Gtk.EventBox):
+ """Individual kanban note widget with editing, deletion, and drag/drop support."""
+
+ __gsignals__ = {
+ "changed": (GObject.SignalFlags.RUN_LAST, None, ()),
+ }
+
+ def __init__(self, text: str):
+ super().__init__()
+ self.text = text
+ self.set_can_focus(True) # Enable keyboard events
+
+ self._setup_ui()
+ self._setup_drag_and_drop()
+ self._connect_signals()
+ self.show_all()
+
+ def _setup_ui(self):
+ """Setup the note's user interface."""
+ self.box = Gtk.Box(name="kanban-note", spacing=4)
+
+ # Text label
+ self.label = Gtk.Label(label=self.text)
+ self.label.set_line_wrap(True)
+ self.label.set_line_wrap_mode(Gtk.WrapMode.WORD)
+
+ # Delete button
+ delete_label = Label(name="kanban-btn-neg", markup=icons.trash)
+ self.delete_btn = Gtk.Button(name="kanban-btn", child=delete_label)
+ self.delete_btn.connect("clicked", self._on_delete_clicked)
+
+ # Layout
+ button_container = CenterBox(orientation="v", start_children=[self.delete_btn])
+ self.box.pack_start(self.label, True, True, 0)
+ self.box.pack_start(button_container, False, False, 0)
+ self.add(self.box)
+
+ def _setup_drag_and_drop(self):
+ """Setup drag and drop functionality."""
+ self.drag_source_set(
+ Gdk.ModifierType.BUTTON1_MASK,
+ [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+
+ def _connect_signals(self):
+ """Connect all signal handlers."""
+ self.connect("button-press-event", self._on_button_press)
+ self.connect("key-press-event", self._on_key_press)
+ self.connect("drag-data-get", self._on_drag_data_get)
+ self.connect("drag-data-delete", self._on_drag_data_delete)
+ self.connect("drag-begin", self._on_drag_begin)
+
+ def _on_button_press(self, _widget, event):
+ """Handle mouse button press events."""
+ if event.type == Gdk.EventType._2BUTTON_PRESS:
+ self.start_edit()
+ return False
+ return True
+
+ def _on_key_press(self, _widget, event):
+ """Handle keyboard events for kanban notes."""
+ keyval = event.keyval
+
+ # Enter or F2 - start editing
+ if keyval in (Gdk.KEY_Return, Gdk.KEY_F2):
+ self.start_edit()
+ return True
+
+ # Delete - remove note
+ if keyval == Gdk.KEY_Delete:
+ self._delete_note()
+ return True
+
+ return False
+
+ def _on_drag_begin(self, _widget, context):
+ """Handle drag begin event."""
+ surface = create_drag_surface(self)
+ Gtk.drag_set_icon_surface(context, surface)
+
+ def _on_drag_data_get(self, _widget, _drag_context, data, _info, _time):
+ """Provide data for drag operation."""
+ data.set_text(self.label.get_text(), -1)
+
+ def _on_drag_data_delete(self, _widget, _drag_context):
+ """Handle drag data deletion."""
+ self._delete_note()
+
+ def _on_delete_clicked(self, _button):
+ """Handle delete button click."""
+ self._delete_note()
+
+ def _delete_note(self):
+ """Delete this note from its parent."""
+ parent = self.get_parent()
+ if parent:
+ parent.destroy()
+
+ def start_edit(self):
+ """Start inline editing of this note."""
+ row = self.get_parent()
+ if not row:
+ return
+
+ editor = KanbanEditor(self.label.get_text())
+ editor.connect("confirmed", self._on_edit_confirmed)
+ editor.connect("canceled", self._on_edit_canceled)
+
+ # Replace self with editor
+ row.remove(self)
+ row.add(editor)
+ row.show_all()
+
+ # Focus the editor after a short delay
+ GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False))
+
+ def _on_edit_confirmed(self, editor, text):
+ """Handle edit confirmation."""
+ self.label.set_text(text)
+ self._replace_editor_with_self(editor)
+ self.emit("changed")
+
+ def _on_edit_canceled(self, editor):
+ """Handle edit cancellation."""
+ self._replace_editor_with_self(editor)
+
+ def _replace_editor_with_self(self, editor):
+ """Replace the editor with this note widget."""
+ row = editor.get_parent()
+ if row:
+ row.remove(editor)
+ row.add(self)
+ row.show_all()
+
+
+class KanbanColumn(Gtk.Frame):
+ """A kanban column containing notes with drag/drop support."""
+
+ __gsignals__ = {
+ "changed": (GObject.SignalFlags.RUN_LAST, None, ()),
+ }
+
+ def __init__(self, title: str):
+ super().__init__(name="kanban-column")
+ self.title = title
+ self._setup_ui()
+ self._setup_drag_and_drop()
+ self._configure_size()
+ self.show_all()
+
+ def _configure_size(self):
+ """Configure size constraints for launcher compatibility."""
+ self.set_hexpand(True)
+ self.set_vexpand(True)
+ self.set_size_request(-1, COLUMN_HEIGHT)
+
+ def _setup_ui(self):
+ """Setup the column's user interface."""
+ # Main container
+ self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+
+ # Notes list
+ self.listbox = Gtk.ListBox()
+ self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
+
+ # Add button
+ add_label = Label(name="kanban-btn-label", markup=icons.add)
+ self.add_btn = Gtk.Button(name="kanban-btn-add", child=add_label)
+ self.add_btn.connect("clicked", self._on_add_clicked)
+
+ # Header with title and add button
+ title_label = Label(name="column-header", label=self.title)
+ header = CenterBox(
+ name="kanban-header",
+ center_children=[title_label],
+ end_children=[self.add_btn],
+ )
+
+ # Scrolled window for notes
+ self.scroller = ScrolledWindow(
+ name="scrolled-window", propagate_height=False, propagate_width=False
+ )
+ self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ self.scroller.add(self.listbox)
+ self.scroller.set_vexpand(True)
+
+ # Layout
+ self.box.pack_start(header, False, False, 0)
+ self.box.pack_start(self.scroller, True, True, 0)
+ self.box.pack_start(self.add_btn, False, False, 0)
+ self.add(self.box)
+
+ def _setup_drag_and_drop(self):
+ """Setup drag and drop functionality."""
+ self.listbox.drag_dest_set(
+ Gtk.DestDefaults.ALL,
+ [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+
+ # Connect drag and drop signals
+ self.listbox.connect("drag-data-received", self._on_drag_data_received)
+ self.listbox.connect("drag-motion", self._on_drag_motion)
+ self.listbox.connect("drag-leave", self._on_drag_leave)
+
+ def _on_add_clicked(self, _button):
+ """Handle add button click to create a new note."""
+ editor = KanbanEditor()
+ row = Gtk.ListBoxRow(name="kanban-row")
+ row.add(editor)
+ self.listbox.add(row)
+ self.listbox.show_all()
+ editor.text_view.grab_focus()
+
+ # Connect editor signals
+ editor.connect("confirmed", self._on_editor_confirmed)
+ editor.connect("canceled", self._on_editor_canceled)
+
+ # Scroll to bottom after row is loaded
+ GLib.idle_add(self._scroll_to_bottom)
+
+ def _on_editor_confirmed(self, editor, text):
+ """Handle editor confirmation for new note."""
+ note = KanbanNote(text)
+ note.connect("changed", lambda _: self.emit("changed"))
+
+ # Replace editor with note
+ row = editor.get_parent()
+ if row:
+ row.remove(editor)
+ row.add(note)
+ self.listbox.show_all()
+ self.emit("changed")
+
+ def _on_editor_canceled(self, editor):
+ """Handle editor cancellation for new note."""
+ row = editor.get_parent()
+ if row:
+ row.destroy()
+
+ def _scroll_to_bottom(self):
+ """Scroll the column to the bottom."""
+ adj = self.scroller.get_vadjustment()
+ adj.set_value(adj.get_upper())
+ return False # Don't repeat
+
+ def add_note(self, text: str, suppress_signal: bool = False):
+ """Add a new note to this column."""
+ note = KanbanNote(text)
+ note.connect("changed", lambda _: self.emit("changed"))
+
+ row = Gtk.ListBoxRow(name="kanban-row")
+ row.add(note)
+ row.connect("destroy", lambda _: self.emit("changed"))
+
+ self.listbox.add(row)
+ self.listbox.show_all()
+
+ if not suppress_signal:
+ self.emit("changed")
+
+ def get_notes(self) -> List[str]:
+ """Get all note texts in this column."""
+ return [
+ row.get_children()[0].label.get_text()
+ for row in self.listbox.get_children()
+ if row.get_children() and isinstance(row.get_children()[0], KanbanNote)
+ ]
+
+ def clear_notes(self, suppress_signal: bool = False):
+ """Clear all notes from this column."""
+ for row in self.listbox.get_children():
+ row.destroy()
+ if not suppress_signal:
+ self.emit("changed")
+
+ def _on_drag_data_received(self, _widget, drag_context, _x, y, data, _info, time):
+ """Handle drag data received event."""
+ text = data.get_text()
+ if not text:
+ return
+
+ # Find insertion point
+ row = self.listbox.get_row_at_y(y)
+
+ # Create new note
+ new_note = KanbanNote(text)
+ new_note.connect("changed", lambda _: self.emit("changed"))
+ new_row = Gtk.ListBoxRow(name="kanban-row")
+ new_row.add(new_note)
+ new_row.connect("destroy", lambda _: self.emit("changed"))
+
+ # Insert at appropriate position
+ if row:
+ self.listbox.insert(new_row, row.get_index())
+ else:
+ self.listbox.add(new_row)
+
+ self.listbox.show_all()
+ drag_context.finish(True, False, time)
+ self.emit("changed")
+
+ def _on_drag_motion(self, _widget, drag_context, _x, _y, time):
+ """Handle drag motion event."""
+ Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time)
+ return True
+
+ def _on_drag_leave(self, widget, _drag_context, _time):
+ """Handle drag leave event."""
+ widget.get_parent().get_parent().drag_unhighlight()
+
+
+class KanbanBoard(Gtk.Box):
+ """Main kanban board widget with three columns and command handling."""
+
+ __gsignals__ = {
+ "todo-added": (GObject.SignalFlags.RUN_LAST, None, ()),
+ }
+
+ def __init__(self):
+ super().__init__(name="kanban", orientation=Gtk.Orientation.VERTICAL)
+
+ # Command state
+ self.pending_add_text = None
+ self.pending_command = None
+
+ self._setup_ui()
+ self._setup_columns()
+ self._load_state()
+ self.show_all()
+
+ def _setup_ui(self):
+ """Setup the board's user interface."""
+ # Configure size for launcher compatibility
+ self.set_size_request(-1, BOARD_HEIGHT)
+ self.set_vexpand(False)
+ self.set_hexpand(True)
+ self.set_can_focus(True)
+
+ # Connect keyboard events
+ self.connect("key-press-event", self._on_key_press)
+
+ # Hidden entry for launcher Enter key handling
+ from fabric.widgets.entry import Entry
+
+ self.hidden_entry = Entry(name="kanban-hidden-entry")
+ self.hidden_entry.set_size_request(1, 1)
+ self.hidden_entry.set_opacity(0.0)
+ self.hidden_entry.connect("activate", self._on_hidden_entry_activate)
+ self.add(self.hidden_entry)
+
+ # Grid for columns
+ self.grid = Gtk.Grid(
+ column_spacing=4,
+ column_homogeneous=True,
+ row_spacing=4,
+ row_homogeneous=True,
+ )
+ self.grid.set_vexpand(True)
+ self.grid.set_hexpand(True)
+ self.add(self.grid)
+
+ def _setup_columns(self):
+ """Setup the kanban columns."""
+ self.columns = [
+ KanbanColumn("To Do"),
+ KanbanColumn("In Progress"),
+ KanbanColumn("Done"),
+ ]
+
+ # Arrange columns vertically (stacked)
+ for i, column in enumerate(self.columns):
+ self.grid.attach(column, 0, i, 1, 1)
+ column.connect("changed", lambda _: self._save_state())
+
+ def _load_state(self):
+ """Load kanban state from file."""
+ try:
+ with open(KANBAN_STATE_FILE, "r") as f:
+ state = json.load(f)
+ for col_data in state["columns"]:
+ for column in self.columns:
+ if column.title == col_data["title"]:
+ column.clear_notes(suppress_signal=True)
+ for note_text in col_data["notes"]:
+ column.add_note(note_text, suppress_signal=True)
+ break
+ except FileNotFoundError:
+ pass # No state file yet
+ except Exception as e:
+ print(f"Error loading kanban state: {e}")
+
+ def _save_state(self):
+ """Save kanban state to file."""
+ state = {
+ "columns": [
+ {"title": col.title, "notes": col.get_notes()} for col in self.columns
+ ]
+ }
+ try:
+ with open(KANBAN_STATE_FILE, "w") as f:
+ json.dump(state, f, indent=2)
+ except Exception as e:
+ print(f"Error saving kanban state: {e}")
+
+ def _on_key_press(self, _widget, event):
+ """Handle key press events for the kanban widget."""
+ keyval = event.keyval
+
+ # Enter - handle pending commands
+ if keyval == Gdk.KEY_Return:
+ return self._handle_enter_key()
+
+ # Ctrl+N - add new note to first column
+ if keyval == Gdk.KEY_n and event.state & Gdk.ModifierType.CONTROL_MASK:
+ self.columns[0]._on_add_clicked(None)
+ return True
+
+ # Ctrl+1, Ctrl+2, Ctrl+3 - add note to specific column
+ if event.state & Gdk.ModifierType.CONTROL_MASK:
+ column_shortcuts = {Gdk.KEY_1: 0, Gdk.KEY_2: 1, Gdk.KEY_3: 2}
+ if keyval in column_shortcuts:
+ column_idx = column_shortcuts[keyval]
+ if column_idx < len(self.columns):
+ self.columns[column_idx]._on_add_clicked(None)
+ return True
+
+ return False
+
+ def _handle_enter_key(self) -> bool:
+ """Handle Enter key press for command execution."""
+ if self.pending_command:
+ command, text = self.pending_command
+ success = False
+
+ if command == "add":
+ self.columns[0].add_note(text)
+ success = True
+ elif command == "move":
+ success = self._execute_move_command(text)
+ elif command == "done":
+ success = self._execute_done_command(text)
+ elif command == "remove":
+ success = self._execute_remove_command(text)
+ elif command == "clear":
+ success = self._execute_clear_command()
+
+ if success:
+ self._save_state()
+
+ # Clear pending command and notify plugin to reset
+ self._clear_pending_commands()
+ self.emit("todo-added") # Signal for plugin reset
+ return True
+
+ elif self.pending_add_text:
+ # Fallback for old add system
+ self.columns[0].add_note(self.pending_add_text)
+ self._save_state()
+ self._clear_pending_commands()
+ self.emit("todo-added")
+ return True
+
+ return False
+
+ def _on_hidden_entry_activate(self, _entry):
+ """Handle activation of the hidden entry (Enter key from launcher)."""
+ self._handle_enter_key()
+
+ def _clear_pending_commands(self):
+ """Clear all pending command state."""
+ self.pending_command = None
+ self.pending_add_text = None
+
+ def set_pending_add_text(self, text: str):
+ """Set the pending add text for Enter key handling."""
+ self.pending_add_text = text
+
+ def _execute_move_command(self, todo_text: str) -> bool:
+ """Execute move command to advance todo to next state."""
+ search_text = todo_text.lower().strip()
+
+ # Find todo across all columns
+ for col_idx, column in enumerate(self.columns):
+ notes = column.get_notes()
+ for note_text in notes:
+ if search_text in note_text.lower():
+ return self._move_note_to_next_column(column, note_text, col_idx)
+ return False
+
+ def _execute_done_command(self, todo_text: str) -> bool:
+ """Execute done command to move todo directly to Done column."""
+ search_text = todo_text.lower().strip()
+
+ # Find todo in To Do and In Progress columns only
+ for column in self.columns[:2]:
+ notes = column.get_notes()
+ for note_text in notes:
+ if search_text in note_text.lower():
+ return self._move_note_to_done_column(column, note_text)
+
+ # Check if already in Done column
+ done_notes = self.columns[2].get_notes()
+ return not any(search_text in note.lower() for note in done_notes)
+
+ def _move_note_to_next_column(
+ self, current_column: KanbanColumn, note_text: str, col_idx: int
+ ) -> bool:
+ """Move a note to the next column in sequence."""
+ # Determine next column
+ if col_idx == 0: # To Do -> In Progress
+ next_col_idx = 1
+ elif col_idx == 1: # In Progress -> Done
+ next_col_idx = 2
+ else: # Already in Done
+ return False
+
+ # Remove from current column and add to next
+ self._remove_note_from_column(current_column, note_text)
+ self.columns[next_col_idx].add_note(note_text)
+ return True
+
+ def _move_note_to_done_column(
+ self, current_column: KanbanColumn, note_text: str
+ ) -> bool:
+ """Move a note directly to the Done column."""
+ self._remove_note_from_column(current_column, note_text)
+ self.columns[2].add_note(note_text) # Done column
+ return True
+
+ def _execute_remove_command(self, todo_text: str) -> bool:
+ """Execute remove command to delete a specific todo."""
+ search_text = todo_text.lower().strip()
+
+ # Find todo across all columns
+ for column in self.columns:
+ notes = column.get_notes()
+ for note_text in notes:
+ if search_text in note_text.lower():
+ self._remove_note_from_column(column, note_text)
+ return True
+ return False
+
+ def _execute_clear_command(self) -> bool:
+ """Execute clear command to remove all todos from all columns."""
+ for column in self.columns:
+ column.clear_notes(suppress_signal=True)
+ return True
+
+ def _remove_note_from_column(self, column: KanbanColumn, note_text: str):
+ """Remove a specific note from a column."""
+ for row in column.listbox.get_children():
+ if row.get_children():
+ note_widget = row.get_children()[0]
+ if (
+ isinstance(note_widget, KanbanNote)
+ and note_widget.label.get_text() == note_text
+ ):
+ row.destroy()
+ break
+
+
+class KanbanPlugin(PluginBase):
+ """Kanban board plugin for the launcher providing task management interface."""
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Kanban"
+ self.description = "Kanban board for task management"
+ self._current_widget = None
+ self._launcher_instance = None
+
+ def initialize(self):
+ """Initialize the Kanban plugin."""
+ self.set_triggers(["kanban"])
+ self.description = (
+ "Kanban board for task management. "
+ "Commands: 'add ', 'move ', 'done ', 'remove ', 'clear'. "
+ "Keyboard: Enter/F2 to edit, Del to delete, Ctrl+N to add note"
+ )
+ self._setup_launcher_hooks()
+
+ def cleanup(self):
+ """Cleanup the Kanban plugin."""
+ if self._current_widget:
+ self._destroy_current_widget()
+ self._cleanup_launcher_hooks()
+
+ def _destroy_current_widget(self):
+ """Safely destroy the current widget."""
+ if self._current_widget.get_parent():
+ self._current_widget.get_parent().remove(self._current_widget)
+ self._current_widget.destroy()
+ self._current_widget = None
+
+ def _setup_launcher_hooks(self):
+ """Setup hooks to monitor launcher state."""
+ try:
+ import gc
+
+ # Find the launcher instance using garbage collection
+ 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."""
+ self._launcher_instance = None
+
+ def _reset_to_trigger(self):
+ """Reset launcher to trigger word and refresh."""
+ if not (
+ self._launcher_instance and hasattr(self._launcher_instance, "search_entry")
+ ):
+ return
+
+ trigger = "kanban "
+ try:
+
+ def reset_and_refresh():
+ self._launcher_instance.search_entry.set_text(trigger)
+ self._launcher_instance.search_entry.set_position(-1)
+ self._launcher_instance._perform_search(trigger)
+ return False
+
+ GLib.timeout_add(50, reset_and_refresh)
+ except Exception as e:
+ print(f"Could not reset to trigger: {e}")
+
+ def _delayed_reset_for_add(self):
+ """Reset to trigger with minimal delay for add operations."""
+ try:
+ GLib.timeout_add(50, lambda: (self._reset_to_trigger(), False))
+ except ImportError:
+ self._reset_to_trigger()
+
+ def _find_todo_in_columns(
+ self, search_text: str
+ ) -> Tuple[Optional[int], Optional[str]]:
+ """Find a todo by partial text match across all columns."""
+ if not self._current_widget:
+ return None, None
+
+ search_text = search_text.lower().strip()
+ for col_idx, column in enumerate(self._current_widget.columns):
+ notes = column.get_notes()
+ for note_text in notes:
+ if search_text in note_text.lower():
+ return col_idx, note_text
+ return None, None
+
+ def _move_todo_to_next_state(self, todo_text: str) -> Tuple[bool, str]:
+ """Move a todo to the next state (To Do -> In Progress -> Done)."""
+ if not self._current_widget:
+ return False, "Kanban board not initialized"
+
+ col_idx, found_text = self._find_todo_in_columns(todo_text)
+ if col_idx is None:
+ return False, f"Todo '{todo_text}' not found"
+
+ # Determine next column
+ state_transitions = {
+ 0: (1, "In Progress"), # To Do -> In Progress
+ 1: (2, "Done"), # In Progress -> Done
+ }
+
+ if col_idx not in state_transitions:
+ return False, f"Todo '{found_text}' is already completed"
+
+ next_col_idx, next_state = state_transitions[col_idx]
+
+ # Move the note
+ self._current_widget._remove_note_from_column(
+ self._current_widget.columns[col_idx], found_text
+ )
+ self._current_widget.columns[next_col_idx].add_note(found_text)
+ self._current_widget._save_state()
+
+ return True, f"Moved '{found_text}' to {next_state}"
+
+ def _execute_complete_action(self, todo_text: str):
+ """Execute the complete action (move directly to Done) and reset to trigger."""
+ if not self._current_widget:
+ return
+
+ col_idx, found_text = self._find_todo_in_columns(todo_text)
+ if col_idx is None or col_idx == 2: # Not found or already in Done
+ return
+
+ # Move to Done column
+ self._current_widget._remove_note_from_column(
+ self._current_widget.columns[col_idx], found_text
+ )
+ self._current_widget.columns[2].add_note(found_text)
+ self._current_widget._save_state()
+ self._delayed_reset_for_add()
+
+ def _execute_add_action(self, todo_text: str):
+ """Execute the add action and reset to trigger."""
+ if not self._current_widget:
+ return
+
+ self._current_widget.columns[0].add_note(todo_text)
+ self._current_widget._save_state()
+ self._delayed_reset_for_add()
+
+ def _execute_remove_action(self, todo_text: str):
+ """Execute the remove action and reset to trigger."""
+ if not self._current_widget:
+ return
+
+ success = self._current_widget._execute_remove_command(todo_text)
+ if success:
+ self._current_widget._save_state()
+ self._delayed_reset_for_add()
+
+ def _execute_clear_action(self):
+ """Execute the clear action and reset to trigger."""
+ if not self._current_widget:
+ return
+
+ self._current_widget._execute_clear_command()
+ self._current_widget._save_state()
+ self._delayed_reset_for_add()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process Kanban queries and return results."""
+ query = query_string.strip()
+
+ # Get or create kanban widget
+ kanban_widget = self._get_or_create_widget()
+
+ # Process command and set pending state
+ self._process_command(kanban_widget, query)
+
+ # Return kanban board result
+ return [self._create_kanban_result(kanban_widget)]
+
+ def _get_or_create_widget(self) -> KanbanBoard:
+ """Get existing widget or create a new one."""
+ if not self._current_widget:
+ self._current_widget = KanbanBoard()
+ self._current_widget.connect(
+ "todo-added", lambda _: self._delayed_reset_for_add()
+ )
+ return self._current_widget
+
+ def _process_command(self, widget: KanbanBoard, query: str):
+ """Process the query command and set pending state."""
+ # Clear previous state
+ widget.set_pending_add_text(None)
+ widget.pending_command = None
+
+ # Parse commands
+ if query.startswith("add "):
+ note_text = query[4:].strip()
+ if note_text:
+ widget.set_pending_add_text(note_text)
+ widget.pending_command = ("add", note_text)
+
+ elif query.startswith(("move ", "progress ")):
+ todo_text = self._extract_command_text(query)
+ if todo_text:
+ widget.pending_command = ("move", todo_text)
+
+ elif query.startswith(("done ", "complete ")):
+ todo_text = self._extract_command_text(query)
+ if todo_text:
+ widget.pending_command = ("done", todo_text)
+
+ elif query.startswith("remove "):
+ todo_text = self._extract_command_text(query)
+ if todo_text:
+ widget.pending_command = ("remove", todo_text)
+
+ elif query.strip() == "clear":
+ widget.pending_command = ("clear", "")
+
+ def _extract_command_text(self, query: str) -> Optional[str]:
+ """Extract text from command query."""
+ parts = query.split(" ", 1)
+ return parts[1].strip() if len(parts) > 1 else None
+
+ def _create_kanban_result(self, widget: KanbanBoard) -> Result:
+ """Create the kanban board result."""
+ return Result(
+ title="Kanban Board",
+ subtitle="Task management with kanban-style columns",
+ icon_markup=icons.kanban,
+ action=lambda: None, # Widget handles Enter key
+ relevance=1.0,
+ plugin_name=self.display_name,
+ custom_widget=widget,
+ data={"type": "kanban_board", "keep_launcher_open": True},
+ )
diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py
new file mode 100644
index 0000000..d9f95c4
--- /dev/null
+++ b/modules/launcher/plugins/otp.py
@@ -0,0 +1,851 @@
+import json
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from fabric.utils import get_relative_path
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+from services.auth import (
+ generate_totp,
+ get_time_remaining_with_blink,
+ parse_otpauth_uri,
+ scan_qr_and_add_account,
+ validate_base32_secret,
+)
+
+
+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/assets/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(1):
+ current_time = time.time()
+ if current_time - self.last_update >= 1:
+ self.last_update = current_time
+ 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
+
+ from gi.repository import GLib
+
+ 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:
+ from gi.repository import GLib
+
+ # 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_markup=icons.question,
+ 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_markup=icons.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_markup=icons.key,
+ 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_markup=icons.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_markup=icons.key,
+ 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_markup=icons.cancel,
+ 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_markup=icons.key,
+ 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_markup=icons.magnifier,
+ 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_markup=icons.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_markup=icons.question,
+ 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_markup=icons.bulb,
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.connect,
+ 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_markup=icons.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_markup=icons.connect,
+ 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_markup=icons.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_markup=icons.trash,
+ 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_markup=icons.trash,
+ 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_markup=icons.trash,
+ 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_markup=icons.cancel,
+ 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_markup=icons.trash,
+ 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_markup=icons.cancel,
+ 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_markup=icons.check,
+ 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_markup=icons.cancel,
+ 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_markup=icons.check,
+ action=lambda: self._trigger_refresh(),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ data={"type": "success", "keep_launcher_open": True},
+ )
+ ]
diff --git a/modules/launcher/plugins/password.py b/modules/launcher/plugins/password.py
new file mode 100644
index 0000000..18f1f65
--- /dev/null
+++ b/modules/launcher/plugins/password.py
@@ -0,0 +1,692 @@
+import base64
+import json
+import subprocess
+import threading
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from fabric.utils import get_relative_path
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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/assets/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_markup=icons.key,
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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:
+ 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:
+ 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:
+ 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:
+ from gi.repository import GLib
+
+ 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/modules/launcher/plugins/power.py b/modules/launcher/plugins/power.py
new file mode 100644
index 0000000..ffdc919
--- /dev/null
+++ b/modules/launcher/plugins/power.py
@@ -0,0 +1,103 @@
+from typing import List
+
+from fabric.utils import exec_shell_command_async
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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": icons.shutdown,
+ "action": self.shutdown,
+ },
+ "restart": {
+ "description": "Restart the system",
+ "icon": icons.reboot,
+ "action": self.restart,
+ },
+ "lock": {
+ "description": "Lock the screen",
+ "icon": icons.lock,
+ "action": self.lock,
+ },
+ "suspend": {
+ "description": "Suspend the system",
+ "icon": icons.suspend,
+ "action": self.suspend,
+ },
+ "logout": {
+ "description": "Logout from current session",
+ "icon": icons.logout,
+ "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_markup=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_markup=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/modules/launcher/plugins/process.py b/modules/launcher/plugins/process.py
new file mode 100644
index 0000000..d94bfbe
--- /dev/null
+++ b/modules/launcher/plugins/process.py
@@ -0,0 +1,524 @@
+import threading
+import time
+from typing import List
+
+import psutil
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+
+class ProcessPlugin(PluginBase):
+ """
+ Plugin for viewing and managing running processes with live CPU/RAM updates.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Process Manager"
+ self.description = "View and manage running processes"
+
+ # Threading for auto-refresh
+ self.refresh_thread = None
+ self.stop_refresh = threading.Event()
+ self.last_update = 0
+
+ # CPU monitoring cache to get meaningful readings
+ self._cpu_cache = {}
+ self._last_cpu_update = 0
+
+ def initialize(self):
+ """Initialize the process plugin."""
+ self.set_triggers(["ps"])
+ # Pre-populate CPU cache for immediate meaningful readings
+ self._initialize_cpu_monitoring()
+ self._start_refresh_thread()
+
+ def _initialize_cpu_monitoring(self):
+ """Initialize CPU monitoring for all processes to get meaningful readings immediately."""
+ try:
+ for proc in psutil.process_iter(["pid"]):
+ try:
+ pid = proc.info["pid"]
+ # Initialize CPU monitoring - this establishes baseline for future readings
+ proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+ except (
+ psutil.NoSuchProcess,
+ psutil.AccessDenied,
+ psutil.ZombieProcess,
+ ):
+ continue
+ self._last_cpu_update = time.time()
+ except Exception as e:
+ print(f"ProcessPlugin: Error initializing CPU monitoring: {e}")
+
+ def cleanup(self):
+ """Cleanup the process plugin."""
+ self.stop_refresh.set()
+ if self.refresh_thread:
+ self.refresh_thread.join(timeout=1)
+ # Clear CPU cache
+ self._cpu_cache.clear()
+
+ def _get_live_process_data(self):
+ """Get fresh live process data with CPU/RAM information, grouped by application."""
+ try:
+ current_time = time.time()
+
+ # Initialize CPU monitoring if this is the first call or enough time has passed
+ if current_time - self._last_cpu_update > 0.1: # Update every 100ms
+ # Pre-populate CPU cache for all processes
+ for proc in psutil.process_iter(["pid"]):
+ try:
+ pid = proc.info["pid"]
+ # Initialize CPU monitoring - this call establishes baseline
+ proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+ except (
+ psutil.NoSuchProcess,
+ psutil.AccessDenied,
+ psutil.ZombieProcess,
+ ):
+ continue
+ self._last_cpu_update = current_time
+
+ # Group processes by application name
+ app_groups = {}
+
+ for proc in psutil.process_iter(["pid", "name", "status", "ppid"]):
+ try:
+ info = proc.info
+ if info["status"] == psutil.STATUS_ZOMBIE:
+ continue
+
+ pid = info["pid"]
+ name = info["name"] or "Unknown"
+
+ # Get CPU percentage - use cached process if available for better readings
+ if pid in self._cpu_cache:
+ try:
+ cpu_percent = self._cpu_cache[pid].cpu_percent()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ cpu_percent = proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+ else:
+ cpu_percent = proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+
+ # Normalize CPU to 0-100% range (divide by number of cores)
+ cpu_cores = psutil.cpu_count()
+ if cpu_cores and cpu_percent > 0:
+ cpu_percent = min(100.0, cpu_percent / cpu_cores)
+
+ memory_info = proc.memory_info()
+ memory_percent = proc.memory_percent()
+ memory_mb = memory_info.rss / (1024 * 1024) if memory_info else 0
+
+ process_data = {
+ "pid": pid,
+ "ppid": info["ppid"],
+ "name": name,
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory_percent,
+ "memory_mb": memory_mb,
+ "status": info["status"],
+ }
+
+ # Group processes by application name
+ app_name = self._get_app_name(name)
+ if app_name not in app_groups:
+ app_groups[app_name] = []
+ app_groups[app_name].append(process_data)
+
+ except (
+ psutil.NoSuchProcess,
+ psutil.AccessDenied,
+ psutil.ZombieProcess,
+ ):
+ continue
+
+ # Create grouped results
+ grouped_processes = []
+ for app_name, app_processes in app_groups.items():
+ if len(app_processes) == 1:
+ # Single process - show as individual
+ proc = app_processes[0]
+ proc["process_count"] = 1
+ grouped_processes.append(proc)
+ else:
+ # Multiple processes - group them and sum resources
+ total_cpu = sum(p["cpu_percent"] for p in app_processes)
+ total_memory_mb = sum(p["memory_mb"] for p in app_processes)
+ total_memory_percent = sum(
+ p["memory_percent"] for p in app_processes
+ )
+
+ # Find the main process (usually the one with lowest PID)
+ main_process = min(app_processes, key=lambda x: x["pid"])
+
+ grouped_process = {
+ # Use main process PID for killing
+ "pid": main_process["pid"],
+ "name": app_name,
+ "cpu_percent": total_cpu,
+ "memory_percent": total_memory_percent,
+ "memory_mb": total_memory_mb,
+ "status": main_process["status"],
+ "process_count": len(app_processes),
+ "child_pids": [p["pid"] for p in app_processes],
+ }
+ grouped_processes.append(grouped_process)
+
+ # Clean up old entries from cache
+ all_pids = set()
+ for app_processes in app_groups.values():
+ all_pids.update(p["pid"] for p in app_processes)
+ self._cpu_cache = {
+ pid: proc for pid, proc in self._cpu_cache.items() if pid in all_pids
+ }
+
+ # Sort by combined CPU and memory usage for better display
+ grouped_processes.sort(
+ key=lambda x: (x["cpu_percent"] + x["memory_percent"]), reverse=True
+ )
+ return grouped_processes
+
+ except Exception as e:
+ print(f"ProcessPlugin: Error getting process data: {e}")
+ return []
+
+ def _get_app_name(self, process_name: str) -> str:
+ """Extract application name from process name, handling common patterns."""
+ # Remove common suffixes and normalize
+ name = process_name.lower()
+
+ # Handle common browser patterns
+ if "firefox" in name:
+ return "Firefox"
+ elif "chrome" in name or "chromium" in name:
+ return "Chrome"
+ elif "zen" in name or name in [
+ "socket process",
+ "privileged cont",
+ "rdd process",
+ "isolated web co",
+ "web content",
+ "webextensions",
+ "utility process",
+ "isolated servic",
+ ]:
+ return "Zen Browser"
+ elif "code" in name and ("helper" in name or "oss" in name or name == "code"):
+ return "VS Code"
+ elif name.startswith("python") and len(name) > 6:
+ return "Python"
+ elif name.startswith("node") and len(name) > 4:
+ return "Node.js"
+ elif "electron" in name:
+ return "Electron"
+ elif "java" in name:
+ return "Java"
+ elif "gnome" in name:
+ return "GNOME"
+ elif "gtk" in name:
+ return "GTK App"
+
+ # Remove common suffixes
+ suffixes = [
+ "-bin",
+ "-real",
+ "-wrapped",
+ ".bin",
+ ".exe",
+ "-helper",
+ "-gpu",
+ "-renderer",
+ ]
+ for suffix in suffixes:
+ if name.endswith(suffix):
+ name = name[: -len(suffix)]
+ break
+
+ # Capitalize first letter
+ return name.capitalize()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search for processes matching the query with live updates - ALWAYS FRESH DATA."""
+ # ALWAYS get completely fresh data - NO CACHING EVER
+ processes = self._get_live_process_data()
+
+ query = query_string.strip().lower()
+ results = []
+
+ # Filter processes based on query
+ filtered_processes = processes
+ if query:
+ filtered_processes = [
+ proc
+ for proc in processes
+ if query in proc["name"].lower() or query in str(proc["pid"])
+ ]
+
+ # Limit to top 20 processes to avoid overwhelming the UI
+ for proc in filtered_processes[:20]:
+ result = self._create_process_result(proc)
+ if result:
+ results.append(result)
+
+ return results
+
+ def _create_process_result(self, proc_data: dict) -> Result:
+ """Create a Result object for a process or grouped application."""
+ try:
+ pid = proc_data["pid"]
+ name = proc_data["name"]
+ cpu_percent = proc_data["cpu_percent"]
+ memory_percent = proc_data["memory_percent"]
+ memory_mb = proc_data["memory_mb"]
+ process_count = proc_data.get("process_count", 1)
+
+ # Format the title with process count if grouped
+ if process_count > 1:
+ title = f"{name} ({process_count} processes)"
+ else:
+ title = f"{name} (PID: {pid})"
+
+ # Add visual indicators for high usage
+ cpu_indicator = (
+ "🔥" if cpu_percent > 80 else "⚡" if cpu_percent > 50 else ""
+ )
+ mem_indicator = "💾" if memory_percent > 80 else ""
+
+ subtitle = f"CPU: {cpu_percent:.1f}%{cpu_indicator} | Memory: {memory_mb:.1f}MB ({memory_percent:.1f}%){mem_indicator}"
+
+ # Choose icon based on CPU usage
+ if cpu_percent > 50:
+ icon_markup = icons.cpu
+ elif memory_percent > 50:
+ icon_markup = icons.memory
+ else:
+ icon_markup = icons.terminal
+
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_markup=icon_markup,
+ action=lambda: None, # No action on Enter - just show process info
+ relevance=min(1.0, (cpu_percent + memory_percent) / 100.0),
+ plugin_name=self.display_name,
+ data={
+ "type": "process",
+ "pid": pid,
+ "name": name,
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory_percent,
+ "memory_mb": memory_mb,
+ "process_count": process_count,
+ "child_pids": proc_data.get("child_pids", [pid]),
+ "keep_launcher_open": True, # Keep launcher open to see live updates
+ "alt_action": lambda data=proc_data: self._kill_process_group(data),
+ },
+ )
+ except Exception as e:
+ print(f"ProcessPlugin: Error creating process result: {e}")
+ return None
+
+ def _kill_process_group(self, proc_data: dict):
+ """Kill a process group (application with all its subprocesses)."""
+ try:
+ process_count = proc_data.get("process_count", 1)
+ child_pids = proc_data.get("child_pids", [proc_data["pid"]])
+ app_name = proc_data["name"]
+
+ if process_count == 1:
+ # Single process
+ self._kill_process(proc_data["pid"])
+ else:
+ # Multiple processes - kill all
+ killed_count = 0
+ for pid in child_pids:
+ if self._kill_process(pid, silent=True):
+ killed_count += 1
+
+
+ except Exception as e:
+ print(f"✗ Error killing process group: {e}")
+
+ def _kill_process(self, pid: int, silent: bool = False) -> bool:
+ """Kill a process by PID. Returns True if successful."""
+ try:
+ proc = psutil.Process(pid)
+ proc_name = proc.name()
+
+ # Try graceful termination first
+ proc.terminate()
+
+ # Wait a bit for graceful termination
+ try:
+ proc.wait(timeout=3)
+ return True
+ except psutil.TimeoutExpired:
+ # Force kill if graceful termination failed
+ proc.kill()
+ if not silent:
+ print(f"✓ Force killed process '{proc_name}' (PID: {pid})")
+ return True
+
+ except psutil.NoSuchProcess:
+ if not silent:
+ print(f"✗ Process {pid} not found - may have already been terminated")
+ return False
+ except psutil.AccessDenied:
+ if not silent:
+ print(
+ f"✗ Access denied - cannot kill process {pid} (insufficient permissions)"
+ )
+ return False
+ except Exception as e:
+ if not silent:
+ print(f"✗ Error killing process {pid}: {e}")
+ return False
+
+ def _start_refresh_thread(self):
+ """Start background thread for auto-refreshing process data in milliseconds."""
+
+ def refresh_loop():
+ # Check every 500ms for real-time updates
+ while not self.stop_refresh.wait(0.5):
+ current_time = time.time()
+ if current_time - self.last_update >= 0.5: # Update every 500ms
+ self.last_update = current_time
+ # Update existing process labels without full refresh
+ 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 process data in existing result items (like OTP plugin)."""
+ try:
+ import gc
+
+ from gi.repository import GLib
+
+ 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_process_results = any(
+ result.data and result.data.get("type") == "process"
+ for result in obj.results
+ if hasattr(result, "data") and result.data
+ )
+
+ if has_process_results:
+ self._update_existing_process_labels(obj.results_box)
+ return False
+ except Exception:
+ pass
+ return False
+
+ GLib.idle_add(do_update)
+ except Exception:
+ pass
+
+ def _update_existing_process_labels(self, results_box):
+ """Update subtitle labels in existing ResultItem widgets with FRESH data (optimized)."""
+ try:
+ # Get fresh process data in background thread - optimized
+ def get_data_async():
+ return self._get_live_process_data()
+
+ # Run data collection in background to avoid blocking UI
+ import concurrent.futures
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
+ future = executor.submit(get_data_async)
+ try:
+ current_processes = future.result(timeout=1.0) # 1 second timeout
+ process_dict = {proc["pid"]: proc for proc in current_processes}
+
+ 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") == "process"
+ ):
+ self._update_process_result_item(child, process_dict)
+ except concurrent.futures.TimeoutError:
+ # Skip update if it takes too long
+ pass
+ except Exception as e:
+ print(f"Error updating process labels: {e}")
+
+ def _update_process_result_item(self, result_item, process_dict):
+ """Update both the title and subtitle of a specific process ResultItem."""
+ try:
+ pid = result_item.result.data.get("pid")
+ if not pid or pid not in process_dict:
+ return
+
+ proc_data = process_dict[pid]
+ cpu_percent = proc_data["cpu_percent"]
+ memory_percent = proc_data["memory_percent"]
+ memory_mb = proc_data["memory_mb"]
+
+ # CPU is already normalized in _get_live_process_data, so no need to normalize again
+
+ # Update the subtitle with live data
+ cpu_indicator = (
+ "🔥" if cpu_percent > 80 else "⚡" if cpu_percent > 50 else ""
+ )
+ mem_indicator = "💾" if memory_percent > 80 else ""
+ new_subtitle = f"CPU: {cpu_percent:.1f}%{cpu_indicator} | Memory: {memory_mb:.1f}MB ({memory_percent:.1f}%){mem_indicator}"
+
+ # Find and update the subtitle label widget
+ self._find_and_update_subtitle_label(result_item, new_subtitle)
+
+ # Update the result data for consistency
+ result_item.result.data.update(
+ {
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory_percent,
+ "memory_mb": memory_mb,
+ }
+ )
+
+ except Exception as e:
+ print(f"Error updating process result item: {e}")
+
+ def _find_and_update_subtitle_label(self, result_item, new_subtitle):
+ """Find the subtitle label widget and update its text."""
+
+ 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_text"):
+ subtitle_label.set_text(new_subtitle)
diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py
new file mode 100644
index 0000000..bcfbb26
--- /dev/null
+++ b/modules/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
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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_markup=icons.timer_on,
+ 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_markup=icons.timer_on,
+ 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_markup=icons.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_markup=icons.timer_on,
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.timer_on,
+ 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_markup=icons.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_markup=icons.alert,
+ action=lambda: None,
+ relevance=0.5,
+ plugin_name=self.display_name,
+ data={"type": "error"},
+ )
+ )
+
+ return results
diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py
new file mode 100644
index 0000000..3db965b
--- /dev/null
+++ b/modules/launcher/plugins/screencapture.py
@@ -0,0 +1,685 @@
+import subprocess
+from typing import List
+
+from fabric.utils import get_relative_path
+
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+from utils.icons import screenrecord, screenshots, ssfull, ssregion, stop
+
+
+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"
+ # Path to the screen-capture script
+ self.script_path = get_relative_path("../../../scripts/screen-capture.sh")
+
+ 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",
+ "ss": "Take a screenshot of the main display",
+ "screenshot-region": "Take a screenshot of selected region",
+ "ss-region": "Take a screenshot of selected region",
+ "screenshot-both": "Take a screenshot of both displays",
+ "ss-both": "Take a screenshot of both displays",
+ "screenshot-hdmi": "Take a screenshot of HDMI display",
+ "ss-hdmi": "Take a screenshot of HDMI display",
+ # Recording commands (with audio)
+ "record": "Start recording main display with audio",
+ "rec": "Start recording main display with audio",
+ "record-region": "Start recording selected region with audio",
+ "rec-region": "Start recording selected region with audio",
+ "record-hdmi": "Start recording HDMI display with audio",
+ "rec-hdmi": "Start recording HDMI display with audio",
+ # Recording commands (no audio)
+ "record-noaudio": "Start recording main display without audio",
+ "rec-noaudio": "Start recording main display without audio",
+ "record-noaudio-region": "Start recording selected region without audio",
+ "rec-noaudio-region": "Start recording selected region without audio",
+ "record-noaudio-hdmi": "Start recording HDMI display without audio",
+ "rec-noaudio-hdmi": "Start recording HDMI display without audio",
+ # High-quality recording commands
+ "record-hq": "Start high-quality recording of main display",
+ "rec-hq": "Start high-quality recording of main display",
+ "record-hq-region": "Start high-quality recording of selected region",
+ "rec-hq-region": "Start high-quality recording of selected region",
+ "record-hq-hdmi": "Start high-quality recording of HDMI display",
+ "rec-hq-hdmi": "Start high-quality recording of HDMI display",
+ # GIF recording commands
+ "record-gif": "Start GIF recording of main display",
+ "rec-gif": "Start GIF recording of main display",
+ "record-gif-region": "Start GIF recording of selected region",
+ "rec-gif-region": "Start GIF recording of selected region",
+ # Control commands
+ "stop": "Stop current recording",
+ # Conversion commands
+ "convert-webm": "Convert latest MKV recording to WebM format",
+ "conv-webm": "Convert latest MKV recording to WebM format",
+ "convert-iphone": "Convert latest MKV recording for iPhone compatibility",
+ "conv-iphone": "Convert latest MKV recording for iPhone compatibility",
+ "convert-youtube": "Convert latest recording for YouTube upload",
+ "conv-youtube": "Convert latest recording for YouTube upload",
+ "convert-gif": "Convert latest recording to GIF format",
+ "conv-gif": "Convert latest recording to GIF format",
+ # Conversion commands with file input
+ "convert-webm-file": "Convert specific MKV file to WebM format",
+ "conv-webm-file": "Convert specific MKV file to WebM format",
+ "convert-iphone-file": "Convert specific MKV file for iPhone compatibility",
+ "conv-iphone-file": "Convert specific MKV file for iPhone compatibility",
+ "convert-youtube-file": "Convert specific video file for YouTube upload",
+ "conv-youtube-file": "Convert specific video file for YouTube upload",
+ "convert-gif-file": "Convert specific video file to GIF format",
+ "conv-gif-file": "Convert specific video file to GIF format",
+ }
+
+ def _run_script(self, *args):
+ """Execute the screen-capture script with given arguments."""
+ try:
+ subprocess.Popen([self.script_path] + list(args))
+ except Exception as e:
+ print(f"Error running screen-capture script: {e}")
+
+ def _run_script_with_file(self, format_type: str, file_path: str):
+ """Execute the screen-capture script with file parameter."""
+ try:
+ subprocess.Popen([self.script_path, "convert", format_type, file_path])
+ except Exception as e:
+ print(f"Error running screen-capture script with file: {e}")
+
+ def _is_recording(self):
+ """Check if recording is currently active."""
+ try:
+ result = subprocess.run(
+ [self.script_path, "status"], capture_output=True, text=True
+ )
+ return result.stdout.strip() == "true"
+ except Exception:
+ return False
+
+ def _get_command_result(self, command: str) -> Result:
+ """Get a Result object for a specific command."""
+ # Import here to avoid circular imports
+
+ command_info = {
+ # Screenshot commands
+ "screenshot": (
+ "Take Screenshot (eDP-1)",
+ "Capture the main display",
+ ssfull,
+ lambda: self._run_script("screenshot", "eDP-1"),
+ ),
+ "ss": (
+ "Take Screenshot (eDP-1)",
+ "Capture the main display",
+ ssfull,
+ lambda: self._run_script("screenshot", "eDP-1"),
+ ),
+ "screenshot-region": (
+ "Take Region Screenshot",
+ "Capture a selected region",
+ ssregion,
+ lambda: self._run_script("screenshot", "selection"),
+ ),
+ "ss-region": (
+ "Take Region Screenshot",
+ "Capture a selected region",
+ ssregion,
+ lambda: self._run_script("screenshot", "selection"),
+ ),
+ "screenshot-both": (
+ "Take Screenshot (Both Displays)",
+ "Capture both displays combined",
+ screenshots,
+ lambda: self._run_script("screenshot", "both"),
+ ),
+ "ss-both": (
+ "Take Screenshot (Both Displays)",
+ "Capture both displays combined",
+ screenshots,
+ lambda: self._run_script("screenshot", "both"),
+ ),
+ "screenshot-hdmi": (
+ "Take Screenshot (HDMI-A-1)",
+ "Capture HDMI display",
+ ssfull,
+ lambda: self._run_script("screenshot", "HDMI-A-1"),
+ ),
+ "ss-hdmi": (
+ "Take Screenshot (HDMI-A-1)",
+ "Capture HDMI display",
+ ssfull,
+ lambda: self._run_script("screenshot", "HDMI-A-1"),
+ ),
+ # Recording commands (with audio)
+ "record": (
+ "Start Recording (eDP-1)",
+ "Record the main display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "eDP-1"),
+ ),
+ "rec": (
+ "Start Recording (eDP-1)",
+ "Record the main display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "eDP-1"),
+ ),
+ "record-region": (
+ "Start Region Recording",
+ "Record a selected region with audio",
+ screenrecord,
+ lambda: self._run_script("record", "selection"),
+ ),
+ "rec-region": (
+ "Start Region Recording",
+ "Record a selected region with audio",
+ screenrecord,
+ lambda: self._run_script("record", "selection"),
+ ),
+ "record-hdmi": (
+ "Start Recording (HDMI-A-1)",
+ "Record HDMI display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "HDMI-A-1"),
+ ),
+ "rec-hdmi": (
+ "Start Recording (HDMI-A-1)",
+ "Record HDMI display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "HDMI-A-1"),
+ ),
+ # Recording commands (no audio)
+ "record-noaudio": (
+ "Start Recording No Audio (eDP-1)",
+ "Record the main display without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "eDP-1"),
+ ),
+ "rec-noaudio": (
+ "Start Recording No Audio (eDP-1)",
+ "Record the main display without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "eDP-1"),
+ ),
+ "record-noaudio-region": (
+ "Start Region Recording No Audio",
+ "Record a selected region without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "selection"),
+ ),
+ "rec-noaudio-region": (
+ "Start Region Recording No Audio",
+ "Record a selected region without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "selection"),
+ ),
+ "record-noaudio-hdmi": (
+ "Start Recording No Audio (HDMI-A-1)",
+ "Record HDMI display without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "HDMI-A-1"),
+ ),
+ "rec-noaudio-hdmi": (
+ "Start Recording No Audio (HDMI-A-1)",
+ "Record HDMI display without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "HDMI-A-1"),
+ ),
+ # High-quality recording commands
+ "record-hq": (
+ "Start HQ Recording (eDP-1)",
+ "High-quality recording for YouTube",
+ screenrecord,
+ lambda: self._run_script("record-hq", "eDP-1"),
+ ),
+ "rec-hq": (
+ "Start HQ Recording (eDP-1)",
+ "High-quality recording for YouTube",
+ screenrecord,
+ lambda: self._run_script("record-hq", "eDP-1"),
+ ),
+ "record-hq-region": (
+ "Start HQ Region Recording",
+ "High-quality region recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "selection"),
+ ),
+ "rec-hq-region": (
+ "Start HQ Region Recording",
+ "High-quality region recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "selection"),
+ ),
+ "record-hq-hdmi": (
+ "Start HQ Recording (HDMI-A-1)",
+ "High-quality HDMI recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "HDMI-A-1"),
+ ),
+ "rec-hq-hdmi": (
+ "Start HQ Recording (HDMI-A-1)",
+ "High-quality HDMI recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "HDMI-A-1"),
+ ),
+ # GIF recording commands
+ "record-gif": (
+ "Start GIF Recording (eDP-1)",
+ "Record as optimized GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "eDP-1"),
+ ),
+ "rec-gif": (
+ "Start GIF Recording (eDP-1)",
+ "Record as optimized GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "eDP-1"),
+ ),
+ "record-gif-region": (
+ "Start GIF Region Recording",
+ "Record selected region as GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "selection"),
+ ),
+ "rec-gif-region": (
+ "Start GIF Region Recording",
+ "Record selected region as GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "selection"),
+ ),
+ # Control commands
+ "stop": (
+ "Stop Recording",
+ "Stop the current screen recording",
+ stop,
+ lambda: self._run_script("record", "stop"),
+ ),
+ # Conversion commands
+ "convert-webm": (
+ "Convert Latest to WebM",
+ "Convert latest MKV recording to WebM format",
+ screenrecord,
+ lambda: self._run_script("convert", "webm"),
+ ),
+ "conv-webm": (
+ "Convert Latest to WebM",
+ "Convert latest MKV recording to WebM format",
+ screenrecord,
+ lambda: self._run_script("convert", "webm"),
+ ),
+ "convert-iphone": (
+ "Convert Latest for iPhone",
+ "Convert latest MKV recording for iPhone compatibility",
+ screenrecord,
+ lambda: self._run_script("convert", "iphone"),
+ ),
+ "conv-iphone": (
+ "Convert Latest for iPhone",
+ "Convert latest MKV recording for iPhone compatibility",
+ screenrecord,
+ lambda: self._run_script("convert", "iphone"),
+ ),
+ "convert-youtube": (
+ "Convert Latest for YouTube",
+ "Convert latest recording for YouTube upload",
+ screenrecord,
+ lambda: self._run_script("convert", "youtube"),
+ ),
+ "conv-youtube": (
+ "Convert Latest for YouTube",
+ "Convert latest recording for YouTube upload",
+ screenrecord,
+ lambda: self._run_script("convert", "youtube"),
+ ),
+ "convert-gif": (
+ "Convert Latest to GIF",
+ "Convert latest recording to GIF format",
+ screenrecord,
+ lambda: self._run_script("convert", "gif"),
+ ),
+ "conv-gif": (
+ "Convert Latest to GIF",
+ "Convert latest recording to GIF format",
+ screenrecord,
+ lambda: self._run_script("convert", "gif"),
+ ),
+ # File-based conversion commands (these will be handled specially)
+ "convert-webm-file": (
+ "Convert File to WebM",
+ "Type filename after command (e.g., convert-webm-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "conv-webm-file": (
+ "Convert File to WebM",
+ "Type filename after command (e.g., conv-webm-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "convert-iphone-file": (
+ "Convert File for iPhone",
+ "Type filename after command (e.g., convert-iphone-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "conv-iphone-file": (
+ "Convert File for iPhone",
+ "Type filename after command (e.g., conv-iphone-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "convert-youtube-file": (
+ "Convert File for YouTube",
+ "Type filename after command (e.g., convert-youtube-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "conv-youtube-file": (
+ "Convert File for YouTube",
+ "Type filename after command (e.g., conv-youtube-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "convert-gif-file": (
+ "Convert File to GIF",
+ "Type filename after command (e.g., convert-gif-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ "conv-gif-file": (
+ "Convert File to GIF",
+ "Type filename after command (e.g., conv-gif-file video.mkv)",
+ screenrecord,
+ None, # Will be handled in query method
+ ),
+ }
+
+ 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_markup=icon,
+ action=action,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ else: # File-based command, show instruction
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_markup=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 = []
+
+ # Check for file-based conversion commands with parameters
+ file_conversion_commands = {
+ "convert-webm-file": "webm",
+ "conv-webm-file": "webm",
+ "convert-iphone-file": "iphone",
+ "conv-iphone-file": "iphone",
+ "convert-youtube-file": "youtube",
+ "conv-youtube-file": "youtube",
+ "convert-gif-file": "gif",
+ "conv-gif-file": "gif",
+ }
+
+ # Parse query for file-based commands
+ query_parts = query.split()
+ if len(query_parts) >= 2:
+ command = query_parts[0]
+ file_param = " ".join(query_parts[1:])
+
+ if command in file_conversion_commands:
+ format_type = file_conversion_commands[command]
+ return [
+ Result(
+ title=f"Convert {file_param} to {format_type.upper()}",
+ subtitle=f"Convert specified file to {format_type} format",
+ icon_markup=screenrecord,
+ action=lambda fp=file_param,
+ ft=format_type: self._run_script_with_file(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_markup=stop,
+ action=lambda: self._run_script("record", "stop"),
+ 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_markup=ssfull,
+ action=lambda: self._run_script("screenshot", "eDP-1"),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Region Screenshot",
+ subtitle="Capture a selected region",
+ icon_markup=ssregion,
+ action=lambda: self._run_script("screenshot", "selection"),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Screenshot (Both Displays)",
+ subtitle="Capture both displays combined",
+ icon_markup=screenshots,
+ action=lambda: self._run_script("screenshot", "both"),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Screenshot (HDMI-A-1)",
+ subtitle="Capture HDMI display",
+ icon_markup=ssfull,
+ action=lambda: self._run_script("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_markup=screenrecord,
+ action=lambda: self._run_script("record", "eDP-1"),
+ relevance=0.7,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Region Recording",
+ subtitle="Record a selected region",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("record", "selection"),
+ relevance=0.6,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Recording (HDMI-A-1)",
+ subtitle="Record HDMI display with audio",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("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_markup=screenrecord,
+ action=lambda: self._run_script("record-noaudio", "eDP-1"),
+ relevance=0.65,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Region Recording No Audio",
+ subtitle="Record a selected region without audio",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("record-noaudio", "selection"),
+ relevance=0.55,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Recording No Audio (HDMI-A-1)",
+ subtitle="Record HDMI display without audio",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("record-noaudio", "HDMI-A-1"),
+ 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_markup=screenrecord,
+ action=lambda: self._run_script("record-hq", "eDP-1"),
+ relevance=0.4,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start HQ Region Recording",
+ subtitle="High-quality region recording",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("record-hq", "selection"),
+ relevance=0.3,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start HQ Recording (HDMI-A-1)",
+ subtitle="High-quality HDMI recording",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("record-hq", "HDMI-A-1"),
+ 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_markup=screenrecord,
+ action=lambda: self._run_script("record-gif", "eDP-1"),
+ relevance=0.1,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start GIF Region Recording",
+ subtitle="Record selected region as GIF",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("record-gif", "selection"),
+ 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_markup=screenrecord,
+ action=lambda: self._run_script("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_markup=screenrecord,
+ action=lambda: self._run_script("convert", "iphone"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest for YouTube",
+ subtitle="Convert latest recording for YouTube upload",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("convert", "youtube"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest to GIF",
+ subtitle="Convert latest recording to GIF format",
+ icon_markup=screenrecord,
+ action=lambda: self._run_script("convert", "gif"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ return results
diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py
new file mode 100644
index 0000000..65142f1
--- /dev/null
+++ b/modules/launcher/plugins/system.py
@@ -0,0 +1,234 @@
+import json
+import os
+import shlex
+import threading
+import time
+from typing import List, Set, Union
+
+from fabric.utils import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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_markup=icons.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/modules/launcher/plugins/tmux.py b/modules/launcher/plugins/tmux.py
new file mode 100644
index 0000000..23e1267
--- /dev/null
+++ b/modules/launcher/plugins/tmux.py
@@ -0,0 +1,292 @@
+import subprocess
+import threading
+import time
+from typing import List
+
+from fabric.utils import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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
+ self._cache_ttl = 2 # Cache sessions for 2 seconds
+
+ # Threading for auto-refresh
+ self.refresh_thread = None
+ self.stop_refresh = threading.Event()
+
+ def initialize(self):
+ """Initialize the tmux plugin."""
+ self.set_triggers(["tmux"])
+ self._start_refresh_thread()
+
+ 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."""
+ while not self.stop_refresh.is_set():
+ try:
+ current_time = time.time()
+ if current_time - self._last_cache_update > self._cache_ttl:
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = current_time
+
+ # Sleep for a short interval
+ self.stop_refresh.wait(1)
+ except Exception as e:
+ print(f"TmuxPlugin: Error in refresh thread: {e}")
+ self.stop_refresh.wait(5) # 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 = []
+
+ # Get current sessions (use cache if recent)
+ current_time = time.time()
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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/modules/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py
new file mode 100644
index 0000000..e349a80
--- /dev/null
+++ b/modules/launcher/plugins/wallpaper.py
@@ -0,0 +1,996 @@
+import colorsys
+import hashlib
+import json
+import os
+import random
+import re
+import threading
+import time
+from typing import Dict, List, Optional
+
+from fabric.utils.helpers import exec_shell_command_async
+from gi.repository import GdkPixbuf
+from PIL import Image
+
+import config.data as data
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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:
+ print(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}")
+ except Exception as e:
+ print(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:
+ print(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:
+ print(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
+ from gi.repository import GLib
+
+ 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:
+ print(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:
+ with Image.open(full_path) as img:
+ # Use faster thumbnail creation with smaller size for better performance
+ img.thumbnail((32, 32), Image.Resampling.LANCZOS)
+ img.save(cache_path, "PNG", optimize=True)
+ except Exception as e:
+ print(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:
+ print(f"Error loading thumbnail for {wallpaper}: {e}")
+ self.thumbnail_cache[wallpaper] = None
+ else:
+ self.thumbnail_cache[wallpaper] = None
+ except Exception as e:
+ print(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:
+ print(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 with optional matugen integration."""
+ 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)
+
+ # Apply wallpaper with or without matugen
+ matugen_enabled = self._get_matugen_state()
+ if matugen_enabled:
+ exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}')
+ else:
+ exec_shell_command_async(
+ f'swww img "{
+ full_path
+ }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
+ )
+
+ 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: 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, 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}'
+ )
+
+ # 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}')
+
+ 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}')
+
+ # 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_markup=random.choice(
+ [
+ icons.dice_1,
+ icons.dice_2,
+ icons.dice_3,
+ icons.dice_4,
+ icons.dice_5,
+ icons.dice_6,
+ ]
+ ),
+ 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_markup=random.choice(
+ [
+ icons.dice_1,
+ icons.dice_2,
+ icons.dice_3,
+ icons.dice_4,
+ icons.dice_5,
+ icons.dice_6,
+ ]
+ ),
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=random.choice(
+ [
+ icons.dice_1,
+ icons.dice_2,
+ icons.dice_3,
+ icons.dice_4,
+ icons.dice_5,
+ icons.dice_6,
+ ]
+ ),
+ 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_markup=icons.wallpapers 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/modules/launcher/plugins/websearch.py b/modules/launcher/plugins/websearch.py
new file mode 100644
index 0000000..575492a
--- /dev/null
+++ b/modules/launcher/plugins/websearch.py
@@ -0,0 +1,260 @@
+import subprocess
+import urllib.parse
+from typing import List
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.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": icons.google,
+ "description": "Search with Google",
+ },
+ "duckduckgo": {
+ "name": "DuckDuckGo",
+ "url": "https://duckduckgo.com/?q={}",
+ "icon": icons.duckduckgo,
+ "description": "Search with DuckDuckGo (privacy-focused)",
+ },
+ "youtube": {
+ "name": "YouTube",
+ "url": "https://www.youtube.com/results?search_query={}",
+ "icon": icons.youtube,
+ "description": "Search videos on YouTube",
+ },
+ "github": {
+ "name": "GitHub",
+ "url": "https://github.com/search?q={}",
+ "icon": icons.github,
+ "description": "Search repositories on GitHub",
+ },
+ "stackoverflow": {
+ "name": "Stack Overflow",
+ "url": "https://stackoverflow.com/search?q={}",
+ "icon": icons.stackoverflow,
+ "description": "Search programming questions on Stack Overflow",
+ },
+ "wikipedia": {
+ "name": "Wikipedia",
+ "url": "https://en.wikipedia.org/wiki/Special:Search?search={}",
+ "icon": icons.wikipedia,
+ "description": "Search articles on Wikipedia",
+ },
+ "reddit": {
+ "name": "Reddit",
+ "url": "https://www.reddit.com/search/?q={}",
+ "icon": icons.reddit,
+ "description": "Search discussions on Reddit",
+ },
+ "linkedin": {
+ "name": "LinkedIn",
+ "url": "https://www.linkedin.com/search/results/all/?keywords={}",
+ "icon": icons.linkedin,
+ "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_markup=icons.world,
+ 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_markup=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_markup=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_markup=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/modules/launcher/result.py b/modules/launcher/result.py
new file mode 100644
index 0000000..fce3550
--- /dev/null
+++ b/modules/launcher/result.py
@@ -0,0 +1,52 @@
+from dataclasses import dataclass
+from typing import Any, Callable, Optional
+
+from gi.repository 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/modules/launcher/result_item.py b/modules/launcher/result_item.py
new file mode 100644
index 0000000..27dcf82
--- /dev/null
+++ b/modules/launcher/result_item.py
@@ -0,0 +1,161 @@
+import gi
+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 modules.launcher.result import Result
+
+gi.require_version("Gtk", "3.0")
+
+
+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=32, 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-x-executable",
+ icon_size=32,
+ 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/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py
new file mode 100644
index 0000000..cb67687
--- /dev/null
+++ b/modules/launcher/trigger_config.py
@@ -0,0 +1,59 @@
+import json
+import os
+from typing import Any, Dict, List
+
+from fabric.utils import get_relative_path
+
+import utils.icons as icons
+
+
+class TriggerConfig:
+ def __init__(self, config_path: str = None):
+ if config_path is None:
+ config_path = get_relative_path("../../config/assets/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": "apps",
+ "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_name = self.launcher_config.get(trigger, {}).get(
+ "icon", self.settings.get("default_icon", "apps")
+ )
+ return getattr(icons, icon_name, icons.apps)
+
+ 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/modules/notification_popup.py b/modules/notification_popup.py
index 82dcb5d..ab4e1bc 100644
--- a/modules/notification_popup.py
+++ b/modules/notification_popup.py
@@ -1,21 +1,36 @@
-import os
-from gi.repository import GdkPixbuf, GLib, Gdk
-from fabric.notifications import (
- Notification,
- NotificationAction,
- NotificationCloseReason,
-)
-from loguru import logger
+import uuid
+
+from fabric.notifications.service import Notification, NotificationAction, Notifications
from fabric.widgets.box import Box
from fabric.widgets.button import Button
-from fabric.widgets.image import Image
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.wayland import WaylandWindow as Window
-from utils import CustomImage
+from gi.repository import GLib, Gtk
+from loguru import logger
+
+import config.data as data
import utils.icons as icons
-from services import notification_service
+from utils.custom_image import CustomImage
+from utils.wayland import WaylandWindow as Window
+from utils.notification_utils import (
+ cache_notification_pixbuf,
+ load_scaled_pixbuf,
+ get_shared_notification_history,
+ NotificationHistory,
+ delete_cached_image,
+)
+
+
+def get_limited_apps_history():
+ config = data.load_config()
+ return config.get("limited_apps_history", ["Spotify"])
+
+
+def get_history_ignored_apps():
+ config = data.load_config()
+ return config.get("history_ignored_apps", ["Modus"])
class ActionButton(Button):
@@ -26,7 +41,14 @@ class ActionButton(Button):
name="action-button",
h_expand=True,
on_clicked=self.on_clicked,
- child=Label(name="button-label", label=action.label),
+ child=Label(
+ name="button-label",
+ h_expand=True,
+ h_align="fill",
+ ellipsization="end",
+ max_chars_width=1,
+ label=action.label,
+ ),
)
self.action = action
self.notification_box = notification_box
@@ -50,25 +72,78 @@ class ActionButton(Button):
self.action.parent.close("dismissed-by-user")
-class NotificationWidget(Box):
+class NotificationBox(Box):
def __init__(self, notification: Notification, timeout_ms=5000, **kwargs):
super().__init__(
name="notification-box",
orientation="v",
h_align="fill",
h_expand=True,
- children=[
- self.create_content(notification),
- self.create_action_buttons(notification),
- ],
+ children=[],
)
self.notification = notification
- self.timeout_ms = timeout_ms
- self._timeout_id = None
- self.start_timeout()
+ self.uuid = str(uuid.uuid4())
- def create_header(self, notification):
- app_icon = (
+ if timeout_ms == 0:
+ self.timeout_ms = 0
+ else:
+ live_timeout = getattr(self.notification, "timeout", -1)
+ self.timeout_ms = live_timeout if live_timeout != -1 else timeout_ms
+ self._timeout_id = None
+ self._container = None
+ self.cached_image_path = None
+
+ if self.timeout_ms > 0:
+ self.start_timeout()
+
+ # Try to access image_pixbuf with error handling
+ try:
+ if self.notification.image_pixbuf:
+ cache_path = cache_notification_pixbuf(self)
+ if cache_path:
+ self.cached_image_path = cache_path
+ else:
+ logger.warning(
+ f"NotificationBox {
+ self.uuid
+ }: Caching failed, cached_image_path not set."
+ )
+ else:
+ logger.debug(f"NotificationBox {self.uuid}: No image to cache.")
+ except Exception as e:
+ logger.debug(f"NotificationBox {self.uuid}: Error accessing image_pixbuf: {e}")
+ # Continue without image - this is not a critical error
+
+ content = self.create_content()
+ action_buttons = self.create_action_buttons()
+ self.add(content)
+ if action_buttons:
+ self.add(action_buttons)
+
+ self.connect("enter-notify-event", self.on_hover_enter)
+ self.connect("leave-notify-event", self.on_hover_leave)
+
+ self._destroyed = False
+ self._is_history = False
+
+ def set_is_history(self, is_history):
+ # Ensure the object is properly initialized
+ if not hasattr(self, "_is_history"):
+ self._is_history = False
+ self._is_history = is_history
+
+ def set_container(self, container):
+ # Ensure the object is properly initialized
+ if not hasattr(self, "_container"):
+ self._container = None
+ self._container = container
+
+ def get_container(self):
+ return getattr(self, "_container", None)
+
+ def create_header(self):
+ notification = self.notification
+ self.app_icon_image = (
Image(
name="notification-icon",
image_file=notification.app_icon[7:],
@@ -81,6 +156,10 @@ class NotificationWidget(Box):
icon_size=24,
)
)
+ self.app_name_label_header = Label(
+ notification.app_name, name="notification-app-name", h_align="start"
+ )
+ self.header_close_button = self.create_close_button()
return CenterBox(
name="notification-title",
@@ -88,215 +167,550 @@ class NotificationWidget(Box):
Box(
spacing=4,
children=[
- app_icon,
- Label(
- notification.app_name,
- name="notification-app-name",
- h_align="start",
- ),
+ self.app_icon_image,
+ self.app_name_label_header,
],
)
],
- end_children=[self.create_close_button()],
+ end_children=[self.header_close_button],
+ )
+
+ def create_content(self):
+ notification = self.notification
+ pixbuf = load_scaled_pixbuf(self, 48, 48)
+ self.notification_image_box = Box(
+ name="notification-image",
+ orientation="v",
+ children=[CustomImage(pixbuf=pixbuf), Box(v_expand=True)],
+ )
+ self.notification_summary_label = Label(
+ name="notification-summary",
+ markup=notification.summary,
+ h_align="start",
+ max_chars_width=16,
+ ellipsization="end",
+ )
+ self.notification_app_name_label_content = Label(
+ name="notification-app-name",
+ markup=notification.app_name,
+ h_align="start",
+ max_chars_width=16,
+ ellipsization="end",
+ )
+ self.notification_body_label = (
+ Label(
+ markup=notification.body,
+ h_align="start",
+ max_chars_width=34,
+ ellipsization="end",
+ )
+ if notification.body
+ else Box()
+ )
+ self.notification_body_label.set_single_line_mode(
+ True
+ ) if notification.body else None
+ self.notification_text_box = Box(
+ name="notification-text",
+ orientation="v",
+ v_align="center",
+ h_expand=True,
+ h_align="start",
+ children=[
+ Box(
+ name="notification-summary-box",
+ orientation="h",
+ children=[
+ self.notification_summary_label,
+ Box(
+ name="notif-sep",
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ ),
+ self.notification_app_name_label_content,
+ ],
+ ),
+ self.notification_body_label,
+ ],
+ )
+ self.content_close_button = self.create_close_button()
+ self.content_close_button_box = Box(
+ orientation="v",
+ children=[
+ self.content_close_button,
+ ],
)
- def create_content(self, notification):
return Box(
name="notification-content",
spacing=8,
children=[
- Box(
- name="notification-image",
- children=CustomImage(
- pixbuf=notification.image_pixbuf.scale_simple(
- 48, 48, GdkPixbuf.InterpType.BILINEAR
- )
- if notification.image_pixbuf
- else self.get_pixbuf(notification.app_icon, 48, 48)
- ),
- ),
- Box(
- name="notification-text",
- orientation="v",
- v_align="center",
- children=[
- Box(
- name="notification-summary-box",
- orientation="h",
- children=[
- Label(
- name="notification-summary",
- markup=notification.summary.replace("\n", " "),
- h_align="start",
- ellipsization="end",
- ),
- Label(
- name="notification-app-name",
- markup=" | " + notification.app_name,
- h_align="start",
- ellipsization="end",
- ),
- ],
- ),
- Label(
- markup=notification.body.replace("\n", " "),
- h_align="start",
- ellipsization="end",
- )
- if notification.body
- else Box(),
- ],
- ),
- Box(h_expand=True),
- Box(
- orientation="v",
- children=[
- self.create_close_button(),
- Box(v_expand=True),
- ],
- ),
+ self.notification_image_box,
+ self.notification_text_box,
+ self.content_close_button_box,
],
)
- def get_pixbuf(self, icon_path, width, height):
- if icon_path.startswith("file://"):
- icon_path = icon_path[7:]
-
- if not os.path.exists(icon_path):
- logger.warning(f"Icon path does not exist: {icon_path}")
+ def create_action_buttons(self):
+ notification = self.notification
+ if not notification.actions:
return None
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
- return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
- except Exception as e:
- logger.error(f"Failed to load or scale icon: {e}")
- return None
-
- 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)
- ],
- )
+ grid = Gtk.Grid()
+ grid.set_column_homogeneous(True)
+ grid.set_column_spacing(4)
+ for i, action in enumerate(notification.actions):
+ action_button = ActionButton(action, i, len(notification.actions), self)
+ grid.attach(action_button, i, 0, 1, 1)
+ return grid
def create_close_button(self):
- close_button = Button(
+ self.close_button = Button(
name="notif-close-button",
child=Label(name="notif-close-label", markup=icons.cancel),
on_clicked=lambda *_: self.notification.close("dismissed-by-user"),
)
- close_button.connect(
- "enter-notify-event", lambda *_: self.hover_button(close_button)
+ self.close_button.connect(
+ "enter-notify-event", lambda *_: self.hover_button(self.close_button)
)
- close_button.connect(
- "leave-notify-event", lambda *_: self.unhover_button(close_button)
+ self.close_button.connect(
+ "leave-notify-event", lambda *_: self.unhover_button(self.close_button)
)
- return close_button
+ return self.close_button
+
+ def on_hover_enter(self, *args):
+ container = getattr(self, "_container", None)
+ if container:
+ container.pause_and_reset_all_timeouts()
+
+ def on_hover_leave(self, *args):
+ container = getattr(self, "_container", None)
+ if container:
+ container.resume_all_timeouts()
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:
+ if hasattr(self, "_timeout_id") and 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()
+ # Safety check: ensure _destroyed attribute exists
+ if not hasattr(self, "_destroyed"):
+ logger.warning(
+ f"NotificationBox {
+ getattr(self, 'uuid', 'unknown')
+ } missing _destroyed attribute, skipping close"
+ )
+ return False
+
+ if not self._destroyed:
+ try:
+ logger.debug(
+ f"Notification {
+ self.notification.id
+ } timeout expired, closing notification."
+ )
+ self.notification.close("expired")
+ self.stop_timeout()
+ except Exception as e:
+ logger.error(
+ f"Error in close_notification for notification {
+ self.notification.id
+ }: {e}"
+ )
return False
- def pause_timeout(self):
- self.stop_timeout()
+ def destroy(self, from_history_delete=False):
+ if (
+ hasattr(self, "cached_image_path")
+ and self.cached_image_path
+ and (not getattr(self, "_is_history", False) or from_history_delete)
+ ):
+ delete_cached_image(self.cached_image_path)
- def resume_timeout(self):
- self.start_timeout()
+ # Ensure _destroyed attribute exists before setting it
+ if hasattr(self, "_destroyed"):
+ self._destroyed = True
+ else:
+ # If _destroyed doesn't exist, create it
+ self._destroyed = True
- def destroy(self):
self.stop_timeout()
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")
+ container = getattr(self, "_container", None)
+ if container:
+ container.pause_and_reset_all_timeouts()
def unhover_button(self, button):
- self.resume_timeout()
- self.set_pointer_cursor(button, "arrow")
+ container = getattr(self, "_container", None)
+ if container:
+ container.resume_all_timeouts()
-class NotificationRevealer(Revealer):
- def __init__(self, notification: Notification, **kwargs):
- self.notif_box = NotificationWidget(notification)
- self._notification = notification
- super().__init__(
- child=Box(
- children=[self.notif_box],
- ),
- transition_duration=250,
- transition_type="slide-down",
- )
-
- self.connect(
- "notify::child-revealed",
- lambda *_: self.destroy() if not self.get_child_revealed() else None,
- )
-
- self._notification.connect("closed", self.on_resolved)
-
- def on_resolved(
+class NotificationContainer(Box):
+ def __init__(
self,
- notification: Notification,
- reason: NotificationCloseReason,
+ notification_history_instance: NotificationHistory,
+ revealer_transition_type: str = "slide-down",
):
- self.set_reveal_child(False)
+ super().__init__(name="notification-container-main", orientation="v", spacing=4)
+ self.notification_history = notification_history_instance
+
+ self._server = Notifications()
+ self._server.connect("notification-added", self.on_new_notification)
+ self._pending_removal = False
+ self._is_destroying = False
+
+ self.stack = Gtk.Stack(
+ name="notification-stack",
+ transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT,
+ transition_duration=200,
+ visible=True,
+ )
+ self.navigation = Box(
+ name="notification-navigation", spacing=4, h_align="center"
+ )
+ self.stack_box = Box(
+ name="notification-stack-box",
+ h_align="center",
+ h_expand=False,
+ children=[self.stack],
+ )
+ self.prev_button = Button(
+ name="nav-button",
+ child=Label(name="nav-button-label", markup=icons.chevron_left),
+ on_clicked=self.show_previous,
+ )
+ self.close_all_button = Button(
+ name="nav-button",
+ child=Label(name="nav-button-label", markup=icons.cancel),
+ on_clicked=self.close_all_notifications,
+ )
+ self.close_all_button_label = self.close_all_button.get_child()
+ self.close_all_button_label.add_style_class("close")
+ self.next_button = Button(
+ name="nav-button",
+ child=Label(name="nav-button-label", markup=icons.chevron_right),
+ on_clicked=self.show_next,
+ )
+ for button in [self.prev_button, self.close_all_button, self.next_button]:
+ button.connect(
+ "enter-notify-event", lambda *_: self.pause_and_reset_all_timeouts()
+ )
+ button.connect("leave-notify-event", lambda *_: self.resume_all_timeouts())
+ self.navigation.add(self.prev_button)
+ self.navigation.add(self.close_all_button)
+ self.navigation.add(self.next_button)
+
+ self.navigation_revealer = Revealer(
+ transition_type="slide-down",
+ transition_duration=200,
+ child=self.navigation,
+ reveal_child=False,
+ )
+
+ self.notification_box_container = Box(
+ name="notification-box-internal-container",
+ orientation="v",
+ children=[self.stack_box, self.navigation_revealer],
+ )
+
+ self.main_revealer = Revealer(
+ name="notification-main-revealer",
+ transition_type=revealer_transition_type,
+ transition_duration=250,
+ child_revealed=False,
+ child=self.notification_box_container,
+ )
+
+ self.add(self.main_revealer)
+
+ self.notifications = []
+ self.current_index = 0
+ self.update_navigation_buttons()
+ self._destroyed_notifications = set()
+
+ def on_new_notification(self, fabric_notif, id):
+ notification_history_instance = self.notification_history
+ if notification_history_instance.do_not_disturb_enabled:
+ notification = fabric_notif.get_notification_from_id(id)
+ new_box = NotificationBox(notification)
+ try:
+ if notification.image_pixbuf:
+ cache_notification_pixbuf(new_box)
+ except Exception as e:
+ logger.debug(f"Error accessing image_pixbuf for notification {id}: {e}")
+ new_box.set_is_history(True)
+ notification_history_instance.add_notification(new_box)
+ return
+
+ notification = fabric_notif.get_notification_from_id(id)
+ new_box = NotificationBox(notification)
+ new_box.set_container(self)
+ notification.connect("closed", self.on_notification_closed)
+
+ app_name = notification.app_name
+ if app_name in get_history_ignored_apps():
+ logger.info(
+ f"Ignoring notification from {app_name} as it is in the ignored list."
+ )
+ # Don't add to history, just show the notification and let it timeout normally
+ new_box.set_is_history(False)
+ # Continue with normal notification display but skip history logic
+ self.stack.add_named(new_box, str(id))
+ self.notifications.append(new_box)
+ self.current_index = len(self.notifications) - 1
+ self.stack.set_visible_child(new_box)
+
+ for notification_box in self.notifications:
+ notification_box.start_timeout()
+ self.main_revealer.show_all()
+ self.main_revealer.set_reveal_child(True)
+ self.update_navigation_buttons()
+ return
+ if app_name in get_limited_apps_history():
+ notification_history_instance.clear_history_for_app(app_name)
+
+ existing_notification_index = -1
+ for index, existing_box in enumerate(self.notifications):
+ if existing_box.notification.app_name == app_name:
+ existing_notification_index = index
+ break
+
+ if existing_notification_index != -1:
+ old_notification_box = self.notifications.pop(
+ existing_notification_index
+ )
+ self.stack.remove(old_notification_box)
+ old_notification_box.set_is_history(True)
+ notification_history_instance.add_notification(old_notification_box)
+ old_notification_box.destroy()
+
+ self.stack.add_named(new_box, str(id))
+ self.notifications.append(new_box)
+ self.current_index = len(self.notifications) - 1
+ self.stack.set_visible_child(new_box)
+ else:
+ while len(self.notifications) >= 5:
+ oldest_notification = self.notifications[0]
+ oldest_notification.set_is_history(True)
+ notification_history_instance.add_notification(oldest_notification)
+ self.stack.remove(oldest_notification)
+ self.notifications.pop(0)
+ if self.current_index > 0:
+ self.current_index -= 1
+ self.stack.add_named(new_box, str(id))
+ self.notifications.append(new_box)
+ self.current_index = len(self.notifications) - 1
+ self.stack.set_visible_child(new_box)
+ else:
+ while len(self.notifications) >= 5:
+ oldest_notification = self.notifications[0]
+ oldest_notification.set_is_history(True)
+ notification_history_instance.add_notification(oldest_notification)
+ self.stack.remove(oldest_notification)
+ self.notifications.pop(0)
+ if self.current_index > 0:
+ self.current_index -= 1
+ self.stack.add_named(new_box, str(id))
+ self.notifications.append(new_box)
+ self.current_index = len(self.notifications) - 1
+ self.stack.set_visible_child(new_box)
+
+ for notification_box in self.notifications:
+ notification_box.start_timeout()
+ self.main_revealer.show_all()
+ self.main_revealer.set_reveal_child(True)
+ self.update_navigation_buttons()
+
+ def show_previous(self, *args):
+ if self.current_index > 0:
+ self.current_index -= 1
+ self.stack.set_visible_child(self.notifications[self.current_index])
+ self.update_navigation_buttons()
+
+ def show_next(self, *args):
+ if self.current_index < len(self.notifications) - 1:
+ self.current_index += 1
+ self.stack.set_visible_child(self.notifications[self.current_index])
+ self.update_navigation_buttons()
+
+ def update_navigation_buttons(self):
+ self.prev_button.set_sensitive(self.current_index > 0)
+ self.next_button.set_sensitive(self.current_index < len(self.notifications) - 1)
+ should_reveal = len(self.notifications) > 1
+ self.navigation_revealer.set_reveal_child(should_reveal)
+
+ def on_notification_closed(self, notification, reason):
+ if self._is_destroying:
+ return
+ if notification.id in self._destroyed_notifications:
+ return
+ self._destroyed_notifications.add(notification.id)
+ try:
+ notif_to_remove = None
+ for i, notif_box in enumerate(self.notifications):
+ if notif_box.notification.id == notification.id:
+ notif_to_remove = (i, notif_box)
+ break
+ if not notif_to_remove:
+ return
+ i, notif_box = notif_to_remove
+ reason_str = str(reason)
+
+ notification_history_instance = self.notification_history
+
+ # Check if this notification should be ignored from history
+ app_name = notification.app_name
+ if app_name not in get_history_ignored_apps():
+ notif_box.set_is_history(True)
+ notification_history_instance.add_notification(notif_box)
+
+ if reason_str == "NotificationCloseReason.DISMISSED_BY_USER":
+ logger.info(
+ f"User dismissed notification {
+ notification.id
+ }, added to history"
+ )
+ elif (
+ reason_str == "NotificationCloseReason.EXPIRED"
+ or reason_str == "NotificationCloseReason.CLOSED"
+ or reason_str == "NotificationCloseReason.UNDEFINED"
+ ):
+ logger.info(
+ f"Notification {
+ notification.id
+ } closed automatically, added to history"
+ )
+ else:
+ logger.warning(
+ f"Unknown close reason: {reason_str} for notification {
+ notification.id
+ }. Still added to history."
+ )
+ else:
+ logger.info(
+ f"Notification from {app_name} closed but not added to history (ignored app)"
+ )
+
+ notif_box.stop_timeout()
+
+ if len(self.notifications) == 1:
+ self._is_destroying = True
+ self.main_revealer.set_reveal_child(False)
+ GLib.timeout_add(
+ self.main_revealer.get_transition_duration(),
+ self._destroy_container,
+ )
+ return
+
+ new_index = i
+ if i == self.current_index:
+ new_index = max(0, i - 1)
+ elif i < self.current_index:
+ new_index = self.current_index - 1
+
+ if notif_box.get_parent() == self.stack:
+ self.stack.remove(notif_box)
+ self.notifications.pop(i)
+
+ if new_index >= len(self.notifications) and len(self.notifications) > 0:
+ new_index = len(self.notifications) - 1
+
+ self.current_index = new_index
+
+ if len(self.notifications) > 0:
+ self.stack.set_visible_child(self.notifications[self.current_index])
+
+ self.update_navigation_buttons()
+ except Exception as e:
+ logger.error(f"Error closing notification: {e}")
+
+ def _destroy_container(self):
+ try:
+ self.notifications.clear()
+ self._destroyed_notifications.clear()
+ for child in self.stack.get_children():
+ self.stack.remove(child)
+ child.destroy()
+ self.current_index = 0
+ except Exception as e:
+ logger.error(f"Error cleaning up the container: {e}")
+ finally:
+ self._is_destroying = False
+ return False
+
+ def pause_and_reset_all_timeouts(self):
+ if self._is_destroying:
+ return
+ for notification in self.notifications[:]:
+ try:
+ if hasattr(notification, "pause_timeout"):
+ notification.pause_timeout()
+ except Exception as e:
+ logger.error(f"Error pausing timeout: {e}")
+
+ def resume_all_timeouts(self):
+ if self._is_destroying:
+ return
+ for notification in self.notifications[:]:
+ try:
+ if hasattr(notification, "resume_timeout"):
+ notification.resume_timeout()
+ except Exception as e:
+ logger.error(f"Error resuming timeout: {e}")
+
+ def close_all_notifications(self, *args):
+ notifications_to_close = self.notifications.copy()
+ for notification_box in notifications_to_close:
+ notification_box.notification.close("dismissed-by-user")
class NotificationPopup(Window):
- def __init__(self):
- self._server = notification_service
- self.cache_notification_service = notification_service
- self.notifications = Box(
- v_expand=True,
- h_expand=True,
- style="margin: 1px 0px 1px 1px;",
- orientation="v",
- spacing=5,
- )
- self._server.connect("notification-added", self.on_new_notification)
+ def __init__(self, **kwargs):
+ y_pos = data.NOTIF_POS.lower() if hasattr(data, "NOTIF_POS") else "top"
+ x_pos = "right"
+
+ if hasattr(data, "DOCK_POSITION") and data.DOCK_POSITION in ["Top", "Bottom"]:
+ x_pos = "right"
super().__init__(
- anchor="top right",
- child=self.notifications,
- layer="overlay",
- all_visible=True,
+ name="notification-popup",
+ anchor=f"{x_pos} {y_pos}",
+ layer="top",
+ keyboard_mode="none",
+ exclusivity="none",
visible=True,
- exclusive=False,
+ all_visible=True,
)
- def on_new_notification(self, fabric_notif, id):
- notification: Notification = fabric_notif.get_notification_from_id(id)
- # Always cache the notification regardless of DND status
- self.cache_notification_service.cache_notification(notification)
-
- # Only show popup if DND is not enabled
- if not self.cache_notification_service.dont_disturb:
- new_box = NotificationRevealer(fabric_notif.get_notification_from_id(id))
- self.notifications.add(new_box)
- new_box.set_reveal_child(True)
- logger.info(f"[Notification] New notification from {notification.app_name}")
- else:
- logger.info(f"[Notification] DND enabled, notification from {notification.app_name} not shown")
+ self.widgets = kwargs.get("widgets", None)
+
+ self.notification_history = (
+ self.widgets.notification_history
+ if self.widgets
+ else get_shared_notification_history()
+ )
+ self.notification_container = NotificationContainer(
+ notification_history_instance=self.notification_history,
+ revealer_transition_type="slide-down" if y_pos == "top" else "slide-up",
+ )
+
+ self.show_box = Box()
+ self.show_box.set_size_request(1, 1)
+
+ self.add(
+ Box(
+ name="notification-popup-box",
+ orientation="v",
+ children=[self.notification_container, self.show_box],
+ )
+ )
diff --git a/modules/osd.py b/modules/osd.py
index 6d79a60..3667080 100644
--- a/modules/osd.py
+++ b/modules/osd.py
@@ -1,21 +1,23 @@
import time
-from typing import ClassVar
+from typing import ClassVar, Literal
from fabric.audio import Audio
from fabric.widgets.box import Box
-from fabric.widgets.scale import Scale, ScaleMark
-from fabric.widgets.wayland import WaylandWindow as Window
-from fabric.utils import invoke_repeater
-from gi.repository import GObject
-from services.brightness import Brightness
from fabric.widgets.label import Label
-from utils import Animator
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scale import Scale, ScaleMark
+from gi.repository import GLib, GObject
+
+import utils.icons as icons
+from services.brightness import Brightness
+from utils.animator import Animator
+from utils.wayland import WaylandWindow as Window
class AnimatedScale(Scale):
def __init__(self, **kwargs):
super().__init__(**kwargs)
- self.animator = None # Lazily initialized
+ self.animator = None
def animate_value(self, value: float):
if not self.animator:
@@ -23,30 +25,23 @@ class AnimatedScale(Scale):
bezier_curve=(0.34, 1.56, 0.64, 1.0),
duration=0.8,
min_value=self.min_value,
- max_value=self.max_value,
+ max_value=self.value,
tick_widget=self,
notify_value=lambda p, *_: self.set_value(p.value),
)
self.animator.pause()
self.animator.min_value = self.value
- self.animator.max_value = min(max(value, self.min_value), self.max_value)
+ self.animator.max_value = value
self.animator.play()
-class BaseOSDContainer(Box):
- def __init__(self, label_text, **kwargs):
- super().__init__(**kwargs, orientation="v", spacing=8, name="osd")
-
- self.header = Box(orientation="h")
- self.label = Label(
- label=label_text, name="osd-label", h_align="start", h_expand=True
- )
- self.value_label = Label(name="osd-label", h_align="end")
- self.header.add(self.label)
- self.header.add(self.value_label)
-
+class BrightnessOSDContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs, orientation="h", spacing=12, name="osd")
+ self.brightness_service = Brightness.get_initial()
+ self.icon = Label(markup=icons.brightness, name="brighntess-icons")
self.scale = AnimatedScale(
- marks=(ScaleMark(value=i) for i in range(0, 100, 10)),
+ marks=(ScaleMark(value=i) for i in range(0, 101, 10)),
value=70,
min_value=0,
max_value=100,
@@ -54,17 +49,8 @@ class BaseOSDContainer(Box):
orientation="h",
)
- self.add(self.header)
+ self.add(self.icon)
self.add(self.scale)
-
- def update_value_label(self, value):
- self.value_label.set_label(str(round(value)))
-
-
-class BrightnessOSDContainer(BaseOSDContainer):
- def __init__(self, **kwargs):
- super().__init__(label_text="Brightness", **kwargs)
- self.brightness_service = Brightness()
self.update_brightness()
self.scale.connect("value-changed", lambda *_: self.update_brightness())
@@ -72,85 +58,339 @@ class BrightnessOSDContainer(BaseOSDContainer):
def update_brightness(self) -> None:
current_brightness = self.brightness_service.screen_brightness
+ normalized_brightness = self._normalize_brightness(current_brightness)
if current_brightness != 0:
- normalized_brightness = self._normalize_brightness(current_brightness)
- self.scale.set_value(normalized_brightness)
- self.update_value_label(normalized_brightness)
+ self.scale.animate_value(normalized_brightness)
+ self.update_icon(normalized_brightness)
+
+ def update_icon(self, brightness_percentage: float) -> None:
+ icon_name = self.get_brightness_icon(brightness_percentage)
+ self.icon.set_label(icon_name)
def on_brightness_changed(self, _sender: any, value: float, *_args) -> None:
normalized_brightness = self._normalize_brightness(value)
self.scale.animate_value(normalized_brightness)
- self.update_value_label(normalized_brightness)
+ self.update_icon(normalized_brightness)
def _normalize_brightness(self, brightness: float) -> float:
- normalized = (brightness / self.brightness_service.max_screen) * 100
- return max(0, min(100, normalized)) # Ensure value stays within bounds
+ return (brightness / self.brightness_service.max_screen) * 100
+
+ def get_brightness_icon(self, brightness_percentage: float) -> str:
+ if brightness_percentage >= 67:
+ return icons.brightness_high
+ elif brightness_percentage >= 34:
+ return icons.brightness_medium
+ else:
+ return icons.brightness_low
-class AudioOSDContainer(BaseOSDContainer):
+class AudioOSDContainer(Box):
__gsignals__: ClassVar[dict] = {
"volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
}
def __init__(self, **kwargs):
- super().__init__(label_text="Volume", **kwargs)
+ super().__init__(**kwargs, orientation="h", spacing=13, name="osd")
self.audio = Audio()
+ self.icon = Label(markup=icons.vol_high, name="vol-icon")
+ 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.previous_volume = None
+ self.previous_muted = None
+
+ self.add(self.icon)
+ self.add(self.scale)
self.sync_with_audio()
self.scale.connect("value-changed", self.on_volume_changed)
self.audio.connect("notify::speaker", self.on_audio_speaker_changed)
+
+ # Connect to speaker-changed signal directly
self.audio.connect("speaker-changed", self.on_speaker_changed)
+ # Connect to current speaker if available
+ if self.audio.speaker:
+ self.audio.speaker.connect(
+ "notify::volume", self.on_volume_property_changed
+ )
+ self.audio.speaker.connect("notify::muted", self.on_mute_changed)
+
def sync_with_audio(self):
if self.audio.speaker:
volume = round(self.audio.speaker.volume)
- self.scale.set_value(
- max(0, min(100, volume))
- ) # Ensure value stays within bounds
- self.update_value_label(volume)
+ self.scale.set_value(volume)
+ self.update_icon(volume)
+ self.previous_volume = volume
+ self.previous_muted = self.audio.speaker.muted
def on_volume_changed(self, *_):
if self.audio.speaker:
volume = self.scale.value
if 0 <= volume <= 100:
self.audio.speaker.set_volume(volume)
- self.update_value_label(volume)
+ self.update_icon(volume)
+
+ if volume == 0 or (self.audio.speaker and self.audio.speaker.muted):
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+ self.icon.remove_style_class("muted")
+
self.emit("volume-changed")
def on_audio_speaker_changed(self, *_):
if self.audio.speaker:
- self.audio.speaker.connect("changed", self.update_volume)
+ self.audio.speaker.connect(
+ "notify::volume", self.on_volume_property_changed
+ )
+ self.audio.speaker.connect("notify::muted", self.on_mute_changed)
self.update_volume()
def on_speaker_changed(self, *_):
self.update_volume()
+ self.update_icon(round(self.audio.speaker.volume))
+
+ def on_volume_property_changed(self, *_):
+ if self.audio.speaker:
+ current_volume = round(self.audio.speaker.volume)
+ if self.previous_volume is None or current_volume != self.previous_volume:
+ self.previous_volume = current_volume
+ self.update_volume()
+ self.update_icon(current_volume)
+ self.emit("volume-changed")
def update_volume(self, *_):
if self.audio.speaker and not self.is_hovered():
volume = round(self.audio.speaker.volume)
- self.scale.set_value(
- max(0, min(100, volume))
- ) # Ensure value stays within bounds
- self.update_value_label(volume)
+ self.scale.set_value(volume)
+ self.update_icon(volume)
+
+ if volume == 0 or (self.audio.speaker and self.audio.speaker.muted):
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+ self.icon.remove_style_class("muted")
+
+ def update_icon(self, volume):
+ if not self.audio.speaker:
+ return
+
+ vol_high_icon = icons.vol_high
+ vol_medium_icon = icons.vol_medium
+ vol_mute_icon = icons.vol_off
+ vol_off_icon = icons.vol_mute
+
+ if self.audio.speaker and "bluetooth" in self.audio.speaker.icon_name:
+ vol_high_icon = icons.bluetooth_connected
+ vol_medium_icon = icons.bluetooth
+ vol_mute_icon = icons.bluetooth_off
+ vol_off_icon = icons.bluetooth_disconnected
+
+ if self.audio.speaker.muted:
+ self.icon.set_label(vol_mute_icon)
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ if volume == 0:
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+ self.icon.remove_style_class("muted")
+
+ if volume >= 67:
+ self.icon.set_label(vol_high_icon)
+ elif volume >= 34:
+ self.icon.set_label(vol_medium_icon)
+ elif volume >= 1:
+ # Use medium for low volumes too
+ self.icon.set_label(vol_medium_icon)
+ else:
+ self.icon.set_label(vol_off_icon)
+
+ def _get_audio_icon_name(self, volume: int) -> str:
+ if volume >= 67:
+ return icons.vol_high
+ elif volume >= 34:
+ return icons.vol_medium
+ elif volume >= 1:
+ return icons.vol_mute
+ else:
+ return icons.vol_off
+
+ def on_mute_changed(self, *_):
+ if self.audio.speaker:
+ current_muted = self.audio.speaker.muted
+ if self.previous_muted is None or current_muted != self.previous_muted:
+ self.previous_muted = current_muted
+ self.update_icon(round(self.audio.speaker.volume))
+ GLib.idle_add(lambda: self.emit("volume-changed"))
+
+
+class MicrophoneOSDContainer(Box):
+ __gsignals__: ClassVar[dict] = {
+ "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
+ }
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs, orientation="h", spacing=13, name="osd")
+ self.audio = Audio()
+ self.icon = Label(markup=icons.mic, name="mic-icon")
+ 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.previous_volume = None
+ self.previous_muted = None
+
+ self.add(self.icon)
+ self.add(self.scale)
+ self.sync_with_audio()
+
+ self.scale.connect("value-changed", self.on_volume_changed)
+ self.audio.connect("notify::microphone", self.on_audio_microphone_changed)
+
+ # Connect to microphone-changed signal directly
+ self.audio.connect("microphone-changed", self.on_microphone_changed)
+
+ if self.audio.microphone:
+ self.audio.microphone.connect(
+ "notify::volume", self.on_volume_property_changed
+ )
+ self.audio.microphone.connect("notify::muted", self.on_mute_changed)
+
+ def sync_with_audio(self):
+ if self.audio.microphone:
+ volume = round(self.audio.microphone.volume)
+ self.scale.set_value(volume)
+ self.update_icon(volume)
+ self.previous_volume = volume
+ self.previous_muted = self.audio.microphone.muted
+
+ def on_volume_changed(self, *_):
+ if self.audio.microphone:
+ volume = self.scale.value
+ if 0 <= volume <= 100:
+ self.audio.microphone.set_volume(volume)
+ self.update_icon(volume)
+
+ if volume == 0 or (
+ self.audio.microphone and self.audio.microphone.muted
+ ):
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+ self.icon.remove_style_class("muted")
+
+ self.emit("mic-changed")
+
+ def on_audio_microphone_changed(self, *_):
+ if self.audio.microphone:
+ self.audio.microphone.connect(
+ "notify::volume", self.on_volume_property_changed
+ )
+ self.audio.microphone.connect("notify::muted", self.on_mute_changed)
+ self.update_volume()
+
+ def on_microphone_changed(self, *_):
+ self.update_volume()
+ self.update_icon(round(self.audio.microphone.volume))
+
+ def on_volume_property_changed(self, *_):
+ if self.audio.microphone:
+ current_volume = round(self.audio.microphone.volume)
+ if self.previous_volume is None or current_volume != self.previous_volume:
+ self.previous_volume = current_volume
+ self.update_volume()
+ self.update_icon(current_volume)
+ self.emit("mic-changed")
+
+ def update_volume(self, *_):
+ if self.audio.microphone and not self.is_hovered():
+ volume = round(self.audio.microphone.volume)
+ self.scale.set_value(volume)
+ self.update_icon(volume)
+
+ if volume == 0 or (self.audio.microphone and self.audio.microphone.muted):
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+ self.icon.remove_style_class("muted")
+
+ def update_icon(self, volume):
+ if not self.audio.microphone:
+ return
+
+ if self.audio.microphone.muted:
+ self.icon.set_label(icons.mic_mute)
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ if volume == 0:
+ self.scale.add_style_class("muted")
+ self.icon.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+ self.icon.remove_style_class("muted")
+
+ if volume >= 67:
+ self.icon.set_label(icons.mic)
+ elif volume >= 34:
+ self.icon.set_label(icons.mic)
+ elif volume >= 1:
+ self.icon.set_label(icons.mic)
+ else:
+ self.icon.set_label(icons.mic_mute)
+
+ def on_mute_changed(self, *_):
+ if self.audio.microphone:
+ current_muted = self.audio.microphone.muted
+ if self.previous_muted is None or current_muted != self.previous_muted:
+ self.previous_muted = current_muted
+ self.update_icon(round(self.audio.microphone.volume))
+ GLib.idle_add(lambda: self.emit("mic-changed"))
class OSD(Window):
def __init__(self, **kwargs):
self.audio_container = AudioOSDContainer()
self.brightness_container = BrightnessOSDContainer()
+ self.microphone_container = MicrophoneOSDContainer()
self.timeout = 1000
- self.controls_box = Box(
- orientation="h",
- spacing=20,
- children=[self.brightness_container, self.audio_container],
+ self.revealer = Revealer(
+ transition_type="slide-up",
+ transition_duration=100,
+ child_revealed=False,
+ )
+
+ self.main_box = Box(
+ orientation="v",
+ spacing=13,
+ children=[self.revealer],
)
super().__init__(
layer="overlay",
- anchor="top",
- child=self.controls_box,
+ anchor="bottom",
+ child=self.main_box,
visible=False,
pass_through=True,
keyboard_mode="on-demand",
@@ -159,16 +399,35 @@ class OSD(Window):
self.last_activity_time = time.time()
- self.audio_container.audio.connect("notify::speaker", self.show_controls)
+ self.audio_container.connect("volume-changed", self.show_audio)
self.brightness_container.brightness_service.connect(
- "screen", self.show_controls
+ "screen", self.show_brightness
)
- self.audio_container.connect("volume-changed", self.show_controls)
+ self.microphone_container.connect("mic-changed", self.show_microphone)
- invoke_repeater(100, self.check_inactivity, initial_call=True)
+ GLib.timeout_add(100, self.check_inactivity)
- def show_controls(self, *_):
+ def show_audio(self, *_):
+ self.show_box(box_to_show="audio")
+ self.reset_inactivity_timer()
+
+ def show_brightness(self, *_):
+ self.show_box(box_to_show="brightness")
+ self.reset_inactivity_timer()
+
+ def show_microphone(self, *_):
+ self.show_box(box_to_show="microphone")
+ self.reset_inactivity_timer()
+
+ def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]):
self.set_visible(True)
+ if box_to_show == "audio":
+ self.revealer.children = self.audio_container
+ elif box_to_show == "brightness":
+ self.revealer.children = self.brightness_container
+ elif box_to_show == "microphone":
+ self.revealer.children = self.microphone_container
+ self.revealer.set_reveal_child(True)
self.reset_inactivity_timer()
def start_hide_timer(self):
diff --git a/modules/switcher.py b/modules/switcher.py
new file mode 100644
index 0000000..784e92b
--- /dev/null
+++ b/modules/switcher.py
@@ -0,0 +1,293 @@
+import json
+import subprocess
+
+from fabric.hyprland.widgets import get_hyprland_connection
+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 gi.repository import Gdk
+
+import config.data as data
+from utils.icon_resolver import IconResolver
+from utils.wayland import WaylandWindow as Window
+
+
+class ApplicationSwitcher(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="application-switcher",
+ layer="top",
+ anchor="center",
+ exclusivity="auto",
+ keyboard_mode="exclusive",
+ **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 = 64
+
+ 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="v",
+ spacing=0,
+ h_align="center",
+ v_align="center",
+ )
+ container.add(self.view)
+ 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 _is_special_workspace(self, client):
+ """Check if a client is in a special workspace"""
+ if "workspace" not in client:
+ return False
+
+ workspace = client["workspace"]
+ if "name" in workspace:
+ workspace_name = str(workspace["name"])
+ # Special workspaces typically start with "special:" or have negative IDs
+ if workspace_name.startswith("special:"):
+ return True
+
+ if "id" in workspace:
+ workspace_id = workspace["id"]
+ # Special workspaces have negative IDs
+ if workspace_id < 0:
+ return True
+
+ return False
+
+ 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=12,
+ h_align="center",
+ v_align="center",
+ )
+ self.view.add(current_row)
+
+ for i, window in enumerate(self.windows):
+ class_name = window.get("class", "").lower()
+ title = window.get("title", "")
+
+ 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
+ )
+
+ button_content = Box(
+ name="switcher-button",
+ orientation="v",
+ spacing=4,
+ h_align="center",
+ v_align="center",
+ children=[
+ Image(pixbuf=icon_img),
+ Label(
+ label=title[:15] + "..." if len(title) > 15 else title,
+ h_align="center",
+ v_align="center",
+ max_width_chars=15,
+ ellipsize="end",
+ ),
+ ],
+ )
+
+ event_box = EventBox(
+ name="window-button",
+ style_classes=["active"] if i == self.current_index else None,
+ child=button_content,
+ )
+ current_row.add(event_box)
+
+ if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows):
+ current_row = Box(
+ name="window-row",
+ orientation="h",
+ spacing=12,
+ h_align="center",
+ v_align="center",
+ )
+ self.view.add(current_row)
+
+ self.view.show_all()
+ self.update_selection()
+ except Exception as e:
+ print(f"Failed to update windows: {e}")
+
+ def on_key_press(self, widget, 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, widget, 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")
+ 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:
+ cmd = ["hyprctl", "dispatch", "focuswindow", f"address:{address}"]
+ subprocess.run(cmd, capture_output=True, text=True, timeout=2)
+ 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/config/scripts/gamemode.sh b/scripts/gamemode.sh
similarity index 100%
rename from config/scripts/gamemode.sh
rename to scripts/gamemode.sh
diff --git a/config/scripts/hyprpicker.sh b/scripts/hyprpicker.sh
similarity index 100%
rename from config/scripts/hyprpicker.sh
rename to scripts/hyprpicker.sh
diff --git a/scripts/screen-capture.sh b/scripts/screen-capture.sh
new file mode 100755
index 0000000..a1fd899
--- /dev/null
+++ b/scripts/screen-capture.sh
@@ -0,0 +1,704 @@
+#!/bin/env bash
+
+# Script name
+SCRIPT_NAME=$(basename "$0")
+
+# Function to display usage
+usage() {
+ cat < [options]
+
+Commands:
+ screenshot Take a screenshot
+ Targets:
+ selection - Screenshot selected area
+ eDP-1 - Screenshot eDP-1 display
+ HDMI-A-1 - Screenshot HDMI-A-1 display
+ both - Screenshot both displays
+
+ record Start/stop recording (with audio)
+ Targets:
+ selection - Record selected area
+ eDP-1 - Record eDP-1 display
+ HDMI-A-1 - Record HDMI-A-1 display
+ stop - Stop current recording
+
+ record-noaudio Start/stop recording (no audio)
+ Targets:
+ selection - Record selected area without audio
+ eDP-1 - Record eDP-1 display without audio
+ HDMI-A-1 - Record HDMI-A-1 display without audio
+ stop - Stop current recording
+
+ record-hq Start/stop high-quality recording (for YouTube)
+ Targets:
+ selection - Record selected area in high quality
+ eDP-1 - Record eDP-1 display in high quality
+ HDMI-A-1 - Record HDMI-A-1 display in high quality
+ stop - Stop current recording
+
+ record-gif Start/stop GIF recording
+ Targets:
+ selection - Record selected area as GIF
+ eDP-1 - Record eDP-1 display as GIF
+ HDMI-A-1 - Record HDMI-A-1 display as GIF
+ stop - Stop current recording
+
+ status Check if recording is active (exit 0 if recording, 1 if not)
+
+ convert [file] Convert recordings
+ Formats:
+ webm - Convert latest MKV to WebM (or specify file)
+ iphone - Convert latest MKV to iPhone format (or specify file)
+ youtube - Convert latest video to YouTube format (or specify file)
+ gif - Convert latest video to GIF (or specify file)
+
+ Optional [file] parameter:
+ - If not provided, converts the latest recorded video
+ - If provided, converts the specified file (full path or filename in Recordings folder)
+
+Examples:
+ $SCRIPT_NAME screenshot selection
+ $SCRIPT_NAME record eDP-1
+ $SCRIPT_NAME record-noaudio selection
+ $SCRIPT_NAME record-hq eDP-1
+ $SCRIPT_NAME record-gif selection
+ $SCRIPT_NAME record stop
+ $SCRIPT_NAME convert gif # Convert latest video to GIF
+ $SCRIPT_NAME convert youtube # Convert latest video for YouTube
+ $SCRIPT_NAME convert webm /path/to/video.mkv # Convert specific file to WebM
+
+EOF
+ exit 1
+}
+
+# Check if no arguments provided
+if [ $# -eq 0 ]; then
+ usage
+fi
+
+# Function to send screenshot notification with action buttons
+send_screenshot_notification() {
+ local full_path="$1"
+ local save_dir=$(dirname "$full_path")
+
+ ACTION=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \
+ -A "view=View" -A "edit=Edit" -A "open=Open Folder")
+
+ case "$ACTION" in
+ view) xdg-open "$full_path" ;;
+ edit) swappy -f "$full_path" ;;
+ open) xdg-open "$save_dir" ;;
+ esac
+}
+
+# Function to send recording notification with action buttons
+send_recording_notification() {
+ local full_path="$1"
+ local save_dir=$(dirname "$full_path")
+
+ ACTION=$(notify-send -a "Modus" -i "camera-video-symbolic" "Recording saved" "in $full_path" \
+ -A "view=View" -A "open=Open Folder")
+
+ case "$ACTION" in
+ view) xdg-open "$full_path" ;;
+ open) xdg-open "$save_dir" ;;
+ esac
+}
+
+wf-recorder_check() {
+ if pgrep -x "wf-recorder" >/dev/null; then
+ pkill -INT -x wf-recorder
+ # Get the recording file path and send notification with actions
+ if [ -f /tmp/recording.txt ]; then
+ recording_file=$(cat /tmp/recording.txt)
+ wl-copy <"$recording_file"
+ send_recording_notification "$recording_file"
+ else
+ notify-send "Recording stopped" "wf-recorder process terminated"
+ fi
+ # Clean up recording start time file
+ rm -f /tmp/recording_start_time.txt
+ exit 0
+ fi
+}
+
+# Function to record with standard settings
+record_video() {
+ local output_file="$1"
+ shift
+
+ wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1"
+}
+
+# Function to record without audio
+record_video_noaudio() {
+ local output_file="$1"
+ shift
+
+ wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" --no-audio
+}
+
+record_high_quality() {
+ local output_file="$1"
+ shift
+
+ # High quality settings for YouTube uploads
+ # - h264_vaapi for hardware encoding (if available) or libx264 for software
+ # - yuv420p pixel format for maximum compatibility
+ # - High bitrate (8000k) for quality
+ # - GOP size of 30 for better seeking
+ # - Preset 'slow' for better compression efficiency
+ # - CRF 18 for high quality (lower = better quality, 0-51 scale)
+ # - Audio at 192k bitrate
+ # - 60 FPS for smooth motion
+ # - No color filters to maintain original colors
+
+ # Check if VAAPI hardware encoding is available
+ if vainfo &>/dev/null && wf-recorder --help | grep -q "h264_vaapi"; then
+ # Use hardware encoding for better performance
+ wf-recorder "$@" -f "$output_file" \
+ -c h264_vaapi \
+ -p "preset=slow" \
+ -p "crf=18" \
+ -r 60 \
+ -b 8000000 \
+ -B 192000 \
+ --pixel-format yuv420p \
+ -g 30
+ else
+ # Fallback to software encoding
+ wf-recorder "$@" -f "$output_file" \
+ -c libx264 \
+ -p "preset=slow" \
+ -p "crf=18" \
+ -r 60 \
+ -b 8000000 \
+ -B 192000 \
+ --pixel-format yuv420p \
+ -g 30
+ fi
+}
+
+record_gif() {
+ local output_file="$1"
+ shift
+
+ # Record temporary video first (MKV format for better quality)
+ local temp_video="/tmp/gif_recording_$(date +%s).mkv"
+ echo "$temp_video" >/tmp/gif_temp_video.txt
+
+ # GIF-optimized recording settings:
+ # - Lower framerate (15 fps) for smaller file size
+ # - No audio recording
+ # - Standard codec for compatibility
+ wf-recorder "$@" -f "$temp_video" \
+ -c libvpx-vp9 \
+ -r 15 \
+ --pixel-format yuv420p \
+ --no-audio
+
+ # After recording stops, convert to GIF
+ if [ -f "$temp_video" ]; then
+ notify-send "Converting to GIF" "Processing recording..."
+
+ # Create high-quality GIF with optimized palette
+ # Using ffmpeg with palette generation for better colors
+ 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" 2>/tmp/gif_conversion.log
+
+ if [ $? -eq 0 ]; then
+ # Clean up temp file
+ rm -f "$temp_video"
+ rm -f /tmp/gif_temp_video.txt
+
+ # Copy to clipboard and send notification with actions
+ wl-copy <"$output_file"
+ send_recording_notification "$output_file"
+ else
+ error=$(cat /tmp/gif_conversion.log | tail -n 5)
+ notify-send "GIF Conversion Failed" "Error: $error"
+ rm -f "$temp_video"
+ rm -f /tmp/gif_temp_video.txt
+ fi
+ fi
+}
+
+# Function to find the latest video file for conversion
+find_latest_video() {
+ local format="$1"
+ local recording_dir="${HOME}/Videos/Recordings"
+
+ case "$format" in
+ "webm"|"iphone")
+ # For webm and iphone, only look for MKV files
+ find "$recording_dir" -name "*.mkv" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-
+ ;;
+ "youtube"|"gif")
+ # For youtube and gif, look for both MKV and MP4 files
+ find "$recording_dir" \( -name "*.mkv" -o -name "*.mp4" \) -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-
+ ;;
+ *)
+ echo ""
+ ;;
+ esac
+}
+
+# Function to resolve video file path
+resolve_video_file() {
+ local format="$1"
+ local file_param="$2"
+ local recording_dir="${HOME}/Videos/Recordings"
+
+ if [ -n "$file_param" ]; then
+ # File parameter provided
+ if [ -f "$file_param" ]; then
+ # Full path provided and exists
+ echo "$file_param"
+ elif [ -f "$recording_dir/$file_param" ]; then
+ # Filename provided, exists in recordings folder
+ echo "$recording_dir/$file_param"
+ else
+ echo ""
+ fi
+ else
+ # No file parameter, find latest
+ find_latest_video "$format"
+ fi
+}
+
+# Parse command
+COMMAND="$1"
+TARGET="$2"
+FILE_PARAM="$3" # Optional file parameter for convert command
+
+# Set up file paths
+IMG="${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png"
+VID="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv"
+
+case "$COMMAND" in
+"screenshot")
+ case "$TARGET" in
+ "selection")
+ grim -g "$(slurp)" "$IMG"
+ wl-copy <"$IMG"
+ send_screenshot_notification "$IMG"
+ ;;
+ "eDP-1")
+ grim -c -o eDP-1 "$IMG"
+ wl-copy <"$IMG"
+ send_screenshot_notification "$IMG"
+ ;;
+ "HDMI-A-1")
+ grim -c -o HDMI-A-1 "$IMG"
+ wl-copy <"$IMG"
+ send_screenshot_notification "$IMG"
+ ;;
+ "both")
+ grim -c -o eDP-1 "${IMG//.png/-eDP-1.png}"
+ grim -c -o HDMI-A-1 "${IMG//.png/-HDMI-A-1.png}"
+ montage "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" -tile 2x1 -geometry +0+0 "$IMG"
+ wl-copy <"$IMG"
+ rm "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}"
+ send_screenshot_notification "$IMG"
+ ;;
+ *)
+ echo "Error: Invalid screenshot target '$TARGET'"
+ usage
+ ;;
+ esac
+ ;;
+
+"record")
+ case "$TARGET" in
+ "stop")
+ wf-recorder_check
+ ;;
+ "selection")
+ wf-recorder_check
+ echo "$VID" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ record_video "$VID" -g "$(slurp)"
+ ;;
+ "eDP-1")
+ wf-recorder_check
+ echo "$VID" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ record_video "$VID" -a -o eDP-1
+ ;;
+ "HDMI-A-1")
+ wf-recorder_check
+ echo "$VID" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ record_video "$VID" -a -o HDMI-A-1
+ ;;
+ *)
+ echo "Error: Invalid record target '$TARGET'"
+ usage
+ ;;
+ esac
+ ;;
+
+"record-noaudio")
+ case "$TARGET" in
+ "stop")
+ wf-recorder_check
+ ;;
+ "selection")
+ wf-recorder_check
+ echo "$VID" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ record_video_noaudio "$VID" -g "$(slurp)"
+ ;;
+ "eDP-1")
+ wf-recorder_check
+ echo "$VID" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ record_video_noaudio "$VID" -o eDP-1
+ ;;
+ "HDMI-A-1")
+ wf-recorder_check
+ echo "$VID" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ record_video_noaudio "$VID" -o HDMI-A-1
+ ;;
+ *)
+ echo "Error: Invalid record-noaudio target '$TARGET'"
+ usage
+ ;;
+ esac
+ ;;
+
+"record-hq")
+ # Change file extension to mp4 for high quality recordings
+ VID_HQ="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4"
+
+ case "$TARGET" in
+ "stop")
+ wf-recorder_check
+ ;;
+ "selection")
+ wf-recorder_check
+ echo "$VID_HQ" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ notify-send "High Quality Recording" "Starting YouTube-quality recording..."
+ record_high_quality "$VID_HQ" -g "$(slurp)"
+ ;;
+ "eDP-1")
+ wf-recorder_check
+ echo "$VID_HQ" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ notify-send "High Quality Recording" "Starting YouTube-quality recording on eDP-1..."
+ record_high_quality "$VID_HQ" -a -o eDP-1
+ ;;
+ "HDMI-A-1")
+ wf-recorder_check
+ echo "$VID_HQ" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ notify-send "High Quality Recording" "Starting YouTube-quality recording on HDMI-A-1..."
+ record_high_quality "$VID_HQ" -a -o HDMI-A-1
+ ;;
+ *)
+ echo "Error: Invalid record-hq target '$TARGET'"
+ usage
+ ;;
+ esac
+ ;;
+
+"record-gif")
+ # GIF files go to a specific location
+ GIF="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif"
+
+ case "$TARGET" in
+ "stop")
+ wf-recorder_check
+ ;;
+ "selection")
+ wf-recorder_check
+ echo "$GIF" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ notify-send "GIF Recording" "Starting GIF recording (15 FPS)..."
+ record_gif "$GIF" -g "$(slurp)"
+ ;;
+ "eDP-1")
+ wf-recorder_check
+ echo "$GIF" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ notify-send "GIF Recording" "Starting GIF recording on eDP-1..."
+ record_gif "$GIF" -o eDP-1
+ ;;
+ "HDMI-A-1")
+ wf-recorder_check
+ echo "$GIF" >/tmp/recording.txt
+ date +%s >/tmp/recording_start_time.txt
+ notify-send "GIF Recording" "Starting GIF recording on HDMI-A-1..."
+ record_gif "$GIF" -o HDMI-A-1
+ ;;
+ *)
+ echo "Error: Invalid record-gif target '$TARGET'"
+ usage
+ ;;
+ esac
+ ;;
+
+"status")
+ # Check if wf-recorder is running
+ if pgrep -x "wf-recorder" >/dev/null; then
+ echo "true"
+ exit 0
+ else
+ echo "false"
+ exit 0
+ fi
+ ;;
+
+"convert")
+ case "$TARGET" in
+ "webm")
+ # Check if ffmpeg is installed
+ if ! command -v ffmpeg >/dev/null 2>&1; then
+ notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
+ exit 1
+ fi
+
+ # Resolve the video file to convert
+ video_file=$(resolve_video_file "webm" "$FILE_PARAM")
+
+ if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
+ if [ -n "$FILE_PARAM" ]; then
+ notify-send "WebM Conversion Error" "File not found: $FILE_PARAM"
+ else
+ notify-send "WebM Conversion Error" "No MKV files found in Recordings folder"
+ fi
+ exit 1
+ fi
+
+ # Ensure it's an MKV file
+ if [[ "$video_file" != *.mkv ]]; then
+ notify-send "WebM Conversion Error" "Only MKV files can be converted to WebM. Found: $(basename "$video_file")"
+ exit 1
+ fi
+
+ webm_file="${video_file%.mkv}.webm"
+
+ # Check if webm version doesn't already exist
+ if [ -f "$webm_file" ]; then
+ notify-send "WebM Conversion Skipped" "WebM version already exists: $(basename "$webm_file")"
+ exit 0
+ fi
+
+ notify-send "Converting to WebM" "Processing: $(basename "$video_file")"
+
+ # Convert the file
+ ffmpeg -y -i "$video_file" -c:v libvpx -b:v 1M -c:a libvorbis "$webm_file" 2>/tmp/ffmpeg_error.log
+
+ if [ $? -eq 0 ]; then
+ file_size=$(du -h "$webm_file" | cut -f1)
+ notify-send "WebM Conversion Success" "$(basename "$video_file") → $(basename "$webm_file") ($file_size)"
+ else
+ error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
+ notify-send "WebM Conversion Failed" "Error: $error"
+ fi
+ ;;
+
+ "iphone")
+ # Check if ffmpeg is installed
+ if ! command -v ffmpeg >/dev/null 2>&1; then
+ notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
+ exit 1
+ fi
+
+ # Resolve the video file to convert
+ video_file=$(resolve_video_file "iphone" "$FILE_PARAM")
+
+ if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
+ if [ -n "$FILE_PARAM" ]; then
+ notify-send "iPhone Conversion Error" "File not found: $FILE_PARAM"
+ else
+ notify-send "iPhone Conversion Error" "No MKV files found in Recordings folder"
+ fi
+ exit 1
+ fi
+
+ # Ensure it's an MKV file
+ if [[ "$video_file" != *.mkv ]]; then
+ notify-send "iPhone Conversion Error" "Only MKV files can be converted for iPhone. Found: $(basename "$video_file")"
+ exit 1
+ fi
+
+ base_filename=$(basename "$video_file")
+
+ # Skip files with "iphone" in the filename
+ if [[ $base_filename == *"iphone"* ]]; then
+ notify-send "iPhone Conversion Skipped" "File already appears to be iPhone format: $(basename "$video_file")"
+ exit 0
+ fi
+
+ iphone_file="${video_file%.mkv}-iphone.mp4"
+
+ # Check if iPhone version doesn't already exist
+ if [ -f "$iphone_file" ]; then
+ notify-send "iPhone Conversion Skipped" "iPhone version already exists: $(basename "$iphone_file")"
+ exit 0
+ fi
+
+ notify-send "Converting for iPhone" "Processing: $(basename "$video_file")"
+
+ # Convert the file
+ ffmpeg -y -i "$video_file" -vcodec h264 -acodec aac "$iphone_file" 2>/tmp/ffmpeg_error.log
+
+ if [ $? -eq 0 ]; then
+ file_size=$(du -h "$iphone_file" | cut -f1)
+ notify-send "iPhone Conversion Success" "$(basename "$video_file") → $(basename "$iphone_file") ($file_size)"
+ else
+ error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
+ notify-send "iPhone Conversion Failed" "Error: $error"
+ fi
+ ;;
+
+ "youtube")
+ # Check if ffmpeg is installed
+ if ! command -v ffmpeg >/dev/null 2>&1; then
+ notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
+ exit 1
+ fi
+
+ # Resolve the video file to convert
+ video_file=$(resolve_video_file "youtube" "$FILE_PARAM")
+
+ if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
+ if [ -n "$FILE_PARAM" ]; then
+ notify-send "YouTube Conversion Error" "File not found: $FILE_PARAM"
+ else
+ notify-send "YouTube Conversion Error" "No video files found in Recordings folder"
+ fi
+ exit 1
+ fi
+
+ base_filename=$(basename "$video_file")
+
+ # Skip files already marked as YouTube uploads
+ if [[ $base_filename == *"youtube"* ]]; then
+ notify-send "YouTube Conversion Skipped" "File already appears to be YouTube format: $(basename "$video_file")"
+ exit 0
+ fi
+
+ # Create YouTube optimized filename
+ youtube_file="${video_file%.*}-youtube.mp4"
+
+ # Check if YouTube version doesn't already exist
+ if [ -f "$youtube_file" ]; then
+ notify-send "YouTube Conversion Skipped" "YouTube version already exists: $(basename "$youtube_file")"
+ exit 0
+ fi
+
+ notify-send "Converting for YouTube" "Processing: $(basename "$video_file")"
+
+ # YouTube recommended settings:
+ # - H.264 codec with High profile
+ # - 1080p or source resolution
+ # - 60fps or source framerate
+ # - High bitrate for quality (8-12 Mbps for 1080p60)
+ # - AAC audio at 384kbps
+ # - yuv420p pixel format for compatibility
+ # - Keyframe interval of 2 seconds (GOP)
+ # - No filters to preserve original colors
+
+ ffmpeg -y -i "$video_file" \
+ -c:v libx264 \
+ -profile:v high \
+ -preset slow \
+ -crf 18 \
+ -pix_fmt yuv420p \
+ -c:a aac \
+ -b:a 384k \
+ -movflags +faststart \
+ "$youtube_file" 2>/tmp/ffmpeg_error.log
+
+ if [ $? -eq 0 ]; then
+ file_size=$(du -h "$youtube_file" | cut -f1)
+ notify-send "YouTube Conversion Success" "$(basename "$video_file") → $(basename "$youtube_file") ($file_size)"
+ else
+ error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
+ notify-send "YouTube Conversion Failed" "Error converting $(basename "$video_file"): $error"
+ fi
+ ;;
+
+ "gif")
+ # Check if ffmpeg is installed
+ if ! command -v ffmpeg >/dev/null 2>&1; then
+ notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
+ exit 1
+ fi
+
+ # Resolve the video file to convert
+ video_file=$(resolve_video_file "gif" "$FILE_PARAM")
+
+ if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
+ if [ -n "$FILE_PARAM" ]; then
+ notify-send "GIF Conversion Error" "File not found: $FILE_PARAM"
+ else
+ notify-send "GIF Conversion Error" "No video files found in Recordings folder"
+ fi
+ exit 1
+ fi
+
+ base_filename=$(basename "$video_file")
+
+ # Skip files already GIFs
+ if [[ $base_filename == *.gif ]]; then
+ notify-send "GIF Conversion Skipped" "File is already a GIF: $(basename "$video_file")"
+ exit 0
+ fi
+
+ # Create GIF filename
+ gif_file="${video_file%.*}.gif"
+
+ # Check if GIF version doesn't already exist
+ if [ -f "$gif_file" ]; then
+ notify-send "GIF Conversion Skipped" "GIF version already exists: $(basename "$gif_file")"
+ exit 0
+ fi
+
+ notify-send "Converting to GIF" "Processing: $(basename "$video_file")"
+
+ # Get video dimensions for scaling
+ width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 "$video_file")
+
+ # Scale down if wider than 800px to keep file size reasonable
+ if [ "$width" -gt 800 ]; then
+ scale_filter="scale=800:-1:flags=lanczos,"
+ else
+ scale_filter=""
+ fi
+
+ # Create high-quality GIF with optimized palette
+ ffmpeg -i "$video_file" \
+ -vf "${scale_filter}fps=15,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 \
+ "$gif_file" 2>/tmp/ffmpeg_error.log
+
+ if [ $? -eq 0 ]; then
+ file_size=$(du -h "$gif_file" | cut -f1)
+ notify-send "GIF Conversion Success" "$(basename "$video_file") → $(basename "$gif_file") ($file_size)"
+ send_recording_notification "$gif_file"
+ else
+ error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
+ notify-send "GIF Conversion Failed" "Error converting $(basename "$video_file"): $error"
+ fi
+ ;;
+
+ *)
+ echo "Error: Invalid convert format '$TARGET'"
+ usage
+ ;;
+ esac
+ ;;
+
+*)
+ echo "Error: Invalid command '$COMMAND'"
+ usage
+ ;;
+esac
diff --git a/services/__init__.py b/services/__init__.py
index cdba666..3f03f12 100644
--- a/services/__init__.py
+++ b/services/__init__.py
@@ -1,10 +1,4 @@
-from fabric.audio import Audio
-from .brightness import Brightness
-from .network import NetworkClient
-from .notification import *
-from .powerprofile import *
-
-network_client = NetworkClient()
-brightness = Brightness()
-audio = Audio()
-notification_service = CustomNotifications()
+"""
+Modus services package.
+Contains background services and utilities for the shell.
+"""
diff --git a/services/auth.py b/services/auth.py
new file mode 100644
index 0000000..3a7b070
--- /dev/null
+++ b/services/auth.py
@@ -0,0 +1,348 @@
+import json
+import os
+import subprocess
+import time
+from datetime import datetime
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+import pyotp
+from PIL import Image
+from pyzbar.pyzbar import decode
+
+import config.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)
+ print(f"File {file_path} created successfully!")
+
+ return file_path
+
+
+def capture_selected_area(filename="/tmp/screenshot.png"):
+ """
+ Uses slurp to let the user select an area of the screen,
+ then captures that area using grim.
+ """
+ try:
+ # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)
+ result = subprocess.run(["slurp"], check=True, capture_output=True, text=True)
+ geometry = result.stdout.strip()
+ if not geometry:
+ print("No area selected with slurp.")
+ return None
+ except subprocess.CalledProcessError as e:
+ print("Error selecting area with slurp:", e)
+ return None
+
+ try:
+ # grim uses -g to capture a specific region
+ subprocess.run(["grim", "-g", geometry, filename], check=True)
+ except subprocess.CalledProcessError as e:
+ print("Error capturing screenshot with grim:", e)
+ return None
+
+ return filename
+
+
+def read_and_save_to_json():
+ # Get the full path to the JSON file
+ json_file = get_otp_file_path()
+
+ screenshot = capture_selected_area()
+ if screenshot is None:
+ print("Failed to capture the selected area.")
+ return False
+
+ # Short delay to ensure the file is written
+ time.sleep(1)
+
+ # Open the captured image
+ try:
+ img = Image.open(screenshot)
+ except Exception as e:
+ print("Error opening the image:", e)
+ return False
+
+ # Decode QR Code(s) from the image
+ decoded_objects = decode(img)
+ if not decoded_objects:
+ print("No QR Code detected in the selected area.")
+ return False
+
+ results = []
+
+ for obj in decoded_objects:
+ data = obj.data.decode("utf-8")
+ print("QR Code detected:", data)
+
+ result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
+
+ if data.startswith("otpauth://"):
+ parsed = urlparse(data)
+ query = parse_qs(parsed.query)
+
+ # Extract the secret properly
+ secret = query.get("secret", [None])[0]
+ issuer_from_query = query.get("issuer", [None])[0]
+
+ # Extract the label (path), which may contain issuer and account
+ label = parsed.path.lstrip("/") if parsed.path else ""
+ account_name = label
+ issuer_from_path = None
+
+ if ":" in label:
+ parts = label.split(":", 1)
+ issuer_from_path = parts[0]
+ account_name = parts[1]
+
+ # Prefer issuer from path if available, otherwise use from query
+ issuer = issuer_from_path or issuer_from_query
+
+ # Extract the period (default is 30 seconds)
+ period = int(query.get("period", ["30"])[0])
+
+ # Create TOTP object with the correct interval
+ totp = pyotp.TOTP(secret, interval=period)
+ current_otp = totp.now()
+ print(f"Generated OTP: {current_otp} (valid for {period} seconds)")
+
+ result_entry.update(
+ {
+ "type": "otp",
+ "secret": secret,
+ "issuer": issuer,
+ "account_name": account_name,
+ }
+ )
+ else:
+ result_entry["type"] = "unknown"
+ print("Unrecognized format. Expected a URI like otpauth://")
+ return False
+
+ results.append(result_entry)
+
+ # Load existing data if the file exists
+ existing_data = []
+ if os.path.exists(json_file):
+ try:
+ with open(json_file, "r") as f:
+ existing_data = json.load(f)
+ except json.JSONDecodeError:
+ print(f"Error reading the file {json_file}. Creating a new one.")
+
+ # Append new results
+ existing_data.extend(results)
+
+ # Save to JSON file
+ with open(json_file, "w") as f:
+ json.dump(existing_data, f, indent=4)
+
+ print(f"OTP data saved to {json_file}")
+ return True
+
+
+def CodeOTP(uri):
+ parsed = urlparse(uri)
+ query = parse_qs(parsed.query)
+ secret = query.get("secret", [None])[0]
+
+ if secret is None:
+ return None
+ else:
+ totp = pyotp.TOTP(secret)
+ return totp.now()
+
+
+# TOTP/OTP utility functions
+def generate_totp(secret: str) -> str:
+ """Generate TOTP code from secret."""
+ try:
+ return pyotp.TOTP(secret).now()
+ except Exception as e:
+ print(f"Error generating TOTP: {e}")
+ return None
+
+
+def get_time_remaining() -> int:
+ """Get seconds remaining until next token refresh."""
+ return 30 - (int(time.time()) % 30)
+
+
+def get_time_remaining_with_blink() -> str:
+ """Get time remaining with blinking effect."""
+ time_remaining = get_time_remaining()
+ current_second = int(time.time())
+ should_blink = current_second % 2 == 0
+
+ if should_blink:
+ return f"{time_remaining}s"
+ else:
+ return f"{time_remaining}s"
+
+
+def validate_base32_secret(secret: str) -> dict:
+ """Validate and clean Base32 secret."""
+ import base64
+ import re
+
+ try:
+ # Clean up the secret - remove spaces, dashes, and convert to uppercase
+ clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper()
+
+ # Remove any non-base32 characters
+ clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
+
+ # Add padding if needed (Base32 requires padding to multiple of 8)
+ while len(clean_secret) % 8 != 0:
+ clean_secret += "="
+
+ # Validate Base32 format
+ try:
+ base64.b32decode(clean_secret)
+ except Exception as e:
+ return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
+
+ # Test if the secret can generate a valid TOTP
+ try:
+ test_totp = pyotp.TOTP(clean_secret)
+ test_code = test_totp.now()
+ if not test_code or len(test_code) != 6:
+ raise ValueError("Generated invalid TOTP code")
+ except Exception as e:
+ return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"}
+
+ return {"success": True, "secret": clean_secret}
+ except Exception as e:
+ return {"success": False, "error": f"Unexpected error: {str(e)}"}
+
+
+def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
+ """Parse otpauth URI and extract account information."""
+ try:
+ parsed = urlparse(uri)
+ if parsed.scheme != "otpauth" or parsed.netloc != "totp":
+ return {
+ "success": False,
+ "error": "Only otpauth://totp/ URIs are supported",
+ }
+
+ if not account_name:
+ account_path = parsed.path.lstrip("/")
+ if ":" in account_path:
+ issuer, extracted_name = account_path.split(":", 1)
+ account_name = extracted_name
+ else:
+ account_name = account_path
+
+ params = parse_qs(parsed.query)
+ secret = params.get("secret", [""])[0]
+ issuer = params.get("issuer", [""])[0]
+ algorithm = params.get("algorithm", ["SHA1"])[0]
+ digits = int(params.get("digits", ["6"])[0])
+ period = int(params.get("period", ["30"])[0])
+
+ if not secret:
+ return {"success": False, "error": "No secret found in URI"}
+
+ return {
+ "success": True,
+ "account_name": account_name,
+ "secret": secret,
+ "issuer": issuer,
+ "algorithm": algorithm,
+ "digits": digits,
+ "period": period,
+ }
+ except Exception as e:
+ return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"}
+
+
+def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
+ """Scan QR code and add OTP account to secrets file."""
+ try:
+ # Capture QR code from screen
+ screenshot_path = capture_selected_area()
+ if not screenshot_path:
+ return {"success": False, "error": "QR scan cancelled or failed"}
+
+ # Decode QR code
+ try:
+ img = Image.open(screenshot_path)
+ decoded_objects = decode(img)
+
+ if not decoded_objects:
+ return {
+ "success": False,
+ "error": "No QR code detected in selected area",
+ }
+
+ # Process the first QR code found
+ qr_data = decoded_objects[0].data.decode("utf-8")
+ print(f"QR Code detected: {qr_data}")
+
+ if qr_data.startswith("otpauth://"):
+ # Parse otpauth URI
+ result = parse_otpauth_uri(qr_data, account_name)
+ if not result["success"]:
+ return result
+
+ # Load existing secrets
+ secrets = {}
+ if os.path.exists(secrets_file_path):
+ try:
+ with open(secrets_file_path, "r", encoding="utf-8") as f:
+ secrets = json.load(f)
+ except Exception as e:
+ print(f"Error loading secrets: {e}")
+
+ # Add new account
+ secrets[result["account_name"]] = {
+ "secret": result["secret"],
+ "issuer": result["issuer"],
+ "algorithm": result["algorithm"],
+ "digits": result["digits"],
+ "period": result["period"],
+ }
+
+ # Save secrets
+ try:
+ os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)
+ with open(secrets_file_path, "w", encoding="utf-8") as f:
+ json.dump(secrets, f, indent=2)
+ except Exception as e:
+ return {
+ "success": False,
+ "error": f"Error saving secrets: {str(e)}",
+ }
+
+ display_name = (
+ f"{result['issuer']} - {result['account_name']}"
+ if result["issuer"]
+ else result["account_name"]
+ )
+ return {
+ "success": True,
+ "account_name": result["account_name"],
+ "display_name": display_name,
+ "message": f"Successfully added OTP account: {display_name}",
+ }
+ else:
+ return {"success": False, "error": "QR code is not an otpauth URI"}
+
+ except Exception as e:
+ return {"success": False, "error": f"Error processing QR code: {str(e)}"}
+
+ except Exception as e:
+ return {"success": False, "error": f"Error during QR scan: {str(e)}"}
diff --git a/services/battery.py b/services/battery.py
new file mode 100644
index 0000000..b6b8e3c
--- /dev/null
+++ b/services/battery.py
@@ -0,0 +1,169 @@
+from fabric.core import Property, Service, Signal
+from pydbus import SystemBus
+import psutil
+from gi.repository import GLib
+
+DeviceState = {
+ 0: "UNKNOWN",
+ 1: "CHARGING",
+ 2: "DISCHARGING",
+ 3: "EMPTY",
+ 4: "FULLY_CHARGED",
+ 5: "PENDING_CHARGE",
+ 6: "PENDING_DISCHARGE",
+}
+
+
+class Battery(Service):
+ @staticmethod
+ def seconds_to_hours_minutes(seconds):
+ hours = seconds // 3600
+ minutes = (seconds % 3600) // 60
+ return f"{hours}h {minutes}m" if hours else f"{minutes}m"
+
+ @Signal
+ def changed(self) -> None: ...
+
+ @Signal
+ def profile_changed(self, value: str) -> None: ...
+
+ @Property(int, "readable")
+ def percentage(self):
+ if self._use_psutil_fallback:
+ if self._psutil_battery:
+ return int(self._psutil_battery.percent)
+ return 0
+ return int(self._battery.Percentage)
+
+ @Property(str, "readable")
+ def temperature(self):
+ if self._use_psutil_fallback:
+ return "N/A" # psutil doesn't provide temperature
+ return (
+ f"{self._battery.Temperature}°C"
+ if hasattr(self._battery, "Temperature")
+ else "N/A"
+ )
+
+ @Property(str, "readable")
+ def time_to_empty(self):
+ if self._use_psutil_fallback:
+ if self._psutil_battery and hasattr(self._psutil_battery, 'secsleft'):
+ return self.seconds_to_hours_minutes(self._psutil_battery.secsleft)
+ return "N/A"
+ return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0))
+
+ @Property(str, "readable")
+ def time_to_full(self):
+ if self._use_psutil_fallback:
+ return "N/A" # psutil doesn't provide time to full
+ return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0))
+
+ @Property(str, "readable")
+ def icon_name(self):
+ if self._use_psutil_fallback:
+ return "battery" # Generic icon name for psutil fallback
+ return self._battery.IconName
+
+ @Property(str, "readable")
+ def state(self):
+ if self._use_psutil_fallback:
+ if self._psutil_battery:
+ # psutil returns power_plugged boolean, convert to state
+ if self._psutil_battery.power_plugged:
+ if self._psutil_battery.percent >= 100:
+ return "FULLY_CHARGED"
+ else:
+ return "CHARGING"
+ else:
+ return "DISCHARGING"
+ return "UNKNOWN"
+ return DeviceState.get(self._battery.State, "UNKNOWN")
+
+ @Property(str, "readable")
+ def capacity(self):
+ if self._use_psutil_fallback:
+ return "N/A" # psutil doesn't provide capacity info
+ return f"{int(self._battery.Capacity)}%"
+
+ @Property(bool, "readable", default_value=False)
+ def is_present(self):
+ if self._use_psutil_fallback:
+ return self._psutil_battery is not None
+ return self._battery.IsPresent
+
+ @Property(str, "readable")
+ def power_profile(self):
+ if self._profile_proxy:
+ return self._profile_proxy.ActiveProfile
+ return None
+
+ def set_power_profile(self, profile: str) -> bool:
+ if not self._profile_proxy:
+ return False
+ if profile not in [p.Profile for p in self._profile_proxy.Profiles]:
+ return False
+ try:
+ self._profile_proxy.ActiveProfile = profile
+ self.profile_changed.emit(profile)
+ self.changed.emit()
+ return True
+ except Exception:
+ return False
+
+ def __init__(self):
+ super().__init__()
+ self._bus = SystemBus()
+ self._use_psutil_fallback = False
+ self._psutil_battery = None
+
+ # Battery device
+ try:
+ self._battery = self._bus.get(
+ "org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0"
+ )
+ self._battery.onPropertiesChanged = self.handle_battery_change
+ except Exception:
+ # Fallback to psutil if UPower is not available
+ self._use_psutil_fallback = True
+ try:
+ self._psutil_battery = psutil.sensors_battery()
+ if self._psutil_battery is None:
+ return # No battery found
+ # Start periodic updates for psutil fallback
+ GLib.timeout_add_seconds(5, self._update_psutil_battery)
+ except Exception:
+ return # psutil battery not available either
+
+ # PowerProfiles
+ try:
+ self._profile_proxy = self._bus.get(
+ "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles"
+ )
+ # Use onPropertiesChanged for consistency with battery device
+ self._profile_proxy.onPropertiesChanged = (
+ lambda _, changed, __: self._handle_profile_props_changed(changed)
+ )
+ except Exception:
+ self._profile_proxy = None
+
+ self.changed.emit()
+
+ def _update_psutil_battery(self):
+ """Update psutil battery data periodically"""
+ try:
+ self._psutil_battery = psutil.sensors_battery()
+ self.changed.emit()
+ except Exception:
+ pass # Continue trying
+ return True # Keep the timeout active
+
+ def _handle_profile_props_changed(self, changed):
+ """Internal handler for property changes that processes only the changed properties"""
+ if "ActiveProfile" in changed:
+ new_profile = changed["ActiveProfile"]
+ self.profile_changed.emit(new_profile)
+ self.changed.emit()
+
+ def handle_battery_change(self, iface, changed, invalidated):
+ self.changed.emit()
diff --git a/services/brightness.py b/services/brightness.py
index 49e7040..88d0d2e 100644
--- a/services/brightness.py
+++ b/services/brightness.py
@@ -1,92 +1,73 @@
import os
-import subprocess
from fabric.core.service import Property, Service, Signal
-from fabric.utils import exec_shell_command, monitor_file
+from fabric.utils import exec_shell_command_async, monitor_file
from gi.repository import GLib
-from loguru import logger # Import loguru logger
+from loguru import logger
-# Helper function to execute brightnessctl asynchronously
def exec_brightnessctl_async(args: str):
- # Executes brightnessctl command asynchronously, ensuring no resource leaks.
- try:
- # Use subprocess.Popen to run the command without blocking
- process = subprocess.Popen(
- f"brightnessctl {args}",
- shell=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
-
- # Wait for the process to complete
- stdout, stderr = process.communicate()
-
- if process.returncode != 0:
- logger.error(f"Error executing brightnessctl: {stderr.decode().strip()}")
- else:
- logger.debug(
- f"brightnessctl output: {stdout.decode().strip()}"
- ) # Optional: Log the output
- except Exception as e:
- logger.exception(f"Exception in exec_brightnessctl_async: {e}")
+ exec_shell_command_async(f"brightnessctl {args}", lambda _: None)
# Discover screen backlight device
try:
- screen_device = str(exec_shell_command("ls -w1 /sys/class/backlight")).split("\n")[
- 0
- ]
-except IndexError:
- screen_device = None
-
-
-class NoBrightnessError(ImportError):
- # Raised when brightness-related dependencies are missing.
- def __init__(self, *args):
- super().__init__(
- "Brightness control not available or missing dependencies", *args
- )
+ screen_device = os.listdir("/sys/class/backlight")
+ screen_device = screen_device[0] if screen_device else ""
+except FileNotFoundError:
+ logger.error("No backlight devices found, brightness control disabled")
+ screen_device = ""
class Brightness(Service):
- # Service to manage screen brightness levels.
+ """Service to manage screen brightness levels."""
+
+ instance = None
+
+ @staticmethod
+ def get_initial():
+ if Brightness.instance is None:
+ Brightness.instance = Brightness()
+
+ return Brightness.instance
@Signal
def screen(self, value: int) -> None:
"""Signal emitted when screen brightness changes."""
- pass # Implement as needed for your application
+ # Implement as needed for your application
def __init__(self, **kwargs):
super().__init__(**kwargs)
- if not screen_device:
- raise NoBrightnessError()
-
# Path for screen backlight control
self.screen_backlight_path = f"/sys/class/backlight/{screen_device}"
# Initialize maximum brightness level
- self.max_screen = self._read_max_brightness(self.screen_backlight_path)
+ self.max_screen = self.do_read_max_brightness(self.screen_backlight_path)
+
+ if screen_device == "":
+ return
# Monitor screen brightness file
self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness")
+
self.screen_monitor.connect(
"changed",
lambda _, file, *args: self.emit(
- "screen", round(int(file.load_bytes()[0].get_data()))
+ "screen",
+ round(int(file.load_bytes()[0].get_data())),
),
)
# Log the initialization of the service
logger.info(f"Brightness service initialized for device: {screen_device}")
- def _read_max_brightness(self, path: str) -> int:
+ def do_read_max_brightness(self, path: str) -> int:
# Reads the maximum brightness value from the specified path.
max_brightness_path = os.path.join(path, "max_brightness")
if os.path.exists(max_brightness_path):
with open(max_brightness_path) as f:
- return int(f.read().strip())
+ return int(f.readline())
return -1 # Return -1 if file doesn't exist, indicating an error.
@Property(int, "read-write")
@@ -95,7 +76,7 @@ class Brightness(Service):
brightness_path = os.path.join(self.screen_backlight_path, "brightness")
if os.path.exists(brightness_path):
with open(brightness_path) as f:
- return int(f.read().strip())
+ return int(f.readline())
logger.warning(f"Brightness file does not exist: {brightness_path}")
return -1 # Return -1 if file doesn't exist, indicating error.
@@ -108,7 +89,6 @@ class Brightness(Service):
try:
exec_brightnessctl_async(f"--device '{screen_device}' set {value}")
self.emit("screen", int((value / self.max_screen) * 100))
- logger.info(f"Set screen brightness to {value} (out of {self.max_screen})")
except GLib.Error as e:
logger.error(f"Error setting screen brightness: {e.message}")
except Exception as e:
diff --git a/services/mpris.py b/services/mpris.py
new file mode 100644
index 0000000..99dd76a
--- /dev/null
+++ b/services/mpris.py
@@ -0,0 +1,244 @@
+from gi.repository import Playerctl, GLib
+from fabric.utils import bulk_connect
+from fabric.core.service import Service, Signal
+from fabric import Fabricator
+
+from config.data import ALLOWED_PLAYERS
+
+import gi
+
+gi.require_version("Playerctl", "2.0")
+
+
+class PlayerService(Service):
+ @Signal
+ def shuffle_toggle(self, player: Playerctl.Player, status: bool) -> None: ...
+
+ @Signal
+ def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ...
+
+ @Signal
+ def pause(self) -> None: ...
+
+ @Signal
+ def play(self) -> None: ...
+
+ @Signal
+ def track_position(self, pos: float, dur: float) -> None: ...
+
+ def __init__(self, player: Playerctl.Player, **kwargs):
+ self._player: Playerctl.Player = player
+ super().__init__(**kwargs)
+ if player.props.player_name in ALLOWED_PLAYERS:
+ self._player.connect("playback-status::playing", self.on_play)
+ self._player.connect("playback-status::paused", self.on_pause)
+ self._player.connect("shuffle", self.on_shuffle)
+ self._player.connect("metadata", self.on_metadata)
+ self._player.connect("seeked", self.on_seeked)
+
+ self.status = self._player.props.playback_status
+ self.pos_fabricator = Fabricator(
+ interval=1000, # 1s
+ poll_from=lambda f, *_: self._safe_get_position(),
+ on_changed=lambda f, *_: self.fabricating(),
+ )
+ # Add state tracking to prevent repeated operations
+ self._fabricator_running = False
+ self._cleanup_done = False
+ self.poll_progress()
+
+ def _safe_get_position(self):
+ """Safely get player position with error handling"""
+ try:
+ if not self._player or self._cleanup_done:
+ return 0
+ return self._player.get_position()
+ except Exception:
+ # Player is no longer available, stop the fabricator
+ if hasattr(self, 'pos_fabricator') and self.pos_fabricator and self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass
+ return 0
+
+ def cleanup(self):
+ """Clean up resources when service is no longer needed"""
+ if self._cleanup_done:
+ return # Already cleaned up
+
+ try:
+ if hasattr(self, 'pos_fabricator') and self.pos_fabricator:
+ if self._fabricator_running:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ self.pos_fabricator = None
+ self._cleanup_done = True
+ except Exception as e:
+ print(f"[DEBUG] Error during PlayerService cleanup: {e}")
+
+ def on_seeked(self, player, position):
+ if (not self._cleanup_done and
+ self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and
+ not self._fabricator_running):
+ try:
+ self.pos_fabricator.start()
+ self._fabricator_running = True
+ except Exception:
+ pass # Ignore if already started
+
+ def set_position(self, pos: float):
+ if not self._cleanup_done and self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass # Ignore if already stopped
+
+ micro_pos = int(pos * 1_000_000)
+ try:
+ self._player.set_position(micro_pos)
+ except GLib.Error as e:
+ print(f"Failed to seek: {e}")
+
+ def poll_progress(self):
+ if self._cleanup_done:
+ return # Don't operate on cleaned up fabricator
+
+ if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
+ if not self._fabricator_running:
+ try:
+ self.pos_fabricator.start()
+ self._fabricator_running = True
+ except Exception:
+ pass # Ignore if already started
+ else:
+ if self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass # Ignore if already stopped
+
+ def fabricating(self):
+ try:
+ # Check if cleanup has been done
+ if self._cleanup_done:
+ return
+
+ # Validate player object
+ if not self._player or not hasattr(self._player, 'get_position'):
+ return
+
+ # Get position safely
+ try:
+ pos = self._player.get_position() / 1_000_000 # seconds
+ except Exception:
+ # Player is no longer available, stop fabricator
+ if self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass
+ return
+
+ # Get duration safely from multiple sources
+ dur = 0.0 # Default to 0
+
+ if (hasattr(self._player, 'props') and
+ hasattr(self._player.props, 'metadata') and
+ self._player.props.metadata):
+
+ metadata = self._player.props.metadata
+
+ # Try mpris:length first (most common)
+ try:
+ raw_dur = metadata["mpris:length"]
+ if raw_dur and raw_dur > 0:
+ dur = float(raw_dur) / 1_000_000 # seconds
+ except (KeyError, Exception):
+ pass
+
+ # Fallback to xesam:duration (alternative field)
+ if dur <= 0:
+ try:
+ raw_dur = metadata["xesam:duration"]
+ if raw_dur and raw_dur > 0:
+ dur = float(raw_dur) / 1_000_000 # seconds
+ except (KeyError, Exception):
+ pass
+
+ # Try to get duration from player directly (some players support this)
+ if dur <= 0:
+ try:
+ if hasattr(self._player, 'get_metadata'):
+ player_metadata = self._player.get_metadata()
+ if player_metadata:
+ raw_dur = player_metadata["mpris:length"]
+ if raw_dur and raw_dur > 0:
+ dur = float(raw_dur) / 1_000_000
+ except (KeyError, Exception):
+ pass
+
+ # Validate values before emitting signal
+ # Allow dur=0 for position-only tracking
+ if pos is not None and dur is not None and pos >= 0 and dur >= 0:
+ self.track_position(pos, dur)
+
+ except Exception:
+ pass
+
+ def on_play(self, player, status):
+ self.status = player.props.playback_status
+ self.poll_progress()
+ self.play()
+
+ def on_pause(self, player, status):
+ self.poll_progress()
+ self.pause()
+
+ def on_shuffle(self, player, status):
+ self.shuffle_toggle(player, status)
+
+ def on_metadata(self, player, metadata):
+ keys = metadata.keys()
+ self.meta_change(metadata, player)
+
+
+class PlayerManager(Service):
+ @Signal
+ def new_player(self, player: Playerctl.Player) -> Playerctl.Player: ...
+
+ @Signal
+ def player_vanish(self, player: Playerctl.Player) -> None: ...
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._manager = Playerctl.PlayerManager()
+ self._manager.connect("name-appeared", self._on_name_appeared, self._manager)
+ self._manager.connect(
+ "player-vanished", self._on_player_vanished, self._manager
+ )
+
+ self._players = {}
+
+ def init_all_players(self):
+ # invoked in the UI
+ for player_obj in self._manager.props.player_names:
+ name_str = player_obj.name
+ if name_str in ALLOWED_PLAYERS:
+ player = Playerctl.Player.new_from_name(player_obj)
+ self._manager.manage_player(player)
+ self.new_player(player)
+
+ def _on_name_appeared(self, sender, name, manager):
+ name_str = name.name
+ if name_str in ALLOWED_PLAYERS:
+ player = Playerctl.Player.new_from_name(name)
+ self._manager.manage_player(player)
+ self.new_player(player)
+
+ def _on_player_vanished(self, sender, player, manager):
+ self.player_vanish(player)
\ No newline at end of file
diff --git a/services/network.py b/services/network.py
index 04ebcae..1124b4a 100644
--- a/services/network.py
+++ b/services/network.py
@@ -1,16 +1,301 @@
-from typing import Any, List, Literal
+from typing import Optional
import gi
from fabric.core.service import Property, Service, Signal
-from fabric.utils import bulk_connect, exec_shell_command_async
-from gi.repository import Gio
+from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case
+from gi.repository import NM, GLib
from loguru import logger
-try:
- gi.require_version("NM", "1.0")
- from gi.repository import NM
-except ValueError:
- logger.error("Failed to start network manager")
+gi.require_version("NM", "1.0") # Ensure the correct version is loaded
+
+
+class NetworkClient(Service):
+ """A service to manage network devices"""
+
+ @Signal
+ def wifi_device_added(self) -> None: ...
+
+ @Signal
+ def ethernet_device_added(self) -> None: ...
+
+ @Signal
+ def wifi_device_removed(self) -> None: ...
+
+ @Signal
+ def ethernet_device_removed(self) -> None: ...
+
+ @Signal
+ def changed(self) -> None: ...
+
+ @Signal
+ def ready(self) -> None: ...
+
+ @Property(bool, "readable", default_value=False)
+ def is_ready(self) -> bool:
+ """Returns whether the client is ready to use."""
+ return self._client is not None
+
+ @Property(list, "readable")
+ def connections(self) -> Optional[list]:
+ """Returns the active connections, if available."""
+ return self._client.get_property("connections") if self._client else None
+
+ @Property(object, "readable")
+ def wifi_device(self) -> Optional[object]:
+ """Returns the WiFi device if available."""
+ return self._wifi_device
+
+ @Property(object, "readable")
+ def ethernet_device(self) -> Optional[object]:
+ """Returns the Ethernet device if available."""
+ return self._ethernet_device
+
+ @Property(str, "readable")
+ def primary_connection(self) -> Optional[str]:
+ """Returns the primary connection if available."""
+ return self._client.get_property("primary_connection") if self._client else None
+
+ @Property(str, "readable")
+ def active_connection(self) -> Optional[str]:
+ """Returns the active connection if available."""
+ return self._client.get_property("active_connection") if self._client else None
+
+ @Property(str, "readable")
+ def state(self) -> str:
+ """Returns the current network state."""
+ if not self._client:
+ return "disconnected"
+ return snake_case_to_kebab_case(
+ get_enum_member_name(
+ self._client.get_property("state"), default="disconnected"
+ )
+ )
+
+ @Property(str, "readable")
+ def connectivity(self) -> str:
+ """Returns the connectivity state."""
+ if not self._client:
+ return "disconnected"
+ return snake_case_to_kebab_case(
+ get_enum_member_name(
+ self._client.get_property("connectivity"), default="disconnected"
+ )
+ )
+
+ @Property(list, "readable")
+ def devices(self) -> Optional[list]:
+ """Returns the list of network devices if available."""
+ return self._client.get_property("devices") if self._client else None
+
+ @Property(str, "readable")
+ def hostname(self) -> Optional[str]:
+ """Returns the hostname if available."""
+ return self._client.get_property("hostname") if self._client else None
+
+ @Property(bool, "read-write", default_value=False)
+ def networking_enabled(self) -> bool:
+ """Checks if networking is enabled."""
+ return (
+ self._client.get_property("networking_enabled") if self._client else False
+ )
+
+ @networking_enabled.setter
+ def networking_enabled(self, value: bool):
+ """Sets the networking state if the client is available."""
+ if self._client:
+ self._client.set_property("networking_enabled", value)
+
+ @Property(bool, "read-write", default_value=False)
+ def wireless_enabled(self) -> bool:
+ """Checks if wireless networking is enabled."""
+ return self._client.get_property("wireless_enabled") if self._client else False
+
+ @wireless_enabled.setter
+ def wireless_enabled(self, value: bool):
+ """Sets the wireless networking state if the client is available."""
+ if self._client:
+ self._client.set_property("wireless_enabled", value)
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self._client: NM.Client = None
+ self._wifi_device: Wifi | None = None
+ self._ethernet_device: Ethernet | None = None
+
+ logger.info("[Network] Initializing client asynchronously...")
+
+ # Start async NM client initialization
+ NM.Client.new_async(None, self.on_client_ready)
+
+ def on_client_ready(self, source, result):
+ """Callback when NM.Client is ready."""
+ try:
+ self._client = NM.Client.new_finish(result) # Retrieve client instance
+ logger.info("[Network] NM.Client initialized successfully!")
+
+ # Connect signals
+ bulk_connect(
+ self._client,
+ {
+ "device-added": lambda _, device: self.on_device_added(
+ device=device
+ ),
+ "device-removed": lambda _, device: self.on_device_removed(
+ device=device
+ ),
+ "notify::state": lambda *args: self.notifier("state"),
+ "notify::networking-enabled": lambda *args: self.notifier(
+ "networking-enabled"
+ ),
+ "notify::wireless-enabled": lambda *args: self.notifier(
+ "wireless-enabled"
+ ),
+ # "notify::primary-connection": lambda *args: self.notifier('primary-connection'),
+ # "notify::active-connection": lambda *args: self.notifier('active-connection'),
+ # "active-connection-added": lambda *args: self.emit("changed"),
+ # "active-connection-removed": lambda *args: self.emit("changed")
+ },
+ )
+
+ # Process devices AFTER client is ready
+ for device in self.do_get_raw_devices():
+ self.on_device_added(device=device)
+
+ self.notify("state")
+ self.notify("networking-enabled")
+ self.notify("wireless-enabled")
+ self.notify("is-ready")
+ self.ready.emit()
+
+ except Exception as e:
+ logger.error(f"[Network] Error initializing NM.Client: {e}")
+
+ def do_get_raw_devices(self) -> list[NM.Device]:
+ return [
+ dev
+ for dev in self.devices
+ if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET)
+ ]
+
+ def on_device_added(self, device):
+ device_type = device.get_device_type()
+ if device_type == NM.DeviceType.WIFI and not self._wifi_device:
+ logger.info("[Network] WiFi device detected, initializing...")
+ self._wifi_device = Wifi(client=self, device=device)
+ self.wifi_device_added.emit()
+
+ elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device:
+ logger.info("[Network] Ethernet device detected, initializing...")
+ self._ethernet_device = Ethernet(client=self, device=device)
+ self.ethernet_device_added.emit()
+
+ def on_device_removed(self, device):
+ if device == self._wifi_device:
+ logger.info("[Network] WiFi device removed.")
+ self._wifi_device = None
+ self.wifi_device_removed.emit()
+
+ elif device == self._ethernet_device:
+ logger.info("[Network] Ethernet device removed.")
+ self._ethernet_device = None
+ self.ethernet_device_removed.emit()
+
+ def toggle_network(self):
+ """Enable or disable Network"""
+ self.networking_enabled = not self.networking_enabled
+
+ def deactivate_connection(self, connection):
+ """Disconnect"""
+ self._client.deactivate_connection_async(connection, None, None)
+
+ def notifier(self, name):
+ self.notify(name)
+ self.emit("changed")
+
+
+class AccessPoint(Service):
+ """A service to manage access points"""
+
+ @Signal
+ def changed(self) -> None: ...
+
+ @Property(object, "readable")
+ def device(self) -> object:
+ return self._device
+
+ @Property(int, "readable")
+ def strength(self) -> int:
+ return self._ap.get_property("strength")
+
+ @Property(int, "readable")
+ def frequency(self) -> int:
+ return self._ap.get_property("frequency")
+
+ @Property(str, "readable")
+ def bssid(self) -> str:
+ return self._ap.get_property("bssid") if self._ap else None
+
+ @Property(str, "readable")
+ def hw_address(self) -> str:
+ return self._ap.get_property("hw_address")
+
+ @Property(str, "readable")
+ def ssid(self) -> str:
+ ssid = self._ap.get_ssid()
+ return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
+
+ @Property(str, "readable")
+ def icon(self) -> str:
+ return {
+ 80: "network-wireless-signal-excellent-symbolic",
+ 60: "network-wireless-signal-good-symbolic",
+ 40: "network-wireless-signal-ok-symbolic",
+ 20: "network-wireless-signal-weak-symbolic",
+ 00: "network-wireless-signal-none-symbolic",
+ }.get(
+ min(80, 20 * round(self.strength / 20)),
+ "network-wireless-no-route-symbolic",
+ )
+
+ @Property(bool, "readable", default_value=False)
+ def requires_password(self) -> bool:
+ ssid = self.ssid
+ settings = self._client.connections
+ connection = None
+ for setting in settings:
+ wifi_setting = setting.get_setting_wireless()
+ if (
+ wifi_setting
+ and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
+ ):
+ connection = setting
+ break
+ if not connection:
+ return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags())
+ return False
+
+ @Property(bool, "readable", default_value=False)
+ def is_active(self) -> bool:
+ if self._device.active_access_point:
+ return self.bssid == self._device.active_access_point.get_bssid()
+ return False
+
+ def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs):
+ super().__init__(**kwargs)
+ self._client: NetworkClient = device.client
+ self._device: Wifi = device
+ self._ap: NM.AccessPoint = ap
+
+ self._ap.connect("notify::strength", lambda *args: self.notifier("strength"))
+ self._device.connect(
+ "notify::active-access-point", lambda *args: self.notifier("is-active")
+ )
+
+ def notifier(self, name: str, *args):
+ self.notify(name)
+ self.emit("changed")
+ return
class Wifi(Service):
@@ -20,186 +305,242 @@ class Wifi(Service):
def changed(self) -> None: ...
@Signal
- def enabled(self) -> bool: ...
+ def ap_added(self, ap: AccessPoint) -> None: ...
@Signal
- def scanning(self, is_scanning: bool) -> None: ...
+ def ap_removed(self, ap: AccessPoint) -> None: ...
- def __init__(self, client: NM.Client, device: NM.DeviceWifi, **kwargs):
- self._client: NM.Client = client
- self._device: NM.DeviceWifi = device
- self._ap: NM.AccessPoint | None = None
- self._ap_signal: int | None = None
+ @Property(NetworkClient, "readable")
+ def client(self) -> NetworkClient:
+ """Returns the client"""
+ return self._client
+
+ @Property(bool, "read-write", default_value=False)
+ def wireless_enabled(self) -> bool:
+ """Returns if the wifi is enabled"""
+ return self._client.get_property("wireless_enabled")
+
+ @wireless_enabled.setter
+ def wireless_enabled(self, value: bool):
+ return self._client.set_property("wireless_enabled", value)
+
+ @Property(list[AccessPoint], "readable")
+ def access_points(self) -> list[AccessPoint]:
+ return sorted(
+ self._access_points.values(), key=lambda x: x.is_active, reverse=True
+ )
+
+ @Property(AccessPoint, "readable")
+ def active_access_point(self) -> Optional[AccessPoint]:
+ return self._active_access_point
+
+ def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs):
super().__init__(**kwargs)
+ self._client: NetworkClient = client
+ self._device: NM.DeviceWifi = device
+ self._active_access_point: NM.AccessPoint | None = None
+ self._access_points: dict[str, AccessPoint] = {}
+
+ bulk_connect(
+ self._device,
+ {
+ "notify::active-access-point": lambda *args: self.on_access_point_activated(),
+ "access-point-added": lambda _, ap: self.on_access_point_added(ap=ap),
+ "access-point-removed": lambda _, ap: self.on_access_point_removed(
+ ap=ap
+ ),
+ # "state-changed": lambda device, new, old, reason: self.on_state_changed(new),
+ },
+ )
self._client.connect(
- "notify::wireless-enabled",
- lambda *args: self.notifier("enabled", args),
+ "notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled")
)
- if self._device:
- bulk_connect(
- self._device,
- {
- "notify::active-access-point": lambda *args: self._activate_ap(),
- "access-point-added": lambda *args: self.emit("changed"),
- "access-point-removed": lambda *args: self.emit("changed"),
- "state-changed": lambda *args: self.ap_update(),
- },
- )
- self._activate_ap()
- def ap_update(self):
+ for ap in self.do_get_access_points():
+ self.on_access_point_added(ap=ap)
+
+ self.on_access_point_activated()
+
+ def on_state_changed(self, state):
self.emit("changed")
- for sn in [
- "enabled",
- "internet",
- "strength",
- "frequency",
- "access-points",
- "ssid",
- "state",
- "icon-name",
- ]:
- self.notify(sn)
- def _activate_ap(self):
- if self._ap:
- self._ap.disconnect(self._ap_signal)
- self._ap = self._device.get_active_access_point()
- if not self._ap:
- return
+ def do_get_access_points(self):
+ return self._device.get_access_points()
- self._ap_signal = self._ap.connect(
- "notify::strength", lambda *args: self.ap_update()
- ) # type: ignore
+ def on_access_point_added(self, ap):
+ ssid = ap.get_ssid()
+ ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
- def toggle_wifi(self):
- self._client.wireless_set_enabled(not self._client.wireless_get_enabled())
+ access_point: AccessPoint = AccessPoint(ap=ap, device=self)
- # def set_active_ap(self, ap):
- # self._device.access
+ self._access_points[ssid] = access_point
+
+ self.ap_added.emit(access_point)
+
+ # self.notifier("access-points")
+
+ logger.info(f"[Wifi] New access point added with ssid: {ssid}")
+
+ def on_access_point_removed(self, ap):
+ ssid = ap.get_ssid()
+ ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
+
+ if not (access_point := self._access_points.pop(ssid, None)):
+ return logger.warning(
+ f"[Network] tried to remove a unknwon access point with ssid {ssid}"
+ )
+
+ self.ap_removed.emit(access_point)
+
+ logger.info(f"[Wifi] Access point with ssid: {ssid} removed.")
+
+ def on_access_point_activated(self):
+ if self._device.get_active_access_point():
+ self._active_access_point: AccessPoint = AccessPoint(
+ ap=self._device.get_active_access_point(), device=self
+ )
+
+ else:
+ self._active_access_point = None
+
+ self.notifier("active-access-point")
+
+ logger.info("[Wifi] New active connection")
+
+ def disconnect_wifi(self):
+ """Disconnect from the current WiFi network."""
+ active_connection = self._device.get_active_connection()
+ self._client.deactivate_connection(active_connection)
+ logger.info("[Wifi] Wifi network disconnected")
def scan(self):
- """Start scanning for WiFi networks and emit scanning signal"""
- if self._device:
- self.emit("scanning", True) # Emit signal that scanning has started
- self._device.request_scan_async(
+ self._device.request_scan_async(
+ None,
+ lambda device, result: [
+ device.request_scan_finish(result),
+ self.notifier("access-points"),
+ ],
+ )
+ logger.info("[Wifi] Scan started")
+
+ def toggle_wifi(self):
+ """Enable or disable WiFi"""
+ self.wireless_enabled = not self.wireless_enabled
+
+ def connect_to_wifi(self, ap: AccessPoint, password: str = None):
+ """Connect to a WiFi network."""
+
+ ssid = ap.ssid
+
+ if ssid == "Unknown":
+ logger.error("Invalid access point data")
+ return False
+
+ logger.info(f"Connecting to WiFi SSID: {ssid}")
+
+ # Check for existing connections
+ settings = self._client.connections
+ connection = None
+
+ for setting in settings:
+ wifi_setting = setting.get_setting_wireless()
+ if (
+ wifi_setting
+ and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
+ ):
+ connection = setting
+ break
+
+ if not connection:
+ # Create a new connection profile
+ logger.info(f"Creating new WiFi connection for SSID '{ssid}'")
+ connection = NM.SimpleConnection.new()
+
+ # Required connection settings
+ s_con = NM.SettingConnection.new()
+ s_con.set_property(NM.SETTING_CONNECTION_ID, ssid)
+ s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless")
+ s_con.set_property(
+ NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface()
+ ) # Set interface name
+ connection.add_setting(s_con)
+
+ # Wireless settings
+ s_wifi = NM.SettingWireless.new()
+ s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode()))
+ s_wifi.set_property(
+ NM.SETTING_WIRELESS_MODE, "infrastructure"
+ ) # Ensure mode is correct
+ connection.add_setting(s_wifi)
+
+ # Security settings (only if password is required and provided)
+ if ap.requires_password:
+ if not password:
+ logger.error("Password required but not provided")
+ return False
+
+ s_sec = NM.SettingWirelessSecurity.new()
+ s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk")
+ s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password)
+ connection.add_setting(s_sec)
+
+ # IPv4 settings
+ s_ipv4 = NM.SettingIP4Config.new()
+ s_ipv4.set_property("method", "auto")
+ connection.add_setting(s_ipv4)
+
+ # IPv6 settings
+ s_ipv6 = NM.SettingIP6Config.new()
+ s_ipv6.set_property("method", "auto")
+ connection.add_setting(s_ipv6)
+
+ # Callback for async connection
+ def on_connection_added(client, result, ssid, device):
+ try:
+ new_connection = client.add_connection_finish(result)
+ if not new_connection:
+ logger.error(f"Failed to create connection for '{ssid}'")
+ return
+
+ logger.info(f"Connection for '{ssid}' added successfully")
+
+ client.activate_connection_async(
+ new_connection,
+ device,
+ None,
+ None,
+ lambda c, r: logger.info(f"Connected to '{ssid}'")
+ if c
+ else logger.error(f"Failed to connect to '{ssid}'"),
+ )
+ except Exception as e:
+ logger.error(f"Failed to add connection: {e}")
+
+ # Save the new connection
+ self._client._client.add_connection_async(
+ connection,
+ True,
None,
- lambda device, result: [
- device.request_scan_finish(result),
- self.emit(
- "scanning", False
- ), # Emit signal that scanning has stopped
- ],
+ lambda c, r: on_connection_added(c, r, ssid, self._device),
)
+ # Activate the connection
+ self._client._client.activate_connection_async(
+ connection,
+ self._device,
+ None,
+ None,
+ lambda client, result: logger.info(f"Connected to '{ssid}'"),
+ )
+
+ return True
+
def notifier(self, name: str, *args):
self.notify(name)
self.emit("changed")
return
- @Property(bool, "read-write", default_value=False)
- def enabled(self) -> bool: # type: ignore
- return bool(self._client.wireless_get_enabled())
-
- @enabled.setter
- def enabled(self, value: bool):
- self._client.wireless_set_enabled(value)
-
- @Property(int, "readable")
- def strength(self):
- return self._ap.get_strength() if self._ap else -1
-
- @Property(str, "readable")
- def icon_name(self):
- if not self._ap:
- return "network-wireless-disabled-symbolic"
-
- if self.internet == "activated":
- return {
- 80: "network-wireless-signal-excellent-symbolic",
- 60: "network-wireless-signal-good-symbolic",
- 40: "network-wireless-signal-ok-symbolic",
- 20: "network-wireless-signal-weak-symbolic",
- 00: "network-wireless-signal-none-symbolic",
- }.get(
- min(80, 20 * round(self._ap.get_strength() / 20)),
- "network-wireless-no-route-symbolic",
- )
- if self.internet == "activating":
- return "network-wireless-acquiring-symbolic"
-
- return "network-wireless-offline-symbolic"
-
- @Property(int, "readable")
- def frequency(self):
- return self._ap.get_frequency() if self._ap else -1
-
- @Property(int, "readable")
- def internet(self):
- return {
- NM.ActiveConnectionState.ACTIVATED: "activated",
- NM.ActiveConnectionState.ACTIVATING: "activating",
- NM.ActiveConnectionState.DEACTIVATING: "deactivating",
- NM.ActiveConnectionState.DEACTIVATED: "deactivated",
- }.get(
- self._device.get_active_connection().get_state(),
- "unknown",
- )
-
- @Property(object, "readable")
- def access_points(self) -> List[object]:
- points: list[NM.AccessPoint] = self._device.get_access_points()
-
- def make_ap_dict(ap: NM.AccessPoint):
- return {
- "bssid": ap.get_bssid(),
- # "address": ap.get_
- "last_seen": ap.get_last_seen(),
- "ssid": NM.utils_ssid_to_utf8(ap.get_ssid().get_data())
- if ap.get_ssid()
- else "Unknown",
- "active-ap": self._ap,
- "strength": ap.get_strength(),
- "frequency": ap.get_frequency(),
- "icon-name": {
- 80: "network-wireless-signal-excellent-symbolic",
- 60: "network-wireless-signal-good-symbolic",
- 40: "network-wireless-signal-ok-symbolic",
- 20: "network-wireless-signal-weak-symbolic",
- 00: "network-wireless-signal-none-symbolic",
- }.get(
- min(80, 20 * round(ap.get_strength() / 20)),
- "network-wireless-no-route-symbolic",
- ),
- }
-
- return list(map(make_ap_dict, points))
-
- @Property(str, "readable")
- def ssid(self):
- if not self._ap:
- return "Disconnected"
- ssid = self._ap.get_ssid().get_data()
- return NM.utils_ssid_to_utf8(ssid) if ssid else "Unknown"
-
- @Property(int, "readable")
- def state(self):
- return {
- NM.DeviceState.UNMANAGED: "unmanaged",
- NM.DeviceState.UNAVAILABLE: "unavailable",
- NM.DeviceState.DISCONNECTED: "disconnected",
- NM.DeviceState.PREPARE: "prepare",
- NM.DeviceState.CONFIG: "config",
- NM.DeviceState.NEED_AUTH: "need_auth",
- NM.DeviceState.IP_CONFIG: "ip_config",
- NM.DeviceState.IP_CHECK: "ip_check",
- NM.DeviceState.SECONDARIES: "secondaries",
- NM.DeviceState.ACTIVATED: "activated",
- NM.DeviceState.DEACTIVATING: "deactivating",
- NM.DeviceState.FAILED: "failed",
- }.get(self._device.get_state(), "unknown")
-
class Ethernet(Service):
"""A service to manage ethernet devices"""
@@ -211,124 +552,80 @@ class Ethernet(Service):
def enabled(self) -> bool: ...
@Property(int, "readable")
- def speed(self) -> int:
- return self._device.get_speed()
+ def speed(self) -> str:
+ speed_mbps = self._device.get_speed()
+ return f"{speed_mbps} Mb/s"
+
+ @Property(str, "readable")
+ def state(self) -> str:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(self._device.get_state(), default="disconnected")
+ )
@Property(str, "readable")
def internet(self) -> str:
- return {
- NM.ActiveConnectionState.ACTIVATED: "activated",
- NM.ActiveConnectionState.ACTIVATING: "activating",
- NM.ActiveConnectionState.DEACTIVATING: "deactivating",
- NM.ActiveConnectionState.DEACTIVATED: "deactivated",
- }.get(
- self._device.get_active_connection().get_state(),
- "disconnected",
- )
+ if self._active_connection:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(
+ self._active_connection.get_state(), default="disconnected"
+ )
+ )
+ return "disconnected"
+
+ @Property(str, "readable")
+ def iface(self) -> str:
+ return self._device.get_iface() if self._device else None
@Property(str, "readable")
def icon_name(self) -> str:
network = self.internet
if network == "activated":
return "network-wired-symbolic"
-
elif network == "activating":
return "network-wired-acquiring-symbolic"
-
- elif self._device.get_connectivity != NM.ConnectivityState.FULL:
- return "network-wired-no-route-symbolic"
-
return "network-wired-disconnected-symbolic"
def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None:
super().__init__(**kwargs)
self._client: NM.Client = client
self._device: NM.DeviceEthernet = device
+ self._active_connection = None
- for names in (
- "active-connection",
- "icon-name",
- "internet",
- "speed",
- "state",
- ):
- self._device.connect(f"notify::{names}", lambda *_: self.notifier(names))
-
- self._device.connect("notify::speed", lambda *_: print(_))
-
- def notifier(self, names):
- self.notify(names)
- self.emit("changed")
-
-
-class NetworkClient(Service):
- """A service to manage network devices"""
-
- @Signal
- def device_ready(self) -> None: ...
-
- def __init__(self, **kwargs):
- self._client: NM.Client | None = None
- self.wifi_device: Wifi | None = None
- self.ethernet_device: Ethernet | None = None
- super().__init__(**kwargs)
- NM.Client.new_async(
- cancellable=None,
- callback=self._init_network_client,
- **kwargs,
+ self._device.connect(
+ "state-changed", lambda *args: self.on_network_state_changed()
)
- def _init_network_client(self, client: NM.Client, task: Gio.Task, **kwargs):
- self._client = client
- wifi_device: NM.DeviceWifi | None = self._get_device(NM.DeviceType.WIFI) # type: ignore
- ethernet_device: NM.DeviceEthernet | None = self._get_device(
- NM.DeviceType.ETHERNET
- )
+ self.update_active_connection()
- if wifi_device:
- self.wifi_device = Wifi(self._client, wifi_device)
- self.emit("device-ready")
+ def on_network_state_changed(self):
+ """Called when networking is toggled on/off."""
+ if self.state != "unmanaged":
+ # Re-initialize device when networking is re-enabled
+ self.update_active_connection()
+ else:
+ self._active_connection = None
- if ethernet_device:
- self.ethernet_device = Ethernet(client=self._client, device=ethernet_device)
- self.emit("device-ready")
+ def update_active_connection(self):
+ """Updates the active connection and connects to its state change signal."""
+ active_connection = self._device.get_active_connection()
+ if active_connection:
+ self._active_connection = active_connection
+ self._active_connection.connect(
+ "state-changed", lambda *args: self.emit("changed")
+ )
- self.notify("primary-device")
-
- def _get_device(self, device_type) -> Any:
- devices: List[NM.Device] = self._client.get_devices() # type: ignore
- return next(
- (
- x
- for x in devices
- if x.get_device_type() == device_type
- and x.get_active_connection() is not None
- ),
- None,
- )
-
- def _get_primary_device(self) -> Literal["wifi", "wired"] | None:
- if not self._client:
- return None
-
- if self._client.get_primary_connection() is None:
- return "wifi"
- return (
- "wifi"
- if "wireless"
- in str(self._client.get_primary_connection().get_connection_type())
- else "wired"
- if "ethernet"
- in str(self._client.get_primary_connection().get_connection_type())
- else None
- )
-
- def connect_wifi_bssid(self, bssid):
- # We are using nmcli here, idk im lazy
- exec_shell_command_async(
- f"nmcli device wifi connect {bssid}", lambda *args: print(args)
- )
-
- @Property(str, "readable")
- def primary_device(self) -> Literal["wifi", "wired"] | None:
- return self._get_primary_device()
+ def get_network_stats(self):
+ """Fetch received and transmitted bytes from the system files"""
+ try:
+ # Read data from /sys/class/net for accurate speed
+ with open(
+ f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r"
+ ) as rx_file:
+ rx_bytes = int(rx_file.read().strip())
+ with open(
+ f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r"
+ ) as tx_file:
+ tx_bytes = int(tx_file.read().strip())
+ return rx_bytes, tx_bytes
+ except FileNotFoundError:
+ return None, None
diff --git a/services/notification.py b/services/notification.py
deleted file mode 100644
index 4da918b..0000000
--- a/services/notification.py
+++ /dev/null
@@ -1,226 +0,0 @@
-import json
-import os
-from typing import List
-
-from fabric import Signal
-from fabric.notifications import Notification, Notifications
-from loguru import logger
-import gi
-gi.require_version('GLib', '2.0')
-from gi.repository import GLib
-
-import config.data as data
-
-NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notifications.json"
-
-
-class CustomNotifications(Notifications):
- """A service to manage the notifications."""
-
- @Signal
- def clear_all(self, value: bool) -> None:
- """Signal emitted when notifications are emptied."""
- # Implement as needed for your application
-
- @Signal
- def notification_count(self, value: int) -> None:
- """Signal emitted when a new notification is added."""
- # Implement as needed for your application
-
- @Signal
- def dnd(self, value: bool) -> None:
- """Signal emitted when dnd is toggled."""
- # Implement as needed for your application
-
- @property
- def count(self) -> int:
- """Return the count of notifications."""
- return len(self.all_notifications)
-
- @property
- def dont_disturb(self) -> bool:
- """Return the pause status."""
- return self._dont_disturb
-
- @dont_disturb.setter
- def dont_disturb(self, value: bool):
- """Set the pause status."""
- self._dont_disturb = value
- self.emit("dnd", value)
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.all_notifications = []
- self._count = 0 # Will be updated to highest ID when loading
- self.deserialized_notifications = []
- self._dont_disturb = False
- self._load_notifications()
-
- def _load_notifications(self):
- """Read notifications from the cache file."""
- if os.path.exists(NOTIFICATION_CACHE_FILE):
- try:
- with open(NOTIFICATION_CACHE_FILE, "r") as file:
- notifications = json.load(file)
-
- def validate_with_id(notif):
- """Helper to validate and return ID if valid."""
- try:
- self._deserialize_notification(notif)
- return (True, notif, notif.get("id", 0))
- except Exception as e:
- msg = f"[Notification] Invalid: {str(e)[:50]}"
- logger.error(msg)
- return (False, None, 0)
-
- # Validate all notifications at once
- results = [validate_with_id(n) for n in notifications]
-
- # Process results and find highest ID
- valid_notifications = []
- highest_id = self._count # Start with current count
- for is_valid, notif, notif_id in results:
- if is_valid:
- valid_notifications.append(notif)
- highest_id = max(highest_id, notif_id)
-
- self.all_notifications = valid_notifications
- self._count = highest_id # Update to highest ID seen
- self._write_notifications(self.all_notifications)
-
- except (json.JSONDecodeError, KeyError, ValueError, IndexError) as e:
- logger.error(f"[Notification] {e}")
- self.all_notifications = []
- self._count = 0
-
- def remove_notification(self, id: int):
- """Remove the notification of given id."""
- item = next((p for p in self.all_notifications if p["id"] == id), None)
- if item:
- self.all_notifications.remove(item)
- self._write_notifications(self.all_notifications)
- self.emit("notification_count", len(self.all_notifications))
-
- # Emit clear_all signal if there are no notifications left
- if len(self.all_notifications) == 0:
- self.emit("clear_all", True)
-
- def cache_notification(self, data: Notification):
- """Cache the notification without widget_config and max_count."""
-
- # First clean up any invalid notifications
- self._cleanup_invalid_notifications()
-
- # Create the new notification
- self._count += 1
- new_id = self._count
- timestamp = GLib.get_real_time() / 1000000 # Convert microseconds to seconds
- serialized_data = data.serialize()
- serialized_data.update({
- "id": new_id,
- "app_name": data.app_name,
- "timestamp": timestamp
- })
-
- # Append the new notification
- self.all_notifications.append(serialized_data)
-
- self._write_notifications(self.all_notifications)
- self.emit("notification_count", len(self.all_notifications))
-
- def _cleanup_invalid_notifications(self):
- """Remove any invalid notifications."""
-
- def validate_with_id(notif):
- """Helper to validate and return result with ID."""
- try:
- self._deserialize_notification(notif)
- return (True, notif, None)
- except Exception as e:
- msg = f"[Notification] Removing invalid: {str(e)[:50]}"
- logger.debug(msg)
- return (False, None, notif.get("id", 0))
-
- # Validate all notifications at once
- results = [validate_with_id(n) for n in self.all_notifications]
-
- # Process results
- valid_notifications = []
- invalid_count = 0
- for is_valid, notif, invalid_id in results:
- if is_valid:
- valid_notifications.append(notif)
- else:
- invalid_count += 1
- self.emit("notification-closed", invalid_id, "dismissed-by-limit")
-
- if invalid_count > 0:
- self.all_notifications = valid_notifications
- self._write_notifications(self.all_notifications)
- self.emit("notification_count", len(self.all_notifications))
-
- def _deserialize_notification(self, notification):
- """Deserialize a notification."""
- try:
- notif = Notification.deserialize(notification)
- # Store the timestamp in the notification object
- notif.timestamp = notification.get("timestamp", GLib.get_real_time() / 1000000)
- return notif
- except Exception as e:
- logger.error(
- f"[Notification] Deserialize failed for: {notification}, Error: {e}"
- )
- raise
-
- def clear_all_notifications(self):
- """Empty the notifications."""
- logger.info("[Notification] Clearing all notifications")
- # Clear notifications but preserve the highest ID we've seen
- highest_id = self._count
- self.all_notifications = []
- self._write_notifications(self.all_notifications)
- self.emit("notification_count", 0)
- self.emit("clear_all", True)
- # Restore the ID counter so new notifications get unique IDs
- self._count = highest_id
-
- def _write_notifications(self, data):
- """Write the notifications to the cache file."""
- try:
- with open(NOTIFICATION_CACHE_FILE, "w") as f:
- json.dump(data, f, indent=4, ensure_ascii=False)
- logger.info("[Notification] Notifications written successfully.")
- except Exception as e:
- logger.error(f"[Notification] Failed to write notifications: {e}")
-
- def get_deserialized(self) -> List[Notification]:
- """Return the notifications."""
-
- def deserialize_with_id(notif):
- """Helper to deserialize and return result with ID."""
- try:
- return (self._deserialize_notification(notif), None)
- except Exception as e:
- msg = f"[Notification] Deserialize failed: {str(e)[:50]}"
- logger.error(msg)
- return (None, notif.get("id"))
-
- # Process all notifications at once
- results = [
- deserialize_with_id(notification) for notification in self.all_notifications
- ]
-
- # Split into successful and failed
- deserialized = []
- invalid_ids = []
- for result, error_id in results:
- if result is not None:
- deserialized.append(result)
- elif error_id is not None:
- invalid_ids.append(error_id)
-
- # Clean up invalid notifications
- for invalid_id in invalid_ids:
- self.remove_notification(invalid_id)
-
- return deserialized
diff --git a/services/powerprofile.py b/services/powerprofile.py
deleted file mode 100644
index cccf03e..0000000
--- a/services/powerprofile.py
+++ /dev/null
@@ -1,88 +0,0 @@
-import dbus
-from dbus.mainloop.glib import DBusGMainLoop
-from fabric import Service, Signal
-from loguru import logger
-
-
-class PowerProfiles(Service):
- """Service to interact with the PowerProfiles service."""
-
- @Signal
- def profile(self, value: str) -> None:
- """Signal emitted when profile changes."""
-
- instance = None
-
- @staticmethod
- def get_default():
- if PowerProfiles.instance is None:
- PowerProfiles.instance = PowerProfiles()
-
- return PowerProfiles.instance
-
- def __init__(
- self,
- **kwargs,
- ):
- super().__init__(
- **kwargs,
- )
-
- self.bus_name = "net.hadess.PowerProfiles"
- self.object_path = "/net/hadess/PowerProfiles"
-
- self.power_profiles = {
- "power-saver": {
- "name": "Power Saver",
- "icon_name": "power-profile-power-saver-symbolic",
- },
- "balanced": {
- "name": "Balanced",
- "icon_name": "power-profile-balanced-symbolic",
- },
- "performance": {
- "name": "Performance",
- "icon_name": "power-profile-performance-symbolic",
- },
- }
-
- # Set up the dbus main loop
- DBusGMainLoop(set_as_default=True)
-
- self.bus = dbus.SystemBus()
-
- self.power_profiles_obj = self.bus.get_object(self.bus_name, self.object_path)
-
- self.iface = dbus.Interface(
- self.power_profiles_obj, "org.freedesktop.DBus.Properties"
- )
-
- # Connect the 'g-properties-changed' signal to the handler
- self.iface.connect_to_signal("PropertiesChanged", self.handle_property_change)
-
- def get_current_profile(self):
- try:
- return self.iface.Get("net.hadess.PowerProfiles", "ActiveProfile").strip()
-
- except dbus.DBusException as e:
- logger.error(f"[PowerProfile] Error retrieving current power profile: {e}")
- return "balanced"
-
- def set_power_profile(self, profile):
- try:
- self.iface.Set(self.bus_name, "ActiveProfile", dbus.String(profile))
- logger.info(f"[PowerProfile] Power profile set to {profile}")
- except dbus.DBusException as e:
- logger.error(
- f"[PowerProfile] Could not change power level to {profile}: {e}"
- )
-
- # Function to handle properties change signals
- def handle_property_change(self, proxy, changed, invalidated):
- # Print the changed ActiveProfile
- if "ActiveProfile" in changed:
- logger.info(f"Profile changed: {changed['ActiveProfile']}")
- self.emit("profile", changed["ActiveProfile"])
-
- def get_profile_icon(self, profile):
- return self.power_profiles.get(profile, "balanced").get("icon_name")
diff --git a/services/screen_record.py b/services/screen_record.py
deleted file mode 100644
index 7fbf6d5..0000000
--- a/services/screen_record.py
+++ /dev/null
@@ -1,142 +0,0 @@
-import datetime
-import subprocess
-
-from fabric.core.service import Property, Service, Signal
-from fabric.utils import exec_shell_command, exec_shell_command_async
-from gi.repository import Gio, GLib
-from loguru import logger
-
-
-class ScreenRecorder(Service):
- @Signal
- def recording(self, value: bool) -> None: ...
-
- def __init__(self, **kwargs):
- self.screenshot_path = GLib.get_home_dir() + "/Pictures/Screenshots/"
- self.screenrecord_path = GLib.get_home_dir() + "/Videos/Screencasting/"
-
- super().__init__(**kwargs)
-
- def screenshot(self, fullscreen=False, save_copy=True):
- time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
- file_path = self.screenshot_path + str(time) + ".png"
- command = (
- ["grimblast", "copysave", "screen", file_path]
- if save_copy
- else ["grimblast", "copyscreen"]
- )
- if not fullscreen:
- command[2] = "area"
- try:
- subprocess.run(command, check=True)
- self.send_screenshot_notification(
- file_path=file_path if file_path else None,
- )
- except Exception as e:
- logger.error(f"[SCREENSHOT] Failed to run command: {command}")
-
- def screencast_start(self, fullscreen=False):
- if self.is_recording:
- logger.error(
- "[SCREENRECORD] Another instance of wf-recorder is already running"
- )
- return
- time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
- file_path = self.screenrecord_path + str(time) + ".mp4"
- self._current_screencast_path = file_path
- area = "" if fullscreen else f"-g '{exec_shell_command('slurp')}'"
- command = f"wf-recorder --file={file_path} --pixel-format yuv420p {area}"
- exec_shell_command_async(command)
- self.emit("recording", True)
-
- def screencast_stop(self):
- exec_shell_command_async("killall -INT wf-recorder")
- self.emit("recording", False)
- self.send_screencast_notification(self._current_screencast_path)
-
- def send_screencast_notification(self, file_path):
- # TODO: Generate a thumbnail
- # exec_shell_command_async(f"totem-video-thumbnailer {file_path} {FABRIC_CACHE}")
-
- 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.screenrecord_path}")
- case "view":
- exec_shell_command_async(f"xdg-open {file_path}")
-
- proc.communicate_utf8_async(None, None, do_callback)
-
- 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.screenshot_path}")
- case "view":
- exec_shell_command_async(f"xdg-open {file_path}")
- case "edit":
- exec_shell_command_async(f"swappy -f {file_path}")
-
- proc.communicate_utf8_async(None, None, do_callback)
-
- @Property(bool, "readable", default_value=False)
- def is_recording(self):
- return False if len(exec_shell_command("pidof wf-recorder")) == 0 else True
diff --git a/styles/bar.css b/styles/bar.css
deleted file mode 100644
index 6f4993c..0000000
--- a/styles/bar.css
+++ /dev/null
@@ -1,214 +0,0 @@
-#bar {
- padding: 0px 5px 0px 5px;
- margin: 1px;
- transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1);
- background-color: var(--surface);
- /* Bar background */
-}
-
-#start-container,
-#center-container,
-#end-container {
- padding-top: 2px;
- padding-bottom: 2px;
-}
-
-#bar-corner * {
- background-color: var(--surface);
-}
-
-/**
- * ── Workspace ─────────────────────────────────────────────────────────
- */
-#workspaces {
- font-style: normal;
- background-color: var(--surface-container);
- padding: 4px;
- margin-top: 1px;
- margin-bottom: 1px;
- border-radius: 8px;
-}
-
-/**
- * ── Update ────────────────────────────────────────────────────────────
- */
-#update {
- background-color: var(--surface-container);
- margin-top: 1px;
- margin-bottom: 1px;
- border-radius: 8px;
- padding: 1px;
- min-width: 24px;
- min-height: 24px;
- transition: background-color 0.1s ease;
-}
-
-#update:hover {
- background-color: var(--surface-bright);
-}
-
-#update:active {
- color: var(--shadow);
- background-color: var(--primary);
-}
-
-#update-icon {
- color: var(--primary);
- font-size: 20px;
- padding: 1px;
- border-radius: 11px;
- transition: all 0.1s ease;
-}
-
-#updated-icon {
- color: var(--primary);
- font-size: 20px;
- padding: 4px;
- border-radius: 8px;
- transition: all 0.1s ease;
-}
-
-#update-label {
- color: var(--primary);
- font-size: 10px;
- font-weight: bold;
- padding: 0px;
- margin-right: 4px;
- border-radius: 8px;
- transition: all 0.1s ease;
-}
-
-/**
- * ── Clock ─────────────────────────────────────────────────────────────
- */
-#datetime {
- background-color: var(--surface-container);
- margin-top: 1px;
- margin-bottom: 1px;
- padding: 4px;
- border-radius: 8px;
-}
-
-#datetime:hover {
- background-color: var(--surface-bright);
-}
-
-/**
- * ── Tray ──────────────────────────────────────────────────────────────
- */
-#tray {
- background-color: var(--surface-container);
- margin-top: 1px;
- margin-bottom: 1px;
- padding: 4px;
- border-radius: 8px;
-}
-
-#tray > button {
- margin: 0px;
- padding: 0px;
-}
-
-menu {
- border: solid 1px var(--outline-variant);
- border-radius: 16px;
- background-color: var(--surface);
- padding: 6px;
-}
-
-menu > menuitem {
- border-radius: 10px;
- padding: 6px 10px;
-}
-
-menu > menuitem:hover {
- background-color: var(--surface-bright);
-}
-
-menu > menuitem:hover > label {
- color: var(--on-surface);
-}
-
-/**
- * ── Logo ──────────────────────────────────────────────────────────────
- */
-#button-bar {
- background-color: var(--surface-container);
- margin-top: 1px;
- padding: 4px;
- border-radius: 8px;
- margin-bottom: 1px;
- transition: all 0.1s ease;
-}
-
-#button-bar-label {
- color: var(--primary);
- font-size: 24px;
- border-radius: 8px;
- transition: all 0.1s ease;
-}
-
-#button-bar:hover #button-bar-label {
- background-color: var(--surface_bright);
-}
-
-#button-bar:active #button-bar-label {
- color: var(--shadow);
- background-color: var(--primary);
-}
-
-/**
- * ── System Indicator ─────────────────────────────────────────────────
- */
-#system-indicator {
- background-color: var(--surface-container);
- margin-top: 1px;
- padding: 4px;
- margin-bottom: 1px;
- border-radius: 8px;
-}
-
-#system-indicator-icon {
- font-size: 20px;
-}
-
-#system-notif-label {
- font-size: 18px;
-}
-
-/**
- * ── Metric ────────────────────────────────────────────────────────────
- */
-#metrics {
- background-color: var(--surface-container);
- border-radius: 8px;
-}
-
-#metrics-circle {
- color: var(--surface-bright);
- border: 3px solid var(--primary);
-}
-
-#metrics-circle.bat {
- border: 3px solid var(--green);
-}
-
-#metrics-circle.alert {
- border: 3px solid var(--red-dim);
-}
-
-#metrics-icon {
- font-size: 15px;
-}
-
-#metrics-icon.alert {
- color: var(--red-dim);
-}
-
-#metrics-level {
- font-weight: bold;
-}
-
-#metrics-sep {
- min-width: 6px;
-}
diff --git a/styles/calendar.css b/styles/calendar.css
new file mode 100644
index 0000000..f67a694
--- /dev/null
+++ b/styles/calendar.css
@@ -0,0 +1,79 @@
+/* Main calendar widget container */
+#calendar-widget {
+ border: 4px solid var(--surface);
+ padding: 4px;
+}
+
+#calendar-header-box {
+ border-radius: 16px;
+ border: 2px solid var(--surface);
+ padding: 4px;
+}
+
+/* Navigation buttons */
+#calendar-nav-button {
+ background-color: var(--surface);
+ border-radius: 8px;
+ padding: 4px;
+}
+
+#calendar-nav-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#calendar-nav-button:active {
+ background-color: var(--primary);
+
+}
+
+/* Month/Year header */
+#calendar-month-year {
+ color: var(--foreground);
+ font-weight: 700;
+ font-size: 12px;
+ padding: 4px 0;
+ margin-bottom: 2px;
+}
+
+/* Calendar grid */
+#calendar-grid {
+ border-radius: 16px;
+ padding: 4px;
+}
+
+/* Day headers (Mon, Tue, Wed, etc.) */
+#calendar-day-header {
+ color: var(--primary);
+ border-radius: 8px;
+ padding: 4px;
+}
+
+
+#calendar-day-label,
+#calendar-day-empty {
+ padding: 2px;
+ min-width: 23px;
+ min-height: 23px;
+}
+
+
+#calendar-day-label.today {
+ background-color: var(--foreground);
+ color: var(--shadow);
+ border-radius: 20px;
+}
+
+#calendar-day-label.weekend {
+ color: var(--red-dim);
+ font-weight: 600;
+}
+
+
+
+
+
+
+
+
+
+
diff --git a/styles/colors.css b/styles/colors.css
deleted file mode 100644
index f30081e..0000000
--- a/styles/colors.css
+++ /dev/null
@@ -1,44 +0,0 @@
-:vars {
- --foreground: #f1dfdb;
- --background: #1a1110;
- --cursor: #f1dfdb;
- --primary: #ffb4a5;
- --primary-container: #733428;
- --on-primary: #561e14;
- --on-primary-container: #ffdad3;
- --secondary: #e7bdb5;
- --on-secondary: #442a24;
- --secondary-container: #5d3f39;
- --tertiary: #dcc48c;
- --tertiary-container: #554519;
- --on-tertiary: #3d2f04;
- --on-tertiary-container: #f9e0a6;
- --surface: #1a1110;
- --surface-container: #271d1b;
- --surface-bright: #423734;
- --surface-variant: #534340;
- --surface-container-low: #231918;
- --on-surface: #f1dfdb;
- --error: #ffb4ab;
- --error-dim: #ff8678;
- --on-error: #690005;
- --error-container: #93000a;
- --on-error-container: #ffdad6;
- --outline: #a08c88;
- --outline-variant: #534340;
- --shadow: #000000;
- --red: #ffb4a7;
- --red-dim: #ff8974;
- --green: #b7d085;
- --green-dim: #a1c260;
- --yellow: #dec56e;
- --yellow-dim: #d5b444;
- --blue: #cebdfe;
- --blue-dim: #a98bfd;
- --magenta: #fcb0d5;
- --magenta-dim: #fa7fbb;
- --cyan: #84d5c4;
- --cyan-dim: #5ec8b2;
- --white: #82d3e0;
-
-}
diff --git a/styles/components.css b/styles/components.css
new file mode 100644
index 0000000..486030d
--- /dev/null
+++ b/styles/components.css
@@ -0,0 +1,270 @@
+#music-player {
+ background-color: var(--surface);
+ padding: 4px;
+}
+
+#music-player.invert {
+ border-radius: 12px;
+}
+
+#player-circle {
+ padding: 4px;
+ color: var(--surface-bright);
+ border: 3px solid var(--primary);
+}
+#music-label {
+ font-size: 16px;
+}
+
+/*
+ * ── language indicator ────────────────────────────────────────────────
+*/
+
+#language {
+ background-color: var(--surface);
+ padding: 8px;
+}
+
+#language.invert {
+ border-radius: 12px;
+}
+
+#lang-label {
+ font-weight: bold;
+}
+
+#lang-label.icon {
+ color: var(--primary);
+ font-size: 20px;
+}
+
+/*
+ * ── battery ───────────────────────────────────────────────────────────
+*/
+
+#battery {
+ background-color: var(--surface);
+ padding: 4px;
+}
+
+#battery.invert {
+ border-radius: 12px;
+}
+
+#battery-circle {
+ color: var(--surface-bright);
+ border: 3px solid var(--primary);
+}
+
+#battery-circle.alert {
+ border: 2px solid var(--red-dim);
+}
+
+#battery-icon {
+ font-size: 16px;
+}
+
+#battery-icon.alert {
+ color: var(--red-dim);
+}
+
+#battery-level {
+ color: var(--primary);
+ font-weight: bold;
+ margin: 0 4px;
+}
+
+#battery-circle.power-saver {
+ border: 3px solid var(--green);
+}
+
+#battery-circle.performance {
+ border: 3px solid var(--red);
+}
+
+#battery-circle.discharging {
+ border: 3px solid var(--yellow);
+}
+
+#battery-circle.discharging-low {
+ border: 3px solid var(--orange);
+}
+
+#battery-circle.discharging-critical {
+ border: 3px solid var(--red-dim);
+}
+
+#battery-icon.discharging {
+ color: var(--yellow);
+}
+
+#battery-icon.discharging-low {
+ color: var(--orange);
+}
+
+#battery-icon.discharging-critical {
+ color: var(--red-dim);
+}
+
+/*
+ * ── battery popup ─────────────────────────────────────────────────────
+*/
+
+#battery-popup-content {
+ background-color: var(--shadow);
+ border: 1px solid var(--surface);
+ border-radius: 12px;
+ padding: 16px;
+ margin: 8px;
+}
+
+.power-profiles {
+ padding-top: 12px;
+ margin-top: 8px;
+}
+
+#bat-icon {
+ font-size: 18px;
+}
+
+.power-profile-btn {
+ background-color: var(--surface);
+ border-radius: 8px;
+ padding: 10px 20px;
+ transition: all 0.2s ease;
+}
+.power-profile-btn:hover,
+.power-profile-btn:focus {
+ background-color: var(--surface-bright);
+}
+
+.power-profile-btn.active {
+ background-color: var(--primary);
+}
+
+.power-profile-btn.active #bat-icon {
+ color: var(--shadow);
+}
+
+/* Battery info styles */
+.battery-percentage {
+ font-size: 24px;
+ font-weight: bold;
+ color: var(--primary);
+}
+
+.battery-state {
+ font-size: 14px;
+ color: var(--text-dim);
+}
+
+.battery-label {
+ font-size: 12px;
+ color: var(--text-dim);
+ min-width: 80px;
+}
+
+.battery-value {
+ font-size: 12px;
+ color: var(--text);
+ font-weight: 500;
+}
+
+#percentage-state-box {
+ margin-bottom: 8px;
+}
+
+#capacity-box,
+#time-box,
+#profile-label-box {
+ margin-bottom: 4px;
+}
+
+/*
+ * ── metrics ───────────────────────────────────────────────────────────
+*/
+
+#metrics {
+ background-color: var(--surface);
+ padding: 4px;
+}
+
+#metrics.invert {
+ background-color: var(--surface);
+ border-radius: 12px;
+}
+
+#metrics-circle {
+ padding: 4px;
+ color: var(--surface-bright);
+ border: 3px solid var(--primary);
+}
+
+#metrics-icon {
+ font-size: 16px;
+}
+
+#metrics-icon.alert {
+ color: var(--red-dim);
+}
+
+#metrics-level {
+ color: var(--primary);
+ font-weight: bold;
+ margin: 0 4px;
+}
+
+#metrics-sep {
+ min-width: 4px;
+}
+
+/*
+ * ── datetime ──────────────────────────────────────────────────────────
+*/
+
+#date-time {
+ background-color: var(--surface);
+ padding: 8px;
+ font-weight: bold;
+}
+#date-time.vertical {
+ padding: 8px 0;
+}
+#date-time.invert {
+ background-color: var(--surface);
+ border-radius: 12px;
+}
+
+/*
+ * ── tray ──────────────────────────────────────────────────────────────
+*/
+
+#tray {
+ background-color: var(--surface);
+ padding: 8px;
+}
+
+#tray.invert {
+ border-radius: 12px;
+}
+
+menu {
+ border: solid 1px;
+ border-radius: 16px;
+ border-color: var(--surface);
+ background-color: var(--shadow);
+ padding: 6px;
+}
+
+menu > menuitem {
+ border-radius: 10px;
+ padding: 6px 10px;
+}
+
+menu > menuitem:hover {
+ background-color: var(--primary);
+}
+
+menu > menuitem:hover > label {
+ color: var(--shadow);
+}
diff --git a/styles/controls.css b/styles/controls.css
new file mode 100644
index 0000000..c42022b
--- /dev/null
+++ b/styles/controls.css
@@ -0,0 +1,33 @@
+#control {
+ padding: 4px;
+ background-color: var(--surface);
+}
+
+#control.invert {
+ border-radius: 12px;
+ background-color: var(--surface);
+}
+
+#button-volume,
+#button-mic,
+#button-brightness {
+ color: var(--surface-bright);
+ border: 3px solid var(--primary);
+}
+
+#button-volume.muted,
+#button-mic.muted {
+ color: var(--surface-bright);
+ border: 3px solid var(--outline);
+}
+
+#vol-label,
+#mic-label,
+#brightness-label {
+ font-size: 16px;
+}
+
+#vol-label.muted,
+#mic-label.muted {
+ color: var(--outline);
+}
diff --git a/styles/dashboard.css b/styles/dashboard.css
index 6d99fc2..745ccd9 100644
--- a/styles/dashboard.css
+++ b/styles/dashboard.css
@@ -1,163 +1,193 @@
-#buttons-grid {
- min-height: 52px;
+#dashboard-box {
+ transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+ background-color: var(--shadow);
+ border-radius: 25px;
+ padding: 6px;
+ margin-bottom: 65px;
}
-#network-button,
-#bluetooth-button,
-#night-mode-button,
-#power-profile-button,
-#dark-mode-button,
-#caffeine-button {
+#inner {
+ margin: 3px;
+}
+
+.tile {
+ margin: 0 6px;
+ min-height: 60px;
+ min-width: 140px;
border-radius: 20px;
}
-
-#network-button,
-#bluetooth-button {
- background-color: unset;
- padding: 0;
+.tile.mini {
+ min-height: 46px;
+ min-width: 46px;
+ border-radius: 100%;
}
-
-#night-mode-button,
-#dark-mode-button,
-#power-profile-button,
-#caffeine-button {
+.tile.on {
background-color: var(--primary);
- padding: 0 10px;
+}
+.tile.off {
+ background-color: var(--surface-bright);
+}
+.tile.on .desc-label,
+.tile.on .tile-label,
+.tile.on .tile-icon {
+ color: var(--shadow);
+ transition: all;
+}
+.tile.off .desc-label,
+.tile.off .tile-label,
+.tile.off .tile-icon {
+ color: var(--foreground);
+ transition: all;
}
-#night-mode-button.disabled,
-#dark-mode-button.disabled,
-#power-profile-button.disabled,
-#caffeine-button.disabled {
+.tile-icon {
+ font-size: 18px;
+ margin: 10px;
+}
+.tile-icon.mini {
+ font-size: 20px;
+ transition: font-size 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+}
+.tile-icon.maxi {
+ font-size: 16px;
+ transition: font-size 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+}
+
+.tile-menu {
+ margin: -999px;
+}
+.tile-menu.expand {
+ margin: 0px;
+ transition: margin 0.15s cubic-bezier(0.5, 0.25, 0, 1);
+}
+.tile-menu.contract {
+ margin: -200px;
+ transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+}
+
+#bluetooth-header,
+#network-header {
+ border: 2px solid var(--surface);
+ padding: 4px;
+ border-radius: 12px;
+}
+
+#bluetooth-device,
+#wifi-ap-slot {
+ border: 2px solid var(--surface);
+ border-radius: 12px;
+ padding: 4px;
+}
+
+#bluetooth-scan,
+#bluetooth-back,
+#network-refresh,
+#network-back {
background-color: var(--surface);
+ border-radius: 8px;
+ padding: 4px;
}
-#network-status-button,
-#network-menu-button,
-#bluetooth-status-button,
-#bluetooth-menu-button {
- background-color: var(--primary);
- padding: 0 8px;
+#bluetooth-back-label,
+#network-back-label {
+ font-size: 20px;
}
-#network-menu-button,
-#bluetooth-menu-button {
- border-left: 1px solid var(--outline);
- min-width: 24px;
-}
-
-#network-status-button:hover,
-#network-menu-button:hover,
-#bluetooth-status-button:hover,
-#bluetooth-menu-button:hover,
-#bluetooth-button:hover,
-#night-mode-button:hover,
-#power-profile-button:hover,
-#dark-mode-button:hover,
-#caffeine-button:hover {
- background-color: var(--foreground);
-}
-
-/* Estados Disabled */
-#network-status-button.disabled,
-#network-menu-button.disabled,
-#bluetooth-status-button.disabled,
-#bluetooth-menu-button.disabled {
- background-color: var(--surface);
-}
-
-#network-menu-button.disabled,
-#bluetooth-menu-button.disabled {
- border-left: 1px solid var(--surface-bright);
-}
-
-#network-status-button.disabled:hover,
-#network-menu-button.disabled:hover,
-#bluetooth-status-button.disabled:hover,
-#bluetooth-menu-button.disabled:hover,
-#night-mode-button.disabled:hover,
-#power-profile-button.disabled:hover,
-#dark-mode-button.disabled:hover,
-#caffeine-button.disabled:hover {
+#bluetooth-scan:hover,
+#bluetooth-back:hover,
+#network-refresh:hover,
+#network-back:hover {
background-color: var(--surface-bright);
}
-#network-status-button,
-#bluetooth-status-button {
- border-radius: 20px 0 0 20px;
-}
-
-#network-menu-button,
-#bluetooth-menu-button {
- border-radius: 0 20px 20px 0;
-}
-
-#network-icon,
-#bluetooth-icon,
-#night-mode-icon,
-#dark-mode-icon,
-#power-profile-icon,
-#caffeine-icon {
- color: var(--shadow);
- font-size: 24px;
-}
-
-#network-icon.disabled,
-#bluetooth-icon.disabled,
-#night-mode-icon.disabled,
-#power-profile-icon.disabled,
-#dark-mode-icon.disabled,
-#caffeine-icon.disabled {
- color: var(--outline);
-}
-
-#network-menu-label,
-#bluetooth-menu-label {
- color: var(--shadow);
- font-size: 16px;
-}
-
-#network-menu-label.disabled,
-#bluetooth-menu-label.disabled {
- color: var(--outline);
-}
-
-#network-label,
-#bluetooth-label,
-#night-mode-label,
-#power-profile-label,
-#caffeine-label,
-#dark-mode-label {
- color: var(--shadow);
- font-size: 14px;
+#bluetooth-scan label,
+#bluetooth-toggle label,
+#wifi-connect-button label {
font-weight: bold;
- margin-bottom: -4px;
}
-#network-label.disabled,
-#bluetooth-label.disabled,
-#night-mode-label.disabled,
-#power-profile-label.disabled,
-#dark-mode-label.disabled,
-#caffeine-label.disabled {
- color: var(--outline);
+#bluetooth-text,
+#bluetooth-section {
+ font-weight: bold;
}
-#network-ssid,
-#bluetooth-status,
-#night-mode-status,
-#power-profile-status,
-#dark-mode-status,
-#caffeine-status {
- color: var(--surface-bright);
- font-size: 12px;
+#bluetooth-section {
+ background-color: var(--surface);
+ border-radius: 12px;
+ padding: 8px;
}
-#network-ssid.disabled,
-#bluetooth-status.disabled,
-#night-mode-status.disabled,
-#power-profile-status.disabled,
-#dark-mode-status.disabled,
-#caffeine-status.disabled {
- color: var(--outline);
+#bluetooth-connection {
+ font-size: 20px;
+}
+
+#bluetooth-connect,
+#wifi-connect-button {
+ font-weight: bold;
+ background-color: var(--surface);
+ border-radius: 8px;
+ padding: 8px;
+}
+
+#wifi-connect-button.connected {
+ background-color: var(--green);
+}
+
+#wifi-connect-button.connected label {
+ color: var(--shadow);
+}
+
+#bluetooth-connect.connected {
+ background-color: var(--blue);
+}
+
+#bluetooth-connect.connected label {
+ color: var(--shadow);
+}
+
+#bluetooth-connect:hover {
+ background-color: var(--surface-bright);
+}
+
+#bluetooth-paired,
+#bluetooth-available {
+ background-color: var(--surface);
+ border-radius: 20px;
+ padding: 4px;
+}
+
+#bt-sep {
+ /*padding: 1px;*/
+ border-radius: 16px;
+ /*background-color: var(--surface);*/
+ /*margin: 0 16px;*/
+}
+
+#bluetooth-scan-label,
+#network-refresh-label {
+ font-size: 20px;
+ color: var(--primary);
+}
+
+#bluetooth-scan.scanning {
+ animation: blink 0.5s ease infinite;
+}
+
+#bluetooth-scan-label.scanning {
+ animation: blink 0.5s ease infinite;
+}
+
+@keyframes blink {
+ 0% {
+ background-color: var(--blue);
+ color: var(--shadow);
+ }
+ 50% {
+ background-color: var(--shadow);
+ color: var(--blue);
+ }
+ 100% {
+ background-color: var(--blue);
+ color: var(--shadow);
+ }
}
diff --git a/styles/dock.css b/styles/dock.css
index c0a9860..7c0abdc 100644
--- a/styles/dock.css
+++ b/styles/dock.css
@@ -1,52 +1,99 @@
#dock {
- background-color: var(--surface);
+ background-color: var(--shadow);
padding: 8px;
- margin: 8px 8px 7px 8px;
+ margin: 8px 8px 0 8px;
border-radius: 20px 20px 0 0;
- transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#dock.top {
+ margin: 0 8px 8px 8px;
+ border-radius: 0 0 20px 20px;
}
#dock.vertical {
- margin: 8px 79px 8px 8px;
+ margin: 8px 0 8px 8px;
border-radius: 20px 0 0 20px;
}
-#dock.vertical-left {
- margin: 8px 39px 8px 0px;
+#dock.vertical.left {
+ margin: 8px 8px 8px 0;
border-radius: 0 20px 20px 0;
}
-#dock.vertical-right{
- margin: 8px 79px 8px 8px;
+#dock.dense {
+ margin: 8px 8px 4px 8px;
+ border-radius: 20px;
+ border: 2px solid var(--surface);
+}
+
+#dock.dense.top {
+ margin: 4px 8px 8px 8px;
+ border-radius: 20px;
+ border: 2px solid var(--surface);
+}
+
+#dock.edge {
+ border-radius: 20px 20px 0 0;
+ border: 2px solid var(--surface);
+ border-bottom: none;
+}
+
+#dock.edge.top {
+ border-radius: 0 0 20px 20px;
+ border: 2px solid var(--surface);
+ border-top: none;
+}
+
+#dock.dense.vertical {
+ margin: 8px 4px 8px 8px;
+ border-radius: 20px;
+ border: 2px solid var(--surface);
+}
+
+#dock.edge.vertical {
border-radius: 20px 0 0 20px;
+ border: 2px solid var(--surface);
+ border-right: none;
+}
+
+#applications-dock {
+ background-color: var(--surface);
+ padding: 5px;
+ margin: 0;
+ border-radius: 16px;
+ border: none;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#applications-dock.invert {
+ border-radius: 12px;
}
#dock-full {
- transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 1;
}
-#dock-full.occluded {
- @apply dock_nmargin();
-}
-
#dock-separator {
- padding: 1px;
- @apply dock_sep();
+ padding: 2px;
border-radius: 16px;
background-color: var(--surface-bright);
}
#dock-app-button {
padding: 4px;
- border-radius: 12px;
+ border-radius: 24px;
+ box-shadow: 0 0 4px alpha(var(--shadow), 0.5);
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
-#dock-app-button:hover,
-#dock-app-button:hover.instance {
+#dock-app-button:hover {
background-color: var(--surface-bright);
- border-top: 1px solid var(--primary);
- padding-top: 3px;
+}
+
+#dock-app-button:hover.instance {
+ background-color: var(--outline);
}
#dock-app-button:active,
@@ -55,24 +102,22 @@
}
#dock-app-button.instance {
- background: linear-gradient(to top, transparent, var(--surface-bright));
- border-bottom: 1px solid var(--surface-container);
- border-top: 1px solid var(--outline);
- padding: 3px 4px;
+ background: var(--surface-bright);
+ border-radius: 12px;
}
#dock-corner-left {
- margin: 0 -8px 7px 0;
+ margin: 0 -8px 0 0;
}
#dock-corner-right {
- margin: 0 0 7px -8px;
+ margin: 0 0 0 -8px;
}
#dock-corner-top {
- margin: 0 79px -8px 0;
+ margin: 0 0 -8px 0;
}
#dock-corner-bottom {
- margin: -8px 79px 0 0;
+ margin: -8px 0 0 0;
}
diff --git a/styles/indicators.css b/styles/indicators.css
new file mode 100644
index 0000000..12a6ec6
--- /dev/null
+++ b/styles/indicators.css
@@ -0,0 +1,53 @@
+#indicator {
+ padding: 4px;
+ background-color: var(--surface);
+}
+
+#indicator.invert {
+ background-color: var(--surface);
+ border-radius: 12px;
+}
+
+#button-network,
+#button-bluetooth {
+ color: var(--surface-bright);
+ border: 3px solid var(--primary);
+}
+
+#button-bar-recording {
+ border-radius: 12px;
+ padding: 4px;
+ animation: recording-blink 1.5s ease-in-out infinite;
+}
+
+#button-bar-recording:hover {
+ background-color: var(--red);
+}
+
+@keyframes recording-blink {
+ 0% {
+ background-color: var(--red-dim);
+ }
+ 50% {
+ background-color: var(--red-dim);
+ }
+ 100% {
+ background-color: var(--red-dim);
+ }
+}
+
+#network-label,
+#bluetooth-label,
+#recording-label,
+#notification-icon {
+ font-size: 20px;
+}
+
+#button-bar-notifications {
+ border-radius: 12px;
+ padding: 4px;
+}
+
+#button-bar-notifications:hover {
+ background-color: var(--surface-bright);
+}
diff --git a/styles/kanban.css b/styles/kanban.css
new file mode 100644
index 0000000..6c889ba
--- /dev/null
+++ b/styles/kanban.css
@@ -0,0 +1,75 @@
+#kanban {
+ /* background-color: var(--shadow); */
+ border-radius: 20px;
+ border: 4px solid var(--surface);
+ padding: 4px;
+}
+
+#kanban-header {
+ border: 2px solid var(--surface);
+ border-radius: 12px;
+ padding: 4px;
+}
+
+#column-header {
+ padding: 4px;
+ font-weight: bold;
+ color: var(--primary);
+}
+
+#kanban-note {
+ background-color: var(--surface);
+ border-radius: 4px;
+ padding: 8px;
+ margin-bottom: 4px;
+}
+
+#kanban-row:first-child > * > * {
+ border-radius: 16px 16px 4px 4px;
+}
+
+#kanban-row:last-child > * > * {
+ border-radius: 4px 4px 16px 16px;
+}
+
+#inline-editor {
+ border: 2px solid var(--surface-bright);
+ border-radius: 12px;
+ padding: 8px;
+}
+
+#kanban-btn,
+#kanban-btn-add {
+ background-color: var(--surface);
+ border-radius: 8px;
+ padding: 4px;
+}
+
+#kanban-btn {
+ background-color: var(--shadow);
+}
+
+#kanban-btn:hover,
+#kanban-btn-add:hover {
+ background-color: var(--surface-bright);
+}
+
+#kanban-btn:active,
+#kanban-btn-add:active {
+ background-color: var(--primary);
+}
+
+#kanban-btn-label,
+#kanban-btn-neg {
+ font-size: 20px;
+ color: var(--primary);
+}
+
+#kanban-btn-neg {
+ color: var(--red-dim);
+}
+
+#kanban-btn:active #kanban-btn-label,
+#kanban-btn-add:active #kanban-btn-label {
+ color: var(--shadow);
+}
diff --git a/styles/launcher.css b/styles/launcher.css
index 8a58328..f115f2b 100644
--- a/styles/launcher.css
+++ b/styles/launcher.css
@@ -1,145 +1,117 @@
-/*
- * ── launcher ──────────────────────────────────────────────────────────
-*/
-#dashboard {
- min-height: 100px;
-}
-
#launcher {
- margin: 18px;
- border: 1px solid var(--outline-variant);
+ background: var(--shadow);
+ padding: 12px;
+ border-radius: 34px;
+ min-width: 550px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#close-button,
+#config-button {
background-color: var(--surface);
- border-radius: 24px;
- padding: 18px;
- transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1);
+ border-radius: 40px;
+ padding: 8px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
-#launcher entry {
- transition: all 0.1s ease;
- border-radius: 11px;
+#close-button:hover,
+#close-button:focus,
+#config-button:hover,
+#config-button:focus {
+ background-color: var(--surface-bright);
+ border-radius: 16px;
+}
+
+#close-button.focused,
+#config-button.focused {
+ background-color: var(--primary);
+ border-radius: 16px;
+ box-shadow: 0 0 0 2px var(--primary-bright);
+}
+
+#close-button.focused #close-label {
+ color: var(--background);
+}
+
+#config-button.focused #config-label {
+ color: var(--background);
+}
+
+#close-button:active {
+ background-color: var(--red-dim);
+ border-radius: 40px;
+}
+
+#close-label {
+ color: var(--red-dim);
+ font-size: 24px;
+}
+
+#close-button:active #close-label {
+ color: var(--shadow);
+}
+
+#config-button:active {
+ background-color: var(--primary);
+ border-radius: 40px;
+}
+
+#config-label {
+ color: var(--primary);
+ font-size: 24px;
+}
+
+#config-button:active #config-label {
+ color: var(--shadow);
+}
+
+#launcher-icon-label {
+ font-size: 32px;
+ padding: 8px;
+}
+
+#launcher-search {
+ font-weight: bold;
+ background-color: var(--surface);
+ color: var(--foreground);
+ border-radius: 16px;
padding: 10px;
- margin: 12px;
- box-shadow: inset 0 0 0 1px var(--outline-variant);
}
-#launcher-content.wallpapers {
- padding: 16px;
- min-height: 420px;
- min-width: 620px;
+#launcher-search selection {
+ color: var(--background);
+ background-color: var(--primary);
}
-.applauncher-scrolled-window {
- transition: min-height 0.2s ease;
+#launcher-results-scroll {
+ margin-top: 10px;
+ border-radius: 16px;
}
-#launcher-content.bluetooth {
- min-height: 464px;
-}
-
-#launcher-content.wifi {
- min-height: 464px;
-}
-
-#launcher-content.tools {
- min-width: 308px;
-}
-
-#launcher-content.calendar {
- min-height: 420px;
- min-width: 420px;
-}
-
-#launcher-content {
- min-width: 485px;
-}
-
-#launcher-content.power {
- min-width: 200px;
- min-height: 52px;
-}
-
-#launcher-content.kanban {
- min-width: 1000px;
- min-height: 478px;
-}
-
-#launcher-content.tmux {
- min-height: 220px;
- min-width: 480px;
-}
-
-#scrolled-window scrollbar {
+#launcher-results-scroll scrollbar {
border-radius: 10px;
background-color: var(--surface);
padding: 4px;
margin-left: 6px;
}
-#scrolled-window slider {
+#launcher-results-scroll slider {
border-radius: 8px;
min-width: 10px;
- min-height: 48px;
+ min-height: 38px;
background-color: var(--primary);
}
-#config-button:active,
-#new-session-button:active {
- background-color: var(--primary);
+#launcher-results {
+ background: transparent;
}
-#config-label,
-#clear-label,
-#new-session-label {
- color: var(--primary);
- font-size: 24px;
-}
-
-#config-button:active #config-label,
-#clear-button:active
- #clear-label
- #new-session-button:active
- #new-session-label {
- color: var(--shadow);
-}
-
-#config-button,
-#clear-button,
-#new-session-button {
- background-color: var(--surface-variant);
- border-radius: 16px;
- padding: 8px;
- margin-bottom: 12px;
- margin-top: 12px;
-}
-
-#config-button:hover,
-#config-button:focus,
-#clear-button:hover,
-#clear-button:focus,
-#new-session-button:hover,
-#new-session-button:focus {
- background-color: var(--surface-bright);
-}
-
-#clear-button:active {
- background-color: var(--red-dim);
-}
-
-/*
- * ── applauncher ───────────────────────────────────────────────────────
-*/
-#app-desc {
- color: var(--outline);
- font-size: 12px;
- font-style: italic;
-}
-
-#slot-button {
- border-radius: 16px;
- padding: 16px;
- min-height: 0;
- transition: padding 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+#launcher-result-item {
+ border-radius: 40px;
+ padding: 0 16px;
animation: loadSlot 0.5s ease;
+ min-height: 52px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes loadSlot {
@@ -151,735 +123,54 @@
opacity: 1;
}
}
-
-#slot-button:hover,
-#slot-button:focus,
-#slot-button:selected,
-#slot-button.selected {
- background-color: var(--surface-bright);
- padding-left: 24px;
-}
-
-#slot-button:active {
- background-color: var(--primary);
-}
-
-#app-icon {
- margin-right: 8px;
-}
-
-#app-label {
- color: var(--foreground);
- font-weight: bold;
-}
-
-#slot-button:hover #app-label,
-#slot-button:focus #app-label,
-#slot-button:selected #app-label,
-#slot-button.selected #app-label {
- color: var(--primary);
-}
-
-#slot-button:active #app-label {
- color: var(--shadow);
-}
-
-/*
- * ── wallpaper ─────────────────────────────────────────────────────────
-*/
-#search-entry-walls {
- min-width: 512px;
-}
-
-#wallpaper-icons:selected {
- background-color: var(--outline);
- border-radius: 0px;
-}
-
-#scheme-dropdown {
- background-color: var(--surface);
- padding: 8px;
-}
-
-#scheme-dropdown > * > * {
- font-weight: bold;
- color: var(--shadow);
-}
-
-#scheme-dropdown.box button.box.cellview {
- background-color: var(--primary);
-}
-
-#scheme-dropdown.box button.box.cellview:selected {
- background-color: var(--surface-bright);
-}
-
-#search-entry-walls {
- min-width: 512px;
-}
-
-/* Customize the overall switch appearance */
-#matugen-switcher,
-#dnd-switch {
- min-width: 40px;
- min-height: 20px;
- background-color: var(--surface);
- border-radius: 15px;
- padding: 2px;
- transition: background-color 0.3s ease;
-}
-
-/* Style the switch's slider */
-#matugen-switcher slider,
-#dnd-switch slider {
- background-color: var(--primary);
+#launcher-result-item:focus,
+#launcher-result-item:selected,
+#launcher-result-item:hover,
+#launcher-result-item.selected {
border-radius: 16px;
- min-width: 20px;
- min-height: 20px;
- transition:
- background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25),
- transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25);
-}
-
-/* When the switch is active (checked) */
-#matugen-switcher:checked,
-#dnd-switch:checked {
- background-color: var(--primary);
-}
-
-/* Optional: additional styling for the slider when active */
-#matugen-switcher:checked slider,
-#dnd-switch:checked slider {
- background-color: var(--shadow);
-}
-
-#matugen-switcher:checked image,
-#dnd-switch:checked image {
- color: var(--shadow);
-}
-
-#mat-label {
- font-size: 24px;
- margin-left: 4px;
-}
-
-#custom-color-selector-box {
background-color: var(--surface);
- border-radius: 20px;
+ padding-left: 20px;
+}
+
+#result-item-main {
+ min-height: 48px;
+}
+
+#launcher-result-item {
+ min-height: 48px;
+}
+
+#result-item-icon {
+ min-width: 48px;
+ min-height: 48px;
+ margin-right: 12px;
+}
+
+#result-item-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--foreground);
+ margin-top: 8px;
+ margin-bottom: 2px;
+}
+
+#result-item-subtitle {
+ font-size: 12px;
+ color: var(--outline);
+ margin-bottom: 8px;
+}
+
+#result-item-plugin {
+ font-size: 10px;
+ color: var(--outline);
+ font-style: italic;
+ margin-bottom: 8px;
+ opacity: 0.7;
+}
+
+#network-password-entry {
+ border: 1px solid var(--outline);
padding: 8px;
-}
-
-#hue-slider {
- margin: 0 24px;
-}
-
-#hue-slider trough {
- background-image: linear-gradient(
- to right,
- #ff0000,
- #ffff00,
- #00ff00,
- #00ffff,
- #0000ff,
- #ff00ff,
- #ff0000
- );
border-radius: 8px;
- min-height: 8px;
-}
-
-#hue-slider slider {
- min-height: 16px;
- min-width: 8px;
- margin: -10px 0;
- background-color: var(--primary);
- box-shadow: 0 0 4px alpha(var(--shadow), 0.5);
-}
-
-#apply-color-button {
- background-color: var(--primary);
- color: var(--shadow);
- border-radius: 20px;
- padding: 8px;
- box-shadow: 0 0 4px alpha(var(--shadow), 0.5);
-}
-
-#apply-color-button:hover {
- background-color: var(--foreground);
-}
-
-#apply-color-button:active {
- background-color: var(--shadow);
-}
-
-#apply-color-label {
- font-size: 16px;
- color: var(--shadow);
-}
-
-#apply-color-button:active #apply-color-label {
- color: var(--foreground);
-}
-/*
- * ── emoji ─────────────────────────────────────────────────────────────
-*/
-
-#emoji #search-entry {
- font-weight: bold;
- background-color: var(--surface);
- color: var(--foreground);
- border-radius: 16px;
- padding: 10px;
-}
-
-#emoji #search-entry selection {
- color: var(--background);
- background-color: var(--primary);
-}
-
-#emoji #close-button {
- background-color: var(--surface);
- border-radius: 16px;
- padding: 8px;
-}
-
-#emoji #close-button:hover,
-#emoji #close-button:focus {
- background-color: var(--surface-bright);
-}
-
-#emoji #close-button:active {
- background-color: var(--red-dim);
-}
-
-#emoji #close-label {
- color: var(--red-dim);
- font-size: 24px;
-}
-
-#emoji #close-button:active #close-label {
- color: var(--shadow);
-}
-
-#emoji #emoji-slot-button {
- border-radius: 16px;
- padding: 16px;
- transition: padding 0.1s ease;
-}
-
-@keyframes loadEmojiSlot {
- 0% {
- opacity: 0;
- }
-
- 100% {
- opacity: 1;
- }
-}
-
-#emoji #emoji-slot-button:hover,
-#emoji #emoji-slot-button:focus,
-#emoji #emoji-slot-button:selected,
-#emoji #emoji-slot-button.selected {
- background-color: var(--surface-bright);
- text-shadow: 0 0 4px var(--shadow);
- animation: highlightEmojiSlot 0.25s cubic-bezier(0, 0, 0.5, 1);
-}
-
-@keyframes highlightEmojiSlot {
- 0% {
- padding: 16px;
- text-shadow: 0 4px 4px var(--shadow);
- }
-
- 50% {
- padding: 8px 16px 24px 16px;
- text-shadow: 0 12px 4px var(--shadow);
- }
-
- 100% {
- padding: 16px;
- text-shadow: 0 4px 4px var(--shadow);
- }
-}
-
-#emoji #emoji-slot-button:active {
- background-color: var(--primary);
-}
-
-#emoji #emoji-name-label {
- color: var(--foreground);
- font-weight: bold;
-}
-
-#emoji #emoji-slot-button:hover #emoji-name-label,
-#emoji #emoji-slot-button:focus #emoji-name-label,
-#emoji #emoji-slot-button:selected #emoji-name-label,
-#emoji #emoji-slot-button.selected #emoji-name-label {
- color: var(--primary);
-}
-
-#emoji #emoji-slot-button:active #emoji-name-label {
- color: var(--shadow);
-}
-
-#emoji #emoji-char-label {
- font-size: 24px;
-}
-
-/*
- * ── powermenu ─────────────────────────────────────────────────────────
-*/
-
-#power-menu-button {
- border-radius: 20px;
- min-width: 52px;
- min-height: 52px;
- padding: 5px;
-}
-
-#power-menu-button label {
- font-size: 35px;
- color: var(--foreground);
-}
-
-#power-menu-button:hover,
-#power-menu-button:focus {
- background-color: var(--surface-bright);
-}
-
-#power-menu-button:hover label,
-#power-menu-button:focus label {
- color: var(--primary);
-}
-
-#power-menu-button:active {
- background-color: var(--primary);
-}
-
-#power-menu-button:active label {
- color: var(--shadow);
-}
-
-/*
- * ── sh runner ─────────────────────────────────────────────────────────
-*/
-
-#sh-item {
- border-radius: 11px;
- background-color: transparent;
- background-image: none;
- box-shadow: none;
- padding: 0px;
-}
-
-#sh-item {
- font-size: 24px;
-}
-
-#sh-item:hover,
-#sh-item:focus {
- text-shadow: 2px 2px 2px var(--primary);
-}
-
-/*
- * ── bluetooth ─────────────────────────────────────────────────────────
-*/
-
-#bluetooth-header {
- border: 2px solid var(--shadow);
- padding: 4px;
- background-color: var(--surface-container);
- border-radius: 12px;
-}
-
-#bluetooth-device {
- border: 2px solid var(--shadow);
- background-color: var(--surface-container);
- border-radius: 12px;
- padding: 4px;
-}
-
-#bluetooth-scan,
-#bluetooth-back {
- background-color: var(--surface);
- border-radius: 8px;
- padding: 4px;
-}
-
-#bluetooth-back-label {
- font-size: 20px;
-}
-
-#bluetooth-scan:hover,
-#bluetooth-back:hover {
- background-color: var(--surface-bright);
-}
-
-#bluetooth-scan label,
-#bluetooth-toggle label {
- font-weight: bold;
-}
-
-#bluetooth-text,
-#bluetooth-section {
- font-weight: bold;
-}
-
-#bluetooth-section {
- background-color: var(--surface-container);
- border-radius: 12px;
- padding: 8px;
-}
-
-#bluetooth-connection {
- font-size: 20px;
-}
-
-#bluetooth-connect {
- background-color: var(--surface);
- border-radius: 8px;
- padding: 8px;
-}
-
-#bluetooth-connect:hover {
- background-color: var(--surface-bright);
-}
-
-#bluetooth-paired,
-#bluetooth-available {
- background-color: var(--surface-container);
- border-radius: 20px;
- padding: 4px;
-}
-
-#bt-sep {
- border-radius: 16px;
-}
-
-#bluetooth-scan-label {
- font-size: 20px;
- color: var(--primary);
-}
-
-#bluetooth-scan.scanning {
- animation: blink 0.5s ease infinite;
-}
-
-#bluetooth-scan-label.scanning {
- animation: blink 0.5s ease infinite;
-}
-
-@keyframes blink {
- 0% {
- background-color: var(--blue);
- color: var(--shadow);
- }
-
- 50% {
- background-color: var(--shadow);
- color: var(--blue);
- }
-
- 100% {
- background-color: var(--blue);
- color: var(--shadow);
- }
-}
-
-/*
- * ── wifi ──────────────────────────────────────────────────────────────
-*/
-
-#wifi-header {
- background-color: var(--surface-container);
- padding: 4px;
- border-radius: 20px;
-}
-
-#wifi-device {
- background-color: var(--secondary-container);
- border-radius: 16px;
- padding: 4px;
margin-bottom: 4px;
}
-
-#wifi-toggle-icon {
- font-size: 24px;
-}
-
-#wifi-scan,
-#wifi-toggle {
- background-color: var(--surface-container);
- margin-left: 0px;
- min-width: 44px;
- min-height: 44px;
- border-radius: 100%;
-}
-
-#wifi-scan:hover,
-#wifi-toggle:hover {
- background-color: var(--surface-bright);
-}
-
-#wifi-scan label,
-#wifi-toggle label {
- font-weight: bold;
-}
-
-#wifi-text,
-#wifi-section {
- font-weight: bold;
-}
-
-#wifi-text {
- color: var(--primary);
-}
-
-#wifi-connection {
- font-size: 20px;
-}
-
-#wifi-connect {
- background-color: var(--surface);
- border-radius: 16px;
- padding: 8px 16px;
-}
-
-#wifi-connect:hover {
- background-color: var(--surface-bright);
-}
-
-#wifi-available {
- /*background-color: var(--surface-container);*/
- padding: 4px;
- border-radius: 20px;
-}
-
-/*
- * ── calendar ──────────────────────────────────────────────────────────
-*/
-
-#header {
- border-radius: 12px;
- padding: 4px;
-}
-
-#weekday-row {
- background-color: var(--surface);
- border-radius: 8px;
- padding: 4px;
-}
-
-#month-label,
-#weekday-label,
-#day-label {
- font-weight: bold;
-}
-
-#day-label,
-#day-empty {
- min-width: 38px;
- min-height: 22px;
-}
-
-#day-empty {
- color: var(--surface-bright);
-}
-
-#weekday-label {
- color: var(--primary);
-}
-
-#calendar-grid {
- border-radius: 16px;
- padding: 4px;
-}
-
-#day-label.current-day {
- background-color: var(--primary);
- color: var(--shadow);
- border-radius: 20px;
-}
-
-#prev-month-button,
-#next-month-button {
- background-color: var(--surface);
- border-radius: 8px;
- padding: 4px;
-}
-
-#prev-month-button:hover,
-#next-month-button:hover {
- background-color: var(--surface-bright);
-}
-
-#prev-month-button:active,
-#next-month-button:active {
- background-color: var(--primary);
-}
-
-#month-button-label {
- color: var(--primary);
- font-size: 20px;
-}
-
-#prev-month-button:active #month-button-label,
-#next-month-button:active #month-button-label {
- color: var(--shadow);
-}
-
-#prev-month-day,
-#next-month-day {
- color: mix(var(--on-surface), transparent, 0.6);
-}
-
-/*
- * ── tools ─────────────────────────────────────────────────────────────
-*/
-
-#toolbox-button {
- border-radius: 20px;
- min-width: 52px;
- min-height: 52px;
- padding: 5px;
-}
-
-#toolbox-button label {
- font-size: 35px;
- color: var(--foreground);
-}
-
-#toolbox-button.recording label {
- color: var(--red-dim);
-}
-
-#toolbox-button:hover,
-#toolbox-button:focus {
- background-color: var(--surface-bright);
-}
-
-#toolbox-button:hover label,
-#toolbox-button:focus label {
- color: var(--primary);
-}
-
-#toolbox-button.recording:hover label,
-#toolbox-button.recording:focus label {
- color: var(--red-dim);
-}
-
-#toolbox-button:active {
- background-color: var(--primary);
-}
-
-#toolbox-button:active label {
- color: var(--shadow);
-}
-
-#toolbox-button.recording:active {
- background-color: var(--red-dim);
-}
-
-#toolbox-button.recording:active label {
- color: var(--shadow);
-}
-
-#tool-sep {
- min-width: 4px;
- min-height: 4px;
- margin: 0px 4px;
- border-radius: 16px;
- background-color: var(--surface-bright);
-}
-
-/*
- * ── kanban ────────────────────────────────────────────────────────────
-*/
-
-#kanban-header {
- border: 2px solid var(--outline);
- border-radius: 12px;
- padding: 4px;
-}
-
-#column-header {
- padding: 4px;
- font-weight: bold;
- color: var(--primary);
-}
-
-#kanban-note {
- background-color: var(--surface-container);
- border-radius: 4px;
- padding: 8px;
- margin-bottom: 4px;
-}
-
-#kanban-row:first-child > * > * {
- border-radius: 16px 16px 4px 4px;
-}
-
-#kanban-row:last-child > * > * {
- border-radius: 4px 4px 16px 16px;
-}
-
-#inline-editor {
- border: 2px solid var(--surface-bright);
- border-radius: 12px;
- padding: 8px;
-}
-
-#kanban-btn,
-#kanban-btn-add {
- background-color: var(--surface-container);
- border-radius: 8px;
- padding: 4px;
-}
-
-#kanban-btn {
- background-color: var(--surface);
-}
-
-#kanban-btn:hover,
-#kanban-btn-add:hover {
- background-color: var(--surface-bright);
-}
-
-#kanban-btn:active,
-#kanban-btn-add:active {
- background-color: var(--primary);
-}
-
-#kanban-btn-label,
-#kanban-btn-neg {
- font-size: 20px;
- color: var(--primary);
-}
-
-#kanban-btn-neg {
- color: var(--red-dim);
-}
-
-#kanban-btn:active #kanban-btn-label,
-#kanban-btn-add:active #kanban-btn-label {
- color: var(--surface);
-}
-
-/* tmux */
-#no-notif,
-#no-tmux,
-#no-clip {
- font-size: 96px;
- color: var(--surface-container);
-}
-
-#clip-label {
- font-weight: bold;
-}
-
-#tmux-icon,
-#clip-icon {
- font-size: 20px;
- color: var(--primary);
-}
diff --git a/styles/main.css b/styles/main.css
deleted file mode 100644
index 79e35a8..0000000
--- a/styles/main.css
+++ /dev/null
@@ -1,51 +0,0 @@
-@import "./colors.css";
-@import "./bar.css";
-@import "./notification.css";
-@import "./osd.css";
-@import "./launcher.css";
-@import "./dashboard.css";
-@import "./dock.css";
-@import "./notif_center.css";
-
-* {
- all: unset;
- color: var(--foreground);
- font-size: 15px;
- font-family: "Monaspace Radon";
-}
-
-/*
- * ── tooltip ───────────────────────────────────────────────────────────
-*/
-tooltip {
- border: solid 1px;
- border-radius: 16px;
- border-color: var(--outline);
- background-color: var(--surface);
- animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1);
-}
-
-@keyframes tooltipShow {
- from {
- opacity: 0;
- }
-
- to {
- opacity: 1;
- }
-}
-
-tooltip > * {
- padding: 6px 10px;
- border-radius: 10px;
-}
-
-#corner {
- background-color: var(--surface);
- border-radius: 0;
-}
-
-#corner-container {
- min-width: 20px;
- min-height: 20px;
-}
diff --git a/styles/notif_center.css b/styles/notif_center.css
deleted file mode 100644
index 85f0f1d..0000000
--- a/styles/notif_center.css
+++ /dev/null
@@ -1,28 +0,0 @@
-#nhh {
- font-weight: bold;
-}
-
-#nhh-button {
- border-radius: 8px;
- background-color: var(--surface);
- padding: 4px;
-}
-
-#nhh-button:hover {
- background-color: var(--surface-bright);
-}
-
-#nhh-button-label {
- color: var(--red-dim);
- font-size: 20px;
-}
-
-#dnd-label {
- font-size: 20px;
- margin-left: 8px;
-}
-
-#no-notifications-label {
- font-size: 96px;
- color: var(--surface-container);
-}
diff --git a/styles/notification.css b/styles/notification.css
index dddf9bf..465421b 100644
--- a/styles/notification.css
+++ b/styles/notification.css
@@ -1,29 +1,23 @@
-#notification-action-buttons button.start-action {
- border-radius: 20px;
-}
-
-#notification-action-buttons button {
+#action-button {
+ border-radius: 16px;
background-color: var(--surface);
padding: 8px;
- border-radius: 20px;
- transition: background-color 0.1s ease;
}
-#notification-action-buttons button:hover {
+#action-button:hover {
background-color: var(--surface-bright);
}
-#notification-action-buttons button:active {
+#action-button:active {
background-color: var(--primary);
}
-#notification-action-buttons button:active #button-label {
+#action-button:active #button-label {
color: var(--shadow);
}
#notification-image image {
border-radius: 16px;
- color: var(--on-surface);
}
#notification-summary,
@@ -44,24 +38,42 @@
margin-top: 8px;
}
-#notification-box {
- border-radius: 20px;
- padding: 16px;
- background-color: var(--surface-container-low);
- margin: 4px;
- min-width: 336px;
- transition: background-color 0.1s ease;
+#no-notif {
+ font-size: 96px;
+ color: var(--surface);
}
-#boxed-notification-revealer {
- margin-top: -14px;
+#notification-history {
+ border-radius: 20px;
+ padding: 16px;
+ border: 2px solid var(--surface);
+ background-color: var(--shadow);
+ margin: 4px;
+ min-width: 430px;
+}
+
+#notification-history.vertical {
+ margin: 10px;
+}
+
+#notification-stack-box {
+ border-radius: 32px;
+ padding: 16px;
+ border: 2px solid var(--surface);
+ background-color: var(--shadow);
+ margin: 8px;
+ margin-bottom: 4px;
+ min-width: 330px;
+}
+
+#notification-navigation {
+ padding: 16px;
}
#notif-close-button {
background-color: var(--surface);
border-radius: 16px;
- padding: 4px;
- transition: background-color 0.1s ease;
+ padding: 8px;
}
#notif-close-button:hover,
@@ -75,9 +87,150 @@
#notif-close-label {
color: var(--red-dim);
- font-size: 20px;
+ font-size: 16px;
}
#notif-close-button:active #notif-close-label {
color: var(--shadow);
}
+
+#nav-button {
+ padding: 8px;
+ border-radius: 16px;
+ background: var(--shadow);
+ border: 2px solid var(--surface);
+ margin-top: -12px;
+}
+
+#nav-button:hover {
+ background: var(--surface-bright);
+}
+
+#nav-button:disabled #nav-button-label {
+ color: var(--surface-bright);
+}
+
+#nav-button-label {
+ font-size: 16px;
+}
+
+#nav-button-label.close {
+ color: var(--red-dim);
+}
+
+#nav-button:hover #nav-button-label {
+ color: var(--primary);
+}
+
+#nav-button:hover #nav-button-label.close {
+ color: var(--red-dim);
+}
+
+#notification-history scrollbar {
+ background: transparent;
+}
+
+#notification-history scrollbar.vertical slider {
+ background: var(--primary);
+ border-radius: 8px;
+ min-width: 16px;
+ min-height: 48px;
+ margin: 4px;
+}
+
+#notification-history scrollbar.vertical trough {
+ background: var(--surface);
+ border: none;
+ border-radius: 12px;
+ margin: 4px;
+}
+
+#notification-box-hist {
+ padding: 8px;
+ border-radius: 12px;
+ border: 2px solid var(--surface);
+}
+
+#notification-timestamp {
+ color: var(--surface-bright);
+}
+
+#notification-history-header {
+ border-radius: 12px;
+ border: 2px solid var(--surface);
+ padding: 4px;
+ margin-bottom: 4px;
+}
+
+#nhh {
+ font-weight: bold;
+}
+
+#nhh-button {
+ border-radius: 8px;
+ background-color: var(--surface);
+ padding: 4px;
+}
+
+#nhh-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#nhh-button-label {
+ color: var(--red-dim);
+ font-size: 20px;
+}
+
+#dnd-label {
+ font-size: 20px;
+ margin-left: 4px;
+}
+
+#dnd-switch {
+ min-width: 40px;
+ min-height: 20px;
+ background-color: var(--surface);
+ border-radius: 15px;
+ padding: 2px;
+ transition: background-color 0.3s ease;
+}
+
+#dnd-switch slider {
+ background-color: var(--primary);
+ border-radius: 16px;
+ min-width: 16px;
+ min-height: 8px;
+ transition:
+ background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25),
+ transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25);
+}
+
+#dnd-switch:checked {
+ background-color: var(--primary);
+}
+
+#dnd-switch:checked slider {
+ background-color: var(--shadow);
+}
+
+#dnd-switch:checked image {
+ color: var(--shadow);
+}
+
+#notif-sep {
+ padding: 2px;
+ border-radius: 16px;
+ background-color: var(--surface-bright);
+ margin: 0 8px;
+}
+
+#notif-date-sep {
+ padding: 4px;
+ border-radius: 12px;
+ background-color: var(--surface);
+}
+
+#notif-date-sep-label {
+ color: var(--outline);
+ font-weight: bold;
+}
diff --git a/styles/osd.css b/styles/osd.css
index 017865d..90f015a 100644
--- a/styles/osd.css
+++ b/styles/osd.css
@@ -1,8 +1,7 @@
#osd {
- background-color: var(--surface);
- padding: 10px;
- border-radius: 6px;
- margin-top: 6px;
+ background: var(--shadow);
+ padding: 12px 20px;
+ margin: 70px;
}
#osd scale {
@@ -10,38 +9,41 @@
}
#osd trough {
- background: var(--surface-container);
+ background: var(--surface);
min-height: 10px;
margin-right: 4px;
- border-radius: 99px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
-#osd highlight {
- border-radius: 99px 0px 0px 99px;
+#osd trough highlight {
+ border-radius: 100px 0px 0px 100px;
background: var(--primary);
- margin-right: 6px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#osd.muted trough highlight,
+#osd.muted slider,
+#osd scale.muted trough highlight,
+#osd scale.muted slider {
+ background-color: var(--surface-bright);
}
#osd slider {
- color: var(--on-primary);
background-color: var(--primary);
- background-size: 20px;
- min-width: 5px;
- min-height: 10px;
- border-radius: 12px;
+ background-size: 20px 20px;
+ min-width: 4px;
+ min-height: 8px;
+ border-radius: 2px;
padding: 0px;
- background-repeat: no-repeat;
- background-position: center;
- margin-left: 0px;
- margin-right: 0px;
- margin-top: -10px;
- margin-bottom: -10px;
+ margin: -14px 0;
box-shadow:
- -1px 0px 0px 1px var(--surface-container),
- 1px 0px 0px 1px var(--surface-container);
+ -4px 0 0px 2px var(--shadow),
+ 4px 0 0px 2px var(--shadow);
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
-#osd-label {
- font-weight: bold;
- font-size: 17px;
+#brighntess-icons.muted,
+#vol-icon.muted,
+#mic-icon.muted {
+ color: var(--outline);
}
diff --git a/styles/player.css b/styles/player.css
new file mode 100644
index 0000000..3e22dd1
--- /dev/null
+++ b/styles/player.css
@@ -0,0 +1,203 @@
+.player-mini {
+ min-height: 60px;
+ min-width: 200px;
+ padding-right: 20px;
+
+ box-shadow:
+ inset 0 0 100px rgba(148, 148, 148, 0.3),
+ inset 70px 0 20px rgba(255, 255, 255, 0.3),
+ inset 0px 0px 20px 5px rgba(255, 255, 255, 0.3);
+ border-top-right-radius: 15px;
+ border-bottom-right-radius: 15px;
+ border-top-left-radius: 20px;
+ border-bottom-left-radius: 20px;
+ background-position: center;
+ background-size: cover;
+}
+
+#player-container.mini {
+ min-height: 60px;
+ border-radius: 15px;
+ background-position: center;
+ background-size: cover;
+}
+
+.album-image {
+ min-width: 65px;
+ background-position: center;
+ background-size: cover;
+ border-radius: 15px;
+}
+
+#artist {
+ color: var(--primary);
+}
+
+#song {
+ color: whitesmoke;
+ font-size: 16px;
+ margin-top: 5px;
+}
+#song.mini {
+ color: var(--surface);
+ font-size: 15px;
+ margin-top: 5px;
+}
+
+#source {
+ background-color: antiquewhite;
+ margin-top: -10px;
+}
+#source.mini {
+ margin-left: -20px;
+}
+
+#controls {
+ margin-bottom: -10px;
+}
+
+#play-next.mini,
+#play-previous.mini {
+ text-shadow: none;
+ font-size: 30px;
+ color: var(--surface);
+}
+#play-pause-label {
+ color: var(--surface);
+ text-shadow: none;
+}
+
+#pause-button {
+ background-color: whitesmoke;
+ border-radius: 16px;
+ padding: 10px;
+}
+
+.vertical #pause-button {
+ margin-bottom: 5px;
+}
+
+#pause-button.pause-track {
+ border-radius: 100%;
+}
+
+#pause-label,
+#play-label {
+ color: var(--shadow);
+ /* background-color: var(--error-dim); */
+ font-size: 20px;
+ padding: 2px;
+ border-radius: 999px;
+ min-width: 25px;
+}
+
+#pause-label.mini,
+#play-label.mini {
+ color: white;
+ /* background-color: var(--error-dim); */
+ font-size: 20px;
+ padding: 2px;
+ border-radius: 999px;
+ min-width: 25px;
+}
+
+#shuffle-button,
+#next-button,
+#prev-button,
+#play-pause-button {
+ border: none;
+ padding: 0px;
+ padding-left: 11px;
+ padding-right: 11px;
+}
+
+#shuffle-button:hover,
+#next-button:hover,
+#prev-button:hover,
+#play-pause-button:hover {
+ border-radius: 100%;
+ /* background-color: white; */
+ /* opacity: .3; */
+ box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px;
+}
+
+.player-button {
+ border-radius: 5px;
+ /* background-color: var(--secondary); */
+ /* background-color: white; */
+ opacity: 0.5;
+ min-width: 10px;
+ min-height: 10px;
+ padding: 0px;
+ margin: 0px;
+ border: none;
+ margin-left: 5px;
+}
+
+.player-button.mini {
+ border-radius: 5px;
+ background-color: var(--shadow);
+ /* background-color: white; */
+ opacity: 0.5;
+ min-width: 10px;
+ min-height: 10px;
+ padding: 0px;
+ margin: 0px;
+ border: none;
+ margin-top: 5px;
+}
+
+.player-button.active {
+ opacity: 1;
+ /* min-width: 40px; */
+ /* border-radius: 5px; */
+}
+
+#player-switch-container.vertical-player {
+ margin-top: -23px;
+ padding-bottom: 13px;
+}
+
+#player-switch-container {
+ margin-top: -17px;
+ padding-bottom: 7px;
+}
+
+#player-switch-container.mini {
+ margin-top: 0px;
+ margin-left: -18px;
+ padding-right: 10px;
+}
+
+#controls.vertical {
+ padding-bottom: 10px;
+ margin-bottom: 0px;
+}
+
+.player-icon.mini {
+ font-size: 20px;
+ color: black;
+ text-shadow: 0 0 5px white;
+}
+
+#shuffle {
+ font-size: 17px;
+}
+
+#disable-shuffle {
+ font-size: 27px;
+}
+
+#progress-slider {
+ background-color: white;
+ margin-left: -3px;
+ margin-right: -2px;
+ margin-top: 8px;
+ margin-bottom: 8px;
+ border-radius: 2px;
+}
+
+#progress-other-end {
+ min-width: 50px;
+ background-color: white;
+}
diff --git a/styles/switcher.css b/styles/switcher.css
new file mode 100644
index 0000000..8a33033
--- /dev/null
+++ b/styles/switcher.css
@@ -0,0 +1,36 @@
+#application-switcher-container {
+ background: var(--shadow);
+}
+
+#application-switcher-view {
+ min-height: 120px;
+ padding: 4px;
+}
+
+#switcher-button {
+ padding: 4px;
+ min-width: 100px;
+}
+
+#window-button {
+ background-color: var(--surface);
+}
+
+#switcher-button:hover {
+ background-color: var(--surface);
+}
+
+#window-button.active {
+ background-color: var(--surface-bright);
+ border: 3px solid var(--surface-bright);
+}
+
+#switcher-button label {
+ color: var(--foreground);
+ font-size: 10px;
+ margin-top: 4px;
+}
+
+#window-row {
+ padding: 18px;
+}
diff --git a/styles/workspaces.css b/styles/workspaces.css
new file mode 100644
index 0000000..b728803
--- /dev/null
+++ b/styles/workspaces.css
@@ -0,0 +1,113 @@
+#workspaces {
+ padding: 14px;
+}
+
+#workspace-single {
+ min-width: 34px;
+ background-color: var(--surface);
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#workspace-single.invert {
+ min-height: 34px;
+ border-radius: 12px;
+}
+
+#workspaces-num {
+ padding: 4px;
+}
+
+#workspaces-container {
+ background-color: var(--surface);
+}
+
+#workspaces-container.invert {
+ background-color: var(--surface);
+ border-radius: 12px;
+}
+
+#workspaces > button {
+ min-width: 8px;
+ min-height: 8px;
+ border-radius: 16px;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ background-color: var(--foreground);
+}
+
+#workspaces > button > label {
+ font-size: 0px;
+}
+
+#workspaces > button.empty:hover {
+ background-color: var(--foreground);
+}
+
+#workspaces > button.urgent {
+ background-color: var(--error-dim);
+}
+
+#workspaces > button.active {
+ min-width: 48px;
+ min-height: 8px;
+ background-color: var(--primary);
+}
+
+#workspaces > button.active.vertical {
+ min-width: 8px;
+ min-height: 48px;
+ background-color: var(--primary);
+}
+
+#workspaces > button.empty {
+ background-color: var(--surface-bright);
+}
+
+#workspaces-num > button > label {
+ color: var(--foreground);
+}
+
+#workspaces-num {
+ padding: 8px;
+}
+
+#workspaces-num > button {
+ border-radius: 20px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#workspaces-num > button > label {
+ color: var(--foreground);
+ font-weight: bold;
+ min-width: 20px;
+ min-height: 20px;
+ font-size: 10pt;
+}
+
+#workspaces-num > button:hover {
+ background-color: var(--surface-bright);
+}
+
+#workspaces-num > button.active {
+ background-color: var(--primary);
+ border-radius: 8px;
+}
+
+#workspaces-num > button.active > label {
+ color: var(--shadow);
+}
+
+#workspaces-num > button.empty > label {
+ color: var(--surface-bright);
+}
+
+#workspaces-num > button.empty:hover > label {
+ color: var(--foreground);
+}
+
+#workspaces-num > button.empty:hover {
+ background-color: transparent;
+}
+
+#workspaces-num > button.urgent > label {
+ color: var(--error-dim);
+}
diff --git a/utils/__init__.py b/utils/__init__.py
index 43abad3..3f03f12 100644
--- a/utils/__init__.py
+++ b/utils/__init__.py
@@ -1,4 +1,4 @@
-from .custom_image import *
-from .icon_resolver import *
-from .occlusion import *
-from .animator import *
+"""
+Modus services package.
+Contains background services and utilities for the shell.
+"""
diff --git a/utils/circle_image.py b/utils/circle_image.py
new file mode 100644
index 0000000..acfdc6d
--- /dev/null
+++ b/utils/circle_image.py
@@ -0,0 +1,118 @@
+import math
+from typing import Literal
+
+import cairo
+import gi
+from fabric.core.service import Property
+from fabric.widgets.widget import Widget
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
+
+
+class CircleImage(Gtk.DrawingArea, Widget):
+ """A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
+
+ @Property(int, "read-write")
+ def angle(self) -> int:
+ return self._angle
+
+ @angle.setter
+ def angle(self, value: int):
+ self._angle = value % 360
+ self.queue_draw()
+
+ def __init__(
+ self,
+ image_file: str | None = None,
+ pixbuf: GdkPixbuf.Pixbuf | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: int | None = None,
+ **kwargs,
+ ):
+ Gtk.DrawingArea.__init__(self)
+ Widget.__init__(
+ self,
+ name=name,
+ visible=visible,
+ all_visible=all_visible,
+ style=style,
+ tooltip_text=tooltip_text,
+ tooltip_markup=tooltip_markup,
+ h_align=h_align,
+ v_align=v_align,
+ h_expand=h_expand,
+ v_expand=v_expand,
+ size=size,
+ **kwargs,
+ )
+ self.size = size if size is not None else 100 # Default size if not provided
+ self._angle = 0
+ self._orig_image: GdkPixbuf.Pixbuf | None = None # Original image for reprocessing
+ self._image: GdkPixbuf.Pixbuf | None = None
+ if image_file:
+ pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
+ self._orig_image = pix
+ self._image = self._process_image(pix)
+ elif pixbuf:
+ self._orig_image = pixbuf
+ self._image = self._process_image(pixbuf)
+ self.connect("draw", self.on_draw)
+
+ def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
+ """Crop the image to a centered square and scale it to the widget’s size."""
+ width, height = pixbuf.get_width(), pixbuf.get_height()
+ if width != height:
+ square_size = min(width, height)
+ x_offset = (width - square_size) // 2
+ y_offset = (height - square_size) // 2
+ pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
+ else:
+ square_size = width
+ if square_size != self.size:
+ pixbuf = pixbuf.scale_simple(self.size, self.size, GdkPixbuf.InterpType.BILINEAR)
+ return pixbuf
+
+ def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
+ if self._image:
+ ctx.save()
+ # Create a circular clipping path
+ ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
+ ctx.clip()
+ # Rotate around the center of the square image
+ ctx.translate(self.size / 2, self.size / 2)
+ ctx.rotate(self._angle * math.pi / 180.0)
+ ctx.translate(-self.size / 2, -self.size / 2)
+ Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
+ ctx.paint()
+ ctx.restore()
+
+ def set_image_from_file(self, new_image_file: str):
+ if not new_image_file:
+ return
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
+ self._orig_image = pixbuf
+ self._image = self._process_image(pixbuf)
+ self.queue_draw()
+
+ def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
+ if not pixbuf:
+ return
+ self._orig_image = pixbuf
+ self._image = self._process_image(pixbuf)
+ self.queue_draw()
+
+ def set_image_size(self, size: int):
+ self.size = size
+ if self._orig_image:
+ self._image = self._process_image(self._orig_image)
+ self.queue_draw()
diff --git a/utils/conversion.py b/utils/conversion.py
new file mode 100644
index 0000000..0f15f9a
--- /dev/null
+++ b/utils/conversion.py
@@ -0,0 +1,619 @@
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from typing import Dict, Optional, Tuple
+
+import requests
+
+
+class CurrencyCache:
+ """Thread-safe currency exchange rate cache with background updates."""
+
+ def __init__(self):
+ # {base_currency: {rates_data, timestamp}}
+ self._cache: Dict[str, Dict] = {}
+ self._cache_lock = threading.Lock()
+ self._cache_ttl = 3600 # 1 hour in seconds
+ self._request_timeout = 2 # 2 seconds for faster response
+ self._executor = ThreadPoolExecutor(
+ max_workers=2, thread_name_prefix="currency"
+ )
+ self._pending_requests: Dict[str, threading.Event] = {}
+
+ def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]:
+ """
+ Get exchange rate from cache or fetch if needed.
+ Returns (rate, cache_age_seconds) or None if unavailable.
+ """
+ from_lower = from_code.lower()
+ to_lower = to_code.lower()
+
+ if from_lower == to_lower:
+ return 1.0, 0.0
+
+ current_time = time.time()
+
+ with self._cache_lock:
+ # Check if we have fresh cached data
+ if from_lower in self._cache:
+ cache_entry = self._cache[from_lower]
+ cache_age = current_time - cache_entry["timestamp"]
+
+ if cache_age < self._cache_ttl and to_lower in cache_entry["rates"]:
+ rate = cache_entry["rates"][to_lower]["rate"]
+ return rate, cache_age
+
+ # Check if we're already fetching this currency
+ if from_lower in self._pending_requests:
+ # Don't wait, return cached data if available (even if stale)
+ if (
+ from_lower in self._cache
+ and to_lower in self._cache[from_lower]["rates"]
+ ):
+ cache_entry = self._cache[from_lower]
+ cache_age = current_time - cache_entry["timestamp"]
+ rate = cache_entry["rates"][to_lower]["rate"]
+ return rate, cache_age
+ return None
+
+ # Start background fetch
+ self._pending_requests[from_lower] = threading.Event()
+
+ # Submit background task to fetch rates
+ self._executor.submit(self._fetch_rates_background, from_lower)
+
+ # Return stale cached data if available
+ with self._cache_lock:
+ if (
+ from_lower in self._cache
+ and to_lower in self._cache[from_lower]["rates"]
+ ):
+ cache_entry = self._cache[from_lower]
+ cache_age = current_time - cache_entry["timestamp"]
+ rate = cache_entry["rates"][to_lower]["rate"]
+ return rate, cache_age
+
+ return None
+
+ def _fetch_rates_background(self, from_code: str):
+ """Fetch exchange rates in background thread."""
+ try:
+ url = f"https://www.floatrates.com/daily/{from_code}.json"
+ response = requests.get(url, timeout=self._request_timeout)
+
+ if response.status_code == 200:
+ rates_data = response.json()
+ current_time = time.time()
+
+ with self._cache_lock:
+ self._cache[from_code] = {
+ "rates": rates_data,
+ "timestamp": current_time,
+ }
+
+ except Exception as e:
+ print(f"Background currency fetch failed for {from_code}: {e}")
+ finally:
+ # Mark request as complete
+ with self._cache_lock:
+ if from_code in self._pending_requests:
+ self._pending_requests[from_code].set()
+ del self._pending_requests[from_code]
+
+ def cleanup(self):
+ """Cleanup resources."""
+ self._executor.shutdown(wait=False)
+ with self._cache_lock:
+ self._cache.clear()
+ self._pending_requests.clear()
+
+
+# Global currency cache instance
+_currency_cache = CurrencyCache()
+
+
+class Units:
+ def __init__(self):
+ self.WEIGHT_CHART: dict[str, tuple[float, float]] = {
+ "kilogram": (1, 1),
+ "kg": (1, 1),
+ "tonne": (1000, 0.001),
+ "ton": (1000, 0.001),
+ "gram": (1e-3, 1e3),
+ "g": (1e-3, 1e3),
+ "milligram": (1e-6, 1e6),
+ "mg": (1e-6, 1e6),
+ "metric-ton": (1000, 0.001),
+ "metric-tonne": (1000, 0.001),
+ "long-ton": (1016.04608, 0.0009842073),
+ "short-ton": (907.184, 0.0011023122),
+ "pound": (0.453592, 2.2046244202),
+ "lb": (0.453592, 2.2046244202),
+ "stone": (6.35029, 0.1574731728),
+ "st": (6.35029, 0.1574731728),
+ "ounce": (0.0283495, 35.273990723),
+ "oz": (0.0283495, 35.273990723),
+ "carrat": (0.0002, 5000),
+ "ct": (0.0002, 5000),
+ "atomic-mass-unit": (1.660540199e-27, 6.022136652e26),
+ }
+
+ self.LENGTH_CHART: dict[str, float] = {
+ # meter
+ "m": 1,
+ "M": 1,
+ "meter": 1,
+ # kilometer
+ "km": 1e3,
+ "KM": 1e3,
+ "kilometer": 1e3,
+ # centimeter
+ "cm": 1e-2,
+ "CM": 1e-2,
+ "centimeter": 1e-2,
+ # millimeter
+ "mm": 1e-3,
+ "MM": 1e-3,
+ "millimeter": 1e-3,
+ # micrometer
+ "um": 1e-6,
+ "UM": 1e-6,
+ "micrometer": 1e-6,
+ # nanometer
+ "nm": 1e-9,
+ "NM": 1e-9,
+ "nanometer": 1e-9,
+ # mile
+ "mi": 1609.344,
+ "MI": 1609.344,
+ "mile": 1609.344,
+ # yard
+ "yd": 0.9144,
+ "YD": 0.9144,
+ "yard": 0.9144,
+ # foot
+ "ft": 0.3048,
+ "FT": 0.3048,
+ "foot": 0.3048,
+ "feet": 0.3048,
+ # inch
+ "in": 0.0254,
+ "IN": 0.0254,
+ "inch": 0.0254,
+ "inches": 0.0254,
+ # nautical mile
+ "nmi": 1852,
+ "NMI": 1852,
+ "nautical-mile": 1852,
+ }
+
+ self.STORAGE_TYPE_CHART: dict[str, float] = {
+ "bit": 1,
+ "byte": 8,
+ "B": 8,
+ "kilobyte": 8192,
+ "KB": 8192,
+ "megabyte": 8388608,
+ "MB": 8388608,
+ "gigabyte": 8589934592,
+ "GB": 8589934592,
+ "terabyte": 8796093022208,
+ "TB": 8796093022208,
+ "petabyte": 9007199254740992,
+ "PB": 9007199254740992,
+ "exabyte": 9223372036854775808,
+ "EB": 9223372036854775808,
+ }
+
+ self.TEMPERATURE_CHART = {
+ "celsius": (lambda v: v + 273.15, lambda v: v - 273.15),
+ "c": (lambda v: v + 273.15, lambda v: v - 273.15),
+ "fahrenheit": (
+ lambda v: (v - 32) * 5 / 9 + 273.15,
+ lambda v: (v - 273.15) * 9 / 5 + 32,
+ ),
+ "f": (
+ lambda v: (v - 32) * 5 / 9 + 273.15,
+ lambda v: (v - 273.15) * 9 / 5 + 32,
+ ),
+ "kelvin": (lambda v: v, lambda v: v),
+ "k": (lambda v: v, lambda v: v),
+ "rankine": (lambda v: v * 5 / 9, lambda v: v * 9 / 5),
+ "reaumur": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5),
+ }
+
+ self.TIME_CHART: dict[str, float] = {
+ "second": 1,
+ "s": 1,
+ "minute": 60,
+ "min": 60,
+ "m": 60,
+ "hour": 3600,
+ "h": 3600,
+ "milisecond": 1e-3,
+ "ms": 1e-3,
+ "day": 86400,
+ "d": 86400,
+ "week": 604800,
+ "w": 604800,
+ "fortnight": 1209600,
+ "month": 2628000, # Approximation (30.44 days)
+ "mo": 2628000, # Approximation (30.44 days)
+ "year": 31536000, # Approximation (365 days)
+ "yr": 31536000, # Approximation (365 days)
+ "decade": 315360000, # Approximation (10 years)
+ "dec": 315360000, # Approximation (10 years)
+ "century": 3153600000, # Approximation (100 years)
+ "cent": 3153600000, # Approximation (100 years)
+ "millennium": 31536000000, # Approximation (1000 years)
+ "millenia": 31536000000, # Approximation (1000 years)
+ }
+
+ self.LIQUID_VOLUME_CHART: dict[str, float] = {
+ "liter": 1,
+ "l": 1,
+ "milliliter": 1e-3,
+ "ml": 1e-3,
+ "gallon": 3.78541,
+ "quart": 0.946353,
+ "pint": 0.473176,
+ "fluid-ounce": 0.0295735,
+ "fl-oz": 0.0295735,
+ "oz": 0.0295735,
+ "ounce": 0.0295735,
+ "cup": 0.236588,
+ "tablespoon": 0.0147868,
+ "tbsp": 0.0147868,
+ "teaspoon": 0.00492892,
+ "tsp": 0.00492892,
+ }
+
+ self.ANGLE_CHART: dict[str, float] = {
+ "degree": 1,
+ "deg": 1,
+ "radian": 57.2958,
+ "rad": 57.2958,
+ "gradian": 0.9,
+ "gon": 0.9,
+ }
+
+ self.ENERGY_CHART: dict[str, float] = {
+ "joule": 1,
+ "j": 1,
+ "kilojoule": 1000,
+ "kj": 1000,
+ "calorie": 4.184,
+ "cal": 4.184,
+ "kilocalorie": 4184,
+ "kcal": 4184,
+ "watt-hour": 3600,
+ "wh": 3600,
+ "kilowatt-hour": 3.6e6,
+ "kwh": 3.6e6,
+ }
+
+ self.SPEED_CHART: dict[str, float] = {
+ "mps": 1,
+ "kmph": 0.277778,
+ "mph": 0.44704,
+ "fps": 0.3048,
+ "knot": 0.514444,
+ }
+
+ self.PRESSURE_CHART: dict[str, float] = {
+ "pascal": 1,
+ "Pa": 1,
+ "bar": 100000,
+ "atm": 101325,
+ "torr": 133.322,
+ "mmHg": 133.322,
+ "psi": 6894.76,
+ }
+
+ self.FORCE_CHART: dict[str, float] = {
+ "newton": 1,
+ "N": 1,
+ "kilonewton": 1000,
+ "kN": 1000,
+ "pound-force": 4.44822,
+ "lbf": 4.44822,
+ "dyne": 1e-5,
+ }
+
+ self.POWER_CHART: dict[str, float] = {
+ "watt": 1,
+ "W": 1,
+ "kilowatt": 1000,
+ "kW": 1000,
+ "horsepower": 745.7,
+ "hp": 745.7,
+ "megawatt": 1e6,
+ "MW": 1e6,
+ }
+
+ self.VOLTAGE_CHART: dict[str, float] = {
+ "volt": 1,
+ "V": 1,
+ "millivolt": 1e-3,
+ "mV": 1e-3,
+ "kilovolt": 1000,
+ "kV": 1000,
+ "megavolt": 1e6,
+ "MV": 1e6,
+ }
+
+ self.CURRENT_CHART: dict[str, float] = {
+ "ampere": 1,
+ "A": 1,
+ "milliampere": 1e-3,
+ "mA": 1e-3,
+ "microampere": 1e-6,
+ "μA": 1e-6,
+ }
+
+ self.RESISTANCE_CHART: dict[str, float] = {
+ "ohm": 1,
+ "Ω": 1,
+ "kilohm": 1000,
+ "kΩ": 1000,
+ "megohm": 1e6,
+ "MΩ": 1e6,
+ }
+
+ self.CAPACITANCE_CHART: dict[str, float] = {
+ "farad": 1,
+ "F": 1,
+ "millifarad": 1e-3,
+ "mF": 1e-3,
+ "microfarad": 1e-6,
+ "μF": 1e-6,
+ "nanofarad": 1e-9,
+ "nF": 1e-9,
+ }
+
+ self.INDUCTANCE_CHART: dict[str, float] = {
+ "henry": 1,
+ "H": 1,
+ "millihenry": 1e-3,
+ "mH": 1e-3,
+ "microhenry": 1e-6,
+ "μH": 1e-6,
+ "nanohenry": 1e-9,
+ "nH": 1e-9,
+ }
+
+ self.FREQUENCY_CHART: dict[str, float] = {
+ "hertz": 1,
+ "Hz": 1,
+ "kilohertz": 1e3,
+ "kHz": 1e3,
+ "megahertz": 1e6,
+ "MHz": 1e6,
+ "gigahertz": 1e9,
+ "GHz": 1e9,
+ }
+
+ self.LUMINANCE_CHART: dict[str, float] = {
+ "candela": 1,
+ "cd": 1,
+ "lumen": 1,
+ "lm": 1,
+ "lux": 1,
+ "lx": 1,
+ }
+
+ self.AREA_CHART: dict[str, float] = {
+ "square-meter": 1,
+ "m2": 1,
+ "square-kilometer": 1e6,
+ "km2": 1e6,
+ "hectare": 1e4,
+ "ha": 1e4,
+ "are": 1e2,
+ "a": 1e2,
+ "square-centimeter": 1e-4,
+ "cm2": 1e-4,
+ "square-millimeter": 1e-6,
+ "mm2": 1e-6,
+ }
+
+ # We no longer use currency_converter here.
+
+
+class Conversion:
+ def __init__(self):
+ self.units = Units()
+ self.currency_cache = _currency_cache
+
+ def convert(self, value: float, from_type: str, to_type: str):
+ """
+ Generalized conversion function that works with all categories,
+ including currency via floatrates.com.
+ """
+ # Collection of all non-currency charts
+ charts = {
+ "WEIGHT_CHART": self.units.WEIGHT_CHART,
+ "LENGTH_CHART": self.units.LENGTH_CHART,
+ "TEMPERATURE_CHART": self.units.TEMPERATURE_CHART,
+ "TIME_CHART": self.units.TIME_CHART,
+ "LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART,
+ "STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART,
+ "ANGLE_CHART": self.units.ANGLE_CHART,
+ "ENERGY_CHART": self.units.ENERGY_CHART,
+ "SPEED_CHART": self.units.SPEED_CHART,
+ "PRESSURE_CHART": self.units.PRESSURE_CHART,
+ "FORCE_CHART": self.units.FORCE_CHART,
+ "POWER_CHART": self.units.POWER_CHART,
+ "VOLTAGE_CHART": self.units.VOLTAGE_CHART,
+ "CURRENT_CHART": self.units.CURRENT_CHART,
+ "RESISTANCE_CHART": self.units.RESISTANCE_CHART,
+ "CAPACITANCE_CHART": self.units.CAPACITANCE_CHART,
+ "INDUCTANCE_CHART": self.units.INDUCTANCE_CHART,
+ "FREQUENCY_CHART": self.units.FREQUENCY_CHART,
+ "LUMINANCE_CHART": self.units.LUMINANCE_CHART,
+ "AREA_CHART": self.units.AREA_CHART,
+ }
+
+ # 1) Check if it's in any of the charts (non-currency)
+ for chart_name, chart in charts.items():
+ if from_type in chart and to_type in chart:
+ # Temperatures use lambdas
+ if chart_name == "TEMPERATURE_CHART":
+ if from_type == to_type:
+ return value
+ to_kelvin = chart[from_type][0]
+ from_kelvin = chart[to_type][1]
+ return from_kelvin(to_kelvin(value))
+
+ # Handle WEIGHT_CHART separately (tuple values)
+ if chart_name == "WEIGHT_CHART":
+ if from_type == to_type:
+ return value
+ to_kg = chart[from_type][0]
+ from_kg = chart[to_type][1]
+ return value * to_kg * from_kg
+
+ # Any other numeric chart
+ if from_type == to_type:
+ return value
+ return value * (chart[from_type] / chart[to_type])
+
+ # 2) If both are currency codes (e.g. “USD”, “ARS”)
+ # we assume they are uppercase and have 3 letters.
+ if (
+ len(from_type) == 3
+ and len(to_type) == 3
+ and from_type.isalpha()
+ and to_type.isalpha()
+ ):
+ result = self._convert_currency_fast(value, from_type, to_type)
+ if result is not None:
+ return result
+ # Fallback to slow method if fast method fails
+ return self._convert_currency_via_floatrates(value, from_type, to_type)
+
+ # 3) If it doesn't fall into any case, error.
+ raise ValueError(f"Unsupported conversion: {from_type} to {to_type}")
+
+ def _convert_currency_fast(
+ self, value: float, from_code: str, to_code: str
+ ) -> Optional[float]:
+ """
+ Fast currency conversion using cached exchange rates.
+ Returns None if rate is not available in cache.
+ """
+ rate_info = self.currency_cache.get_rate(from_code, to_code)
+ if rate_info is not None:
+ rate, _ = rate_info # cache_age not needed here
+ return value * rate
+ return None
+
+ def _convert_currency_via_floatrates(
+ self, value: float, from_code: str, to_code: str
+ ) -> float:
+ """
+ Converts using the JSON from floatrates.com:
+ - Makes GET to https://www.floatrates.com/daily/{from_lower}.json
+ - Takes the rate from the to_lower key and multiplies.
+ """
+ from_lower = from_code.lower()
+ to_lower = to_code.lower()
+
+ if from_lower == to_lower:
+ return value
+
+ url = f"https://www.floatrates.com/daily/{from_lower}.json"
+ resp = requests.get(url, timeout=5)
+ if resp.status_code != 200:
+ raise ValueError(f"Error getting data from floatrates for {from_code}")
+
+ data = resp.json()
+ if to_lower not in data:
+ raise ValueError(
+ f"Target currency '{to_code}' not found in floatrates response for '{
+ from_code
+ }'"
+ )
+
+ rate = data[to_lower]["rate"]
+ return value * rate
+
+ def parse_input_and_convert(self, input: str):
+ parts = input.split()
+ addition = "s" if parts[-1].endswith("s") else ""
+
+ if "and" in parts: # value unit1 and value2 unit2 _ to target_unit
+ parts.remove("and")
+ if len(parts) != 6:
+ raise ValueError(
+ "Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'"
+ )
+
+ value1, from_type1, value2, from_type2, _, to_type = parts
+ value1, value2 = float(value1), float(value2)
+ from_type1 = self.clean_type(from_type1)
+ from_type2 = self.clean_type(from_type2)
+ to_type = self.clean_type(to_type)
+
+ if from_type1 == from_type2:
+ return self.convert(
+ value1 + value2, from_type1, to_type
+ ), to_type + addition
+ else:
+ res = 0
+ res += self.convert(value1, from_type1, to_type)
+ res += self.convert(value2, from_type2, to_type)
+ return res, to_type + addition
+ else:
+ if len(parts) != 4:
+ raise ValueError(
+ "Invalid format. Expected: 'value from_type _ to_type'"
+ )
+ value, from_type, _, to_type = parts
+ value = float(value)
+ from_type = self.clean_type(from_type)
+ to_type = self.clean_type(to_type)
+ return self.convert(value, from_type, to_type), to_type + addition
+
+ def clean_type(self, type: str) -> str:
+ """
+ If it's currency (3 letters), convert to uppercase.
+ If it ends in 's' (and is not 'celsius'), remove the 's' for
+ other units."""
+ if len(type) == 3 and type.isalpha():
+ return type.upper()
+ if type.endswith("s") and type.lower() != "celsius":
+ # For tables that have singular/plural
+ singular = type[:-1].lower()
+ # If it exists in STORAGE_TYPE_CHART, we use it;
+ # if not, we return singular in lowercase for other charts.
+ if singular in self.units.STORAGE_TYPE_CHART:
+ return singular
+ return singular.lower()
+ return type
+
+ def cleanup(self):
+ """Cleanup resources."""
+ self.currency_cache.cleanup()
+
+ def get_currency_cache_info(
+ self, from_code: str, to_code: str
+ ) -> Optional[Tuple[bool, float]]:
+ """
+ Get currency cache information for UI display.
+ Returns (is_fresh, cache_age_seconds) or None if not cached.
+ """
+ rate_info = self.currency_cache.get_rate(from_code, to_code)
+ if rate_info is not None:
+ _, cache_age = rate_info # rate not needed here
+ is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old
+ return is_fresh, cache_age
+ return None
+
+
+# Quick usage example:
+if __name__ == "__main__":
+ conv = Conversion()
+ # Convert 10 USD to ARS:
+ result, suffix = conv.parse_input_and_convert("10 USD _ ARS")
+ print(f"{result:.2f} {suffix}") # Ex: "10 USD _ ARS" -> "38754.23 ARS"
diff --git a/utils/custom_image.py b/utils/custom_image.py
index 1aae7f6..8096a6a 100644
--- a/utils/custom_image.py
+++ b/utils/custom_image.py
@@ -2,9 +2,8 @@ import math
from typing import cast
import cairo
-from gi.repository import Gtk
-
from fabric.widgets.image import Image
+from gi.repository import Gtk
class CustomImage(Image):
diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py
index b8bedeb..a80c8f6 100644
--- a/utils/icon_resolver.py
+++ b/utils/icon_resolver.py
@@ -99,4 +99,4 @@ class IconResolver:
self._get_icon_from_desktop_file(desktop_file)
if desktop_file
else self.default_applicaiton_icon
- )
\ No newline at end of file
+ )
diff --git a/utils/icons.py b/utils/icons.py
index ed61594..2a1059f 100644
--- a/utils/icons.py
+++ b/utils/icons.py
@@ -5,53 +5,47 @@ font_weight: str = "normal"
span: str = f""
# Panels
-apps: str = ""
+apps: str = ""
dashboard: str = ""
chat: str = ""
-wallpapers: str = ""
windows: str = ""
# Bar
colorpicker: str = ""
media: str = ""
+# Toolbox
+
+toolbox: str = ""
+ssfull: str = ""
+ssregion: str = ""
+sswindow: str = ""
+screenshots: str = ""
+screenrecord: str = ""
+recordings: str = ""
+ocr: str = "ﳃ"
+gamemode: str = ""
+gamemode_off: str = ""
+close: str = ""
+
# Circles
temp: str = ""
disk: str = ""
battery: str = ""
memory: str = "流"
cpu: str = ""
+gpu: str = ""
swap: str = ""
-# Toolbox
-toolbox: str = ""
-ssfull: str = ""
-ssregion: str = ""
-screenshots: str = ""
-screenrecord: str = ""
-recordings: str = ""
-ocr: str = "ﳃ"
-gamemode: str = ""
-gamemode_off: str = ""
-close: str = ""
-bell: str = ""
-bell_off: str = ""
-notifications_on: str = ""
-notifications_off: str = ""
-notifications_clear: str = ""
-notifications: str = ""
+
+# AIchat
+reload: str = ""
+detach: str = ""
# Wallpapers
add: str = ""
sort: str = ""
circle: str = ""
-check_box: str = ""
-box: str = ""
-scan: str = ""
-scan_stop: str = ""
-record: str = ""
-update: str = "見"
-updated: str = ""
# Chevrons
chevron_up: str = ""
@@ -79,37 +73,35 @@ bat_low: str = "="
bat_full: str = ""
-moderate_signal: str = ""
-good_signal: str = ""
-no_signal: str = ""
-weak_signal: str = ""
# Applets
-lan: str = ""
+
+# Applets
wifi_0: str = ""
wifi_1: str = ""
wifi_2: str = ""
wifi_3: str = ""
-wifi: str = ""
world: str = ""
world_off: str = ""
bluetooth: str = ""
night: str = ""
coffee: str = ""
-dnd: str = ""
-darkmode: str = "﹖"
-
+notifications: str = ""
+notifications_dots: str = ""
wifi_off: str = ""
bluetooth_off: str = ""
night_off: str = ""
-dnd_off: str = ""
+notifications_off: str = ""
+notifications_clear: str = ""
+
+download: str = ""
+upload: str = ""
# Bluetooth
bluetooth_connected: str = ""
bluetooth_disconnected: str = ""
-
# Player
pause: str = ""
play: str = ""
@@ -124,22 +116,27 @@ music: str = ""
rewind_backward_5: str = "謹"
rewind_forward_5: str = "難"
+next_fill: str = chr(0xF04AD)
+previous_fill: str = chr(0xF04AE)
+
# Volume
vol_off: str = ""
vol_mute: str = ""
vol_medium: str = ""
vol_high: str = ""
-
mic: str = ""
-mic_muted: str = ""
+mic_mute: str = ""
+speaker: str = "𐁅"
+headphones: str = "屮"
+mic_filled: str = "️"
# Overview
circle_plus: str = ""
# Pins
-copy_plus: str = "ﶮ"
+paperclip: str = ""
# Clipboard Manager
clipboard: str = ""
@@ -149,7 +146,6 @@ clip_text: str = ""
accept: str = ""
cancel: str = ""
trash: str = ""
-clear: str = ""
# Config
config: str = ""
@@ -161,6 +157,20 @@ spotify: str = "ﺆ"
disc: str = ""
disc_off: str = ""
+# Brightness
+brightness_low: str = ""
+brightness_medium: str = ""
+brightness_high: str = ""
+
+brightness: str = ""
+
+# Dashboard
+widgets: str = ""
+pins: str = ""
+kanban: str = ""
+wallpapers: str = ""
+sparkles: str = ""
+
# Misc
dot: str = ""
palette: str = ""
@@ -170,8 +180,47 @@ radar: str = ""
emoji: str = ""
keyboard: str = ""
terminal: str = ""
+timer_off: str = ""
+timer_on: str = ""
+spy: str = ""
+calculator: str = ""
+file: str = ""
+calendar: str = ""
+connect: str = ""
+disconnect: str = "ﴖ"
+auth: str = ""
+bookmark: str = ""
+star: str = ""
+process: str = ""
+music: str = ""
+# otp
+check: str = ""
+bulb: str = ""
+key: str = ""
+magnifier: str = ""
+plus: str = ""
+info: str = ""
+
+# Dice
+dice_1: str = ""
+dice_2: str = ""
+dice_3: str = ""
+dice_4: str = ""
+dice_5: str = ""
+dice_6: str = ""
+
+# search engine
+reddit: str = ""
+youtube: str = ""
+duckduckgo: str = ""
+github: str = ""
+stackoverflow: str = ""
+google: str = ""
+linkedin: str = ""
+wikipedia: str = "啕"
+
exceptions: list[str] = ["font_family", "font_weight", "span"]
diff --git a/utils/inhibit.py b/utils/inhibit.py
new file mode 100644
index 0000000..6b5a106
--- /dev/null
+++ b/utils/inhibit.py
@@ -0,0 +1,186 @@
+# From https://github.com/stwa/wayland-idle-inhibitor
+# License: WTFPL Version 2
+
+import argparse
+import os
+import subprocess
+import sys
+from dataclasses import dataclass
+from signal import SIGINT, SIGTERM, signal
+from threading import Event, Timer
+
+import setproctitle
+from pywayland.client.display import Display
+from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (
+ ZwpIdleInhibitManagerV1,
+)
+from pywayland.protocol.wayland.wl_compositor import WlCompositor
+from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
+from pywayland.protocol.wayland.wl_surface import WlSurface
+
+
+@dataclass
+class GlobalRegistry:
+ surface: WlSurface | None = None
+ inhibit_manager: ZwpIdleInhibitManagerV1 | None = None
+
+
+def parse_duration(duration_str: str) -> int:
+ """Parse duration string into seconds.
+ Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off'
+ """
+ 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:
+ raise ValueError(
+ "Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc."
+ )
+
+
+def kill_existing_inhibit_processes():
+ """Kill any existing modus-inhibit processes."""
+ try:
+ # Find all modus-inhibit processes except the current one
+ output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True)
+ pids = output.strip().split("\n")
+ current_pid = str(os.getpid())
+
+ killed_count = 0
+ for pid in pids:
+ pid = pid.strip()
+ if pid and pid != current_pid:
+ try:
+ subprocess.run(["kill", pid], check=True)
+ killed_count += 1
+ except subprocess.CalledProcessError:
+ pass # Process might have already exited
+
+ if killed_count > 0:
+ print(f"Stopped {killed_count} existing inhibit process(es)")
+ else:
+ print("No existing inhibit processes were running")
+ return killed_count
+
+ except subprocess.CalledProcessError:
+ # No processes found
+ print("No existing inhibit processes were running")
+ return 0
+ except Exception as e:
+ print(f"Error stopping existing processes: {e}")
+ return 0
+
+
+def handle_registry_global(
+ wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int
+) -> None:
+ global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()
+
+ if iface_name == "wl_compositor":
+ compositor = wl_registry.bind(id_num, WlCompositor, version)
+ global_registry.surface = compositor.create_surface() # type: ignore
+ elif iface_name == "zwp_idle_inhibit_manager_v1":
+ global_registry.inhibit_manager = wl_registry.bind(
+ id_num, ZwpIdleInhibitManagerV1, version
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Inhibit system idle for a specified duration"
+ )
+ parser.add_argument(
+ "duration",
+ nargs="?",
+ default="0",
+ help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.',
+ )
+ args = parser.parse_args()
+
+ # Handle "off" argument - kill existing processes and exit
+ if args.duration.lower() == "off":
+ kill_existing_inhibit_processes()
+ sys.exit(0)
+
+ done = Event()
+ signal(SIGINT, lambda _, __: done.set())
+ signal(SIGTERM, lambda _, __: done.set())
+
+ global_registry = GlobalRegistry()
+
+ try:
+ display = Display()
+ display.connect()
+
+ registry = display.get_registry() # type: ignore
+ registry.user_data = global_registry
+ registry.dispatcher["global"] = handle_registry_global
+
+ def shutdown() -> None:
+ display.dispatch()
+ display.roundtrip()
+ display.disconnect()
+
+ display.dispatch()
+ display.roundtrip()
+
+ if global_registry.surface is None:
+ print("Error: Failed to create Wayland surface.")
+ shutdown()
+ sys.exit(1)
+
+ if global_registry.inhibit_manager is None:
+ print("Error: Your Wayland compositor does not support idle inhibition.")
+ print("This usually means either:")
+ print(
+ "1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol"
+ )
+ print("2. The protocol is not enabled in your compositor")
+ print("\nFor Hyprland, you can enable it by adding to your config:")
+ print("misc:disable_autoreload = true")
+ print("misc:enable_wayland_protocols = true")
+ shutdown()
+ sys.exit(1)
+
+ inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
+ global_registry.surface
+ )
+
+ display.dispatch()
+ display.roundtrip()
+
+ duration = parse_duration(args.duration)
+ if duration > 0:
+ print(f"Inhibiting idle for {args.duration}...")
+ # Set up timer to release inhibition
+ timer = Timer(duration, lambda: done.set())
+ timer.start()
+ else:
+ print("Inhibiting idle indefinitely...")
+
+ done.wait()
+ print("Shutting down...")
+
+ inhibitor.destroy()
+ if duration > 0:
+ timer.cancel()
+
+ shutdown()
+
+ except Exception as e:
+ print(f"Error: {str(e)}")
+ print("Make sure you're running this under a Wayland session.")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ setproctitle.setproctitle("modus-inhibit")
+ main()
diff --git a/utils/notification_utils.py b/utils/notification_utils.py
new file mode 100644
index 0000000..46b8f6f
--- /dev/null
+++ b/utils/notification_utils.py
@@ -0,0 +1,429 @@
+import json
+import locale
+import os
+from datetime import datetime, timedelta
+
+from fabric.utils.helpers import get_relative_path
+from fabric.widgets.box import Box
+from gi.repository import GdkPixbuf, GLib, GObject
+from loguru import logger
+
+import config.data as data
+
+# Constants
+PERSISTENT_DIR = f"/tmp/{data.APP_NAME}/notifications"
+PERSISTENT_HISTORY_FILE = os.path.join(PERSISTENT_DIR, "notification_history.json")
+CONFIG_FILE = get_relative_path("../config/assets/config.json")
+NOTIFICATION_IMAGE_PREFIX = "notification_"
+NOTIFICATION_IMAGE_SUFFIX = ".png"
+DEFAULT_NOTIFICATION_IMAGE_SIZE = 48
+MAX_PERSISTENT_NOTIFICATIONS = 100
+
+# Global shared notification history instance
+_shared_notification_history = None
+
+
+class HistoricalNotification:
+ """Data structure for historical notification data"""
+
+ def __init__(
+ self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None
+ ):
+ self.id = id
+ self.app_icon = app_icon
+ self.summary = summary
+ self.body = body
+ self.app_name = app_name
+ self.timestamp = timestamp
+ self.cached_image_path = cached_image_path
+ self.image_pixbuf = None
+ self.actions = []
+ self.cached_scaled_pixbuf = None
+
+
+# Image handling functions
+def cache_notification_pixbuf(notification_box):
+ """Cache notification image to disk and return cache file path"""
+ notification = notification_box.notification
+ try:
+ if notification.image_pixbuf:
+ os.makedirs(PERSISTENT_DIR, exist_ok=True)
+ cache_file = os.path.join(
+ PERSISTENT_DIR,
+ f"{NOTIFICATION_IMAGE_PREFIX}{notification_box.uuid}{
+ NOTIFICATION_IMAGE_SUFFIX
+ }",
+ )
+
+ try:
+ scaled = notification.image_pixbuf.scale_simple(
+ DEFAULT_NOTIFICATION_IMAGE_SIZE,
+ DEFAULT_NOTIFICATION_IMAGE_SIZE,
+ GdkPixbuf.InterpType.BILINEAR,
+ )
+ scaled.savev(cache_file, "png", [], [])
+ return cache_file
+ except Exception as e:
+ logger.error(f"Error caching image for notification {notification.id}: {e}")
+ return None
+ else:
+ logger.debug(f"Notification {notification.id} has no image_pixbuf to cache.")
+ return None
+ except Exception as e:
+ logger.debug(f"Error accessing image_pixbuf for notification {notification.id}: {e}")
+ return None
+
+
+def load_scaled_pixbuf(notification_box, width, height):
+ """Load and scale pixbuf from notification box"""
+ notification = notification_box.notification
+ if not hasattr(notification_box, "notification") or notification is None:
+ return None
+
+ pixbuf = None
+
+ # Try to load from cached image first
+ if (
+ hasattr(notification_box, "cached_image_path")
+ and notification_box.cached_image_path
+ and os.path.exists(notification_box.cached_image_path)
+ ):
+ try:
+ logger.debug(
+ f"Attempting to load cached image from: {
+ notification_box.cached_image_path
+ } "
+ f"for notification {notification.id}"
+ )
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(notification_box.cached_image_path)
+ if pixbuf:
+ pixbuf = pixbuf.scale_simple(
+ width, height, GdkPixbuf.InterpType.BILINEAR
+ )
+ return pixbuf
+ except Exception as e:
+ logger.error(
+ f"Error loading cached image from {notification_box.cached_image_path} "
+ f"for notification {notification.id}: {e}"
+ )
+ logger.warning(
+ f"Falling back to notification.image_pixbuf for notification {
+ notification.id
+ }"
+ )
+
+ # Fall back to notification image_pixbuf
+ try:
+ if notification.image_pixbuf:
+ pixbuf = notification.image_pixbuf.scale_simple(
+ width, height, GdkPixbuf.InterpType.BILINEAR
+ )
+ return pixbuf
+ except Exception as e:
+ logger.debug(f"Error accessing image_pixbuf for notification {notification.id}: {e}")
+
+ # Fall back to app icon
+ return get_app_icon_pixbuf(notification.app_icon, width, height)
+
+
+def get_app_icon_pixbuf(icon_path, width, height):
+ """Load and scale app icon pixbuf"""
+ if not icon_path:
+ return None
+ if icon_path.startswith("file://"):
+ icon_path = icon_path[7:]
+ if not os.path.exists(icon_path):
+ logger.warning(f"Icon path does not exist: {icon_path}")
+ return None
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
+ return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
+ except Exception as e:
+ logger.error(f"Failed to load or scale icon: {e}")
+ return None
+
+
+def cleanup_orphan_cached_images(persistent_notifications):
+ """Clean up cached images that no longer have corresponding notifications"""
+ if not os.path.exists(PERSISTENT_DIR):
+ return
+
+ cached_files = [
+ f
+ for f in os.listdir(PERSISTENT_DIR)
+ if f.startswith(NOTIFICATION_IMAGE_PREFIX)
+ and f.endswith(NOTIFICATION_IMAGE_SUFFIX)
+ ]
+ if not cached_files:
+ return
+
+ history_uuids = {
+ note.get("id") for note in persistent_notifications if note.get("id")
+ }
+ for cached_file in cached_files:
+ try:
+ uuid_from_filename = cached_file[
+ len(NOTIFICATION_IMAGE_PREFIX) : -len(NOTIFICATION_IMAGE_SUFFIX)
+ ]
+ if uuid_from_filename not in history_uuids:
+ cache_file_path = os.path.join(PERSISTENT_DIR, cached_file)
+ os.remove(cache_file_path)
+ except Exception as e:
+ print(f"Error processing cached file {cached_file} during cleanup: {e}")
+
+
+def delete_cached_image(cached_image_path):
+ """Delete a specific cached image file"""
+ if cached_image_path and os.path.exists(cached_image_path):
+ try:
+ os.remove(cached_image_path)
+ logger.info(f"Deleted cached image: {cached_image_path}")
+ except Exception as e:
+ logger.error(f"Error deleting cached image {cached_image_path}: {e}")
+
+
+# Time and date utilities
+def get_ordinal(n):
+ """Get ordinal suffix for a number (1st, 2nd, 3rd, etc.)"""
+ if 11 <= (n % 100) <= 13:
+ return "th"
+ else:
+ return {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
+
+
+def get_date_header(dt):
+ """Get human-readable date header for a datetime"""
+ now = datetime.now()
+ today = now.date()
+ date = dt.date()
+
+ if date == today:
+ return "Today"
+ elif date == today - timedelta(days=1):
+ return "Yesterday"
+ else:
+ original_locale = locale.getlocale(locale.LC_TIME)
+ try:
+ locale.setlocale(locale.LC_TIME, ("en_US", "UTF-8"))
+ except locale.Error:
+ locale.setlocale(locale.LC_TIME, "C")
+ try:
+ day = dt.day
+ ordinal = get_ordinal(day)
+ month = dt.strftime("%B")
+ if dt.year == now.year:
+ result = f"{month} {day}{ordinal}"
+ else:
+ result = f"{month} {day}{ordinal}, {dt.year}"
+ finally:
+ locale.setlocale(locale.LC_TIME, original_locale)
+ return result
+
+
+def schedule_midnight_update(callback):
+ """Schedule a callback to run at midnight"""
+ now = datetime.now()
+ next_midnight = datetime.combine(
+ now.date() + timedelta(days=1), datetime.min.time()
+ )
+ delta_seconds = (next_midnight - now).total_seconds()
+ return GLib.timeout_add_seconds(int(delta_seconds), callback)
+
+
+def compute_time_label(arrival_time):
+ """Compute time label for notification timestamp"""
+ return arrival_time.strftime("%H:%M")
+
+
+# Data creation utilities
+def create_notification_data(notification_box):
+ """Create notification data dictionary from notification box"""
+ notification = notification_box.notification
+
+ return {
+ "id": notification_box.uuid,
+ "app_icon": getattr(notification, "app_icon", "dialog-information-symbolic"),
+ "summary": getattr(notification, "summary", "No summary"),
+ "body": getattr(notification, "body", ""),
+ "app_name": getattr(notification, "app_name", "Unknown"),
+ "timestamp": datetime.now().isoformat(),
+ "cached_image_path": getattr(notification_box, "cached_image_path", None),
+ }
+
+
+def create_historical_notification_from_data(note_data):
+ """Create HistoricalNotification object from data dictionary"""
+ return HistoricalNotification(
+ id=note_data.get("id"),
+ app_icon=note_data.get("app_icon"),
+ summary=note_data.get("summary"),
+ body=note_data.get("body"),
+ app_name=note_data.get("app_name"),
+ timestamp=note_data.get("timestamp"),
+ cached_image_path=note_data.get("cached_image_path"),
+ )
+
+
+# Storage utilities
+def ensure_persistent_dir():
+ """Ensure the persistent directory exists"""
+ if not os.path.exists(PERSISTENT_DIR):
+ os.makedirs(PERSISTENT_DIR, exist_ok=True)
+
+
+def load_persistent_history():
+ """Load notification history from persistent storage"""
+ ensure_persistent_dir()
+
+ if os.path.exists(PERSISTENT_HISTORY_FILE):
+ try:
+ with open(PERSISTENT_HISTORY_FILE, "r") as f:
+ notifications = json.load(f)
+ logger.info(
+ f"Loaded {len(notifications)} notifications from persistent history"
+ )
+ return notifications
+ except Exception as e:
+ logger.error(f"Error loading persistent history: {e}")
+ return []
+ else:
+ return []
+
+
+def save_persistent_history(notifications):
+ """Save notification history to persistent storage"""
+ try:
+ ensure_persistent_dir()
+ with open(PERSISTENT_HISTORY_FILE, "w") as f:
+ json.dump(notifications, f, indent=2)
+ logger.info(f"Saved {len(notifications)} notifications to persistent history")
+ except Exception as e:
+ logger.error(f"Error saving persistent history: {e}")
+
+
+def load_dnd_state():
+ """Load Do Not Disturb state from config"""
+ try:
+ if os.path.exists(CONFIG_FILE):
+ with open(CONFIG_FILE, "r") as f:
+ config_data = json.load(f)
+ dnd_enabled = config_data.get("dnd_enabled", False)
+ logger.info(f"Loaded DND state: {dnd_enabled}")
+ return dnd_enabled
+ except Exception as e:
+ logger.error(f"Error loading DND state: {e}")
+
+ return False
+
+
+def save_dnd_state(dnd_enabled):
+ """Save Do Not Disturb state to config"""
+ try:
+ config_data = {}
+ if os.path.exists(CONFIG_FILE):
+ with open(CONFIG_FILE, "r") as f:
+ config_data = json.load(f)
+
+ config_data["dnd_enabled"] = dnd_enabled
+
+ with open(CONFIG_FILE, "w") as f:
+ json.dump(config_data, f, indent=4)
+ logger.debug(f"Saved DND state: {dnd_enabled}")
+ except Exception as e:
+ logger.error(f"Error saving DND state: {e}")
+
+
+# Shared notification history class
+class NotificationHistory(Box):
+ """Shared notification history with DND state management"""
+
+ __gsignals__ = {
+ "dnd-state-changed": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
+ "notification-added": (GObject.SignalFlags.RUN_FIRST, None, ()),
+ }
+
+ def __init__(self, **kwargs):
+ super().__init__(name="notification-history", orientation="v", **kwargs)
+ self.do_not_disturb_enabled = False
+ self.persistent_notifications = []
+ self._load_persistent_history()
+ self._load_dnd_state()
+
+ def set_do_not_disturb_enabled(self, enabled):
+ """Set DND state and emit signal if changed"""
+ if self.do_not_disturb_enabled != enabled:
+ self.do_not_disturb_enabled = enabled
+ save_dnd_state(enabled)
+ self.emit("dnd-state-changed", enabled)
+
+ def _load_persistent_history(self):
+ """Load persistent notification history"""
+ self.persistent_notifications = load_persistent_history()
+
+ def _load_dnd_state(self):
+ """Load DND state from config"""
+ self.do_not_disturb_enabled = load_dnd_state()
+
+ def _save_persistent_history(self):
+ """Save persistent notification history"""
+ save_persistent_history(self.persistent_notifications)
+
+ def add_notification(self, notification_box):
+ """Add notification to history"""
+ try:
+ hist_data = create_notification_data(notification_box)
+ self.persistent_notifications.append(hist_data)
+
+ # Remove old notifications if we exceed the limit
+ if len(self.persistent_notifications) > MAX_PERSISTENT_NOTIFICATIONS:
+ self.persistent_notifications.pop(0)
+
+ self._save_persistent_history()
+ self.emit("notification-added")
+ except Exception as e:
+ print(f"Error adding notification to history: {e}")
+
+ def clear_history_for_app(self, app_name):
+ """Clear history for specific app"""
+ try:
+ original_count = len(self.persistent_notifications)
+ self.persistent_notifications = [
+ notif
+ for notif in self.persistent_notifications
+ if notif.get("app_name") != app_name
+ ]
+ removed_count = original_count - len(self.persistent_notifications)
+ if removed_count > 0:
+ self._save_persistent_history()
+ logger.info(
+ f"Cleared {removed_count} notifications for app: {app_name}"
+ )
+ except Exception as e:
+ print(f"Error clearing history for app {app_name}: {e}")
+
+ def clear_history(self):
+ """Clear all notification history"""
+ try:
+ # Clear cached images for all notifications
+ for notif in self.persistent_notifications:
+ cached_image_path = notif.get("cached_image_path")
+ if cached_image_path:
+ delete_cached_image(cached_image_path)
+
+ # Clear the persistent notifications list
+ self.persistent_notifications.clear()
+
+ # Save the empty history
+ self._save_persistent_history()
+
+ logger.info("Cleared all notification history")
+ except Exception as e:
+ print(f"Error clearing notification history: {e}")
+
+
+def get_shared_notification_history():
+ """Get or create the shared notification history instance"""
+ global _shared_notification_history
+ if _shared_notification_history is None:
+ _shared_notification_history = NotificationHistory()
+ return _shared_notification_history
diff --git a/utils/occlusion.py b/utils/occlusion.py
index bf06350..b81ee75 100644
--- a/utils/occlusion.py
+++ b/utils/occlusion.py
@@ -1,5 +1,5 @@
-import subprocess
import json
+import subprocess
import config.data as data
@@ -138,4 +138,3 @@ def check_occlusion(occlusion_region, workspace=None):
return True # Occlusion region is occupied
return False # No window overlaps the occlusion region
-
diff --git a/utils/wayland.py b/utils/wayland.py
new file mode 100644
index 0000000..472dc40
--- /dev/null
+++ b/utils/wayland.py
@@ -0,0 +1,359 @@
+import re
+from collections.abc import Iterable
+from enum import Enum
+from typing import Literal, cast
+
+import cairo
+import gi
+from fabric.core.service import Property
+from fabric.utils.helpers import extract_css_values, get_enum_member
+from fabric.widgets.window import Window
+from gi.repository import Gdk, GObject, Gtk
+from loguru import logger
+
+gi.require_version("Gtk", "3.0")
+
+try:
+ gi.require_version("GtkLayerShell", "0.1")
+ from gi.repository import GtkLayerShell
+except:
+ raise ImportError(
+ "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)"
+ )
+
+
+class WaylandWindowExclusivity(Enum):
+ NONE = 1
+ NORMAL = 2
+ AUTO = 3
+
+
+class Layer(GObject.GEnum):
+ BACKGROUND = 0
+ BOTTOM = 1
+ TOP = 2
+ OVERLAY = 3
+ ENTRY_NUMBER = 4
+
+
+class KeyboardMode(GObject.GEnum):
+ NONE = 0
+ EXCLUSIVE = 1
+ ON_DEMAND = 2
+ ENTRY_NUMBER = 3
+
+
+class Edge(GObject.GEnum):
+ LEFT = 0
+ RIGHT = 1
+ TOP = 2
+ BOTTOM = 3
+ ENTRY_NUMBER = 4
+
+
+class WaylandWindow(Window):
+ @Property(
+ Layer,
+ flags="read-write",
+ default_value=Layer.TOP,
+ )
+ def layer(self) -> Layer: # type: ignore
+ return self._layer # type: ignore
+
+ @layer.setter
+ def layer(
+ self,
+ value: Literal["background", "bottom", "top", "overlay"] | Layer,
+ ) -> None:
+ self._layer = get_enum_member(Layer, value, default=Layer.TOP)
+ return GtkLayerShell.set_layer(self, self._layer)
+
+ @Property(int, "read-write")
+ def monitor(self) -> int:
+ if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))):
+ return -1
+ display = monitor.get_display() or Gdk.Display.get_default()
+ for i in range(0, display.get_n_monitors()):
+ if display.get_monitor(i) is monitor:
+ return i
+ return -1
+
+ @monitor.setter
+ def monitor(self, monitor: int | Gdk.Monitor) -> bool:
+ if isinstance(monitor, int):
+ display = Gdk.Display().get_default()
+ monitor = display.get_monitor(monitor)
+ return (
+ (GtkLayerShell.set_monitor(self, monitor), True)[1]
+ if monitor is not None
+ else False
+ )
+
+ @Property(WaylandWindowExclusivity, "read-write")
+ def exclusivity(self) -> WaylandWindowExclusivity:
+ return self._exclusivity
+
+ @exclusivity.setter
+ def exclusivity(
+ self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity
+ ) -> None:
+ value = get_enum_member(
+ WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE
+ )
+ self._exclusivity = value
+ match value:
+ case WaylandWindowExclusivity.NORMAL:
+ return GtkLayerShell.set_exclusive_zone(self, True)
+ case WaylandWindowExclusivity.AUTO:
+ return GtkLayerShell.auto_exclusive_zone_enable(self)
+ case _:
+ return GtkLayerShell.set_exclusive_zone(self, False)
+
+ @Property(bool, "read-write", default_value=False)
+ def pass_through(self) -> bool:
+ return self._pass_through
+
+ @pass_through.setter
+ def pass_through(self, pass_through: bool = False):
+ self._pass_through = pass_through
+ region = cairo.Region() if pass_through is True else None
+ self.input_shape_combine_region(region)
+ del region
+ return
+
+ @Property(
+ KeyboardMode,
+ "read-write",
+ default_value=KeyboardMode.NONE,
+ )
+ def keyboard_mode(self) -> KeyboardMode:
+ return self._keyboard_mode
+
+ @keyboard_mode.setter
+ def keyboard_mode(
+ self,
+ value: Literal[
+ "none",
+ "exclusive",
+ "on-demand",
+ "entry-number",
+ ]
+ | KeyboardMode,
+ ):
+ self._keyboard_mode = get_enum_member(
+ KeyboardMode, value, default=KeyboardMode.NONE
+ )
+ return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode)
+
+ @Property(tuple[Edge, ...], "read-write")
+ def anchor(self):
+ return tuple(
+ x
+ for x in [
+ Edge.TOP,
+ Edge.RIGHT,
+ Edge.BOTTOM,
+ Edge.LEFT,
+ ]
+ if GtkLayerShell.get_anchor(self, x)
+ )
+
+ @anchor.setter
+ def anchor(self, value: str | Iterable[Edge]) -> None:
+ self._anchor = value
+ if isinstance(value, (list, tuple)) and all(
+ isinstance(edge, Edge) for edge in value
+ ):
+ for edge in [
+ Edge.TOP,
+ Edge.RIGHT,
+ Edge.BOTTOM,
+ Edge.LEFT,
+ ]:
+ if edge not in value:
+ GtkLayerShell.set_anchor(self, edge, False)
+ GtkLayerShell.set_anchor(self, edge, True)
+ return
+ elif isinstance(value, str):
+ for edge, anchored in WaylandWindow.extract_edges_from_string(
+ value
+ ).items():
+ GtkLayerShell.set_anchor(self, edge, anchored)
+
+ return
+
+ @Property(tuple[int, ...], flags="read-write")
+ def margin(self) -> tuple[int, ...]:
+ return tuple(
+ GtkLayerShell.get_margin(self, x)
+ for x in [
+ Edge.TOP,
+ Edge.RIGHT,
+ Edge.BOTTOM,
+ Edge.LEFT,
+ ]
+ )
+
+ @margin.setter
+ def margin(self, value: str | Iterable[int]) -> None:
+ for edge, mrgv in WaylandWindow.extract_margin(value).items():
+ GtkLayerShell.set_margin(self, edge, mrgv)
+ return
+
+ @Property(object, "read-write")
+ def keyboard_mode(self):
+ kb_mode = GtkLayerShell.get_keyboard_mode(self)
+ if GtkLayerShell.get_keyboard_interactivity(self):
+ kb_mode = KeyboardMode.EXCLUSIVE
+ return kb_mode
+
+ @keyboard_mode.setter
+ def keyboard_mode(
+ self,
+ value: Literal["none", "exclusive", "on-demand"] | KeyboardMode,
+ ):
+ return GtkLayerShell.set_keyboard_mode(
+ self,
+ get_enum_member(
+ KeyboardMode,
+ value,
+ default=KeyboardMode.NONE,
+ ),
+ )
+
+ def __init__(
+ self,
+ layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP,
+ anchor: str = "",
+ margin: str | Iterable[int] = "0px 0px 0px 0px",
+ exclusivity: Literal["auto", "normal", "none"]
+ | WaylandWindowExclusivity = WaylandWindowExclusivity.NONE,
+ keyboard_mode: Literal["none", "exclusive", "on-demand"]
+ | KeyboardMode = KeyboardMode.NONE,
+ pass_through: bool = False,
+ monitor: int | Gdk.Monitor | None = None,
+ title: str = "fabric",
+ type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Window.__init__(
+ self,
+ title=title,
+ type=type,
+ child=child,
+ name=name,
+ visible=False,
+ all_visible=False,
+ style=style,
+ style_classes=style_classes,
+ tooltip_text=tooltip_text,
+ tooltip_markup=tooltip_markup,
+ h_align=h_align,
+ v_align=v_align,
+ h_expand=h_expand,
+ v_expand=v_expand,
+ size=size,
+ **kwargs,
+ )
+ self._layer = Layer.ENTRY_NUMBER
+ self._keyboard_mode = KeyboardMode.NONE
+ self._anchor = anchor
+ self._exclusivity = WaylandWindowExclusivity.NONE
+ self._pass_through = pass_through
+
+ GtkLayerShell.init_for_window(self)
+ GtkLayerShell.set_namespace(self, title)
+ self.connect(
+ "notify::title",
+ lambda *_: GtkLayerShell.set_namespace(self, self.get_title()),
+ )
+ if monitor is not None:
+ self.monitor = monitor
+ self.layer = layer
+ self.anchor = anchor
+ self.margin = margin
+ self.keyboard_mode = keyboard_mode
+ self.exclusivity = exclusivity
+ self.pass_through = pass_through
+ self.show_all() if all_visible is True else self.show() if visible is True else None
+
+ def steal_input(self) -> None:
+ return GtkLayerShell.set_keyboard_interactivity(self, True)
+
+ def return_input(self) -> None:
+ return GtkLayerShell.set_keyboard_interactivity(self, False)
+
+ # custom overrides
+ def show(self) -> None:
+ super().show()
+ return self.do_handle_post_show_request()
+
+ def show_all(self) -> None:
+ super().show_all()
+ return self.do_handle_post_show_request()
+
+ def do_handle_post_show_request(self) -> None:
+ if not self.get_children():
+ logger.warning(
+ "[WaylandWindow] showing an empty window is not recommended, some compositors might freak out."
+ )
+ self.pass_through = self._pass_through
+ return
+
+ @staticmethod
+ def extract_anchor_values(string: str) -> tuple[str, ...]:
+ """
+ extracts the geometry values from a given geometry string.
+
+ :param string: the string containing the geometry values.
+ :type string: str
+ :return: a list of unique directions extracted from the geometry string.
+ :rtype: list
+ """
+ direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"}
+ pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE)
+ matches = pattern.findall(string)
+ return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches)))
+
+ @staticmethod
+ def extract_edges_from_string(string: str) -> dict["Edge", bool]:
+ anchor_values = WaylandWindow.extract_anchor_values(string.lower())
+ return {
+ Edge.TOP: "top" in anchor_values,
+ Edge.RIGHT: "right" in anchor_values,
+ Edge.BOTTOM: "bottom" in anchor_values,
+ Edge.LEFT: "left" in anchor_values,
+ }
+
+ @staticmethod
+ def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]:
+ margins = (
+ extract_css_values(input.lower())
+ if isinstance(input, str)
+ else input
+ if isinstance(input, (tuple, list)) and len(input) == 4
+ else (0, 0, 0, 0)
+ )
+ return {
+ Edge.TOP: margins[0],
+ Edge.RIGHT: margins[1],
+ Edge.BOTTOM: margins[2],
+ Edge.LEFT: margins[3],
+ }
diff --git a/utils/wiggle_bar.py b/utils/wiggle_bar.py
new file mode 100644
index 0000000..69298c2
--- /dev/null
+++ b/utils/wiggle_bar.py
@@ -0,0 +1,174 @@
+import math
+from fabric.core.service import Service, Signal
+from gi.repository import Gtk, Gdk, GLib
+import gi
+
+gi.require_version("Gtk", "3.0")
+
+
+class WigglyWidget(Gtk.DrawingArea, Service):
+ @Signal
+ def on_seek(self, ratio: float) -> None: ...
+
+ def __init__(self):
+ super().__init__(name="wiggle-bar")
+ self.phase = 0
+ self.set_size_request(-1, 20)
+ self.connect("draw", self.on_draw)
+
+ # mouse events
+ self.add_events(
+ Gdk.EventMask.BUTTON_PRESS_MASK
+ | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK
+ )
+
+ self.connect("button-press-event", self.on_button_press)
+ self.connect("motion-notify-event", self.on_motion)
+ self.connect("button-release-event", self.on_button_release)
+
+ GLib.timeout_add(16, self.update) # 60 FPS
+ self.value = 0.0
+ self.amplitude = 2
+ self.dragging = False
+ self.pause = False
+
+ self.show_all()
+
+ def animate_amplitude_to(self):
+ if abs(self.amplitude - self.amplitude_target) < 0.01:
+ self.amplitude = self.amplitude_target
+ self.queue_draw()
+ return False
+ else:
+ self.amplitude += self.amplitude_step
+ self.queue_draw()
+ return True
+
+ def update_amplitude(self, decrease):
+ if decrease:
+ self.amplitude_target = 0
+ self.amplitude_step = (0 - self.amplitude) / 10.0
+ else:
+ self.amplitude_target = 2
+ self.amplitude_step = (2 - self.amplitude) / 10.0
+
+ # animation stops when False is returned btw
+ GLib.timeout_add(16, self.animate_amplitude_to)
+
+ def update_value_from_x(self, x):
+ width = self.get_allocated_width()
+ self.value = max(0.0, min(1.0, x / width))
+ self.on_seek(self.value)
+ self.queue_draw()
+
+ def on_button_press(self, widget, event):
+ self.dragging = True
+ self.update_amplitude(True)
+ self.update_value_from_x(event.x)
+ return True
+
+ # def animate_progress_to(self):
+ # print("frame")
+ # if self.value < self.value_target:
+ # self.value = self.value_target
+ # print(self.value)
+ # self.queue_draw()
+ # return False
+ # else:
+ # self.value += self.value_step
+ # self.queue_draw()
+ # return True
+
+ # def update_value_from_signal(self, new_value):
+ # self.value_target = min(1.0, new_value)
+ # steps = 60 # 1s / 16ms ≈ 60 frames
+ # self.value_step = (self.value_target - self.value) / steps
+ # GLib.timeout_add(16, self.animate_progress_to)
+
+ def update_value_from_signal(self, new_value):
+ self.value = min(1.0, new_value)
+
+ def on_motion(self, widget, event):
+ if self.dragging and not self.pause:
+ self.update_value_from_x(event.x)
+
+ cursor = Gdk.Cursor.new(Gdk.CursorType.HAND1)
+ widget.get_window().set_cursor(cursor)
+
+ return True
+
+ def on_button_release(self, widget, event):
+ if self.pause == False:
+ self.dragging = False
+ self.update_amplitude(False)
+ return True
+
+ def update(self):
+ if self.dragging == False:
+ self.phase += 0.1
+ # if (self.value<1):
+ # self.value += 0.001
+ self.queue_draw()
+ return True
+
+ def on_draw(self, widget, cr):
+ alloc_width = self.get_allocated_width()
+ height = self.get_allocated_height()
+ center_y = height / 2
+ # self.amplitude = 2
+ frequency = 0.3
+ slider_diameter = 6
+
+ width = int(alloc_width * self.value) - slider_diameter + 1
+
+ cr.set_source_rgb(1, 1, 1) # color here btw
+ cr.set_line_width(2)
+
+ last_x = 0 # fallback values in case the slider gets dragged out of range
+ last_y = 0
+
+ cr.move_to(0, center_y)
+ for x in range(width):
+ y = center_y + self.amplitude * math.sin((x * frequency) + self.phase)
+ cr.line_to(x, y)
+ last_x, last_y = x, y
+
+ cr.stroke()
+
+ # Set the color using CSS - the widget should have CSS styling applied
+ # that sets the color to var(--primary). For now, use a fallback approach.
+ style_context = self.get_style_context()
+
+ try:
+ # Get the foreground color which should be styled with CSS
+ color = style_context.get_color(Gtk.StateFlags.NORMAL)
+ cr.set_source_rgba(color.red, color.green, color.blue, color.alpha)
+ except:
+ # Fallback to a nice blue color that matches the primary theme
+ cr.set_source_rgb(0.2, 0.6, 1.0)
+
+ rect_width = 6
+ arc_radius = slider_diameter / 2
+ self.draw_rounded_rect(cr, last_x, height, rect_width, rect_width, arc_radius)
+ cr.fill()
+
+ cr.stroke()
+
+ cr.set_source_rgb(1, 1, 1)
+ cr.set_line_width(1)
+
+ # This is for anti aliasing. When line width becomes 1px and the position is not an integer, two lines of opacity 50% is drawn on two pixel rows.
+ # This moves the line to make it pixel perfect hence not needing anti aliasing
+ cr.translate(0, 0.5)
+
+ cr.move_to(last_x + 2 * arc_radius, height / 2)
+ cr.line_to(alloc_width, height / 2)
+
+ cr.stroke()
+
+ def draw_rounded_rect(self, cr, x, y, width, _height, radius):
+ cr.new_sub_path()
+ cr.arc(x + width - radius, radius, radius, -math.pi, 0)
+ cr.arc(x + width - radius, y - radius, radius, 0, -math.pi)
+ cr.close_path()