diff --git a/main.css b/main.css
index 45f5310..f96e252 100644
--- a/main.css
+++ b/main.css
@@ -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;
diff --git a/main.py b/main.py
index d2f4af0..116a7c7 100644
--- a/main.py
+++ b/main.py
@@ -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():
diff --git a/modules/dock/components/controls.py b/modules/dock/components/controls.py
index d49ae3d..37bd484 100644
--- a/modules/dock/components/controls.py
+++ b/modules/dock/components/controls.py
@@ -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",
diff --git a/modules/launcher/main.py b/modules/launcher/main.py
deleted file mode 100644
index a1d4dd3..0000000
--- a/modules/launcher/main.py
+++ /dev/null
@@ -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'{result["emoji_icon"]}').unwrap()
- elif "icon_markup" in result:
- # Handle icon markup (e.g., power menu icons)
- icon = Label(
- markup=result["icon_markup"],
- h_align="start",
- name="launcher-power-icon",
- )
- # Set font size and family for icon display
- icon.build().set_markup(f'{result["icon_markup"]}').unwrap()
- elif "image_pixbuf" in result:
- # Handle direct pixbuf images (e.g., clipboard images)
- icon = Image(
- pixbuf=result["image_pixbuf"],
- h_align="start",
- name="launcher-app-icon",
- )
- elif "icon" in result and isinstance(result["icon"], DesktopApp):
- # Handle DesktopApp icons
- icon = Image(
- pixbuf=result["icon"].get_icon_pixbuf(size=50),
- h_align="start",
- name="launcher-app-icon",
- )
- else:
- # Handle regular icon names
- icon = Image(
- icon_name=result.get("icon_name", "application-x-executable-symbolic"),
- h_align="start",
- name="launcher-app-icon",
- )
- icon.build().set_pixel_size(50).unwrap()
-
- result_box = Box(
- orientation="h",
- spacing=12,
- children=[
- icon,
- Box(
- orientation="v",
- spacing=2,
- children=[
- Label(
- label=result["title"],
- h_align="start",
- name="launcher-app-title",
- ),
- Label(
- label=result.get("description", ""),
- h_align="start",
- name="launcher-app-description",
- ),
- ]
- ),
- ],
- )
-
- button = Button(
- name="launcher-app",
- child=result_box,
- on_enter_notify_event=lambda *_: self.set_cursor("pointer"),
- on_clicked=lambda *_: (
- result["action"](),
- self.search.set_text(""), # Clear search entry when item is opened
- self.hide(),
- ),
- **kwargs,
- )
-
- return button
-
diff --git a/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py
deleted file mode 100644
index daa8fbd..0000000
--- a/modules/launcher/plugins/__init__.py
+++ /dev/null
@@ -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 []
diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py
deleted file mode 100644
index 1d41a1a..0000000
--- a/modules/launcher/plugins/applications.py
+++ /dev/null
@@ -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
- )
diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py
deleted file mode 100644
index c19c33a..0000000
--- a/modules/launcher/plugins/calculator.py
+++ /dev/null
@@ -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 []
diff --git a/modules/launcher/plugins/cliphist.py b/modules/launcher/plugins/cliphist.py
deleted file mode 100644
index 55f76cd..0000000
--- a/modules/launcher/plugins/cliphist.py
+++ /dev/null
@@ -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*
height:
- new_width = max_size
- new_height = int(height * (max_size / width))
- else:
- new_height = max_size
- new_width = int(width * (max_size / height))
-
- scaled_pixbuf = pixbuf.scale_simple(
- new_width, new_height, GdkPixbuf.InterpType.BILINEAR
- )
- return scaled_pixbuf
-
- except Exception as e:
- pass
-
- return None
-
- def get_action_items(self, query: str) -> List[Dict[str, Any]]:
- """Get quick action items for clipboard history"""
- if not query or len(query) < 2:
- return []
-
- return [
- {
- "title": f'Search clipboard history for "{query}"',
- "icon_name": "edit-paste-symbolic",
- "action": lambda: None, # No specific action needed
- }
- ]
-
-
diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py
deleted file mode 100644
index 17b30b2..0000000
--- a/modules/launcher/plugins/emoji.py
+++ /dev/null
@@ -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
- }
- ]
diff --git a/modules/launcher/plugins/powermenu.py b/modules/launcher/plugins/powermenu.py
deleted file mode 100644
index c9e753d..0000000
--- a/modules/launcher/plugins/powermenu.py
+++ /dev/null
@@ -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 []
diff --git a/modules/launcher/plugins/web_search.py b/modules/launcher/plugins/web_search.py
deleted file mode 100644
index 26dba34..0000000
--- a/modules/launcher/plugins/web_search.py
+++ /dev/null
@@ -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)
diff --git a/modules/osd.py b/modules/osd.py
new file mode 100644
index 0000000..5d19ebf
--- /dev/null
+++ b/modules/osd.py
@@ -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
diff --git a/services/brightness.py b/services/brightness.py
index 49527b9..1cd2915 100644
--- a/services/brightness.py
+++ b/services/brightness.py
@@ -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:
diff --git a/styles/launcher.css b/styles/launcher.css
deleted file mode 100644
index 0b78ccc..0000000
--- a/styles/launcher.css
+++ /dev/null
@@ -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);
-}
diff --git a/styles/osd.css b/styles/osd.css
new file mode 100644
index 0000000..407af9e
--- /dev/null
+++ b/styles/osd.css
@@ -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);
+}