diff --git a/config/cavalcade/cava.ini b/config/cavalcade/cava.ini
new file mode 100644
index 0000000..63c0d55
--- /dev/null
+++ b/config/cavalcade/cava.ini
@@ -0,0 +1,36 @@
+[general]
+bars = 24
+
+sensitivity = 100
+framerate = 60
+lower_cutoff_freq = 50
+higher_cutoff_freq = 8000
+autosens = 1
+
+# these parameters should not be changed
+# bar width and spacing goes wrong with raw output
+bar_width = 1
+bar_spacing = 0
+
+[output]
+method = raw
+raw_target = /tmp/cava.fifo
+bit_format = 16bit
+channels = stereo
+
+[smoothing]
+gravity = 200
+integral = 70
+ignore = 0
+monstercat = 1
+
+[eq]
+1 = 1.00
+2 = 1.00
+3 = 1.00
+4 = 1.00
+5 = 1.00
+6 = 1.00
+7 = 1.00
+8 = 1.00
+9 = 1.00
diff --git a/config/config.json b/config/config.json
index efc9471..fd05dc4 100644
--- a/config/config.json
+++ b/config/config.json
@@ -1,6 +1,6 @@
{
- "prefix_restart": "SUPER ALT",
- "suffix_restart": "B",
+ "prefix_restart": "SHIFT ALT",
+ "suffix_restart": "W",
"prefix_axmsg": "SUPER",
"suffix_axmsg": "A",
"prefix_dash": "SUPER",
@@ -53,6 +53,8 @@
"dock_controls_visible": true,
"dock_indicators_visible": true,
"dock_applications_visible": true,
+ "dock_language_visible": true,
+ "dock_music_player_visible": true,
"workspace_dots": false,
"workspace_nums": false,
"workspace_use_chinese_numerals": false,
diff --git a/config/data.py b/config/data.py
index 9c0338b..dbb4b08 100644
--- a/config/data.py
+++ b/config/data.py
@@ -80,6 +80,7 @@ if os.path.exists(CONFIG_FILE):
"indicators": config.get("dock_indicators_visible", True),
"systray": config.get("dock_tray_visible", True),
"applications": config.get("dock_applications_visible", True),
+ "music_player": config.get("dock_music_player_visible", True),
}
METRICS_DISKS = config.get("metrics_disks", ["/"])
@@ -109,6 +110,7 @@ else:
"indicators": True,
"systray": True,
"applications": True,
+ "music_player": True,
}
METRICS_DISKS = ["/"]
diff --git a/config/hypr/colors.conf b/config/hypr/colors.conf
index 5846e3f..1dce2f8 100644
--- a/config/hypr/colors.conf
+++ b/config/hypr/colors.conf
@@ -1,27 +1,27 @@
-$wallpaper = /home/sankalp/Pictures/wallpapers/165.jpg
-$background = 16130b
-$foreground = eae1d4
+$wallpaper = /home/sankalp/Pictures/wallpapers/179.png
+$background = 101417
+$foreground = e0e3e8
-$primary = e3c46c
-$secondary = d5c5a1
-$tertiary = adcfae
-$surface = 16130b
-$surface_bright = 3d392f
-$outline = 989080
+$primary = 96ccf8
+$secondary = b7c9d9
+$tertiary = d0c0e8
+$surface = 101417
+$surface_bright = 363a3e
+$outline = 8b9198
$error = ff5945
$shadow = 000000
-$red = ffb595
-$green = b7d085
-$yellow = d7c770
-$blue = cebdfe
-$magenta = fcb0d5
-$cyan = 84d5c4
+$red = ffb2b9
+$green = 95d5a7
+$yellow = b8cf84
+$blue = afc6ff
+$magenta = e4b7f3
+$cyan = 82d3e2
$white = 82d3e0
-$red_dim = ff6e2f
-$green_dim = 89ad42
-$yellow_dim = b09c31
-$blue_dim = 8359fc
-$magenta_dim = f84ea1
-$cyan_dim = 3eb59c
+$red_dim = ff4c5c
+$green_dim = 4cb86a
+$yellow_dim = 8baa43
+$blue_dim = 497dff
+$magenta_dim = c360e4
+$cyan_dim = 30b5ce
diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf
index f076ea5..93c85c8 100644
--- a/config/hypr/modus.conf
+++ b/config/hypr/modus.conf
@@ -8,7 +8,7 @@ $fabricSend = fabric-cli exec modus
$axMessage = notify-send "Axenide" "FIRE IN THE HOLEβΌοΈπ£οΈπ₯π³οΈ" -i "/home/sankalp/.config/Modus/assets/ax.png" -A "π£οΈ" -A "π₯" -A "π³οΈ" -a "Source Code"
# Reload Modus
-bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python /home/sankalp/.config/Modus/main.py)
+bind = SHIFT ALT, W, exec, killall modus; uwsm-app $(python /home/sankalp/.config/Modus/main.py)
# Message
bind = SUPER, A, exec, $axMessage
# Dashboard
diff --git a/config/settings/constants.py b/config/settings/constants.py
index 57a6146..dd2753f 100644
--- a/config/settings/constants.py
+++ b/config/settings/constants.py
@@ -66,9 +66,11 @@ DEFAULTS = {
"dock_controls_visible": True,
"dock_indicators_visible": True,
"dock_applications_visible": True,
+ "dock_language_visible": True,
"workspace_nums": False,
"workspace_use_chinese_numerals": False,
"workspace_dots": False,
+ "dock_music_player": True,
"metrics_visible": {
"cpu": True,
"ram": True,
diff --git a/config/settings/gui.py b/config/settings/gui.py
index dd5609b..941b405 100644
--- a/config/settings/gui.py
+++ b/config/settings/gui.py
@@ -281,7 +281,9 @@ class HyprConfGUI(Window):
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")
+ 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 = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
@@ -290,9 +292,11 @@ class HyprConfGUI(Window):
)
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
+ sensitive=current_nums, # Only enabled when numbers mode is selected
+ )
+ self.ws_chinese_switch.set_tooltip_text(
+ "Use Chinese numerals (δΈ, δΊ, δΈ...) instead of Arabic numbers"
)
- 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)
@@ -339,14 +343,14 @@ class HyprConfGUI(Window):
# Add icon size scale
icon_size_label = Label(label="Icon Size", h_align="start", v_align="center")
layout_grid.attach(icon_size_label, 0, 4, 1, 1)
-
+
icon_size_scale_container = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
halign=Gtk.Align.START,
valign=Gtk.Align.CENTER,
- hexpand=True
+ hexpand=True,
)
-
+
self.icon_size_scale = Gtk.Scale.new_with_range(
Gtk.Orientation.HORIZONTAL, 20, 30, 2
)
@@ -355,7 +359,7 @@ class HyprConfGUI(Window):
self.icon_size_scale.set_draw_value(True)
self.icon_size_scale.set_value_pos(Gtk.PositionType.RIGHT)
self.icon_size_scale.set_tooltip_text("Adjust the size of dock icons")
-
+
icon_size_scale_container.add(self.icon_size_scale)
layout_grid.attach(icon_size_scale_container, 1, 4, 3, 1)
@@ -461,7 +465,9 @@ class HyprConfGUI(Window):
"battery": "Battery Indicator",
"controls": "Control Panel",
"indicators": "Indicators",
- "applications": "Taskbar"
+ "applications": "Taskbar",
+ "language": "Language Indicator",
+ "music_player": "Music Player"
}
self.corners_switch = Gtk.Switch(active=bind_vars.get("corners_visible", True))
@@ -507,8 +513,6 @@ class HyprConfGUI(Window):
self.component_switches[name] = component_switch
item_idx += 1
-
-
return scrolled_window
def on_notification_position_changed(self, combo: Gtk.ComboBoxText):
@@ -810,7 +814,9 @@ class HyprConfGUI(Window):
current_bind_vars_snapshot["dock_always_occluded"] = (
self.dock_hover_switch.get_active()
)
- current_bind_vars_snapshot["dock_icon_size"] = int(self.icon_size_scale.get_value())
+ current_bind_vars_snapshot["dock_icon_size"] = int(
+ self.icon_size_scale.get_value()
+ )
current_bind_vars_snapshot["terminal_command"] = self.terminal_entry.get_text()
current_bind_vars_snapshot["corners_visible"] = self.corners_switch.get_active()
current_bind_vars_snapshot["dock_theme"] = (
diff --git a/main.py b/main.py
index e14e541..ce7dc32 100644
--- a/main.py
+++ b/main.py
@@ -8,7 +8,8 @@ from loguru import logger
from config.data import APP_NAME, APP_NAME_CAP, CONFIG_FILE
from modules.corners import Corners
-from modules.dock.main import Dock
+from modules.dock.main import Dock
+from modules.launcher.main import Launcher
gi.require_version("GLib", "2.0")
@@ -16,6 +17,8 @@ for log in [
"fabric.hyprland.widgets",
"fabric.audio.service",
"fabric.bluetooth.service",
+ "services.network",
+ "services.mpris",
]:
logger.disable(log)
@@ -40,6 +43,7 @@ if __name__ == "__main__":
corners = Corners()
dock = Dock()
+ launcher = Launcher()
# Set corners visibility based on config
corners_visible = config.get("corners_visible", True)
@@ -53,7 +57,10 @@ if __name__ == "__main__":
_ = color_css_file.connect("changed", lambda *_: set_css())
app = Application(
- f"{APP_NAME}", dock, corners
+ f"{APP_NAME}",
+ dock,
+ corners,
+ launcher,
) # Make sure corners is added to the app
def set_css():
diff --git a/modules/dock/components/__init__.py b/modules/dock/components/__init__.py
index 9f33bdd..3b8f27f 100644
--- a/modules/dock/components/__init__.py
+++ b/modules/dock/components/__init__.py
@@ -2,8 +2,9 @@ import os
import json
import config.data as data
from fabric.widgets.box import Box
-from fabric.widgets.button import Button
from fabric.widgets.datetime import DateTime
+from fabric.widgets.label import Label
+from fabric.widgets.button import Button
from fabric.system_tray.widgets import SystemTray
from .battery import Battery
from .metrics import Metrics
@@ -11,6 +12,10 @@ from .controls import Controls
from .workspaces import workspace
from .indicators import Indicators
from .applications import Applications
+from .music_player import MusicPlayer
+from fabric.hyprland.widgets import get_hyprland_connection, Language
+import utils.icons as icons
+from fabric.hyprland.service import HyprlandEvent
class DockComponents(Box):
@@ -26,11 +31,19 @@ class DockComponents(Box):
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 # Use the workspace instance directly
+ self.workspaces = workspace
self.metrics = Metrics()
self.battery = Battery()
self.date_time = DateTime(
@@ -43,6 +56,7 @@ class DockComponents(Box):
)
self.controls = Controls()
self.indicators = Indicators()
+ self.music_player = MusicPlayer()
self.systray = SystemTray(
icon_size=18,
spacing=4,
@@ -58,7 +72,9 @@ class DockComponents(Box):
self.applications,
self.indicators,
self.battery,
+ self.music_player,
self.date_time,
+ self.language,
self.systray,
]
@@ -70,9 +86,8 @@ class DockComponents(Box):
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"])
+ should_invert = data.DOCK_THEME in ["Dense", "Edge"] or (
+ data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"]
)
if should_invert:
@@ -88,8 +103,10 @@ class DockComponents(Box):
"date_time": self.date_time,
"controls": self.controls,
"indicators": self.indicators,
+ "music_player": self.music_player,
"systray": self.systray,
"applications": self.applications,
+ "language": self.language,
}
for component_name, widget in components.items():
@@ -104,8 +121,10 @@ class DockComponents(Box):
"date_time": self.date_time,
"controls": self.controls,
"indicators": self.indicators,
+ "music_player": self.music_player,
"systray": self.systray,
"applications": self.applications,
+ "language": self.language,
}
if component_name in components and component_name in self.component_visibility:
@@ -136,3 +155,16 @@ class DockComponents(Box):
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 Language().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
index 8e1ad7b..aabeb62 100644
--- a/modules/dock/components/applications.py
+++ b/modules/dock/components/applications.py
@@ -100,15 +100,19 @@ class Applications(Box):
GLib.timeout_add_seconds(1, self.check_config_change)
def _build_app_identifiers_map(self):
- app_map = {}
+ identifiers = {}
for app in self._all_apps:
- if app.window_class:
- app_map[app.window_class.lower()] = app
- if app.executable:
- app_map[app.executable.lower()] = app
if app.name:
- app_map[app.name.lower()] = app
- return app_map
+ 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:
@@ -230,14 +234,20 @@ class Applications(Box):
cmd_to_run = app_identifier["executable"]
elif "name" in app_identifier and app_identifier["name"]:
cmd_to_run = app_identifier["name"]
- else:
+ elif isinstance(app_identifier, str):
cmd_to_run = app_identifier
if cmd_to_run:
exec_shell_command_async(f"nohup {cmd_to_run} &")
else:
- address = instances[0].get("address")
- if address:
- exec_shell_command(f"hyprctl dispatch focuswindow address:{address}")
+ 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/dock.json")
@@ -263,8 +273,14 @@ class Applications(Box):
self.app_identifiers = self._build_app_identifiers_map()
def _normalize_window_class(self, class_name):
- # Implementation from dock.py
- return class_name.lower()
+ 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 update_applications(self):
# Clear existing children
@@ -368,7 +384,7 @@ class Applications(Box):
)
)
- children.extend(open_buttons)
+ children += open_buttons
# Add all children to the dock
for child in children:
@@ -382,44 +398,51 @@ class Applications(Box):
return True
+ def update_pinned_apps(self, app_identifier, index=None):
+ # Check if app is already pinned
+ 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 pinned, add to pinned apps
+ 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 on_button_press(self, widget, event):
# Handle right-click to pin/unpin applications
if event.button == 3: # Right mouse button
app_identifier = widget.app_identifier
- instances = widget.instances
-
- # Check if app is already pinned
- 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 pinned, add to pinned apps
- if not is_pinned:
- if hasattr(widget, "desktop_app") and widget.desktop_app:
- app = 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.append(app_data_obj)
- else:
- self.pinned.append(app_identifier)
-
- self.config["pinned_apps"] = self.pinned
- self.update_pinned_apps_file()
- self.update_applications()
+ self.update_pinned_apps(app_identifier)
return True
return False
@@ -521,10 +544,14 @@ class Applications(Box):
if not self._drag_in_progress:
return
- # Process drag end...
-
self._drag_in_progress = False
-
- # Allow dock to hide again
if self.dock_instance:
self.dock_instance.prevent_hiding(False)
+
+ def get_focused(self):
+ try:
+ return json.loads(
+ self.conn.send_command("j/activewindow").reply.decode()
+ ).get("address", "")
+ except json.JSONDecodeError:
+ return ""
diff --git a/modules/dock/components/battery.py b/modules/dock/components/battery.py
index 96accce..5abfa84 100644
--- a/modules/dock/components/battery.py
+++ b/modules/dock/components/battery.py
@@ -37,7 +37,6 @@ class Battery(Box):
self.circle_button = Button(
name="battery-button",
child=self.circle,
- style="padding: 0; margin: 0; background: none;",
)
# Add event handling for hover effects
@@ -147,7 +146,6 @@ class Battery(Box):
percentage = self._battery.percentage
state = self._battery.state
- charging = state in ["CHARGING", "FULLY_CHARGED"]
# Get capacity as integer by converting from float
capacity = int(float(self._battery.capacity.rstrip("%")))
tooltip_points = []
@@ -211,7 +209,7 @@ class Battery(Box):
# 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)}%")
@@ -224,9 +222,17 @@ class Battery(Box):
self.circle.remove_style_class("performance")
self.circle.remove_style_class("balanced")
- # Apply power profile styling
+ # Apply power profile styling and icons
if power_profile:
self.circle.add_style_class(power_profile)
+ if power_profile == "power-saver":
+ self.icon.set_markup(icons.power_saving)
+ elif power_profile == "performance":
+ self.icon.set_markup(icons.power_performance)
+ else: # balanced
+ self.icon.set_markup(icons.battery)
+ else:
+ self.icon.set_markup(icons.battery)
# Apply alert styling if battery is low AND not charging
if percentage <= 15 and not charging:
@@ -236,17 +242,18 @@ class Battery(Box):
self.icon.remove_style_class("alert")
self.circle.remove_style_class("alert")
- # Update icon based on battery state
- 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)
+ # 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/indicators.py b/modules/dock/components/indicators.py
index e3d77af..61af85e 100644
--- a/modules/dock/components/indicators.py
+++ b/modules/dock/components/indicators.py
@@ -3,8 +3,6 @@ from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.circularprogressbar import CircularProgressBar
-from gi.repository import Gdk, GLib
-
import config.data as data
import utils.icons as icons
@@ -15,9 +13,9 @@ from fabric.bluetooth import BluetoothClient
class WifiIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar-network", **kwargs)
-
+
# Initialize NetworkClient
- self.network = NetworkClient()
+ self.network = NetworkClient()
# Create progress bar and wifi label
self.progress_bar = CircularProgressBar(
name="button-network",
@@ -27,16 +25,13 @@ class WifiIndicator(Button):
end_angle=390,
)
self.wifi_label = Label(name="network-label", markup=icons.loader)
-
+
# Create overlay with progress bar and label
- self.add(Overlay(
- child=self.progress_bar,
- overlays=self.wifi_label
- ))
+ self.add(Overlay(child=self.progress_bar, overlays=self.wifi_label))
# Connect signals
self.network.connect("ready", self.on_network_ready)
-
+
# If already ready, set up signals immediately
if self.network.is_ready:
self.setup_signals()
@@ -51,22 +46,25 @@ class WifiIndicator(Button):
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)
-
+
# Connect to WiFi device if available
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)
-
+
# Connect to Ethernet device if available
if self.network.ethernet_device:
self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
-
+
self.update_state()
def update_state(self):
# Check ethernet first
- if self.network.ethernet_device and self.network.ethernet_device.state == "activated":
+ 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})"
@@ -133,19 +131,19 @@ class WifiIndicator(Button):
class BluetoothIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar-bluetooth", **kwargs)
-
+
# Initialize BluetoothClient
self.bluetooth = BluetoothClient()
-
+
# Create bluetooth label
self.bt_label = Label(name="bluetooth-label", markup=icons.loader)
self.add(self.bt_label)
-
+
# Connect signals
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)
-
+
# Initial state update
self.update_state()
@@ -193,4 +191,4 @@ class Indicators(Box):
children=[WifiIndicator(), BluetoothIndicator()],
**kwargs,
)
- self.show_all()
\ No newline at end of file
+ self.show_all()
diff --git a/modules/dock/components/music_player.py b/modules/dock/components/music_player.py
new file mode 100644
index 0000000..c9a6787
--- /dev/null
+++ b/modules/dock/components/music_player.py
@@ -0,0 +1,622 @@
+import config.data as data
+import utils.icons as icons
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.stack import Stack
+from gi.repository import GLib, Gdk, GdkPixbuf
+from services.mpris import MediaManager
+from utils.circle_image import CircleImage
+from utils.audio_visualizer import SpectrumRender
+import urllib.parse
+import os
+import requests
+import tempfile
+
+
+class MusicPlayer(EventBox):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="music-player",
+ events=["button-press"],
+ **kwargs
+ )
+
+ orientation = "v" if data.VERTICAL else "h"
+
+ # Create main container box
+ self.main_container = Box(
+ name="music-player-container",
+ spacing=4,
+ orientation=orientation,
+ )
+
+ # Initialize media manager
+ self._media_manager = MediaManager()
+ self._media_manager.connect("player-appeared", self._on_player_changed)
+ self._media_manager.connect("player-vanished", self._on_player_changed)
+
+ self._current_player = None
+ self._manually_selected_player = None
+ self._update_timeout_id = None
+ self._current_player_signals = [] # Track signal connections for current player
+
+ # Marquee animation variables
+ self._marquee_timeout_id = None
+ self._marquee_position = 0
+ self._original_title = ""
+ self._title_needs_marquee = False
+
+ # Thumbnail spinning animation variables
+ self._spin_timeout_id = None
+ self._spin_angle = 0
+
+ # Visualizer state
+ self._show_visualizer = False
+
+ # Create music player view with stack for visualizer/text switching
+ self._create_music_player_view()
+
+ # Set the main container as child
+ self.add(self.main_container)
+
+ # Connect click handler for the entire music player
+ self.connect("button-press-event", self._on_music_player_clicked)
+
+ # Hide initially - will show when media players become available
+ self.set_visible(False)
+
+ # Delay initial update to allow MediaManager to initialize properly
+ GLib.timeout_add(100, self._delayed_initial_update)
+
+ def _create_music_player_view(self):
+ """Create the music player view with stack for visualizer/text switching like PlayerSmall"""
+ orientation = "v" if data.VERTICAL else "h"
+
+ # Create CircleImage for album art thumbnails (clickable to toggle visualizer)
+ self.album_thumbnail = CircleImage(
+ name="music-album-thumbnail",
+ size=34, # Appropriate size for dock
+ v_align="center",
+ visible=True, # Always visible, will show default or album art
+ )
+
+ # Make album thumbnail clickable to toggle visualizer
+ self.album_thumbnail.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.album_thumbnail.connect("button-press-event", self._on_thumbnail_clicked)
+
+ # Create track info display
+ self.track_label = Label(
+ name="music-track",
+ label="No Media",
+ ellipsize=0, # No ellipsize for marquee
+ h_align="center",
+ )
+ # Fixed width to prevent box size changes
+ self.track_label.set_size_request(100, -1)
+ self.track_label.set_hexpand(False)
+ self.track_label.set_vexpand(False)
+
+ # Create spectrum visualizer lazily to save memory
+ self.cavalcade = None
+ self.cavalcade_box = None
+
+ # Create stack for crossfade between visualizer and text
+ self.center_stack = Stack(
+ name="music-player-stack",
+ transition_type="crossfade",
+ transition_duration=100,
+ v_align="center",
+ v_expand=False,
+ children=[self.track_label] # Start with just track label
+ )
+ # Start with track label visible
+ self.center_stack.set_visible_child(self.track_label)
+
+ # Create control buttons
+ self._create_controls()
+
+ # Create main music player layout using CenterBox like PlayerSmall
+ self.music_player_box = CenterBox(
+ name="music-player-centerbox",
+ orientation=orientation,
+ h_expand=True,
+ h_align="fill",
+ v_align="center",
+ v_expand=False,
+ start_children=self.album_thumbnail,
+ center_children=self.center_stack,
+ end_children=self.controls_box,
+ )
+
+ # Add to main container
+ self.main_container.add(self.music_player_box)
+
+ def _load_album_art(self, art_url):
+ """Load album art from URL and set it to the CircleImage"""
+ # Cache the last loaded URL to avoid reloading the same image
+ if hasattr(self, '_last_art_url') and self._last_art_url == art_url:
+ return
+
+ if not art_url:
+ self._hide_album_art()
+ self._last_art_url = None
+ return
+
+ try:
+ if art_url.startswith('file://'):
+ # Handle local file URLs
+ file_path = urllib.parse.unquote(art_url[7:]) # Remove 'file://' prefix
+ if os.path.exists(file_path):
+ self.album_thumbnail.set_image_from_file(file_path)
+ self._show_album_art()
+ self._last_art_url = art_url
+ else:
+ self._hide_album_art()
+ self._last_art_url = None
+ elif art_url.startswith(('http://', 'https://')):
+ # Handle remote URLs by downloading them first
+ response = requests.get(art_url)
+ if response.status_code == 200:
+ # Create a temporary file
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
+ temp_file.write(response.content)
+ temp_path = temp_file.name
+
+ # Load the image from the temporary file
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(temp_path)
+ self.album_thumbnail.set_image_from_pixbuf(pixbuf)
+ self._show_album_art()
+ self._last_art_url = art_url
+
+ # Clean up the temporary file
+ os.unlink(temp_path)
+ else:
+ self._hide_album_art()
+ self._last_art_url = None
+ else:
+ self._hide_album_art()
+ self._last_art_url = None
+ except Exception:
+ self._hide_album_art()
+ self._last_art_url = None
+
+ def _show_album_art(self):
+ """Show album art thumbnail"""
+ self.album_thumbnail.set_visible(True)
+ # Start spinning animation when showing album art
+ self._start_spin_animation()
+
+ def _hide_album_art(self):
+ """Hide album art thumbnail"""
+ self.album_thumbnail.set_visible(False)
+ # Stop spinning animation when hiding album art
+ self._stop_spin_animation()
+
+ def _start_spin_animation(self):
+ """Start the thumbnail spinning animation"""
+ if not self._spin_timeout_id:
+ self._spin_timeout_id = GLib.timeout_add(100, self._animate_spin) # 100ms to save CPU
+
+ def _stop_spin_animation(self):
+ """Stop the thumbnail spinning animation"""
+ if self._spin_timeout_id:
+ GLib.source_remove(self._spin_timeout_id)
+ self._spin_timeout_id = None
+ # Reset angle to 0
+ self._spin_angle = 0
+ self.album_thumbnail.angle = 0
+
+ def _animate_spin(self):
+ """Animate the thumbnail spinning"""
+ if not self._current_player or not self.album_thumbnail.get_visible():
+ return False
+
+ # Only spin when music is playing
+ if self._current_player.status == "playing":
+ self._spin_angle = (self._spin_angle + 5) % 360 # Rotate 2 degrees per frame
+ self.album_thumbnail.angle = self._spin_angle
+
+ return True # Continue animation
+
+ def _update_player_indicators(self):
+ """Update the player indicators to show all available players"""
+ # Get all available players
+ available_players = self._media_manager.players
+
+ # Only show indicators if there are multiple players to save memory
+ if len(available_players) <= 1:
+ # Clear indicators if only one or no players
+ for child in self.player_indicators_box.get_children():
+ self.player_indicators_box.remove(child)
+ return
+
+ # Clear existing indicators
+ for child in self.player_indicators_box.get_children():
+ self.player_indicators_box.remove(child)
+
+ current_player_name = self._current_player.player_name if self._current_player else "None"
+
+ # Create indicators for each player (only when multiple players exist)
+ for player in available_players:
+ player_name = player.player_name or "Unknown"
+ player_icon_markup = self._get_player_icon(player_name)
+
+ # Create clickable button with indicator label
+ indicator_button = Button(
+ name="music-player-indicator-button",
+ child=Label(
+ name="music-player-indicator",
+ markup=player_icon_markup,
+ ),
+ can_focus=False,
+ )
+
+ # Set tooltip with player info
+ is_current_player = (player.player_name == current_player_name)
+ tooltip_text = f"Player: {player_name}"
+ if is_current_player:
+ tooltip_text += " (Active)"
+ else:
+ tooltip_text += " - Click to switch"
+ indicator_button.set_tooltip_text(tooltip_text)
+
+ # Add visual distinction for active player (you can style this with CSS)
+ if is_current_player:
+ indicator_button.get_child().set_name("music-player-indicator-active")
+ else:
+ indicator_button.get_child().set_name("music-player-indicator")
+
+ # Connect click handler with player reference
+ indicator_button.connect("clicked", self._on_player_indicator_clicked, player)
+
+ self.player_indicators_box.add(indicator_button)
+
+ # Force refresh the indicators container
+ self.player_indicators_box.show_all()
+
+ def _create_controls(self):
+ """Create the media control buttons"""
+ self.controls_box = Box(
+ name="music-controls",
+ orientation="h" if not data.VERTICAL else "v",
+ spacing=2,
+ )
+
+ # Previous button
+ self.prev_button = Button(
+ name="music-prev",
+ child=Label(markup=icons.prev),
+ can_focus=False,
+ )
+ self.prev_button.connect("clicked", self._on_previous_clicked)
+ self.prev_button.set_tooltip_text("Previous Track")
+
+ # Play/Pause button
+ self.play_pause_button = Button(
+ name="music-play-pause",
+ child=Label(name="play-pause-icon", markup=icons.play),
+ can_focus=False,
+ )
+ self.play_pause_button.connect("clicked", self._on_play_pause_clicked)
+ self.play_pause_button.set_tooltip_text("Play/Pause")
+
+ # Next button
+ self.next_button = Button(
+ name="music-next",
+ child=Label(markup=icons.next),
+ can_focus=False,
+ )
+ self.next_button.connect("clicked", self._on_next_clicked)
+ self.next_button.set_tooltip_text("Next Track")
+
+ # Player indicators container
+ self.player_indicators_box = Box(
+ name="music-player-indicators",
+ orientation="h" if not data.VERTICAL else "v",
+ spacing=2,
+ )
+
+ # Add buttons and indicators to controls
+ self.controls_box.add(self.prev_button)
+ self.controls_box.add(self.play_pause_button)
+ self.controls_box.add(self.next_button)
+ self.controls_box.add(self.player_indicators_box)
+
+ def _delayed_initial_update(self):
+ """Delayed initial update to allow MediaManager to initialize"""
+ self._update_display()
+ return False # Don't repeat this timeout
+
+ def _connect_player_signals(self, player):
+ """Connect to player signals for immediate updates"""
+ if player:
+ # Connect to metadata changes for immediate track/thumbnail updates
+ metadata_signal = player.connect("metadata_changed", self._on_metadata_changed)
+ status_signal = player.connect("playback_status_changed", self._on_playback_status_changed)
+ self._current_player_signals = [metadata_signal, status_signal]
+
+ def _disconnect_player_signals(self):
+ """Disconnect from current player signals"""
+ if self._current_player and self._current_player_signals:
+ for signal_id in self._current_player_signals:
+ try:
+ self._current_player.disconnect(signal_id)
+ except:
+ pass # Signal might already be disconnected
+ self._current_player_signals = []
+
+ def _on_metadata_changed(self, *_args):
+ """Handle immediate metadata changes (track, artist, album art)"""
+ GLib.idle_add(self._update_display)
+
+ def _on_playback_status_changed(self, *_args):
+ """Handle immediate playback status changes"""
+ GLib.idle_add(self._update_display)
+
+ def _on_player_changed(self, *_args):
+ """Handle player appearance/disappearance"""
+ GLib.idle_add(self._update_display)
+
+ def _update_display(self):
+ """Update the display based on current player state"""
+
+ # Store previous player to detect changes
+ previous_player = self._current_player
+
+ # Determine current player (manual selection takes priority)
+ if self._manually_selected_player:
+ # Check if manually selected player is still available
+ manually_selected_available = next(
+ (p for p in self._media_manager.players
+ if p.player_name == self._manually_selected_player.player_name),
+ None
+ )
+ if manually_selected_available:
+ self._current_player = manually_selected_available
+ self._manually_selected_player = manually_selected_available
+ else:
+ # Manual player no longer available, clear selection
+ self._manually_selected_player = None
+ self._current_player = self._media_manager.current_player
+ else:
+ self._current_player = self._media_manager.current_player
+
+ # Connect to new player signals if player changed
+ if self._current_player != previous_player:
+ self._disconnect_player_signals()
+ if self._current_player:
+ self._connect_player_signals(self._current_player)
+
+
+ if not self._current_player:
+ # No players available, hide the component
+ self.set_visible(False)
+ if self._update_timeout_id:
+ GLib.source_remove(self._update_timeout_id)
+ self._update_timeout_id = None
+ if self._marquee_timeout_id:
+ GLib.source_remove(self._marquee_timeout_id)
+ self._marquee_timeout_id = None
+ # Stop spinning animation when no player
+ self._stop_spin_animation()
+ return False
+
+ self.set_visible(True)
+
+ # Try to load album art, hide if not available
+ album_art_url = self._current_player.album_image_url
+ if album_art_url:
+ self._load_album_art(album_art_url)
+ else:
+ # No album art available, hide thumbnails
+ self._hide_album_art()
+
+ # Update stack display (text vs visualizer)
+ self._update_stack_display()
+
+ # Update player indicators to show all available players
+ self._update_player_indicators()
+
+ # Update play/pause button
+ play_pause_icon = self.play_pause_button.get_child()
+ if self._current_player.status == "playing":
+ play_pause_icon.set_markup(icons.pause)
+ else:
+ play_pause_icon.set_markup(icons.play)
+
+ # Update track info with marquee animation
+ track_title = self._current_player.track_title or "Unknown Track"
+ max_display_chars = 12
+
+ self._original_title = track_title
+ self._title_needs_marquee = len(track_title) > max_display_chars
+
+ # Reset marquee if title changed
+ if self._original_title != getattr(self, '_last_original_title', ''):
+ self._last_original_title = self._original_title
+ self._marquee_position = 0
+ self._start_marquee_animation()
+
+ self._update_marquee_text()
+ self._update_tooltips()
+
+ # Update button sensitivity
+ self.prev_button.set_sensitive(self._current_player.can_go_previous)
+ self.next_button.set_sensitive(self._current_player.can_go_next)
+ self.play_pause_button.set_sensitive(self._current_player.can_pause)
+
+ # Remove all polling - rely entirely on signals for updates
+ if self._update_timeout_id:
+ GLib.source_remove(self._update_timeout_id)
+ self._update_timeout_id = None
+
+ return True
+
+ def _start_marquee_animation(self):
+ """Start or restart the marquee animation"""
+ # Stop existing marquee animation
+ if self._marquee_timeout_id:
+ GLib.source_remove(self._marquee_timeout_id)
+ self._marquee_timeout_id = None
+
+ if self._title_needs_marquee:
+ self._marquee_timeout_id = GLib.timeout_add(250, self._animate_marquee)
+
+ def _animate_marquee(self):
+ """Animate the marquee scrolling"""
+ if not self._current_player:
+ return False
+
+ self._marquee_position += 1
+ title_length = len(self._original_title)
+ loop_length = title_length + 3 # Add spacing between loops
+
+ if self._marquee_position >= loop_length:
+ self._marquee_position = 0
+
+ self._update_marquee_text()
+ return True
+
+ def _update_marquee_text(self):
+ """Update the displayed text based on marquee position"""
+ if not self._current_player:
+ return
+
+ max_display_chars = 12
+
+ if self._title_needs_marquee:
+ extended_text = self._original_title + " "
+ start_pos = self._marquee_position
+ end_pos = start_pos + max_display_chars
+
+ if end_pos <= len(extended_text):
+ display_title = extended_text[start_pos:end_pos]
+ else:
+ part1 = extended_text[start_pos:]
+ part2 = self._original_title[:end_pos - len(extended_text)]
+ display_title = (part1 + part2)[:max_display_chars]
+ else:
+ display_title = self._original_title[:max_display_chars]
+
+ self.track_label.set_label(display_title)
+
+ def _get_player_icon(self, player_name):
+ """Get the appropriate icon for a player based on its name"""
+ if not player_name:
+ return icons.disc
+
+ player_name_lower = player_name.lower()
+
+ if "firefox" in player_name_lower:
+ return icons.firefox
+ elif "spotify" in player_name_lower:
+ return icons.spotify
+ elif any(browser in player_name_lower for browser in [
+ "chromium", "chrome", "edge", "brave", "opera", "vivaldi"
+ ]):
+ return icons.chromium
+ else:
+ return icons.disc
+
+ def _update_tooltips(self):
+ """Update tooltips with full track information"""
+ if not self._current_player:
+ return
+
+ # Get full track information for tooltip
+ full_track_title = self._current_player.track_title or "Unknown Track"
+ track_artist = self._current_player.track_artist or "Unknown Artist"
+ player_name = self._current_player.player_name or "Media Player"
+
+ # Create tooltip text with full information
+ tooltip_text = f"{full_track_title}\nby {track_artist}\n\nPlayer: {player_name}"
+ self.track_label.set_tooltip_text(tooltip_text)
+
+ def _on_play_pause_clicked(self, _button):
+ """Handle play/pause button click"""
+ if self._current_player:
+ self._current_player.play_pause()
+ # No need for manual update - signal will trigger it
+
+ def _on_previous_clicked(self, _button):
+ """Handle previous button click"""
+ if self._current_player:
+ self._current_player.previous()
+ # No need for manual update - signal will trigger it
+
+ def _on_next_clicked(self, _button):
+ """Handle next button click"""
+ if self._current_player:
+ self._current_player.next()
+ # No need for manual update - signal will trigger it
+
+ def _on_player_indicator_clicked(self, _button, player):
+ """Handle player indicator click to switch players"""
+ if player:
+ self._manually_selected_player = player
+ self._update_display()
+
+ def _on_thumbnail_clicked(self, _widget, _event):
+ """Handle thumbnail click to toggle visualizer"""
+ if self._current_player:
+ self._show_visualizer = not self._show_visualizer
+ self._update_stack_display()
+ return True # Stop event propagation
+
+ def _on_music_player_clicked(self, _widget, _event):
+ """Handle music player click to toggle between text and visualizer view"""
+ # Only allow toggling if we have a current player and the click is not on the album thumbnail or player indicators
+ if self._current_player and _event.get_source() not in [self.album_thumbnail, self.player_indicators_box]:
+ self._show_visualizer = not self._show_visualizer
+ self._update_stack_display()
+ return True
+
+ def _create_visualizer_if_needed(self):
+ """Create visualizer lazily to save memory"""
+ if not self.cavalcade:
+ self.cavalcade = SpectrumRender()
+ self.cavalcade_box = self.cavalcade.get_spectrum_box()
+ self.center_stack.add(self.cavalcade_box)
+
+ def _destroy_visualizer(self):
+ """Destroy visualizer to free memory when not needed"""
+ if self.cavalcade_box:
+ self.center_stack.remove(self.cavalcade_box)
+ self.cavalcade_box = None
+ if self.cavalcade and hasattr(self.cavalcade, 'cava'):
+ self.cavalcade.cava.close()
+ self.cavalcade = None
+
+ def _update_stack_display(self):
+ """Update stack to show either text or visualizer"""
+ if self._show_visualizer and self._current_player:
+ # Create visualizer only when needed
+ self._create_visualizer_if_needed()
+ if self.cavalcade_box:
+ self.cavalcade_box.set_visible(True)
+ self.cavalcade_box.show_all()
+ self.center_stack.set_visible_child(self.cavalcade_box)
+ else:
+ # Show text label in stack
+ self.center_stack.set_visible_child(self.track_label)
+ # Destroy visualizer to save memory when not in use
+ self._destroy_visualizer()
+
+ def __del__(self):
+ """Cleanup when component is destroyed"""
+ # Disconnect player signals
+ self._disconnect_player_signals()
+
+ # Clean up all timeouts
+ if self._update_timeout_id:
+ GLib.source_remove(self._update_timeout_id)
+ if self._marquee_timeout_id:
+ GLib.source_remove(self._marquee_timeout_id)
+ if self._spin_timeout_id:
+ GLib.source_remove(self._spin_timeout_id)
+
+ # Clean up visualizer
+ self._destroy_visualizer()
diff --git a/modules/launcher/main.py b/modules/launcher/main.py
new file mode 100644
index 0000000..a1d4dd3
--- /dev/null
+++ b/modules/launcher/main.py
@@ -0,0 +1,336 @@
+from collections.abc import Iterator
+from typing import Dict, Any
+from utils.wayland import WaylandWindow as Window
+from fabric.widgets.box import Box
+from fabric.widgets.entry import Entry
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from fabric.widgets.image import Image
+from fabric.utils import get_relative_path, remove_handler, idle_add, DesktopApp
+from gi.repository import Gdk, GLib, Gtk
+
+# Import plugins
+from .plugins import LauncherPlugin
+from .plugins.applications import ApplicationsPlugin
+from .plugins.calculator import CalculatorPlugin
+from .plugins.web_search import WebSearchPlugin
+from .plugins.emoji import EmojiPlugin
+from .plugins.cliphist import CliphistPlugin
+from .plugins.powermenu import PowerMenuPlugin
+
+class Launcher(Window):
+ def __init__(self):
+ super().__init__(
+ name="launcher",
+ layer="top",
+ anchor="center",
+ exclusivity="none",
+ keyboard_mode="on-demand",
+ visible=False,
+ all_visible=False,
+ )
+ self._arranger_handler: int = 0
+ self._selected_index = 0
+ self._results = []
+ self._active_category = None # No active category by default
+
+ # Initialize plugins
+ self._plugins = [
+ ApplicationsPlugin(),
+ CalculatorPlugin(),
+ WebSearchPlugin(),
+ EmojiPlugin(),
+ CliphistPlugin(),
+ PowerMenuPlugin(),
+ ]
+
+ self.viewport = Box(orientation="v", spacing=2)
+
+ self.search = Entry(
+ placeholder="Search for apps, files, and more...",
+ h_expand=True,
+ name="launcher-search",
+ )
+
+ # Create the apps ScrolledWindow
+ self.apps = ScrolledWindow(
+ spacing=10,
+ min_content_size=(450, 405),
+ max_content_size=(450, 405),
+ child=self.viewport,
+ name="launcher-apps",
+ )
+
+ # Add components to the main window
+ self.add(
+ Box(
+ orientation="v",
+ spacing=8,
+ children=[
+ self.search,
+ self.apps
+ ],
+ )
+ )
+
+ self.show_all()
+ self.hide()
+ self.add_keybinding("Escape", lambda *_: self.close())
+
+ # Set up the search callback after all UI components are created
+ self.search.connect("notify::text", lambda entry, *_: self.arrange_viewport(entry.get_text()))
+
+ # Set up Enter key handling for the search entry
+ self.search.connect("key-press-event", self.on_search_key_press)
+
+ # Initially hide the scrolled window until search begins
+ self.apps.set_visible(False)
+
+
+ def close(self):
+ self.search.set_text("")
+ self._selected_index = 0
+ self.hide()
+
+ def on_search_key_press(self, entry, event):
+ """Handle key press events in the search entry"""
+ # Check if Enter or Return key was pressed
+ if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ self.execute_first_result()
+ return True # Event handled
+ return False # Let other handlers process the event
+
+ def execute_first_result(self):
+ """Execute the action of the first search result if available"""
+ if self._results:
+ first_result = self._results[0]
+ # Execute the action, clear search, and hide launcher
+ first_result["action"]()
+ self.search.set_text("")
+ self.hide()
+
+ def _get_result_priority(self, result: Dict[str, Any], query: str) -> int:
+ """Get priority score for result sorting (lower = higher priority)"""
+ title = result.get("title", "").lower()
+ query_lower = query.lower()
+
+ # Priority 100: Generic web search (fallback option) - Check this FIRST
+ if "search the web for" in title.lower():
+ return 100
+
+ # Priority 0: Clipboard results when searching for "clip" or "clipboard"
+ if query_lower in ["clip", "clipboard"] and "clip_id" in result:
+ return 0
+
+ # Priority 1: Exact matches (applications, emojis with exact names)
+ if title == query_lower:
+ return 1
+
+ # Priority 2: Starts with query (applications, emojis starting with query)
+ if title.startswith(query_lower):
+ return 2
+
+ # Priority 3: Contains query (partial matches)
+ if query_lower in title:
+ return 3
+
+ # Priority 4: Calculator results (specific functional results)
+ if "=" in title and any(char.isdigit() for char in title):
+ return 4
+
+ # Priority 5: Emoji results (specific content matches)
+ if "emoji_icon" in result:
+ return 5
+
+ # Priority 6: Application results (specific app matches)
+ if "icon" in result and hasattr(result["icon"], "get_icon_pixbuf"):
+ return 6
+
+ # Priority 7: Clipboard results (when not directly searching for clipboard)
+ if "clip_id" in result:
+ return 7
+
+ # Priority 8: Other results
+ return 8
+
+ def select_next_result(self):
+ if not self._results:
+ return
+
+ self._selected_index = (self._selected_index + 1) % len(self._results)
+ self.update_selection()
+
+ def update_selection(self):
+ # Remove focus from all buttons
+ for i, child in enumerate(self.viewport.get_children()):
+ if isinstance(child, Button):
+ if i == self._selected_index:
+ child.grab_focus()
+ else:
+ child.set_state_flags(0, True)
+
+ def arrange_viewport(self, query: str = ""):
+ remove_handler(self._arranger_handler) if self._arranger_handler else None
+ self._selected_index = 0
+ self.viewport.children = []
+ self._results = []
+
+ # Show or hide UI elements based on query
+ has_query = bool(query)
+ self.apps.set_visible(has_query)
+
+ # Get results from all plugins
+ for plugin in self._plugins:
+ plugin_results = plugin.search(query)
+ self._results.extend(plugin_results)
+
+ # Sort results by relevance (prioritize specific matches over generic ones)
+ self._results.sort(key=lambda result: self._get_result_priority(result, query))
+
+ results_iter = iter(self._results)
+
+ self._arranger_handler = idle_add(
+ lambda *args: self.add_next_result(*args)
+ or self.resize_viewport(),
+ results_iter,
+ pin=True,
+ )
+
+ # Make sure search entry maintains focus
+ self.search.grab_focus()
+
+ return False
+
+ def update_action_area(self, query: str):
+ self.action_area.children = []
+
+ if not query:
+ return
+
+ # Get action items from all plugins
+ action_items = []
+ for plugin in self._plugins:
+ action_items.extend(plugin.get_action_items(query))
+
+ # Add action items to UI
+ for item in action_items:
+ action_box = Box(orientation="h", name="launcher-action")
+ action_box.add(Image(icon_name=item["icon_name"], name="launcher-action-icon"))
+ action_box.add(Label(label=item["title"], name="launcher-action-label"))
+
+ # Wrap in a button for clickability
+ action_button = Button(
+ name="launcher-action-button",
+ child=action_box,
+ on_clicked=lambda _, item=item: (
+ item["action"](),
+ self.search.set_text(""), # Clear search entry when action is executed
+ self.hide(),
+ ),
+ )
+
+ self.action_area.add(action_button)
+
+ self.action_area.show_all()
+
+ def add_next_result(self, results_iter: Iterator[Dict[str, Any]]):
+ if not (result := next(results_iter, None)):
+ return False
+
+ button = self.bake_result_slot(result)
+ self.viewport.add(button)
+
+ # Highlight the first result without stealing focus
+ if self._selected_index == 0 and self.search.get_text():
+ button.set_state_flags(Gtk.StateFlags.FOCUSED, True)
+
+ return True
+
+ def resize_viewport(self):
+ self.apps.set_min_content_width(
+ self.viewport.get_allocation().width # type: ignore
+ )
+ return False
+
+ def bake_result_slot(self, result: Dict[str, Any], **kwargs) -> Button:
+ # Create a more spotlight-like result entry
+ if "emoji_icon" in result:
+ # Handle emoji icons - display emoji as text
+ icon = Label(
+ label=result["emoji_icon"],
+ h_align="start",
+ name="launcher-emoji-icon",
+ )
+ # Set font size for emoji display
+ icon.build().set_markup(f'{result["emoji_icon"]}').unwrap()
+ elif "icon_markup" in result:
+ # Handle icon markup (e.g., power menu icons)
+ icon = Label(
+ markup=result["icon_markup"],
+ h_align="start",
+ name="launcher-power-icon",
+ )
+ # Set font size and family for icon display
+ icon.build().set_markup(f'{result["icon_markup"]}').unwrap()
+ elif "image_pixbuf" in result:
+ # Handle direct pixbuf images (e.g., clipboard images)
+ icon = Image(
+ pixbuf=result["image_pixbuf"],
+ h_align="start",
+ name="launcher-app-icon",
+ )
+ elif "icon" in result and isinstance(result["icon"], DesktopApp):
+ # Handle DesktopApp icons
+ icon = Image(
+ pixbuf=result["icon"].get_icon_pixbuf(size=50),
+ h_align="start",
+ name="launcher-app-icon",
+ )
+ else:
+ # Handle regular icon names
+ icon = Image(
+ icon_name=result.get("icon_name", "application-x-executable-symbolic"),
+ h_align="start",
+ name="launcher-app-icon",
+ )
+ icon.build().set_pixel_size(50).unwrap()
+
+ result_box = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ icon,
+ Box(
+ orientation="v",
+ spacing=2,
+ children=[
+ Label(
+ label=result["title"],
+ h_align="start",
+ name="launcher-app-title",
+ ),
+ Label(
+ label=result.get("description", ""),
+ h_align="start",
+ name="launcher-app-description",
+ ),
+ ]
+ ),
+ ],
+ )
+
+ button = Button(
+ name="launcher-app",
+ child=result_box,
+ on_enter_notify_event=lambda *_: self.set_cursor("pointer"),
+ on_clicked=lambda *_: (
+ result["action"](),
+ self.search.set_text(""), # Clear search entry when item is opened
+ self.hide(),
+ ),
+ **kwargs,
+ )
+
+ return button
+
diff --git a/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py
new file mode 100644
index 0000000..daa8fbd
--- /dev/null
+++ b/modules/launcher/plugins/__init__.py
@@ -0,0 +1,52 @@
+"""
+Plugin system for the launcher module.
+Allows extending search functionality with different content types.
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Dict, Any, Optional
+
+
+class LauncherPlugin(ABC):
+ """Base class for launcher plugins"""
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Name of the plugin"""
+ pass
+
+ @property
+ @abstractmethod
+ def category(self) -> str:
+ """Category this plugin belongs to"""
+ pass
+
+ @property
+ def icon_name(self) -> str:
+ """Icon name for this plugin's category"""
+ return "application-x-executable-symbolic"
+
+ @abstractmethod
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ """
+ Search for items matching the query
+
+ Returns a list of dictionaries with at least:
+ - title: str
+ - description: str
+ - icon_name: str (optional)
+ - action: callable
+ """
+ pass
+
+ def get_action_items(self, query: str) -> List[Dict[str, Any]]:
+ """
+ Get quick action items for the query
+
+ Returns a list of dictionaries with at least:
+ - title: str
+ - icon_name: str
+ - action: callable
+ """
+ return []
diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py
new file mode 100644
index 0000000..1d41a1a
--- /dev/null
+++ b/modules/launcher/plugins/applications.py
@@ -0,0 +1,78 @@
+from typing import List, Dict, Any
+from fabric.utils import get_desktop_applications, DesktopApp, exec_shell_command_async
+from . import LauncherPlugin
+
+
+class ApplicationsPlugin(LauncherPlugin):
+ """Plugin for searching desktop applications"""
+
+ @property
+ def name(self) -> str:
+ return "Applications"
+
+ @property
+ def category(self) -> str:
+ return "Applications"
+
+ @property
+ def icon_name(self) -> str:
+ return "application-x-executable-symbolic"
+
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ all_apps = get_desktop_applications()
+
+ # Filter apps based on query
+ filtered_apps = [
+ app
+ for app in all_apps
+ if query.casefold()
+ in (
+ (app.display_name or "")
+ + (" " + app.name + " ")
+ + (app.generic_name or "")
+ ).casefold()
+ ]
+
+ # Sort by relevance - exact matches first
+ if query:
+ exact_matches = []
+ partial_matches = []
+
+ for app in filtered_apps:
+ name = (app.display_name or app.name or "").casefold()
+ if name == query.casefold() or name.startswith(query.casefold()):
+ exact_matches.append(app)
+ else:
+ partial_matches.append(app)
+
+ filtered_apps = exact_matches + partial_matches
+
+ # Convert to result format
+ results = []
+ for app in filtered_apps:
+ results.append(
+ {
+ "title": app.display_name or app.name or "Unknown",
+ "description": app.description or "Application",
+ "icon": app, # Pass the whole app object for icon rendering
+ "action": lambda app=app: self.launch_app(app),
+ "app": app, # Store the original app object
+ }
+ )
+
+ return results
+
+ def launch_app(self, app: DesktopApp):
+ command = (
+ " ".join([arg for arg in app.command_line.split() if "%" not in arg])
+ if app.command_line
+ else None
+ )
+ (
+ exec_shell_command_async(
+ f"hyprctl dispatch exec -- {command}",
+ lambda *_: print(f"Launched {app.name}"),
+ )
+ if command
+ else None
+ )
diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py
new file mode 100644
index 0000000..c19c33a
--- /dev/null
+++ b/modules/launcher/plugins/calculator.py
@@ -0,0 +1,54 @@
+import re
+from typing import List, Dict, Any
+from . import LauncherPlugin
+
+
+class CalculatorPlugin(LauncherPlugin):
+ """Plugin for calculator functionality"""
+
+ @property
+ def name(self) -> str:
+ return "Calculator"
+
+ @property
+ def category(self) -> str:
+ return "Utilities"
+
+ @property
+ def icon_name(self) -> str:
+ return "accessories-calculator-symbolic"
+
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ # Only process if query looks like a math expression
+ if not (query.isdigit() or re.match(r"^[\d+\-*/().]+$", query)):
+ return []
+
+ try:
+ result = eval(query)
+ return [
+ {
+ "title": f"{query} = {result}",
+ "description": "Calculator result",
+ "icon_name": "accessories-calculator-symbolic",
+ "action": lambda: None, # No action needed
+ }
+ ]
+ except:
+ return []
+
+ def get_action_items(self, query: str) -> List[Dict[str, Any]]:
+ # Only process if query looks like a math expression
+ if not (query.isdigit() or re.match(r"^[\d+\-*/().]+$", query)):
+ return []
+
+ try:
+ result = eval(query)
+ return [
+ {
+ "title": f"Calculate: {query} = {result}",
+ "icon_name": "accessories-calculator-symbolic",
+ "action": lambda: None, # No action needed
+ }
+ ]
+ except:
+ return []
diff --git a/modules/launcher/plugins/cliphist.py b/modules/launcher/plugins/cliphist.py
new file mode 100644
index 0000000..55f76cd
--- /dev/null
+++ b/modules/launcher/plugins/cliphist.py
@@ -0,0 +1,259 @@
+import re
+import subprocess
+from typing import List, Dict, Any
+
+import gi
+gi.require_version('GdkPixbuf', '2.0')
+from gi.repository import GdkPixbuf
+
+from . import LauncherPlugin
+
+
+class CliphistPlugin(LauncherPlugin):
+ """Plugin for clipboard history functionality using cliphist"""
+
+ @property
+ def name(self) -> str:
+ return "Clipboard History"
+
+ @property
+ def category(self) -> str:
+ return "Utilities"
+
+ @property
+ def icon_name(self) -> str:
+ return "edit-paste-symbolic"
+
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ if not query:
+ return []
+
+ query_lower = query.lower()
+
+ # Show clipboard history when user types "clip" (like Albert launcher)
+ if query_lower == "clip" or query_lower == "clipboard":
+ return self._get_clipboard_history()
+
+ # Also show clipboard history if query starts with "clip " for further filtering
+ if query_lower.startswith("clip ") and len(query) > 5:
+ search_term = query[5:].strip() # Remove "clip " prefix
+ return self._get_clipboard_history(search_term)
+
+ return []
+
+ def _get_clipboard_history(self, search_term: str = "") -> List[Dict[str, Any]]:
+ """Get clipboard history, optionally filtered by search term"""
+ try:
+ # Get clipboard history from cliphist
+ result = subprocess.run(
+ ["cliphist", "list"],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+
+ if result.returncode != 0:
+ return []
+
+ entries = result.stdout.strip().split('\n')
+ if not entries or entries == ['']:
+ return []
+
+ results = []
+ search_lower = search_term.lower() if search_term else ""
+
+ for entry in entries[:50]: # Limit to first 50 entries for performance
+ if not entry.strip():
+ continue
+
+ # Parse entry format: "ID\tCONTENT"
+ parts = entry.split('\t', 1)
+ if len(parts) != 2:
+ continue
+
+ entry_id, content = parts
+ content = content.strip()
+
+ # Check if this is an image
+ is_image = self._is_image_data(content)
+
+ # Skip binary data entries that aren't images
+ if content.startswith('[[') and 'binary data' in content and not is_image:
+ continue
+
+ # If we have a search term, filter by it
+ if search_term and search_lower not in content.lower():
+ continue
+
+ # Prepare display content and icon
+ if is_image:
+ display_content = "[Image]"
+ # Try to load image preview
+ image_pixbuf = self._load_image_preview(entry_id)
+ if image_pixbuf:
+ result_item = {
+ "title": display_content,
+ "image_pixbuf": image_pixbuf, # Custom field for image preview
+ "action": lambda eid=entry_id: self.copy_to_clipboard(eid),
+ "clip_id": entry_id,
+ }
+ else:
+ result_item = {
+ "title": display_content,
+ "icon_name": "image-x-generic-symbolic",
+ "action": lambda eid=entry_id: self.copy_to_clipboard(eid),
+ "clip_id": entry_id,
+ }
+ else:
+ # Truncate long content for display
+ display_content = content
+ if len(display_content) > 80:
+ display_content = display_content[:77] + "..."
+ # Replace newlines with spaces for display
+ display_content = display_content.replace('\n', ' ').replace('\r', ' ')
+
+ result_item = {
+ "title": display_content,
+ "icon_name": "edit-paste-symbolic",
+ "action": lambda eid=entry_id: self.copy_to_clipboard(eid),
+ "clip_id": entry_id,
+ }
+
+ results.append(result_item)
+
+ # # Limit results to prevent overwhelming the UI
+ # if len(results) >= 15:
+ # break
+
+ # Sort results by relevance if we have a search term
+ if search_term:
+ def sort_key(item):
+ title = item["title"].lower()
+ if title.startswith(search_lower):
+ return 0 # Starts with query
+ elif search_lower in title:
+ return 1 # Contains query
+ else:
+ return 2 # Other matches
+ results.sort(key=sort_key)
+
+ return results
+
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
+ return []
+ except Exception as e:
+ print(f"Error in cliphist plugin: {e}")
+ return []
+
+ def copy_to_clipboard(self, entry_id: str):
+ """Copy clipboard history entry back to clipboard"""
+ try:
+ # Use cliphist decode to get the content and pipe it to wl-copy
+ decode_process = subprocess.Popen(
+ ["cliphist", "decode", entry_id],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL
+ )
+
+ copy_process = subprocess.Popen(
+ ["wl-copy"],
+ stdin=decode_process.stdout,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL
+ )
+
+ decode_process.stdout.close() # Allow decode_process to receive SIGPIPE
+ copy_process.wait(timeout=2)
+ decode_process.wait(timeout=2)
+
+ print(f"Copied clipboard history entry {entry_id}")
+
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
+ try:
+ # Fallback to xclip
+ decode_process = subprocess.Popen(
+ ["cliphist", "decode", entry_id],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True
+ )
+
+ content, _ = decode_process.communicate(timeout=2)
+
+ if decode_process.returncode == 0:
+ subprocess.Popen(
+ ["xclip", "-selection", "clipboard"],
+ input=content,
+ text=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL
+ )
+ print(f"Copied clipboard history entry {entry_id} (xclip)")
+
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
+ print(f"Could not copy clipboard history entry {entry_id}")
+ except Exception as e:
+ print(f"Error copying clipboard entry: {e}")
+
+ def _is_image_data(self, content):
+ """Determine if clipboard content is likely an image"""
+ return (
+ content.startswith("data:image/") or
+ content.startswith("\x89PNG") or
+ content.startswith("GIF8") or
+ content.startswith("\xff\xd8\xff") or
+ re.match(r'^\s*
height:
+ new_width = max_size
+ new_height = int(height * (max_size / width))
+ else:
+ new_height = max_size
+ new_width = int(width * (max_size / height))
+
+ scaled_pixbuf = pixbuf.scale_simple(
+ new_width, new_height, GdkPixbuf.InterpType.BILINEAR
+ )
+ return scaled_pixbuf
+
+ except Exception as e:
+ pass
+
+ return None
+
+ def get_action_items(self, query: str) -> List[Dict[str, Any]]:
+ """Get quick action items for clipboard history"""
+ if not query or len(query) < 2:
+ return []
+
+ return [
+ {
+ "title": f'Search clipboard history for "{query}"',
+ "icon_name": "edit-paste-symbolic",
+ "action": lambda: None, # No specific action needed
+ }
+ ]
+
+
diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py
new file mode 100644
index 0000000..17b30b2
--- /dev/null
+++ b/modules/launcher/plugins/emoji.py
@@ -0,0 +1,122 @@
+import json
+import os
+import subprocess
+from typing import List, Dict, Any
+from . import LauncherPlugin
+
+
+class EmojiPlugin(LauncherPlugin):
+ """Plugin for emoji search and clipboard functionality"""
+
+ def __init__(self):
+ self._emojis = {}
+ self._load_emojis()
+
+ def _load_emojis(self):
+ """Load emoji data from the JSON file"""
+ try:
+ emoji_file_path = os.path.expanduser("~/Modus/config/emoji.json")
+ with open(emoji_file_path, "r", encoding="utf-8") as f:
+ self._emojis = json.load(f)
+ except Exception as e:
+ print(f"Failed to load emoji data: {e}")
+ self._emojis = {}
+
+ @property
+ def name(self) -> str:
+ return "Emoji"
+
+ @property
+ def category(self) -> str:
+ return "Utilities"
+
+ @property
+ def icon_name(self) -> str:
+ return "face-smile-symbolic"
+
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ if not query or len(query) < 2: # Require at least 2 characters
+ return []
+
+ results = []
+ query_lower = query.lower()
+
+ # Search through emojis
+ for emoji_char, emoji_data in self._emojis.items():
+ # Search in name, slug, and group
+ searchable_text = (
+ emoji_data.get("name", "").lower()
+ + " "
+ + emoji_data.get("slug", "").lower()
+ + " "
+ + emoji_data.get("group", "").lower()
+ )
+
+ if query_lower in searchable_text:
+ results.append(
+ {
+ "title": emoji_data.get('name', 'Unknown'),
+ "description": f"{emoji_data.get('group', 'Unknown')}",
+ "emoji_icon": emoji_char, # Store the emoji character for display
+ "action": lambda emoji=emoji_char: self.copy_emoji(emoji),
+ }
+ )
+
+ # Limit results to prevent overwhelming the UI
+ if len(results) >= 20:
+ break
+
+ # Sort results by relevance (exact matches first)
+ def sort_key(item):
+ name = item["title"].lower()
+ # Check if query matches the emoji name
+ if name.startswith(query_lower):
+ return 0 # Name starts with query
+ elif query_lower in name:
+ return 1 # Partial name match
+ else:
+ return 2 # Other matches
+
+ results.sort(key=sort_key)
+ return results
+
+ def copy_emoji(self, emoji: str):
+ """Copy emoji to clipboard using wl-copy"""
+ # Use a fire-and-forget approach to avoid blocking
+ try:
+ # Start wl-copy in the background and don't wait for it
+ subprocess.Popen(
+ ["wl-copy", emoji], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ )
+ print(f"Copied emoji to clipboard: {emoji}")
+ except FileNotFoundError:
+ try:
+ # Fallback to xclip
+ process = subprocess.Popen(
+ ["xclip", "-selection", "clipboard"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ process.stdin.write(emoji)
+ process.stdin.close()
+ print(f"Copied emoji to clipboard (xclip): {emoji}")
+ except (FileNotFoundError, OSError):
+ print(f"Could not copy emoji to clipboard: {emoji}")
+ except OSError:
+ print(f"Could not copy emoji to clipboard: {emoji}")
+
+ def get_action_items(self, query: str) -> List[Dict[str, Any]]:
+ """Get quick action items for emoji search"""
+ if not query or len(query) < 2:
+ return []
+
+ # Return a quick action to search for emojis
+ return [
+ {
+ "title": f'Search emojis for "{query}"',
+ "icon_name": "face-smile-symbolic",
+ "action": lambda: None, # No specific action needed
+ }
+ ]
diff --git a/modules/launcher/plugins/powermenu.py b/modules/launcher/plugins/powermenu.py
new file mode 100644
index 0000000..c9e753d
--- /dev/null
+++ b/modules/launcher/plugins/powermenu.py
@@ -0,0 +1,202 @@
+import subprocess
+from typing import List, Dict, Any
+
+import utils.icons as icons
+from . import LauncherPlugin
+
+
+class PowerMenuPlugin(LauncherPlugin):
+ """Plugin for system power management functionality"""
+
+ @property
+ def name(self) -> str:
+ return "Power Menu"
+
+ @property
+ def category(self) -> str:
+ return "System"
+
+ @property
+ def icon_name(self) -> str:
+ return "system-shutdown-symbolic"
+
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ if not query:
+ return []
+
+ query_lower = query.lower()
+
+ # Show power menu when user types "sys", "system", "power", etc.
+ power_triggers = ["sys", "system", "power", "shutdown", "restart", "reboot", "logout", "suspend", "sleep", "lock"]
+
+ if any(query_lower.startswith(trigger) for trigger in power_triggers):
+ return self._get_power_options(query_lower)
+
+ return []
+
+ def _get_power_options(self, query: str = "") -> List[Dict[str, Any]]:
+ """Get available power management options"""
+ power_options = [
+ {
+ "title": "Shutdown",
+ "description": "Turn off the computer completely",
+ "icon_markup": icons.shutdown,
+ "action": self.shutdown_system,
+ "keywords": ["shutdown", "power off", "turn off", "sys", "halt"],
+ },
+ {
+ "title": "Restart",
+ "description": "Restart the computer",
+ "icon_markup": icons.reboot,
+ "action": self.restart_system,
+ "keywords": ["restart", "reboot", "sys"],
+ },
+ {
+ "title": "Logout",
+ "description": "End current user session",
+ "icon_markup": icons.logout,
+ "action": self.logout_session,
+ "keywords": ["logout", "sign out", "end session", "sys"],
+ },
+ {
+ "title": "Suspend",
+ "description": "Suspend to RAM (sleep mode)",
+ "icon_markup": icons.suspend,
+ "action": self.suspend_system,
+ "keywords": ["suspend", "sleep", "sys"],
+ },
+ {
+ "title": "Lock Screen",
+ "description": "Lock the current session",
+ "icon_markup": icons.lock,
+ "action": self.lock_screen,
+ "keywords": ["lock", "lock screen", "sys"],
+ },
+ ]
+
+ # Filter options based on query if it's more specific
+ if len(query) > 3: # More than just "sys"
+ filtered_options = []
+ for option in power_options:
+ # Check if query matches any keywords
+ if any(query in keyword for keyword in option["keywords"]):
+ filtered_options.append(option)
+
+ if filtered_options:
+ power_options = filtered_options
+
+ # Convert to launcher result format
+ results = []
+ for option in power_options:
+ results.append({
+ "title": option["title"],
+ "description": option["description"],
+ "icon_markup": option["icon_markup"], # Use markup for custom icons
+ "action": option["action"],
+ "power_option": True, # Mark as power option for special handling
+ })
+
+ return results
+
+ def shutdown_system(self):
+ """Shutdown the system"""
+ print("Initiating system shutdown...")
+ try:
+ # Try systemctl first (systemd)
+ subprocess.run(["systemctl", "poweroff"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # Fallback to shutdown command
+ subprocess.run(["shutdown", "-h", "now"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Could not shutdown system - no suitable command found")
+
+ def restart_system(self):
+ """Restart the system"""
+ print("Initiating system restart...")
+ try:
+ # Try systemctl first (systemd)
+ subprocess.run(["systemctl", "reboot"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # Fallback to shutdown command
+ subprocess.run(["shutdown", "-r", "now"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Could not restart system - no suitable command found")
+
+ def logout_session(self):
+ """Logout from current session"""
+ try:
+ # Try different logout methods based on desktop environment
+ # Hyprland
+ subprocess.run(["hyprctl", "dispatch", "exit"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # GNOME
+ subprocess.run(["gnome-session-quit", "--logout", "--no-prompt"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # KDE
+ subprocess.run(["qdbus", "org.kde.ksmserver", "/KSMServer", "logout", "0", "0", "0"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # Generic X11
+ subprocess.run(["pkill", "-KILL", "-u", subprocess.getoutput("whoami")], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Could not logout - no suitable method found")
+
+ def suspend_system(self):
+ """Suspend the system"""
+ try:
+ # Try systemctl first (systemd)
+ subprocess.run(["systemctl", "suspend"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # Fallback to pm-suspend
+ subprocess.run(["pm-suspend"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Could not suspend system - no suitable command found")
+
+ def lock_screen(self):
+ """Lock the screen"""
+ try:
+ # Try different lock methods
+ # Hyprland
+ subprocess.run(["hyprlock"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # swaylock (Sway/Wayland)
+ subprocess.run(["swaylock"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # i3lock (X11)
+ subprocess.run(["i3lock"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # GNOME
+ subprocess.run(["gnome-screensaver-command", "--lock"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ try:
+ # KDE
+ subprocess.run(["qdbus", "org.freedesktop.ScreenSaver", "/ScreenSaver", "Lock"], check=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Could not lock screen - no suitable command found")
+
+ def get_action_items(self, query: str) -> List[Dict[str, Any]]:
+ """Get quick action items for power management"""
+ if not query or len(query) < 2:
+ return []
+
+ query_lower = query.lower()
+
+ # Show quick actions for system-related queries
+ if any(trigger in query_lower for trigger in ["sys", "power", "shutdown", "restart"]):
+ return [
+ {
+ "title": f'System power options for "{query}"',
+ "icon_name": "system-shutdown-symbolic",
+ "action": lambda: None, # No specific action needed
+ }
+ ]
+
+ return []
diff --git a/modules/launcher/plugins/web_search.py b/modules/launcher/plugins/web_search.py
new file mode 100644
index 0000000..26dba34
--- /dev/null
+++ b/modules/launcher/plugins/web_search.py
@@ -0,0 +1,49 @@
+from typing import List, Dict, Any
+from fabric.utils import exec_shell_command_async
+from . import LauncherPlugin
+
+
+class WebSearchPlugin(LauncherPlugin):
+ """Plugin for web search functionality"""
+
+ @property
+ def name(self) -> str:
+ return "Web Search"
+
+ @property
+ def category(self) -> str:
+ return "Web"
+
+ @property
+ def icon_name(self) -> str:
+ return "web-browser-symbolic"
+
+ def search(self, query: str) -> List[Dict[str, Any]]:
+ if not query:
+ return []
+
+ return [
+ {
+ "title": f'Search the web for "{query}"',
+ "description": "Open in default browser",
+ "icon_name": "web-browser-symbolic",
+ "action": lambda q=query: self.search_web(q),
+ }
+ ]
+
+ def get_action_items(self, query: str) -> List[Dict[str, Any]]:
+ if not query:
+ return []
+
+ return [
+ {
+ "title": f'Search web for "{query}"',
+ "icon_name": "web-browser-symbolic",
+ "action": lambda q=query: self.search_web(q),
+ }
+ ]
+
+ def search_web(self, query: str):
+ # Use xdg-open to open the default browser with a search query
+ search_url = f"https://www.google.com/search?q={query.replace(' ', '+')}"
+ exec_shell_command_async(f"xdg-open '{search_url}'", lambda *_: None)
diff --git a/services/mpris.py b/services/mpris.py
new file mode 100644
index 0000000..5772460
--- /dev/null
+++ b/services/mpris.py
@@ -0,0 +1,210 @@
+from gi.repository import Playerctl
+from typing import Optional
+import gi
+import contextlib
+from loguru import logger
+
+from fabric.core.service import Service, Signal, Property
+from fabric.utils.helpers import get_enum_member_name, snake_case_to_kebab_case
+
+
+gi.require_version("Playerctl", "2.0")
+
+
+class MediaPlayer(Service):
+ """A service to manage a media player."""
+
+ @Signal
+ def exit(self) -> None: ...
+
+ @Signal
+ def metadata_changed(self) -> None: ...
+
+ @Signal
+ def playback_status_changed(self) -> None: ...
+
+ @Property(str, flags="read-write")
+ def player_name(self) -> str:
+ return self._player.get_property("player_name")
+
+ @Property(int, "read-write", default_value=0)
+ def track_position(self) -> int:
+ return self._player.get_property("position")
+
+ @track_position.setter
+ def track_position(self, new_pos: int):
+ self._player.set_position(new_pos)
+
+ @Property(str, flags="read-write")
+ def album_image_url(self) -> str:
+ return self._player.get_property("metadata").unpack().get("mpris:artUrl", None)
+
+ @Property(int, flags="read-write")
+ def track_duration(self) -> int:
+ default_duration = minutes_to_microseconds(5)
+ metadata = self._player.get_property("metadata").unpack()
+ duration = metadata.get("mpris:length", default_duration) # Default: 5 minutes
+ return default_duration if duration == 0 else duration
+
+ @Property(str, flags="readable")
+ def status(self) -> str:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(
+ self._player.get_property("playback-status"), # type: ignore
+ default="unknown",
+ )
+ )
+
+ @Property(str, flags="readable")
+ def track(self) -> str:
+ return (
+ f"{self.track_artist} {self.track_title}"
+ if self.player_name == "spotify"
+ else self.track_title or "Music"
+ )
+
+ @Property(str, flags="readable")
+ def track_title(self) -> str:
+ return self._player.get_title()
+
+ @Property(str, flags="readable")
+ def track_artist(self) -> str:
+ return self._player.get_artist()
+
+ @Property(bool, "readable", default_value=False)
+ def can_go_next(self) -> bool:
+ return self._player.get_property("can_go_next")
+
+ @Property(bool, "readable", default_value=False)
+ def can_go_previous(self) -> bool:
+ return self._player.get_property("can_go_previous")
+
+ @Property(bool, "readable", default_value=False)
+ def can_seek(self) -> bool:
+ return self._player.get_property("can_seek")
+
+ @Property(bool, "readable", default_value=False)
+ def can_pause(self) -> bool:
+ return self._player.get_property("can_pause")
+
+ def __init__(self, player):
+ super().__init__()
+ self._player = player
+ self._signal_connectors = {}
+
+ signals = {
+ "seeked": lambda *args: self.notify("track_position"),
+ "playback-status": lambda *args: self._on_playback_status_changed(),
+ "metadata": lambda *args: self._on_metadata_changed(),
+ "exit": lambda *args: self._on_exit(),
+ }
+
+ for signal_name, handler in signals.items():
+ self._signal_connectors[signal_name] = self._player.connect(
+ signal_name, handler
+ )
+
+ def _on_playback_status_changed(self):
+ self.notify("status")
+ self.playback_status_changed.emit()
+
+ def _on_metadata_changed(self):
+ for prop in [
+ "track_duration",
+ "track_position",
+ "track_title",
+ "track_artist",
+ "track",
+ "album_image_url",
+ ]:
+ self.notify(prop)
+ self.metadata_changed.emit()
+
+ def _on_exit(self):
+ for signal_id in self._signal_connectors.values():
+ with contextlib.suppress(Exception):
+ self._player.disconnect(signal_id)
+ del self._player
+ self.exit.emit()
+
+ def play_pause(self):
+ if self.can_pause:
+ self._player.play_pause()
+
+ def next(self):
+ if self.can_go_next:
+ self._player.next()
+
+ def previous(self):
+ if self.can_go_previous:
+ self._player.previous()
+
+ def get_position(self):
+ return self._player.get_position() if self._player else 0
+
+
+@staticmethod
+def minutes_to_microseconds(minutes):
+ return minutes * 60 * 1000000
+
+
+class MediaManager(Service):
+ """MediaManager Service using Playerctl to control media players"""
+
+ @Signal
+ def player_appeared(self) -> None: ...
+
+ @Signal
+ def player_vanished(self) -> None: ...
+
+ @Property(list, "readable")
+ def player_names(self):
+ return self._manager.get_property("player_names")
+
+ @Property(list[MediaPlayer], "readable")
+ def players(self) -> list[MediaPlayer]:
+ return self._players.values()
+
+ @Property(MediaPlayer, "readable")
+ def current_player(self) -> Optional[MediaPlayer]:
+ current_player = None
+ for player in self.players:
+ if player.status == "playing":
+ return player
+ current_player = player
+ return current_player
+
+ def __init__(self):
+ super().__init__()
+ self._manager = Playerctl.PlayerManager()
+ self._players: dict[str, MediaPlayer] = {}
+
+ self._manager.connect(
+ "name-appeared", lambda _, player: self._on_player_appeared(player=player)
+ )
+ self._manager.connect(
+ "player-vanished", lambda _, player: self._on_player_vanished(player=player)
+ )
+
+ for player in self._manager.get_property("player-names"):
+ self._on_player_appeared(player=player)
+
+ def _on_player_appeared(self, player):
+ """Callback when a new player appears"""
+ player = Playerctl.Player.new_from_name(player)
+ player_name = player.get_property("player_name")
+
+ logger.info(f"[Media] New player appeared: {player_name}")
+
+ self._manager.manage_player(player)
+ self._players[player_name] = MediaPlayer(player=player)
+ self.notify("current-player")
+ self.player_appeared.emit()
+
+ def _on_player_vanished(self, player):
+ """Callback when a player disappears"""
+ player_name = player.get_property("player_name") or "Unknown"
+ logger.info(f"[Media] Player vanished: {player_name}")
+ self._players.pop(player_name)
+ self.notify("current-player")
+ self.player_vanished.emit()
diff --git a/styles/colors.css b/styles/colors.css
index a35f805..64e41f4 100644
--- a/styles/colors.css
+++ b/styles/colors.css
@@ -1,32 +1,32 @@
:vars {
- --foreground: #ede0d4;
- --background: #18120b;
- --cursor: #ede0d4;
- --primary: #f5bc6f;
- --on-primary: #452b00;
- --secondary: #dec2a2;
- --on-secondary: #3e2d16;
- --tertiary: #b9cda0;
- --on-tertiary: #253515;
- --surface: #18120b;
- --surface-bright: #3f3830;
+ --foreground: #e0e3e8;
+ --background: #101417;
+ --cursor: #e0e3e8;
+ --primary: #96ccf8;
+ --on-primary: #00344f;
+ --secondary: #b7c9d9;
+ --on-secondary: #22323f;
+ --tertiary: #d0c0e8;
+ --on-tertiary: #362b4a;
+ --surface: #101417;
+ --surface-bright: #363a3e;
--error: #ffb4ab;
--error-dim: #ff8678;
--on-error: #690005;
--error-container: #93000a;
- --outline: #9c8f80;
+ --outline: #8b9198;
--shadow: #000000;
- --red: #ffb595;
- --red-dim: #ff9162;
- --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;
+ --red: #ffb2b9;
+ --red-dim: #ff7f8b;
+ --green: #95d5a7;
+ --green-dim: #70c789;
+ --yellow: #b8cf84;
+ --yellow-dim: #a3c15f;
+ --blue: #afc6ff;
+ --blue-dim: #7ca2ff;
+ --magenta: #e4b7f3;
+ --magenta-dim: #d48bec;
+ --cyan: #82d3e2;
+ --cyan-dim: #59c4d8;
--white: #82d3e0;
}
diff --git a/styles/components.css b/styles/components.css
index b4011e2..ec5adcf 100644
--- a/styles/components.css
+++ b/styles/components.css
@@ -1,3 +1,29 @@
+/*
+ * ββ 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;
@@ -24,51 +50,24 @@
color: var(--red-dim);
}
-#battery-save,
-#battery-balanced,
-#battery-performance {
- border-radius: 12px;
- min-width: 28px;
- min-height: 28px;
-}
-
-#battery-save-label,
-#battery-balanced-label,
-#battery-performance-label {
- color: var(--outline);
- font-size: 20px;
-}
-
-#battery-save:hover,
-#battery-balanced:hover,
-#battery-performance:hover {
- background-color: var(--surface-bright);
-}
-
-#battery-save:hover #battery-save-label,
-#battery-balanced:hover #battery-balanced-label,
-#battery-performance:hover #battery-performance-label {
- color: var(--primary);
-}
-
-#battery-save.active,
-#battery-balanced.active,
-#battery-performance.active {
- background-color: var(--primary);
-}
-
-#battery-save.active #battery-save-label,
-#battery-balanced.active #battery-balanced-label,
-#battery-performance.active #battery-performance-label {
- color: var(--shadow);
-}
-
#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);
+}
+
+/*
+ * ββ metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+*/
+
#metrics {
background-color: var(--surface);
padding: 4px;
@@ -103,9 +102,14 @@
min-width: 4px;
}
+/*
+ * ββ datetime ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+*/
+
#date-time {
background-color: var(--surface);
padding: 8px;
+ font-weight: bold;
}
#date-time.vertical {
padding: 8px 0;
@@ -115,6 +119,10 @@
border-radius: 12px;
}
+/*
+ * ββ tray ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+*/
+
#tray {
background-color: var(--surface);
padding: 8px;
@@ -144,3 +152,128 @@ menu > menuitem:hover {
menu > menuitem:hover > label {
color: var(--shadow);
}
+
+/*
+ * ββ music player ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+*/
+
+#music-player {
+ background-color: var(--surface);
+ padding: 4px;
+}
+
+#music-player.invert {
+ border-radius: 12px;
+}
+
+/* Track info styles */
+#music-track {
+ color: var(--primary);
+ font-weight: bold;
+ font-size: 12px;
+}
+
+/* Control buttons base styles */
+#music-prev,
+#music-next {
+ background: none;
+ border: none;
+ padding: 4px;
+}
+
+#music-prev:hover,
+#music-next:hover {
+ background-color: var(--surface-bright);
+}
+
+#music-prev:active,
+#music-next:active {
+ background-color: var(--primary);
+}
+
+#music-prev label,
+#music-next label {
+ color: var(--primary);
+ font-size: 14px;
+}
+
+#music-prev:hover label,
+#music-next:hover label {
+ color: var(--primary);
+}
+
+#music-prev:active label,
+#music-next:active label {
+ color: var(--shadow);
+}
+
+/* Play/Pause button styling */
+#music-play-pause {
+ background-color: var(--surface);
+ border-radius: 12px;
+ padding: 6px;
+ border: none;
+}
+
+#music-play-pause label {
+ color: var(--primary);
+ font-size: 18px;
+}
+
+#music-play-pause:hover {
+ background-color: var(--surface-bright);
+}
+
+#music-play-pause:hover label {
+ color: var(--primary);
+}
+
+#music-play-pause:active {
+ background-color: var(--primary);
+}
+
+#music-play-pause:active label {
+ color: var(--shadow);
+}
+
+/* Player indicators container */
+#music-player-indicators {
+ padding: 4px;
+}
+
+/* Player indicator buttons */
+#music-player-indicator-button {
+ background: none;
+ border: none;
+ padding: 2px;
+ border-radius: 6px;
+}
+
+#music-player-indicator-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#music-player-indicator-button:active {
+ background-color: var(--primary);
+}
+
+/* Player indicator labels */
+#music-player-indicator {
+ color: var(--surface-bright);
+ font-size: 12px;
+}
+
+#music-player-indicator-active {
+ color: var(--primary);
+ font-size: 12px;
+ font-weight: bold;
+}
+
+#music-player-indicator-button:hover #music-player-indicator {
+ color: var(--primary);
+}
+
+#music-player-indicator-button:active #music-player-indicator,
+#music-player-indicator-button:active #music-player-indicator-active {
+ color: var(--shadow);
+}
diff --git a/styles/dock.css b/styles/dock.css
index 6b3259a..0549fe5 100644
--- a/styles/dock.css
+++ b/styles/dock.css
@@ -53,25 +53,6 @@
border-radius: 12px;
}
-#dock.integrated {
- background-color: var(--shadow);
- padding: 4px;
- margin: 0;
- border-radius: 16px;
- border: none;
- transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-#dock.integrated.edge,
-#dock.integrated.dense {
- background-color: var(--surface);
- padding: 4px;
- margin: 0;
- border-radius: 12px;
- border: none;
- transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
#dock-full {
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 1;
diff --git a/styles/indicators.css b/styles/indicators.css
index e313ee0..b938e64 100644
--- a/styles/indicators.css
+++ b/styles/indicators.css
@@ -14,18 +14,7 @@
border: 3px solid var(--primary);
}
-#button-network.muted,
-#button-bluetooth.muted {
- color: var(--surface-bright);
- border: 3px solid var(--outline);
-}
-
#network-label,
#bluetooth-label {
font-size: 20px;
}
-
-#network-label.muted,
-#bluetooth-label.muted {
- color: var(--outline);
-}
diff --git a/utils/audio_visualizer.py b/utils/audio_visualizer.py
new file mode 100644
index 0000000..82b9090
--- /dev/null
+++ b/utils/audio_visualizer.py
@@ -0,0 +1,298 @@
+import configparser
+import ctypes
+import os
+import re
+import signal
+import struct
+import subprocess
+from math import pi
+
+from fabric.utils.helpers import get_relative_path
+from fabric.widgets.overlay import Overlay
+from gi.repository import Gdk, GLib, Gtk
+from loguru import logger
+
+
+def get_bars(file_path):
+ config = configparser.ConfigParser()
+ config.read(file_path)
+ return int(config['general']['bars'])
+
+CAVA_CONFIG = get_relative_path("../config/cavalcade/cava.ini")
+
+bars = get_bars(CAVA_CONFIG)
+
+def set_death_signal():
+ """
+ Set the death signal of the child process to SIGTERM so that if the parent
+ process is killed, the child (cava) is automatically terminated.
+ """
+ libc = ctypes.CDLL("libc.so.6")
+ PR_SET_PDEATHSIG = 1
+ libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
+
+class Cava:
+ """
+ CAVA wrapper.
+ Launch cava process with certain settings and read output.
+ """
+ NONE = 0
+ RUNNING = 1
+ RESTARTING = 2
+ CLOSING = 3
+
+ def __init__(self, mainapp):
+ self.bars = bars
+ self.path = "/tmp/cava.fifo"
+
+ self.cava_config_file = CAVA_CONFIG
+ self.data_handler = mainapp.draw.update
+ self.command = ["cava", "-p", self.cava_config_file]
+ self.state = self.NONE
+
+ self.env = dict(os.environ)
+ self.env["LC_ALL"] = "en_US.UTF-8" # not sure if it's necessary
+
+ is_16bit = True
+ self.byte_type, self.byte_size, self.byte_norm = ("H", 2, 65535) if is_16bit else ("B", 1, 255)
+
+ if not os.path.exists(self.path):
+ os.mkfifo(self.path)
+
+ self.fifo_fd = None
+ self.fifo_dummy_fd = None
+ self.io_watch_id = None
+
+ def _run_process(self):
+ logger.debug("Launching cava process...")
+ try:
+ self.process = subprocess.Popen(
+ self.command,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ env=self.env,
+ preexec_fn=set_death_signal # Ensure cava gets killed when the parent dies.
+ )
+ logger.debug("cava successfully launched!")
+ self.state = self.RUNNING
+ except Exception:
+ logger.exception("Fail to launch cava")
+
+ def _start_io_reader(self):
+ logger.debug("Activating GLib IO watch for cava stream handler")
+ # Open FIFO in non-blocking mode for reading
+ self.fifo_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
+ # Open dummy write end to prevent getting an EOF on our FIFO
+ self.fifo_dummy_fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)
+ self.io_watch_id = GLib.io_add_watch(self.fifo_fd, GLib.IO_IN, self._io_callback)
+
+ def _io_callback(self, source, condition):
+ chunk = self.byte_size * self.bars # number of bytes for given format
+ try:
+ data = os.read(self.fifo_fd, chunk)
+ except OSError as e:
+ # logger.error("Error reading FIFO: {}".format(e))
+ return False
+
+ # When no data is read, do not remove the IO watch immediately.
+ if len(data) < chunk:
+ # Instead of closing the FIFO, we log a warning and continue.
+ # logger.warning("Incomplete data packet received (expected {} bytes, got {}). Waiting for more data...".format(chunk, len(data)))
+ # Returning True keeps the IO watch active. A real EOF will only occur when the writer closes.
+ return True
+
+ fmt = self.byte_type * self.bars # format string for struct.unpack
+ sample = [i / self.byte_norm for i in struct.unpack(fmt, data)]
+ GLib.idle_add(self.data_handler, sample)
+ return True
+
+ def _on_stop(self):
+ logger.debug("Cava stream handler deactivated")
+ if self.state == self.RESTARTING:
+ self.start()
+ elif self.state == self.RUNNING:
+ self.state = self.NONE
+ logger.error("Cava process was unexpectedly terminated.")
+ # self.restart() # May cause infinity loop, need more check
+
+ def start(self):
+ """Launch cava"""
+ self._start_io_reader()
+ self._run_process()
+
+ def restart(self):
+ """Restart cava process"""
+ if self.state == self.RUNNING:
+ logger.debug("Restarting cava process (normal mode) ...")
+ self.state = self.RESTARTING
+ if self.process.poll() is None:
+ self.process.kill()
+ elif self.state == self.NONE:
+ logger.warning("Restarting cava process (after crash) ...")
+ self.start()
+
+ def close(self):
+ """Stop cava process"""
+ self.state = self.CLOSING
+ if self.process.poll() is None:
+ self.process.kill()
+ if self.io_watch_id:
+ GLib.source_remove(self.io_watch_id)
+ if self.fifo_fd:
+ os.close(self.fifo_fd)
+ if self.fifo_dummy_fd:
+ os.close(self.fifo_dummy_fd)
+ if os.path.exists(self.path):
+ os.remove(self.path)
+
+class AttributeDict(dict):
+ """Dictionary with keys as attributes. Does nothing but easy reading"""
+ def __getattr__(self, attr):
+ return self.get(attr, 3)
+
+ def __setattr__(self, attr, value):
+ self[attr] = value
+
+class Spectrum:
+ """Spectrum drawing"""
+ def __init__(self):
+ self.silence_value = 0
+ self.audio_sample = []
+ self.color = None
+
+ self.area = Gtk.DrawingArea()
+ self.area.connect("draw", self.redraw)
+ self.area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+
+ self.sizes = AttributeDict()
+ self.sizes.area = AttributeDict()
+ self.sizes.bar = AttributeDict()
+
+ self.silence = 10
+ self.max_height = 12
+
+ self.area.connect("configure-event", self.size_update)
+ self.color_update()
+
+ def is_silence(self, value):
+ """Check if volume level critically low during last iterations"""
+ self.silence_value = 0 if value > 0 else self.silence_value + 1
+ return self.silence_value > self.silence
+
+ def update(self, data):
+ """Audio data processing"""
+ self.color_update()
+ self.audio_sample = data
+ if not self.is_silence(self.audio_sample[0]):
+ self.area.queue_draw()
+ elif self.silence_value == (self.silence + 1):
+ self.audio_sample = [0] * self.sizes.number
+ self.area.queue_draw()
+
+ def redraw(self, widget, cr):
+ """Draw spectrum graph"""
+ cr.set_source_rgba(*self.color)
+ dx = 3
+
+ center_y = self.sizes.area.height / 2 # center vertical of the drawing area
+ for i, value in enumerate(self.audio_sample):
+ width = self.sizes.area.width / self.sizes.number - self.sizes.padding
+ radius = width / 2
+ height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2
+ if height == self.sizes.zero / 2 + 1:
+ height *= 0.5
+
+ height = min(height, self.max_height)
+
+ # Draw rectangle and arcs for rounded ends
+ cr.rectangle(dx, center_y - height, width, height * 2)
+ cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi)
+ cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi)
+
+ cr.close_path()
+ dx += width + self.sizes.padding
+ cr.fill()
+
+ def size_update(self, *args):
+ """Update drawing geometry"""
+ self.sizes.number = bars
+ self.sizes.padding = 100 / bars
+ self.sizes.zero = 0
+
+ self.sizes.area.width = self.area.get_allocated_width()
+ self.sizes.area.height = self.area.get_allocated_height() - 2
+
+ tw = self.sizes.area.width - self.sizes.padding * (self.sizes.number - 1)
+ self.sizes.bar.width = max(int(tw / self.sizes.number), 1)
+ self.sizes.bar.height = self.sizes.area.height
+
+ def color_update(self):
+ """Set drawing color according to current settings by reading primary color from CSS"""
+ color = "#a5c8ff" # default value
+ try:
+ with open(get_relative_path("../styles/colors.css"), "r") as f:
+ content = f.read()
+ m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
+ if m:
+ color = m.group(1)
+ except Exception as e:
+ logger.error("Failed to read primary color: {}".format(e))
+ red = int(color[1:3], 16) / 255
+ green = int(color[3:5], 16) / 255
+ blue = int(color[5:7], 16) / 255
+ self.color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
+
+class SpectrumRender:
+ def __init__(self, mode=None, **kwargs):
+ self.mode = mode
+
+ self.draw = Spectrum()
+ self.cava = Cava(self)
+ self.cava.start()
+
+ def get_spectrum_box(self):
+ # Get the spectrum box
+ box = Overlay(name="cavalcade", h_align='center', v_align='center')
+ box.set_size_request(90, 34) # Better size for music player
+ box.add_overlay(self.draw.area)
+ box.show_all() # Ensure everything is visible
+ return box
+
+
+class AudioVisualizer(Overlay):
+ """
+ Simple AudioVisualizer wrapper around existing spectrum components
+ for compatibility with the music player component.
+ """
+ def __init__(self, name=None, width=150, height=24, bars=20, **kwargs):
+ super().__init__(name=name, **kwargs)
+
+ self.width = width
+ self.height = height
+ self.bars = bars
+ self.is_playing = False
+
+ # Create spectrum renderer
+ self.spectrum_render = SpectrumRender()
+ self.spectrum_box = self.spectrum_render.get_spectrum_box()
+
+ # Set size
+ self.spectrum_box.set_size_request(width, height)
+
+ # Add to overlay
+ self.add_overlay(self.spectrum_box)
+
+ # Set alignment
+ self.set_halign(Gtk.Align.CENTER)
+ self.set_valign(Gtk.Align.CENTER)
+
+ def set_visible(self, visible):
+ """Override set_visible to control spectrum rendering"""
+ super().set_visible(visible)
+ if visible and self.is_playing:
+ # Ensure spectrum is active when visible and playing
+ if hasattr(self.spectrum_render, 'cava') and self.spectrum_render.cava.state == 0: # NONE
+ self.spectrum_render.cava.start()
+ elif not visible:
+ # Optionally pause spectrum when not visible to save resources
+ pass