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