✨ feat(osd): add OSD for brightness, volume, and microphone controls

This commit is contained in:
sankalp committed 2025-06-16 18:58:34 +05:45
1 parent 99421153ab
commit 90ebffe6dd
15 files changed
+520 -1231

No files matched your search

+1 -1
View File
@@ -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;
+3 -4
View File
@@ -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():
+41 -25
View File
@@ -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",
-336
View File
@@ -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
-52
View File
@@ -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 []
-78
View File
@@ -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
)
-54
View File
@@ -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 []
-259
View File
@@ -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
}
]
-122
View File
@@ -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
}
]
-202
View File
@@ -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 []
-49
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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:
-48
View File
@@ -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);
}
+49
View File
@@ -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);
}