This commit is contained in:
sankalp committed 2025-06-13 21:00:45 +05:45
1 parent 776f2d2da2
commit 4059ff9309
27 files changed
+2725 -221

No files matched your search

+37 -5
View File
@@ -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)
+79 -52
View File
@@ -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 ""
+22 -15
View File
@@ -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
+17 -19
View File
@@ -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()
self.show_all()
+622
View File
@@ -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()
+336
View File
@@ -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'<span font_size="24000">{result["emoji_icon"]}</span>').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'<span font_family="tabler-icons" font_size="32000">{result["icon_markup"]}</span>').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
+52
View File
@@ -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 []
+78
View File
@@ -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
)
+54
View File
@@ -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 []
+259
View File
@@ -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*<img\s+', content) is not None or
"binary" in content.lower() and any(ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"])
)
def _load_image_preview(self, item_id):
"""Load image preview for clipboard item"""
try:
result = subprocess.run(
["cliphist", "decode", item_id],
capture_output=True,
check=True,
timeout=2
)
# Try to load the image data
loader = GdkPixbuf.PixbufLoader()
loader.write(result.stdout)
loader.close()
pixbuf = loader.get_pixbuf()
if pixbuf:
# Scale to thumbnail size
width, height = pixbuf.get_width(), pixbuf.get_height()
max_size = 128 # Match launcher icon size
if width > height:
new_width = max_size
new_height = int(height * (max_size / width))
else:
new_height = max_size
new_width = int(width * (max_size / height))
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
}
]
+122
View File
@@ -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
}
]
+202
View File
@@ -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 []
+49
View File
@@ -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)