diff --git a/src/window/dock.py b/src/window/dock.py
index e22413d..0bb575f 100644
--- a/src/window/dock.py
+++ b/src/window/dock.py
@@ -1,6 +1,8 @@
import json
import subprocess
+import tomlkit
+
from fabric.utils import (
GLib,
Gtk,
@@ -26,14 +28,12 @@ from services.modus import modus_service
from utils.functions import (
clear_children,
is_special_workspace_id,
- read_json_file,
- write_json_file,
)
from utils.icon_resolver import IconResolver
from utils.occlusion import check_occlusion
# Pinned apps file
-PINNED_APPS_FILE = get_relative_path("../../config/dock.json")
+PINNED_APPS_FILE = get_relative_path("../../config/dock.toml")
class AppBar(Box):
@@ -57,7 +57,7 @@ class AppBar(Box):
# Initialize GTK menu
self.menu = Gtk.Menu()
- self.pinned_apps = read_json_file(PINNED_APPS_FILE) or []
+ self.pinned_apps = self._read_pinned_apps()
self.pinned_apps_container = Box()
self.add(self.pinned_apps_container)
@@ -162,6 +162,34 @@ class AppBar(Box):
super().destroy()
+ def _read_pinned_apps(self) -> list:
+ try:
+ if os.path.exists(PINNED_APPS_FILE):
+ with open(PINNED_APPS_FILE, "r") as f:
+ data = tomlkit.load(f)
+ return list(data.get("pinned", []))
+ except Exception as e:
+ logger.error(f"[AppBar] Failed to read pinned apps: {e}")
+ return []
+
+ def _write_pinned_apps(self):
+ try:
+ doc = tomlkit.document()
+ arr = tomlkit.array()
+ for app in self.pinned_apps:
+ if isinstance(app, dict):
+ tbl = tomlkit.inline_table()
+ for k, v in app.items():
+ tbl[k] = v
+ arr.append(tbl)
+ else:
+ arr.append(app)
+ doc["pinned"] = arr
+ with open(PINNED_APPS_FILE, "w") as f:
+ tomlkit.dump(doc, f)
+ except Exception as e:
+ logger.error(f"[AppBar] Failed to write pinned apps: {e}")
+
def _populate_pinned_apps(self):
clear_children(self.pinned_apps_container)
@@ -515,7 +543,7 @@ class AppBar(Box):
except Exception:
self.pinned_apps.append(app_class)
- write_json_file(self.pinned_apps, PINNED_APPS_FILE)
+ self._write_pinned_apps()
self._populate_pinned_apps()
return True
@@ -530,7 +558,7 @@ class AppBar(Box):
self.pinned_apps.pop(i)
if apps_to_remove:
- write_json_file(self.pinned_apps, PINNED_APPS_FILE)
+ self._write_pinned_apps()
self._populate_pinned_apps()
return True
return False
diff --git a/src/window/launcher/plugins/emoji.py b/src/window/launcher/plugins/emoji.py
index fb8d1dd..307f9c3 100644
--- a/src/window/launcher/plugins/emoji.py
+++ b/src/window/launcher/plugins/emoji.py
@@ -278,7 +278,7 @@ class EmojiPlugin(PluginBase):
subtitle = f"{keywords}" + (" • Recent" if is_recent else "")
# Use larger font size for emojis to make them visible
- icon_markup = f"{emoji}"
+ icon_markup = f"{GLib.markup_escape_text(emoji)}"
return Result(
title=name,
diff --git a/src/window/launcher/plugins/wallpaper.py b/src/window/launcher/plugins/wallpaper.py
index c9359ac..8726a8f 100644
--- a/src/window/launcher/plugins/wallpaper.py
+++ b/src/window/launcher/plugins/wallpaper.py
@@ -1,7 +1,8 @@
import colorsys
import hashlib
-import json
import threading
+
+import tomlkit
from typing import Dict, List, Optional
from fabric.utils import (
@@ -74,39 +75,32 @@ class WallpaperPlugin(PluginBase):
)
def _get_matugen_state(self) -> bool:
- """Get current matugen state from config.json."""
- self.matugen_enabled = True # Default to True
+ """Get current matugen state from config.toml."""
+ self.matugen_enabled = True
try:
with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
+ config = tomlkit.load(f)
self.matugen_enabled = config.get("matugen_enabled", True)
except FileNotFoundError:
- # File doesn't exist, keep default True
pass
except Exception as e:
logger.error(f"Error reading config file: {e}")
- # Keep default True on error
return self.matugen_enabled
def _set_matugen_state(self, enabled: bool):
- """Set matugen state and save to config.json."""
+ """Set matugen state and save to config.toml."""
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)
+ config = tomlkit.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)
+ tomlkit.dump(config, f)
except Exception as e:
logger.error(f"Error writing matugen state to config: {e}")
@@ -335,10 +329,9 @@ class WallpaperPlugin(PluginBase):
"""Get current color scheme from config (default to tonal-spot)."""
try:
with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
+ config = tomlkit.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:
logger.error(f"Error reading current scheme from config: {e}")
@@ -349,20 +342,16 @@ class WallpaperPlugin(PluginBase):
scheme_name = self.schemes.get(scheme, scheme)
matugen_enabled = self._get_matugen_state()
- # Save the scheme to config
try:
- # Read current config
- config = {}
+ config = tomlkit.document()
if os.path.exists(data.CONFIG_FILE):
with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
+ config = tomlkit.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)
+ tomlkit.dump(config, f)
except Exception as e:
logger.error(f"Error saving current scheme to config: {e}")
diff --git a/src/window/launcher/trigger_config.py b/src/window/launcher/trigger_config.py
index 00c2559..c5fc726 100644
--- a/src/window/launcher/trigger_config.py
+++ b/src/window/launcher/trigger_config.py
@@ -1,26 +1,28 @@
-from fabric.utils import logger
-import json
-import os
from typing import Any, Dict, List
+import tomlkit
from fabric.utils import get_relative_path
class TriggerConfig:
def __init__(self, config_path: str = None):
if config_path is None:
- config_path = get_relative_path("../../../config/launcher.json")
+ config_path = get_relative_path("../../../config/launcher.toml")
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:
- logger.error(f"Error loading trigger config: {e}")
+ try:
+ with open(config_path, "r") as f:
+ data = tomlkit.load(f)
+ config["launcher_config"] = dict(data.get("launcher_config", {}))
+ config["settings"] = dict(data.get("settings", {}))
+ except FileNotFoundError:
+ pass
+ except Exception as e:
+ from fabric.utils import logger
+
+ logger.error(f"Error loading trigger config: {e}")
self.config = config
self.launcher_config = self.config.get("launcher_config", {})
diff --git a/src/window/panel/components/custom_mods.py b/src/window/panel/components/custom_mods.py
new file mode 100644
index 0000000..2e484a9
--- /dev/null
+++ b/src/window/panel/components/custom_mods.py
@@ -0,0 +1,241 @@
+from pathlib import Path
+
+import tomlkit
+
+from fabric.utils import GLib, logger
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from gi.repository import Gio
+
+from shared.window.applet_window import AppletWindow
+from utils.functions import run_command, thread
+from utils.utils import setup_cursor_hover, svg_file
+
+MODS_CONFIG_PATH = (
+ Path(__file__).resolve().parent.parent.parent.parent.parent / "config" / "mods.toml"
+)
+RELOAD_DELAY_MS = 200
+
+
+def dropdown_divider():
+ return Box(
+ children=[Box(name="dropdown-divider", h_expand=True)],
+ name="dropdown-divider-box",
+ h_align="fill",
+ h_expand=True,
+ )
+
+
+class ModPopup(AppletWindow):
+ def __init__(
+ self, mod_name, options, parent_window, pointing_to, source_button, **kwargs
+ ):
+ self._mod_name = mod_name
+ self._source_button = source_button
+
+ option_widgets = []
+ for opt in sorted(options, key=lambda o: o.get("order", 99)):
+ if opt.get("divider"):
+ option_widgets.append(dropdown_divider())
+
+ btn = Button(
+ name="dropdown-option",
+ child=Label(label=opt["label"], name="dropdown-option-label"),
+ h_align="fill",
+ h_expand=True,
+ )
+ cmd = opt["on-clicked"]
+ btn.connect("clicked", lambda *_, c=cmd: self._run_command(c))
+ setup_cursor_hover(btn, "pointer")
+ option_widgets.append(btn)
+
+ options_box = Box(
+ name="dropdown-options",
+ orientation="vertical",
+ spacing=0,
+ children=option_widgets,
+ )
+
+ main_box = Box(name="dropdown-menu", children=[options_box])
+
+ super().__init__(
+ parent=parent_window,
+ pointing_to=pointing_to,
+ name="dropdown-menu",
+ title="modus-dropdown",
+ layer="overlay",
+ visible=False,
+ **kwargs,
+ )
+ self.children = [main_box]
+ self.connect("notify::visible", self._on_visible_changed)
+
+ def _on_visible_changed(self, *_):
+ if self.get_visible():
+ self._source_button.add_style_class("active")
+ else:
+ self._source_button.remove_style_class("active")
+
+ def _run_command(self, cmd):
+ self.close_applet()
+ thread(run_command, cmd.split(), timeout=30)
+
+ def toggle(self, pointing_to=None):
+ if pointing_to:
+ self.set_pointing_to(pointing_to)
+ super().toggle()
+
+
+class CustomMods(Box):
+ def __init__(self, parent_window, **kwargs):
+ self._parent = parent_window
+ self._monitors = []
+ self._reload_pending = False
+ self._mods_data = {}
+ self._mod_buttons = {}
+ self._mod_popups = {}
+ super().__init__(name="custom-mods", orientation="h", spacing=0, **kwargs)
+ self._load_mods()
+ self._setup_monitor()
+
+ def _load_mods(self):
+ try:
+ if not MODS_CONFIG_PATH.exists():
+ return
+ with open(MODS_CONFIG_PATH) as f:
+ data = tomlkit.load(f)
+ except Exception as e:
+ logger.error(f"[CustomMods] Failed to parse {MODS_CONFIG_PATH}: {e}")
+ return
+
+ mods = {}
+ for mod_name, val in data.get("Mods", {}).items():
+ icon = val.get("icon", "")
+ icon_size = val.get("icon-size", 18)
+ order = val.get("order", 99)
+ on_clicked = val.get("on-clicked")
+ options = val.get("options", [])
+ mods[mod_name] = {
+ "name": mod_name,
+ "icon": icon,
+ "icon_size": icon_size,
+ "order": order,
+ "on-clicked": on_clicked,
+ "options": options,
+ }
+
+ self._rebuild(mods)
+
+ def _on_btn_press(self, btn, event, mod):
+ popup = self._mod_popups.get(mod["name"])
+
+ if event.button == 3:
+ if popup:
+ popup.toggle(pointing_to=btn)
+ return True
+
+ if event.button == 1:
+ if popup and popup._is_open:
+ popup.toggle()
+ return True
+
+ cmd = mod.get("on-clicked")
+ if cmd:
+ thread(run_command, cmd.split(), timeout=30)
+ return True
+
+ if popup:
+ popup.toggle(pointing_to=btn)
+ return True
+
+ return False
+
+ def _rebuild(self, mods):
+ for child in list(self.get_children()):
+ self.remove(child)
+
+ for popup in self._mod_popups.values():
+ if popup:
+ popup.destroy()
+ self._mod_popups.clear()
+ self._mod_buttons.clear()
+ self._mods_data.clear()
+
+ sorted_mods = sorted(mods.values(), key=lambda m: m.get("order", 99))
+
+ for mod in sorted_mods:
+ icon_widget = svg_file(mod["icon"], size=mod["icon_size"])
+ btn = Button(name="global-menu", child=icon_widget)
+ setup_cursor_hover(btn, "pointer")
+
+ popup = None
+ if mod["options"]:
+ popup = ModPopup(
+ mod_name=mod["name"],
+ options=mod["options"],
+ parent_window=self._parent,
+ pointing_to=btn,
+ source_button=btn,
+ )
+
+ btn.connect(
+ "button-press-event",
+ lambda b, e, m=mod: self._on_btn_press(b, e, m),
+ )
+
+ self.add(btn)
+ self._mod_buttons[mod["name"]] = btn
+ self._mod_popups[mod["name"]] = popup
+ self._mods_data[mod["name"]] = mod
+
+ self.show_all()
+
+ def _setup_monitor(self):
+ try:
+ for path in [MODS_CONFIG_PATH, MODS_CONFIG_PATH.parent]:
+ if not path.exists():
+ continue
+ gio_file = Gio.File.new_for_path(str(path))
+ monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
+ monitor.connect("changed", self._on_file_changed)
+ self._monitors.append(monitor)
+ except Exception as e:
+ logger.error(f"[CustomMods] Failed to setup monitor: {e}")
+
+ def _on_file_changed(self, monitor, gfile, other_file, event_type):
+ if event_type == Gio.FileMonitorEvent.DELETED and gfile.get_path() == str(
+ MODS_CONFIG_PATH
+ ):
+ self._schedule_reload()
+ return
+ valid_events = [
+ Gio.FileMonitorEvent.CHANGES_DONE_HINT,
+ Gio.FileMonitorEvent.CHANGED,
+ Gio.FileMonitorEvent.CREATED,
+ ]
+ if event_type in valid_events and not self._reload_pending:
+ path = gfile.get_path()
+ if path and path != str(MODS_CONFIG_PATH):
+ return
+ self._schedule_reload()
+
+ def _schedule_reload(self):
+ self._reload_pending = True
+ GLib.timeout_add(RELOAD_DELAY_MS, self._reload_config)
+
+ def _reload_config(self):
+ self._reload_pending = False
+ self._load_mods()
+ return False
+
+ def destroy(self):
+ for monitor in self._monitors:
+ monitor.cancel()
+ for popup in self._mod_popups.values():
+ if popup:
+ popup.destroy()
+ self._mod_popups.clear()
+ self._mod_buttons.clear()
+ self._mods_data.clear()
+ super().destroy()
diff --git a/src/window/panel/components/google_lens_indicator.py b/src/window/panel/components/google_lens_indicator.py
new file mode 100644
index 0000000..0f3e4df
--- /dev/null
+++ b/src/window/panel/components/google_lens_indicator.py
@@ -0,0 +1,20 @@
+from fabric.widgets.button import Button
+
+from services.google_lens import GoogleLens
+from utils.functions import thread
+from utils.utils import setup_cursor_hover, svg_file
+
+
+class GoogleLensIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(name="panel-button", **kwargs)
+ self._lens = GoogleLens.get_initial()
+
+ self.icon = svg_file("google-lens.svg", size=16)
+ self.add(self.icon)
+
+ setup_cursor_hover(self, "pointer")
+ self.connect("clicked", self.on_clicked)
+
+ def on_clicked(self, *_):
+ thread(self._lens.search)
diff --git a/src/window/panel/main.py b/src/window/panel/main.py
index 1e61f5e..0b76f6b 100644
--- a/src/window/panel/main.py
+++ b/src/window/panel/main.py
@@ -15,6 +15,8 @@ from window.controlcenter.main import ModusControlCenter
from window.notification.notification_center import NotificationCenter
from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray
from window.panel.components.globalmenu import GlobalMenu
+from window.panel.components.custom_mods import CustomMods
+from window.panel.components.google_lens_indicator import GoogleLensIndicator
from window.panel.components.indicators import (
BatteryIndicator,
BluetoothIndicator,
@@ -109,6 +111,8 @@ class Panel(Window):
)
setup_cursor_hover(self.datetime_btn, "pointer")
+ self.google_lens_indicator = GoogleLensIndicator()
+ self.custom_mods = CustomMods(parent_window=self)
self.workspace_indicator = WorkspaceIndicator()
self.recording_indicator = RecordingIndicator()
@@ -235,6 +239,10 @@ class Panel(Window):
# Right
right_children = []
+ if config_data.get("custom_mods", True):
+ right_children.append(self.custom_mods)
+ if config_data.get("google_lens", True):
+ right_children.append(self.google_lens_indicator)
if config_data.get("workspace_indicator", True):
right_children.append(self.workspace_indicator)
@@ -268,6 +276,8 @@ class Panel(Window):
keys = {
"imac_button",
"global_menu",
+ "google_lens",
+ "custom_mods",
"workspace_indicator",
"systray",
"battery",
@@ -328,6 +338,8 @@ class Panel(Window):
# Destroy components
for component in [
self.globalmenu,
+ self.google_lens_indicator,
+ self.custom_mods,
self.workspace_indicator,
self.recording_indicator,
self.indicators,