updates
This commit is contained in:
27 files changed
+2725
-221
No files matched your search
@@ -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)
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user