✨ feat(osd): add OSD for brightness, volume, and microphone controls
This commit is contained in:
15 files changed
+520
-1231
No files matched your search
@@ -2,10 +2,10 @@
|
||||
@import url("./styles/colors.css");
|
||||
@import url("./styles/components.css");
|
||||
@import url("./styles/controls.css");
|
||||
@import url("./styles/launcher.css");
|
||||
@import url("./styles/indicators.css");
|
||||
@import url("./styles/workspaces.css");
|
||||
@import url("./styles/switcher.css");
|
||||
@import url("./styles/osd.css");
|
||||
|
||||
* {
|
||||
all: unset;
|
||||
|
||||
@@ -9,8 +9,8 @@ from loguru import logger
|
||||
from config.data import APP_NAME, APP_NAME_CAP, CONFIG_FILE
|
||||
from modules.corners import Corners
|
||||
from modules.dock.main import Dock
|
||||
from modules.launcher.main import Launcher
|
||||
from modules.switcher import ApplicationSwitcher
|
||||
from modules.osd import OSD
|
||||
|
||||
gi.require_version("GLib", "2.0")
|
||||
|
||||
@@ -44,9 +44,8 @@ if __name__ == "__main__":
|
||||
|
||||
corners = Corners()
|
||||
dock = Dock()
|
||||
launcher = Launcher()
|
||||
switcher = ApplicationSwitcher()
|
||||
|
||||
osd = OSD()
|
||||
# Set corners visibility based on config
|
||||
corners_visible = config.get("corners_visible", True)
|
||||
corners.set_visible(corners_visible)
|
||||
@@ -62,8 +61,8 @@ if __name__ == "__main__":
|
||||
f"{APP_NAME}",
|
||||
dock,
|
||||
corners,
|
||||
launcher,
|
||||
switcher,
|
||||
osd,
|
||||
) # Make sure corners is added to the app
|
||||
|
||||
def set_css():
|
||||
|
||||
@@ -10,13 +10,13 @@ from gi.repository import Gdk, GLib
|
||||
|
||||
import config.data as data
|
||||
import utils.icons as icons
|
||||
from services.brightness import Brightness
|
||||
from services.brightness import Brightness as BrightnessService
|
||||
|
||||
|
||||
class BrightnessSmall(Box):
|
||||
class Brightness(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-brightness", **kwargs)
|
||||
self.brightness = Brightness.get_initial()
|
||||
self.brightness = BrightnessService.get_initial()
|
||||
if self.brightness.screen_brightness == -1:
|
||||
self.destroy()
|
||||
return
|
||||
@@ -46,19 +46,24 @@ class BrightnessSmall(Box):
|
||||
self.brightness.connect("screen", self.on_brightness_changed)
|
||||
self.on_brightness_changed()
|
||||
|
||||
def on_scroll(self, widget, event):
|
||||
def on_scroll(self, _, event):
|
||||
if self.brightness.max_screen == -1:
|
||||
return
|
||||
|
||||
current_brightness = self.brightness.screen_brightness
|
||||
max_brightness = self.brightness.max_screen
|
||||
step = max(1, int(max_brightness * 0.01)) # 1% of max brightness
|
||||
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if hasattr(event, 'delta_y') and abs(event.delta_y) > 0:
|
||||
new_brightness = current_brightness - int(event.delta_y * step)
|
||||
self.brightness.screen_brightness = new_brightness
|
||||
# Remove delta_x handling
|
||||
elif event.direction == Gdk.ScrollDirection.UP:
|
||||
self.brightness.screen_brightness = current_brightness + step
|
||||
elif event.direction == Gdk.ScrollDirection.DOWN:
|
||||
self.brightness.screen_brightness = current_brightness - step
|
||||
|
||||
step_size = 5
|
||||
current_norm = self.progress_bar.value
|
||||
if event.delta_y < 0:
|
||||
new_norm = min(current_norm + (step_size / self.brightness.max_screen), 1)
|
||||
elif event.delta_y > 0:
|
||||
new_norm = max(current_norm - (step_size / self.brightness.max_screen), 0)
|
||||
else:
|
||||
return
|
||||
self.progress_bar.value = new_norm
|
||||
|
||||
def on_progress_value_changed(self, widget, pspec):
|
||||
if self._updating_from_brightness:
|
||||
@@ -100,7 +105,7 @@ class BrightnessSmall(Box):
|
||||
GLib.source_remove(self._update_source_id)
|
||||
super().destroy()
|
||||
|
||||
class VolumeSmall(Box):
|
||||
class Volume(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-vol", **kwargs)
|
||||
self.audio = Audio()
|
||||
@@ -143,11 +148,19 @@ class VolumeSmall(Box):
|
||||
def on_scroll(self, _, event):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
|
||||
current_volume = self.audio.speaker.volume
|
||||
step = 1
|
||||
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if abs(event.delta_y) > 0:
|
||||
self.audio.speaker.volume -= event.delta_y
|
||||
if abs(event.delta_x) > 0:
|
||||
self.audio.speaker.volume += event.delta_x
|
||||
if hasattr(event, 'delta_y') and abs(event.delta_y) > 0:
|
||||
new_volume = current_volume - int(event.delta_y * step)
|
||||
self.audio.speaker.volume = new_volume
|
||||
# Remove delta_x handling
|
||||
elif event.direction == Gdk.ScrollDirection.UP:
|
||||
self.audio.speaker.volume = current_volume + step
|
||||
elif event.direction == Gdk.ScrollDirection.DOWN:
|
||||
self.audio.speaker.volume = current_volume - step
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
if not self.audio.speaker:
|
||||
@@ -183,7 +196,7 @@ class VolumeSmall(Box):
|
||||
else:
|
||||
self.vol_label.set_markup(vol_off_icon)
|
||||
|
||||
class MicSmall(Box):
|
||||
class MicroPhone(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-mic", **kwargs)
|
||||
self.audio = Audio()
|
||||
@@ -227,10 +240,13 @@ class MicSmall(Box):
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
if event.direction == Gdk.ScrollDirection.SMOOTH:
|
||||
if abs(event.delta_y) > 0:
|
||||
if hasattr(event, 'delta_y') and abs(event.delta_y) > 0:
|
||||
self.audio.microphone.volume -= event.delta_y
|
||||
if abs(event.delta_x) > 0:
|
||||
self.audio.microphone.volume += event.delta_x
|
||||
# Remove delta_x handling
|
||||
elif event.direction == Gdk.ScrollDirection.UP:
|
||||
self.audio.microphone.volume += 1
|
||||
elif event.direction == Gdk.ScrollDirection.DOWN:
|
||||
self.audio.microphone.volume -= 1
|
||||
|
||||
def on_microphone_changed(self, *_):
|
||||
if not self.audio.microphone:
|
||||
@@ -257,11 +273,11 @@ class MicSmall(Box):
|
||||
|
||||
class Controls(Box):
|
||||
def __init__(self, **kwargs):
|
||||
brightness = Brightness.get_initial()
|
||||
brightness = BrightnessService.get_initial()
|
||||
children = []
|
||||
if (brightness.screen_brightness != -1):
|
||||
children.append(BrightnessSmall())
|
||||
children.extend([VolumeSmall(), MicSmall()])
|
||||
children.append(Brightness())
|
||||
children.extend([Volume(), MicroPhone()])
|
||||
super().__init__(
|
||||
name="control",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
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 []
|
||||
@@ -1,78 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
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 []
|
||||
@@ -1,259 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
@@ -1,202 +0,0 @@
|
||||
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 []
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
import time
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import utils.icons as icons
|
||||
from fabric.audio import Audio
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.scale import Scale, ScaleMark
|
||||
from gi.repository import GLib, GObject
|
||||
from services.brightness import Brightness
|
||||
from utils.animator import Animator
|
||||
from utils.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
class AnimatedScale(Scale):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.animator = None # Lazily initialized
|
||||
|
||||
def animate_value(self, value: float):
|
||||
if not self.animator:
|
||||
self.animator = Animator(
|
||||
bezier_curve=(0.34, 1.56, 0.64, 1.0),
|
||||
duration=0.8,
|
||||
min_value=self.min_value,
|
||||
max_value=self.value,
|
||||
tick_widget=self,
|
||||
notify_value=lambda p, *_: self.set_value(p.value),
|
||||
)
|
||||
self.animator.pause()
|
||||
self.animator.min_value = self.value
|
||||
self.animator.max_value = value
|
||||
self.animator.play()
|
||||
|
||||
|
||||
class BrightnessOSDContainer(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs, orientation="h", spacing=12, name="osd")
|
||||
self.brightness_service = Brightness.get_initial()
|
||||
self.icon = Label(markup=icons.brightness, name="brighntess-icons")
|
||||
self.scale = AnimatedScale(
|
||||
marks=(ScaleMark(value=i) for i in range(0, 101, 10)),
|
||||
value=70,
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
increments=(1, 1),
|
||||
orientation="h",
|
||||
)
|
||||
|
||||
self.add(self.icon)
|
||||
self.add(self.scale)
|
||||
self.update_brightness()
|
||||
|
||||
self.scale.connect("value-changed", lambda *_: self.update_brightness())
|
||||
self.brightness_service.connect("screen", self.on_brightness_changed)
|
||||
|
||||
def update_brightness(self) -> None:
|
||||
current_brightness = self.brightness_service.screen_brightness
|
||||
if current_brightness != 0:
|
||||
normalized_brightness = self._normalize_brightness(current_brightness)
|
||||
self.scale.animate_value(normalized_brightness)
|
||||
self.update_icon(normalized_brightness)
|
||||
|
||||
def update_icon(self, brightness_percentage: float) -> None:
|
||||
icon_name = self.get_brightness_icon(brightness_percentage)
|
||||
self.icon.set_label(icon_name)
|
||||
|
||||
def on_brightness_changed(self, _sender: any, value: float, *_args) -> None:
|
||||
normalized_brightness = self._normalize_brightness(value)
|
||||
self.scale.animate_value(normalized_brightness)
|
||||
self.update_icon(normalized_brightness)
|
||||
|
||||
def _normalize_brightness(self, brightness: float) -> float:
|
||||
return (brightness / self.brightness_service.max_screen) * 100
|
||||
|
||||
def get_brightness_icon(self, brightness_percentage: float) -> str:
|
||||
if brightness_percentage >= 67:
|
||||
return icons.brightness_high
|
||||
elif brightness_percentage >= 34:
|
||||
return icons.brightness_medium
|
||||
else:
|
||||
return icons.brightness_low
|
||||
|
||||
|
||||
class AudioOSDContainer(Box):
|
||||
__gsignals__: ClassVar[dict] = {
|
||||
"volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs, orientation="h", spacing=13, name="osd")
|
||||
self.audio = Audio()
|
||||
self.icon = Label(markup=icons.vol_high, name="vol-icon")
|
||||
self.scale = AnimatedScale(
|
||||
marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
|
||||
value=70,
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
increments=(1, 1),
|
||||
orientation="h",
|
||||
)
|
||||
|
||||
self.add(self.icon)
|
||||
self.add(self.scale)
|
||||
self.sync_with_audio()
|
||||
|
||||
self.scale.connect("value-changed", self.on_volume_changed)
|
||||
self.audio.connect("notify::speaker", self.on_audio_speaker_changed)
|
||||
|
||||
# Connect to speaker-changed signal directly
|
||||
self.audio.connect("speaker-changed", self.on_speaker_changed)
|
||||
|
||||
# Connect to current speaker if available
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.audio.speaker.connect("notify::muted", self.on_mute_changed)
|
||||
|
||||
def sync_with_audio(self):
|
||||
if self.audio.speaker:
|
||||
volume = round(self.audio.speaker.volume)
|
||||
self.scale.set_value(volume)
|
||||
self.update_icon(volume)
|
||||
|
||||
def on_volume_changed(self, *_):
|
||||
if self.audio.speaker:
|
||||
volume = self.scale.value
|
||||
if 0 <= volume <= 100:
|
||||
self.audio.speaker.set_volume(volume)
|
||||
self.update_icon(volume)
|
||||
|
||||
# Apply muted style based on volume and mute state
|
||||
if volume == 0 or (self.audio.speaker and self.audio.speaker.muted):
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
self.scale.remove_style_class("muted")
|
||||
self.icon.remove_style_class("muted")
|
||||
|
||||
self.emit("volume-changed")
|
||||
|
||||
def on_audio_speaker_changed(self, *_):
|
||||
if self.audio.speaker:
|
||||
self.audio.speaker.connect("changed", self.on_speaker_changed)
|
||||
self.update_volume()
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
# This will be called when any property of the speaker changes, including mute
|
||||
self.update_volume()
|
||||
self.update_icon(round(self.audio.speaker.volume))
|
||||
self.emit("volume-changed")
|
||||
|
||||
def update_volume(self, *_):
|
||||
if self.audio.speaker and not self.is_hovered():
|
||||
volume = round(self.audio.speaker.volume)
|
||||
self.scale.set_value(volume)
|
||||
self.update_icon(volume)
|
||||
|
||||
# Apply muted style based on volume and mute state
|
||||
if volume == 0 or (self.audio.speaker and self.audio.speaker.muted):
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
self.scale.remove_style_class("muted")
|
||||
self.icon.remove_style_class("muted")
|
||||
|
||||
def update_icon(self, volume):
|
||||
# Check if muted first, regardless of volume level
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
|
||||
# Get appropriate icons based on device type
|
||||
vol_high_icon = icons.vol_high
|
||||
vol_medium_icon = icons.vol_medium
|
||||
vol_mute_icon = icons.vol_off
|
||||
vol_off_icon = icons.vol_mute
|
||||
|
||||
# Check if it's a bluetooth device
|
||||
if self.audio.speaker and "bluetooth" in self.audio.speaker.icon_name:
|
||||
vol_high_icon = icons.bluetooth_connected
|
||||
vol_medium_icon = icons.bluetooth
|
||||
vol_mute_icon = icons.bluetooth_off
|
||||
vol_off_icon = icons.bluetooth_disconnected
|
||||
|
||||
# Set the appropriate icon based on mute state and volume
|
||||
if self.audio.speaker.muted:
|
||||
self.icon.set_label(vol_mute_icon)
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
if volume == 0:
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
self.scale.remove_style_class("muted")
|
||||
self.icon.remove_style_class("muted")
|
||||
|
||||
if volume >= 67:
|
||||
self.icon.set_label(vol_high_icon)
|
||||
elif volume >= 34:
|
||||
self.icon.set_label(vol_medium_icon)
|
||||
elif volume >= 1:
|
||||
self.icon.set_label(vol_medium_icon) # Use medium for low volumes too
|
||||
else:
|
||||
self.icon.set_label(vol_off_icon)
|
||||
|
||||
def _get_audio_icon_name(self, volume: int) -> str:
|
||||
# This method is now deprecated in favor of update_icon
|
||||
# but kept for compatibility
|
||||
if volume >= 67:
|
||||
return icons.vol_high
|
||||
elif volume >= 34:
|
||||
return icons.vol_medium
|
||||
elif volume >= 1:
|
||||
return icons.vol_mute
|
||||
else:
|
||||
return icons.vol_off
|
||||
|
||||
def on_mute_changed(self, *_):
|
||||
if self.audio.speaker:
|
||||
self.update_icon(round(self.audio.speaker.volume))
|
||||
# Ensure we emit the signal immediately
|
||||
GLib.idle_add(lambda: self.emit("volume-changed"))
|
||||
|
||||
|
||||
class MicrophoneOSDContainer(Box):
|
||||
__gsignals__: ClassVar[dict] = {
|
||||
"mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs, orientation="h", spacing=13, name="osd")
|
||||
self.audio = Audio()
|
||||
self.icon = Label(markup=icons.mic, name="mic-icon")
|
||||
self.scale = AnimatedScale(
|
||||
marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
|
||||
value=70,
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
increments=(1, 1),
|
||||
orientation="h",
|
||||
)
|
||||
|
||||
self.add(self.icon)
|
||||
self.add(self.scale)
|
||||
self.sync_with_audio()
|
||||
|
||||
self.scale.connect("value-changed", self.on_volume_changed)
|
||||
self.audio.connect("notify::microphone", self.on_audio_microphone_changed)
|
||||
|
||||
# Connect to microphone-changed signal directly
|
||||
self.audio.connect("microphone-changed", self.on_microphone_changed)
|
||||
|
||||
# Connect to current microphone if available
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.audio.microphone.connect("notify::muted", self.on_mute_changed)
|
||||
|
||||
def sync_with_audio(self):
|
||||
if self.audio.microphone:
|
||||
volume = round(self.audio.microphone.volume)
|
||||
self.scale.set_value(volume)
|
||||
self.update_icon(volume)
|
||||
|
||||
def on_volume_changed(self, *_):
|
||||
if self.audio.microphone:
|
||||
volume = self.scale.value
|
||||
if 0 <= volume <= 100:
|
||||
self.audio.microphone.set_volume(volume)
|
||||
self.update_icon(volume)
|
||||
|
||||
# Apply muted style based on volume and mute state
|
||||
if volume == 0 or (self.audio.microphone and self.audio.microphone.muted):
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
self.scale.remove_style_class("muted")
|
||||
self.icon.remove_style_class("muted")
|
||||
|
||||
self.emit("mic-changed")
|
||||
|
||||
def on_audio_microphone_changed(self, *_):
|
||||
if self.audio.microphone:
|
||||
self.audio.microphone.connect("changed", self.on_microphone_changed)
|
||||
self.update_volume()
|
||||
|
||||
def on_microphone_changed(self, *_):
|
||||
# This will be called when any property of the microphone changes, including mute
|
||||
self.update_volume()
|
||||
self.update_icon(round(self.audio.microphone.volume))
|
||||
self.emit("mic-changed")
|
||||
|
||||
def update_volume(self, *_):
|
||||
if self.audio.microphone and not self.is_hovered():
|
||||
volume = round(self.audio.microphone.volume)
|
||||
self.scale.set_value(volume)
|
||||
self.update_icon(volume)
|
||||
|
||||
# Apply muted style based on volume and mute state
|
||||
if volume == 0 or (self.audio.microphone and self.audio.microphone.muted):
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
self.scale.remove_style_class("muted")
|
||||
self.icon.remove_style_class("muted")
|
||||
|
||||
def update_icon(self, volume):
|
||||
# Check if muted first, regardless of volume level
|
||||
if not self.audio.microphone:
|
||||
return
|
||||
|
||||
if self.audio.microphone.muted:
|
||||
self.icon.set_label(icons.mic_mute)
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
if volume == 0:
|
||||
self.scale.add_style_class("muted")
|
||||
self.icon.add_style_class("muted")
|
||||
else:
|
||||
self.scale.remove_style_class("muted")
|
||||
self.icon.remove_style_class("muted")
|
||||
|
||||
if volume >= 67:
|
||||
self.icon.set_label(icons.mic)
|
||||
elif volume >= 34:
|
||||
self.icon.set_label(icons.mic)
|
||||
elif volume >= 1:
|
||||
self.icon.set_label(icons.mic)
|
||||
else:
|
||||
self.icon.set_label(icons.mic_mute)
|
||||
|
||||
def on_mute_changed(self, *_):
|
||||
if self.audio.microphone:
|
||||
self.update_icon(round(self.audio.microphone.volume))
|
||||
# Ensure we emit the signal immediately
|
||||
GLib.idle_add(lambda: self.emit("mic-changed"))
|
||||
|
||||
|
||||
class OSD(Window):
|
||||
def __init__(self, **kwargs):
|
||||
self.audio_container = AudioOSDContainer()
|
||||
self.brightness_container = BrightnessOSDContainer()
|
||||
self.microphone_container = MicrophoneOSDContainer()
|
||||
|
||||
self.timeout = 1000
|
||||
|
||||
self.revealer = Revealer(
|
||||
transition_type="slide-up",
|
||||
transition_duration=100,
|
||||
child_revealed=False,
|
||||
)
|
||||
|
||||
self.main_box = Box(
|
||||
orientation="v",
|
||||
spacing=13,
|
||||
children=[self.revealer],
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
layer="overlay",
|
||||
anchor="bottom",
|
||||
child=self.main_box,
|
||||
visible=False,
|
||||
pass_through=True,
|
||||
keyboard_mode="on-demand",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.last_activity_time = time.time()
|
||||
|
||||
# Connect to audio and brightness signals
|
||||
self.audio_container.audio.connect("notify::speaker", self.show_audio)
|
||||
self.brightness_container.brightness_service.connect("screen", self.show_brightness)
|
||||
self.audio_container.connect("volume-changed", self.show_audio)
|
||||
self.microphone_container.connect("mic-changed", self.show_microphone)
|
||||
|
||||
# Connect to speaker changes to handle new speakers
|
||||
self.audio_container.audio.connect("speaker-changed", self.show_audio)
|
||||
|
||||
# Connect to microphone changes to handle new microphones
|
||||
self.microphone_container.audio.connect("microphone-changed", self.show_microphone)
|
||||
|
||||
# Connect to the current speaker if available
|
||||
if self.audio_container.audio.speaker:
|
||||
self.audio_container.audio.speaker.connect("changed", self.show_audio)
|
||||
|
||||
# Connect to the current microphone if available
|
||||
if self.microphone_container.audio.microphone:
|
||||
self.microphone_container.audio.microphone.connect("changed", self.show_microphone)
|
||||
|
||||
GLib.timeout_add(100, self.check_inactivity)
|
||||
|
||||
def show_audio(self, *_):
|
||||
self.show_box(box_to_show="audio")
|
||||
self.reset_inactivity_timer()
|
||||
|
||||
def show_brightness(self, *_):
|
||||
self.show_box(box_to_show="brightness")
|
||||
self.reset_inactivity_timer()
|
||||
|
||||
def show_microphone(self, *_):
|
||||
self.show_box(box_to_show="microphone")
|
||||
self.reset_inactivity_timer()
|
||||
|
||||
def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]):
|
||||
self.set_visible(True)
|
||||
if box_to_show == "audio":
|
||||
self.revealer.children = self.audio_container
|
||||
elif box_to_show == "brightness":
|
||||
self.revealer.children = self.brightness_container
|
||||
elif box_to_show == "microphone":
|
||||
self.revealer.children = self.microphone_container
|
||||
self.revealer.set_reveal_child(True)
|
||||
self.reset_inactivity_timer()
|
||||
|
||||
def start_hide_timer(self):
|
||||
self.set_visible(False)
|
||||
|
||||
def reset_inactivity_timer(self):
|
||||
self.last_activity_time = time.time()
|
||||
|
||||
def check_inactivity(self):
|
||||
if time.time() - self.last_activity_time >= (self.timeout / 1000):
|
||||
self.start_hide_timer()
|
||||
return True
|
||||
@@ -89,7 +89,6 @@ class Brightness(Service):
|
||||
try:
|
||||
exec_brightnessctl_async(f"--device '{screen_device}' set {value}")
|
||||
self.emit("screen", int((value / self.max_screen) * 100))
|
||||
logger.info(f"Set screen brightness to {value} (out of {self.max_screen})")
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Error setting screen brightness: {e.message}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#launcher {
|
||||
transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1);
|
||||
background-color: var(--shadow);
|
||||
}
|
||||
|
||||
#launcher entry {
|
||||
transition: all 0.1s ease;
|
||||
border-radius: 11px;
|
||||
box-shadow: inset 0 0 0 1px var(--outline);
|
||||
min-width: 400px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#slot-button {
|
||||
border-radius: 40px;
|
||||
padding: 0 16px;
|
||||
animation: loadSlot 0.5s ease;
|
||||
min-height: 52px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes loadSlot {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#slot-button:hover,
|
||||
#slot-button:focus,
|
||||
#slot-button:selected,
|
||||
#slot-button.selected {
|
||||
border-radius: 16px;
|
||||
background-color: var(--surface);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#slot-button:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
#clip-icon {
|
||||
font-size: 20px;
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#osd {
|
||||
background: var(--shadow);
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
#osd scale {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#osd trough {
|
||||
background: var(--surface);
|
||||
min-height: 10px;
|
||||
margin-right: 4px;
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#osd trough highlight {
|
||||
border-radius: 100px 0px 0px 100px;
|
||||
background: var(--primary);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#osd.muted trough highlight,
|
||||
#osd.muted slider,
|
||||
#osd scale.muted trough highlight,
|
||||
#osd scale.muted slider {
|
||||
background-color: var(--surface-bright);
|
||||
}
|
||||
|
||||
#osd slider {
|
||||
background-color: var(--primary);
|
||||
background-size: 20px 20px;
|
||||
min-width: 4px;
|
||||
min-height: 8px;
|
||||
border-radius: 2px;
|
||||
padding: 0px;
|
||||
margin: -14px 0;
|
||||
box-shadow:
|
||||
-4px 0 0px 2px var(--shadow),
|
||||
4px 0 0px 2px var(--shadow);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
#brighntess-icons.muted,
|
||||
#vol-icon.muted,
|
||||
#mic-icon.muted {
|
||||
color: var(--outline);
|
||||
}
|
||||
Reference in new issue
Block a user