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,