♻️ refactor(launcher): improve code structure and plugin functionality

This commit is contained in:
sankalp committed 2025-07-30 13:04:22 +05:45
1 parent 19a2b925d5
commit 0f07104205
31 files changed
+536 -618

No files matched your search

+5 -5
View File
@@ -1,16 +1,16 @@
import setproctitle
from fabric import Application
from fabric.utils import get_relative_path, monitor_file
from loguru import logger
from config.data import APP_NAME
from fabric import Application
from fabric.utils import get_relative_path, monitor_file
from modules.dock import Dock
from modules.launcher.main import Launcher
from modules.osd import OSD
# from modules.corners import Corners
from modules.panel.main import Panel
from modules.dock import Dock
from modules.switcher import ApplicationSwitcher
from modules.launcher.main import Launcher
from modules.osd import OSD
for log in [
"fabric.hyprland.widgets",
+3 -3
View File
@@ -2,6 +2,7 @@ import json
import logging
import cairo
import config.data as data
from fabric.hyprland.widgets import get_hyprland_connection
from fabric.utils import (
exec_shell_command,
@@ -17,13 +18,12 @@ from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.revealer import Revealer
from gi.repository import Gdk, GLib, Gtk
import config.data as data
from modules.corners import MyCorner
from utils.icon_resolver import IconResolver
from utils.occlusion import check_occlusion
from utils.wayland import WaylandWindow as Window
from modules.corners import MyCorner
def read_config():
"""Read and return the full configuration from the JSON file, handling missing file."""
-4
View File
@@ -1197,8 +1197,6 @@ class Launcher(Window):
self.focus_mode = "search"
self._focus_search_entry_without_selection()
def _focus_search_entry_without_selection(self):
"""Focus search entry and position cursor at end without selecting text."""
# First grab focus
@@ -1245,8 +1243,6 @@ class Launcher(Window):
# Also try with a small delay as backup
GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection)
def _navigate_results_forward(self):
"""Navigate to next result or wrap around to first result."""
if self.results and self.selected_index < len(self.results) - 1:
+115 -46
View File
@@ -82,6 +82,7 @@ class BashScriptsPlugin(PluginBase):
"""Get reference to the battery service."""
try:
from services.battery import Battery
self._battery_service = Battery()
except Exception as e:
print(f"BashScriptsPlugin: Error getting battery service: {e}")
@@ -95,7 +96,10 @@ class BashScriptsPlugin(PluginBase):
try:
# Check if profile proxy is available
if not hasattr(self._battery_service, '_profile_proxy') or not self._battery_service._profile_proxy:
if (
not hasattr(self._battery_service, "_profile_proxy")
or not self._battery_service._profile_proxy
):
print("BashScriptsPlugin: Power profile proxy not available")
return False
@@ -114,7 +118,7 @@ class BashScriptsPlugin(PluginBase):
profile_mapping = {
"power-saver": ["power-saver", "powersave", "power_saver"],
"balanced": ["balanced", "balance"],
"performance": ["performance", "performance-mode"]
"performance": ["performance", "performance-mode"],
}
# Find the actual profile name
@@ -129,18 +133,20 @@ class BashScriptsPlugin(PluginBase):
if actual_profile:
self._battery_service._profile_proxy.ActiveProfile = actual_profile
print(f"BashScriptsPlugin: Successfully set power profile to {actual_profile}")
print(
f"BashScriptsPlugin: Successfully set power profile to {actual_profile}"
)
return True
else:
print(f"BashScriptsPlugin: No matching profile found for '{profile}' in available profiles: {available_profiles}")
print(
f"BashScriptsPlugin: No matching profile found for '{profile}' in available profiles: {available_profiles}"
)
return False
except Exception as e:
print(f"BashScriptsPlugin: Error setting power profile: {e}")
return False
def _load_scripts_cache(self):
"""Load scripts cache from JSON file."""
try:
@@ -150,7 +156,9 @@ class BashScriptsPlugin(PluginBase):
self._scripts_cache = cache_data.get("scripts", {})
self._last_cache_update = cache_data.get("last_update", 0)
else:
print("BashScriptsPlugin: No cache file found, will build cache in background")
print(
"BashScriptsPlugin: No cache file found, will build cache in background"
)
except Exception as e:
print(f"BashScriptsPlugin: Error loading scripts cache: {e}")
self._scripts_cache = {}
@@ -172,16 +180,17 @@ class BashScriptsPlugin(PluginBase):
def _start_background_cache_update(self):
"""Start background thread to update scripts cache."""
current_time = time.time()
# Check if cache needs updating
if (current_time - self._last_cache_update > self._cache_update_interval or
not self._scripts_cache):
if (
current_time - self._last_cache_update > self._cache_update_interval
or not self._scripts_cache
):
if not self._cache_building:
self._cache_building = True
self._cache_thread = threading.Thread(
target=self._build_scripts_cache_background,
daemon=True
target=self._build_scripts_cache_background, daemon=True
)
self._cache_thread.start()
@@ -192,11 +201,15 @@ class BashScriptsPlugin(PluginBase):
new_cache = {}
# Scan Modus scripts directory for discovered scripts
if os.path.exists(self.modus_scripts_dir) and os.path.isdir(self.modus_scripts_dir):
if os.path.exists(self.modus_scripts_dir) and os.path.isdir(
self.modus_scripts_dir
):
try:
self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache)
except (PermissionError, FileNotFoundError, OSError) as e:
print(f"BashScriptsPlugin: Error scanning Modus scripts directory: {e}")
print(
f"BashScriptsPlugin: Error scanning Modus scripts directory: {e}"
)
# Update cache atomically
self._scripts_cache = new_cache
@@ -228,11 +241,13 @@ class BashScriptsPlugin(PluginBase):
cache[script_name] = {
"path": script_path,
"name": script_name,
"description": self._get_script_description(script_path),
"description": self._get_script_description(
script_path
),
"type": "discovered",
"executable": os.access(script_path, os.X_OK),
"args": [],
"category": os.path.basename(directory)
"category": os.path.basename(directory),
}
except (PermissionError, FileNotFoundError, OSError) as e:
@@ -244,14 +259,16 @@ class BashScriptsPlugin(PluginBase):
"""Check if a file is a bash script."""
try:
# Check file extension first (most common case)
if file_path.endswith(('.sh', '.bash')):
if file_path.endswith((".sh", ".bash")):
return True
# For files without extension, check shebang
try:
with open(file_path, 'rb') as f:
first_line = f.readline(100).decode('utf-8', errors='ignore')
if first_line.startswith('#!') and ('bash' in first_line or 'sh' in first_line):
with open(file_path, "rb") as f:
first_line = f.readline(100).decode("utf-8", errors="ignore")
if first_line.startswith("#!") and (
"bash" in first_line or "sh" in first_line
):
return True
except (PermissionError, FileNotFoundError, UnicodeDecodeError):
pass
@@ -263,13 +280,13 @@ class BashScriptsPlugin(PluginBase):
def _get_script_description(self, script_path: str) -> str:
"""Extract description from script comments."""
try:
with open(script_path, 'r', encoding='utf-8', errors='ignore') as f:
with open(script_path, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
# Look for description in first few comment lines
for line in lines[:10]:
line = line.strip()
if line.startswith('#') and not line.startswith('#!'):
if line.startswith("#") and not line.startswith("#!"):
# Remove leading # and whitespace
desc = line[1:].strip()
if desc and len(desc) > 5: # Meaningful description
@@ -284,7 +301,9 @@ class BashScriptsPlugin(PluginBase):
query = query_string.strip()
# Start background update if needed (non-blocking)
if not self._scripts_cache or (time.time() - self._last_cache_update > self._cache_update_interval):
if not self._scripts_cache or (
time.time() - self._last_cache_update > self._cache_update_interval
):
self._start_background_cache_update()
results = []
@@ -313,11 +332,15 @@ class BashScriptsPlugin(PluginBase):
max_results = 20
# Sort scripts by name for consistent ordering
sorted_scripts = sorted(self._scripts_cache.items(), key=lambda x: x[1].get("name", ""))
sorted_scripts = sorted(
self._scripts_cache.items(), key=lambda x: x[1].get("name", "")
)
# Add scripts (limit to max_results)
for script_name, script_info in sorted_scripts:
script_results = self._create_script_results_with_args(script_name, script_info, 0.8)
script_results = self._create_script_results_with_args(
script_name, script_info, 0.8
)
for script_result in script_results:
if len(results) < max_results:
results.append(script_result)
@@ -345,7 +368,10 @@ class BashScriptsPlugin(PluginBase):
description_lower = script_info.get("description", "").lower()
# Skip if no match at all
if query_lower not in script_name_lower and query_lower not in description_lower:
if (
query_lower not in script_name_lower
and query_lower not in description_lower
):
continue
# Categorize matches
@@ -359,11 +385,15 @@ class BashScriptsPlugin(PluginBase):
description_matches.append((script_name, script_info, 0.5))
# Combine results in priority order
all_matches = exact_matches + prefix_matches + partial_matches + description_matches
all_matches = (
exact_matches + prefix_matches + partial_matches + description_matches
)
# Convert to Result objects
for script_name, script_info, relevance in all_matches:
script_results = self._create_script_results_with_args(script_name, script_info, relevance)
script_results = self._create_script_results_with_args(
script_name, script_info, relevance
)
for script_result in script_results:
if len(results) < max_results:
results.append(script_result)
@@ -387,10 +417,12 @@ class BashScriptsPlugin(PluginBase):
description_lower = info["description"].lower()
# Check for exact match or partial match
if (query_lower == cmd_lower or
cmd_lower.startswith(query_lower) or
query_lower in cmd_lower or
query_lower in description_lower):
if (
query_lower == cmd_lower
or cmd_lower.startswith(query_lower)
or query_lower in cmd_lower
or query_lower in description_lower
):
relevance = 1.0 if query_lower == cmd_lower else 0.9
@@ -401,7 +433,11 @@ class BashScriptsPlugin(PluginBase):
action=self._create_power_action(info["profile"]),
relevance=relevance,
plugin_name=self.display_name,
data={"command": cmd, "profile": info["profile"], "type": "power_manager"}
data={
"command": cmd,
"profile": info["profile"],
"type": "power_manager",
},
)
results.append(result)
@@ -419,7 +455,11 @@ class BashScriptsPlugin(PluginBase):
action=self._create_power_action(info["profile"]),
relevance=0.8,
plugin_name=self.display_name,
data={"command": cmd, "profile": info["profile"], "type": "power_manager"}
data={
"command": cmd,
"profile": info["profile"],
"type": "power_manager",
},
)
results.append(result)
@@ -427,6 +467,7 @@ class BashScriptsPlugin(PluginBase):
def _create_power_action(self, profile: str):
"""Create an action function for setting power profile."""
def action():
success = self._set_power_profile(profile)
if success:
@@ -439,16 +480,21 @@ class BashScriptsPlugin(PluginBase):
if app and hasattr(app, "launcher"):
launcher = app.launcher
if launcher and hasattr(launcher, "search_entry"):
def clear_search():
launcher.search_entry.set_text("")
return False
# Use a small delay to ensure the action completes first
GLib.timeout_add(50, clear_search)
except Exception as e:
print(f"BashScriptsPlugin: Could not clear search query: {e}")
return action
def _create_script_result(self, script_name: str, script_info: Dict, relevance: float) -> Result:
def _create_script_result(
self, script_name: str, script_info: Dict, relevance: float
) -> Result:
"""Create a Result object for a script."""
script_path = script_info.get("path", "")
description = script_info.get("description", "")
@@ -465,7 +511,9 @@ class BashScriptsPlugin(PluginBase):
if not executable:
subtitle_parts.append("(not executable)")
subtitle = " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}"
subtitle = (
" | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}"
)
# Choose icon based on script type and status
if not executable:
@@ -482,10 +530,16 @@ class BashScriptsPlugin(PluginBase):
action=self._create_script_action(script_name, script_info),
relevance=relevance,
plugin_name=self.display_name,
data={"script_name": script_name, "script_path": script_path, "type": script_type}
data={
"script_name": script_name,
"script_path": script_path,
"type": script_type,
},
)
def _create_script_results_with_args(self, script_name: str, script_info: Dict, relevance: float) -> List[Result]:
def _create_script_results_with_args(
self, script_name: str, script_info: Dict, relevance: float
) -> List[Result]:
"""Create multiple Result objects for scripts that support arguments."""
results = []
@@ -495,7 +549,7 @@ class BashScriptsPlugin(PluginBase):
variants = [
("-rgb", "Pick RGB color"),
("-hex", "Pick HEX color"),
("-hsv", "Pick HSV color")
("-hsv", "Pick HSV color"),
]
for arg, desc in variants:
@@ -503,32 +557,48 @@ class BashScriptsPlugin(PluginBase):
title=f"{script_name} {arg}",
subtitle=f"{desc} | [scripts]",
icon_markup=icons.terminal,
action=self._create_script_action_with_args(script_name, script_info, [arg]),
relevance=relevance + 0.1, # Slightly higher relevance for specific variants
action=self._create_script_action_with_args(
script_name, script_info, [arg]
),
relevance=relevance
+ 0.1, # Slightly higher relevance for specific variants
plugin_name=self.display_name,
data={"script_name": script_name, "script_path": script_info.get("path", ""), "type": script_info.get("type", "discovered"), "args": [arg]}
data={
"script_name": script_name,
"script_path": script_info.get("path", ""),
"type": script_info.get("type", "discovered"),
"args": [arg],
},
)
results.append(variant_result)
else:
# For other scripts, create the basic result
basic_result = self._create_script_result(script_name, script_info, relevance)
basic_result = self._create_script_result(
script_name, script_info, relevance
)
results.append(basic_result)
return results
def _create_script_action(self, script_name: str, script_info: Dict):
"""Create an action function for executing a script."""
def action():
self._execute_script(script_name, script_info)
return action
def _create_script_action_with_args(self, script_name: str, script_info: Dict, args: List[str]):
def _create_script_action_with_args(
self, script_name: str, script_info: Dict, args: List[str]
):
"""Create an action function for executing a script with specific arguments."""
def action():
# Create a modified script_info with the specific arguments
modified_script_info = script_info.copy()
modified_script_info["args"] = args
self._execute_script(script_name, modified_script_info)
return action
def _execute_script(self, script_name: str, script_info: Dict):
@@ -549,4 +619,3 @@ class BashScriptsPlugin(PluginBase):
except Exception as e:
print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}")
-2
View File
@@ -95,8 +95,6 @@ class CalculatorPlugin(PluginBase):
self._conversion_cache[cache_key] = result
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
return [
Result(
title=f"{result:.6g} {to_unit}",
+3 -3
View File
@@ -380,9 +380,9 @@ class ClipboardPlugin(PluginBase):
result = Result(
title=display_text,
subtitle="Text from clipboard",
description=content
if len(content) <= 100
else content[:97] + "...",
description=(
content if len(content) <= 100 else content[:97] + "..."
),
icon_name="edit-paste",
relevance=relevance,
plugin_name=self.name,
-1
View File
@@ -342,7 +342,6 @@ class ProcessPlugin(PluginBase):
if self._kill_process(pid, silent=True):
killed_count += 1
except Exception as e:
print(f"✗ Error killing process group: {e}")
+3 -2
View File
@@ -408,8 +408,9 @@ class RemindersPlugin(PluginBase):
target_time_str
})",
icon_markup=icons.timer_on,
action=lambda ts=time_str,
msg=message: self._create_and_confirm_reminder(ts, msg),
action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder(
ts, msg
),
relevance=1.0,
plugin_name=self.display_name,
data={
+3 -2
View File
@@ -466,8 +466,9 @@ class ScreencapturePlugin(PluginBase):
title=f"Convert {file_param} to {format_type.upper()}",
subtitle=f"Convert specified file to {format_type} format",
icon_markup=screenrecord,
action=lambda fp=file_param,
ft=format_type: self._run_script_with_file(ft, fp),
action=lambda fp=file_param, ft=format_type: self._run_script_with_file(
ft, fp
),
relevance=1.0,
plugin_name=self.display_name,
)
+27 -16
View File
@@ -57,7 +57,9 @@ class SystemPlugin(PluginBase):
self._bin_cache = set(cache_data.get("binaries", []))
self._last_bin_update = cache_data.get("last_update", 0)
else:
print("SystemPlugin: No cache file found, will build cache in background")
print(
"SystemPlugin: No cache file found, will build cache in background"
)
except Exception as e:
print(f"SystemPlugin: Error loading binary cache: {e}")
self._bin_cache = set()
@@ -72,7 +74,7 @@ class SystemPlugin(PluginBase):
cache_data = {
"binaries": sorted(list(self._bin_cache)),
"last_update": self._last_bin_update,
"cache_version": "1.0"
"cache_version": "1.0",
}
with open(self.bin_cache_file, "w", encoding="utf-8") as f:
@@ -85,14 +87,15 @@ class SystemPlugin(PluginBase):
current_time = time.time()
# Check if cache needs updating
if (current_time - self._last_bin_update > self._bin_update_interval or
not self._bin_cache):
if (
current_time - self._last_bin_update > self._bin_update_interval
or not self._bin_cache
):
if not self._cache_building:
self._cache_building = True
self._cache_thread = threading.Thread(
target=self._build_bin_cache_background,
daemon=True
target=self._build_bin_cache_background, daemon=True
)
self._cache_thread.start()
@@ -113,8 +116,9 @@ class SystemPlugin(PluginBase):
# Use os.scandir for better performance than os.listdir
with os.scandir(path) as entries:
for entry in entries:
if (entry.is_file(follow_symlinks=False) and
os.access(entry.path, os.X_OK)):
if entry.is_file(follow_symlinks=False) and os.access(
entry.path, os.X_OK
):
new_cache.add(entry.name)
except (PermissionError, FileNotFoundError, OSError):
continue
@@ -126,7 +130,6 @@ class SystemPlugin(PluginBase):
# Save to disk
self._save_bin_cache()
except Exception as e:
print(f"SystemPlugin: Error building binary cache: {e}")
finally:
@@ -151,7 +154,9 @@ class SystemPlugin(PluginBase):
# Check system binaries
# Start background update if needed (non-blocking) - but only if cache is empty or very old
if not self._bin_cache or (time.time() - self._last_bin_update > self._bin_update_interval):
if not self._bin_cache or (
time.time() - self._last_bin_update > self._bin_update_interval
):
self._start_background_cache_update()
# Optimize search with early termination and result limiting
@@ -174,19 +179,25 @@ class SystemPlugin(PluginBase):
display_command = full_command
command_to_execute = full_command
relevance = 1.0
exact_matches.append((binary, display_command, command_to_execute, relevance))
exact_matches.append(
(binary, display_command, command_to_execute, relevance)
)
elif binary_lower.startswith(binary_query):
# Prefix match - high priority
display_command = binary
command_to_execute = binary
relevance = 0.9
prefix_matches.append((binary, display_command, command_to_execute, relevance))
prefix_matches.append(
(binary, display_command, command_to_execute, relevance)
)
else:
# Partial match - lower priority
display_command = binary
command_to_execute = binary
relevance = 0.7
partial_matches.append((binary, display_command, command_to_execute, relevance))
partial_matches.append(
(binary, display_command, command_to_execute, relevance)
)
# Early termination if we have enough good matches
if len(exact_matches) + len(prefix_matches) >= max_results:
@@ -196,7 +207,9 @@ class SystemPlugin(PluginBase):
all_matches = exact_matches + prefix_matches + partial_matches
# Convert to Result objects (limit to max_results)
for binary, display_command, command_to_execute, relevance in all_matches[:max_results]:
for binary, display_command, command_to_execute, relevance in all_matches[
:max_results
]:
result = Result(
title=display_command,
subtitle=f"Execute: {display_command}",
@@ -210,8 +223,6 @@ class SystemPlugin(PluginBase):
return results # Already sorted by priority
def _create_action(self, command: Union[str, List[str]]):
"""Create an action function for the given command."""
+72 -46
View File
@@ -46,8 +46,7 @@ class TmuxPlugin(PluginBase):
"""Start background thread to refresh session cache."""
if not self.refresh_thread or not self.refresh_thread.is_alive():
self.refresh_thread = threading.Thread(
target=self._refresh_sessions_background,
daemon=True
target=self._refresh_sessions_background, daemon=True
)
self.refresh_thread.start()
@@ -59,7 +58,7 @@ class TmuxPlugin(PluginBase):
if current_time - self._last_cache_update > self._cache_ttl:
self._sessions_cache = self._get_tmux_sessions()
self._last_cache_update = current_time
# Sleep for a short interval
self.stop_refresh.wait(1)
except Exception as e:
@@ -73,12 +72,18 @@ class TmuxPlugin(PluginBase):
["tmux", "list-sessions", "-F", "#{session_name}"],
capture_output=True,
text=True,
timeout=5
timeout=5,
)
if result.returncode == 0:
return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()]
return [
s.strip() for s in result.stdout.strip().split("\n") if s.strip()
]
return []
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError) as e:
except (
subprocess.TimeoutExpired,
subprocess.CalledProcessError,
FileNotFoundError,
) as e:
print(f"TmuxPlugin: Error getting tmux sessions: {e}")
return []
@@ -92,28 +97,32 @@ class TmuxPlugin(PluginBase):
if current_time - self._last_cache_update > self._cache_ttl:
self._sessions_cache = self._get_tmux_sessions()
self._last_cache_update = current_time
sessions = self._sessions_cache
# Handle specific commands
if query.startswith("new ") or query.startswith("create "):
session_name = query.split(" ", 1)[1].strip() if " " in query else ""
results.append(self._create_new_session_result(session_name))
elif query.startswith("kill ") or query.startswith("delete "):
session_name = query.split(" ", 1)[1].strip() if " " in query else ""
if session_name:
matching_sessions = [s for s in sessions if session_name.lower() in s.lower()]
matching_sessions = [
s for s in sessions if session_name.lower() in s.lower()
]
for session in matching_sessions:
results.append(self._create_kill_session_result(session))
elif query.startswith("rename "):
parts = query.split(" ", 2)
if len(parts) >= 3:
old_name, new_name = parts[1], parts[2]
if old_name in sessions:
results.append(self._create_rename_session_result(old_name, new_name))
results.append(
self._create_rename_session_result(old_name, new_name)
)
else:
# Show existing sessions for attachment
if sessions:
@@ -122,13 +131,19 @@ class TmuxPlugin(PluginBase):
filtered_sessions = [s for s in sessions if query in s.lower()]
else:
filtered_sessions = sessions
for session in filtered_sessions:
results.append(self._create_attach_session_result(session))
# Always show option to create new session
if not query or "new" in query or "create" in query:
results.append(self._create_new_session_result(query if query and not any(cmd in query for cmd in ["new", "create"]) else ""))
results.append(
self._create_new_session_result(
query
if query and not any(cmd in query for cmd in ["new", "create"])
else ""
)
)
return results
@@ -140,7 +155,7 @@ class TmuxPlugin(PluginBase):
icon_markup=icons.terminal,
action=lambda: self._attach_to_session(session_name),
relevance=0.9,
data={"type": "attach", "session": session_name}
data={"type": "attach", "session": session_name},
)
def _create_new_session_result(self, session_name: str = "") -> Result:
@@ -152,7 +167,7 @@ class TmuxPlugin(PluginBase):
icon_markup=icons.plus,
action=lambda: self._create_session(session_name),
relevance=0.8,
data={"type": "create", "session": session_name}
data={"type": "create", "session": session_name},
)
def _create_kill_session_result(self, session_name: str) -> Result:
@@ -163,7 +178,7 @@ class TmuxPlugin(PluginBase):
icon_markup=icons.trash,
action=lambda: self._kill_session(session_name),
relevance=0.7,
data={"type": "kill", "session": session_name}
data={"type": "kill", "session": session_name},
)
def _create_rename_session_result(self, old_name: str, new_name: str) -> Result:
@@ -174,14 +189,15 @@ class TmuxPlugin(PluginBase):
icon_markup=icons.config,
action=lambda: self._rename_session(old_name, new_name),
relevance=0.6,
data={"type": "rename", "old_session": old_name, "new_session": new_name}
data={"type": "rename", "old_session": old_name, "new_session": new_name},
)
def _attach_to_session(self, session_name: str):
"""Attach to an existing tmux session."""
try:
terminal_cmd = self._get_terminal_command(f"tmux attach-session -t '{session_name}'")
terminal_cmd = self._get_terminal_command(
f"tmux attach-session -t '{session_name}'"
)
exec_shell_command_async(terminal_cmd)
print(f"TmuxPlugin: Attaching to session '{session_name}'")
except Exception as e:
@@ -197,25 +213,25 @@ class TmuxPlugin(PluginBase):
while str(counter) in existing_sessions:
counter += 1
session_name = str(counter)
# Clean the session name
clean_name = session_name.strip().replace(" ", "_")
# Create session
subprocess.run(
["tmux", "new-session", "-d", "-s", clean_name],
check=True,
timeout=10
["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10
)
# Launch terminal and attach
terminal_cmd = self._get_terminal_command(f"tmux attach-session -t '{clean_name}'")
terminal_cmd = self._get_terminal_command(
f"tmux attach-session -t '{clean_name}'"
)
exec_shell_command_async(terminal_cmd)
# Refresh cache
self._sessions_cache = self._get_tmux_sessions()
self._last_cache_update = time.time()
print(f"TmuxPlugin: Created and attached to session '{clean_name}'")
except Exception as e:
print(f"TmuxPlugin: Error creating session '{session_name}': {e}")
@@ -224,15 +240,13 @@ class TmuxPlugin(PluginBase):
"""Kill a tmux session."""
try:
subprocess.run(
["tmux", "kill-session", "-t", session_name],
check=True,
timeout=10
["tmux", "kill-session", "-t", session_name], check=True, timeout=10
)
# Refresh cache
self._sessions_cache = self._get_tmux_sessions()
self._last_cache_update = time.time()
print(f"TmuxPlugin: Killed session '{session_name}'")
except Exception as e:
print(f"TmuxPlugin: Error killing session '{session_name}': {e}")
@@ -244,32 +258,39 @@ class TmuxPlugin(PluginBase):
subprocess.run(
["tmux", "rename-session", "-t", old_name, clean_name],
check=True,
timeout=10
timeout=10,
)
# Refresh cache
self._sessions_cache = self._get_tmux_sessions()
self._last_cache_update = time.time()
print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'")
except Exception as e:
print(f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}")
print(
f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}"
)
def _get_terminal_command(self, cmd: str) -> str:
"""Get terminal command based on configured terminal or available terminals."""
# First try to use the configured terminal command
if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND:
if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND:
parts = data.TERMINAL_COMMAND.split()
terminal = parts[0]
try:
# Check if the configured terminal is available
subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(
["which", terminal],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return f"{data.TERMINAL_COMMAND} {cmd}"
except subprocess.CalledProcessError:
# If configured terminal is not available, fall back to defaults
pass
# Fallback to checking available terminals
terminals = [
("kitty", f"kitty -e {cmd}"),
@@ -279,14 +300,19 @@ class TmuxPlugin(PluginBase):
("konsole", f"konsole -e {cmd}"),
("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"),
]
for term, term_cmd in terminals:
try:
# Check if terminal is available
subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(
["which", term],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return term_cmd
except subprocess.CalledProcessError:
continue
# Default fallback
return f"kitty -e {cmd}"
+18 -15
View File
@@ -132,6 +132,7 @@ class WallpaperPlugin(PluginBase):
if app and hasattr(app, "launcher"):
launcher = app.launcher
if launcher and hasattr(launcher, "search_entry"):
def clear_and_refresh():
# Clear the search entry to just the trigger
launcher.search_entry.set_text("wall ")
@@ -150,11 +151,9 @@ class WallpaperPlugin(PluginBase):
import gc
for obj in gc.get_objects():
if (
hasattr(obj, "__class__")
and obj.__class__.__name__ == "Launcher"
):
if hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher":
if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"):
def clear_and_refresh():
obj.search_entry.set_text("wall ")
obj.search_entry.set_position(-1)
@@ -554,8 +553,9 @@ class WallpaperPlugin(PluginBase):
status_text
}",
icon_markup=icons.palette,
action=lambda c=hex_color,
s=scheme: self._apply_hex_color_direct(c, s),
action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct(
c, s
),
relevance=1.0,
plugin_name=self.display_name,
data={
@@ -595,10 +595,11 @@ class WallpaperPlugin(PluginBase):
status_text
}",
icon_markup=icons.palette,
action=lambda c=hex_color,
s=scheme: self._apply_hex_color_direct(c, s)
if not matugen_enabled
else None,
action=lambda c=hex_color, s=scheme: (
self._apply_hex_color_direct(c, s)
if not matugen_enabled
else None
),
relevance=0.9,
plugin_name=self.display_name,
data={
@@ -683,11 +684,13 @@ class WallpaperPlugin(PluginBase):
title=f"Random Hex Color{indicator_text}",
subtitle=f"Generate and apply random color • {status_text}",
icon_markup=icons.palette,
action=lambda s=scheme: self._apply_hex_color_direct(
self._generate_random_hex_color(), s
)
if not matugen_enabled
else None,
action=lambda s=scheme: (
self._apply_hex_color_direct(
self._generate_random_hex_color(), s
)
if not matugen_enabled
else None
),
relevance=0.8,
plugin_name=self.display_name,
data={
+3 -1
View File
@@ -25,7 +25,9 @@ class ResultItem(EventBox):
"""Emitted when result is hovered."""
pass
def __init__(self, result: Result, selected: bool = False, index: int = 0, **kwargs):
def __init__(
self, result: Result, selected: bool = False, index: int = 0, **kwargs
):
super().__init__(name="launcher-result-item", **kwargs)
self.result = result
+1 -2
View File
@@ -1,14 +1,13 @@
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
import utils.icons as icons
from services.brightness import Brightness
from utils.animator import Animator
from utils.wayland import WaylandWindow as Window
@@ -52,10 +52,10 @@ def patched_do_update_properties(self, *_):
def _try_load_icon_from_path(self, icon_path):
try:
# Check if it's a file path and handle it directly
if os.path.isabs(icon_path) or '/' in icon_path:
if os.path.isabs(icon_path) or "/" in icon_path:
# Try to load as SVG from the original path if it exists
if os.path.exists(icon_path):
if icon_path.lower().endswith('.svg'):
if icon_path.lower().endswith(".svg"):
# Load SVG directly
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
icon_path, self._icon_size, self._icon_size
@@ -80,11 +80,13 @@ def _try_load_icon_from_path(self, icon_path):
# Remove extension for theme lookup
name_without_ext = os.path.splitext(filename)[0]
default_theme = Gtk.IconTheme.get_default()
# Try filename without extension
if default_theme.has_icon(name_without_ext):
pixbuf = default_theme.load_icon(
name_without_ext, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE
name_without_ext,
self._icon_size,
Gtk.IconLookupFlags.FORCE_SIZE,
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
@@ -109,9 +111,9 @@ def _try_load_icon_from_path(self, icon_path):
"/usr/local/share/icons",
"/usr/local/share/pixmaps",
os.path.expanduser("~/.local/share/icons"),
os.path.expanduser("~/.icons")
os.path.expanduser("~/.icons"),
]
filename = os.path.basename(icon_path)
for icon_dir in common_icon_dirs:
potential_path = os.path.join(icon_dir, filename)
@@ -129,7 +131,7 @@ def _try_load_icon_from_path(self, icon_path):
except Exception:
pass
return False
@@ -146,11 +148,11 @@ def apply_enhanced_system_tray():
# Store original method
global original_do_update_properties
original_do_update_properties = SystemTrayItem.do_update_properties
# Attach helper methods to SystemTrayItem class
SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path
SystemTrayItem._set_tooltip = _set_tooltip
# Replace the do_update_properties method
SystemTrayItem.do_update_properties = patched_do_update_properties
+40 -15
View File
@@ -54,26 +54,36 @@ class WifiIndicator(Button):
self.network.ethernet_device
and self.network.ethernet_device.state == "activated"
):
self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi.svg"))
self.wifi_icon.set_from_file(
get_relative_path("../../../config/assets/icons/wifi.svg")
)
tooltip = f"Connected to Ethernet ({self.network.ethernet_device.speed})"
self.set_tooltip_text(tooltip)
return
if not self.network.wifi_device:
self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi-off.svg"))
self.wifi_icon.set_from_file(
get_relative_path("../../../config/assets/icons/wifi-off.svg")
)
tooltip = "No WiFi device found"
elif not self.network.wifi_device.wireless_enabled:
self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi-off.svg"))
self.wifi_icon.set_from_file(
get_relative_path("../../../config/assets/icons/wifi-off.svg")
)
tooltip = "WiFi disabled"
else:
active_ap = self.network.wifi_device.active_access_point
if active_ap:
strength = active_ap.strength
# Use wifi.svg for all connected states since we don't have signal strength icons
self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi.svg"))
self.wifi_icon.set_from_file(
get_relative_path("../../../config/assets/icons/wifi.svg")
)
tooltip = f"Connected to {active_ap.ssid} ({strength}%)"
else:
self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi-off.svg"))
self.wifi_icon.set_from_file(
get_relative_path("../../../config/assets/icons/wifi-off.svg")
)
tooltip = "Not connected"
self.set_tooltip_text(tooltip)
@@ -127,12 +137,16 @@ class BluetoothIndicator(Button):
def update_state(self):
if not self.bluetooth.enabled:
self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth-off.svg"))
self.bt_icon.set_from_file(
get_relative_path("../../../config/assets/icons/bluetooth-off.svg")
)
tooltip = "Bluetooth disabled"
else:
connected_devices = self.bluetooth.connected_devices
if connected_devices:
self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth.svg"))
self.bt_icon.set_from_file(
get_relative_path("../../../config/assets/icons/bluetooth.svg")
)
if len(connected_devices) == 1:
device = connected_devices[0]
tooltip = f"Connected to {device.alias}"
@@ -141,7 +155,9 @@ class BluetoothIndicator(Button):
else:
tooltip = f"Connected to {len(connected_devices)} devices"
else:
self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth.svg"))
self.bt_icon.set_from_file(
get_relative_path("../../../config/assets/icons/bluetooth.svg")
)
tooltip = "No devices connected"
self.set_tooltip_text(tooltip)
@@ -155,11 +171,10 @@ class BluetoothIndicator(Button):
def on_device_removed(self, _, address):
self.update_state()
class Battery(Box):
def __init__(self, **kwargs):
super().__init__(
orientation="v", visible=True, **kwargs
)
super().__init__(orientation="v", visible=True, **kwargs)
self._battery = BatteryService()
self._battery.changed.connect(self.update_battery)
@@ -167,7 +182,9 @@ class Battery(Box):
self.icon = Svg(
name="indicators-icon",
size=24,
svg_file=get_relative_path("../../../config/assets/icons/battery/battery-100.svg"),
svg_file=get_relative_path(
"../../../config/assets/icons/battery/battery-100.svg"
),
)
self.battery_button = Button(
child=self.icon,
@@ -259,21 +276,29 @@ class Battery(Box):
self.icon.remove_style_class("alert")
if state == "FULLY_CHARGED" or (percentage >= 100 and state == "CHARGING"):
self.icon.set_from_file(get_relative_path("../../../config/assets/icons/battery/battery-100-charging.svg"))
self.icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/battery/battery-100-charging.svg"
)
)
elif state == "CHARGING":
# Calculate battery level and use corresponding charging icon
level = min(100, max(0, int(percentage // 10) * 10))
if level == 0 and percentage > 0:
level = 10
icon_name = f"battery-{level:03d}-charging.svg"
self.icon.set_from_file(get_relative_path(f"../../../config/assets/icons/battery/{icon_name}"))
self.icon.set_from_file(
get_relative_path(f"../../../config/assets/icons/battery/{icon_name}")
)
else:
# Calculate battery level and use corresponding icon
level = min(100, max(0, int(percentage // 10) * 10))
if level == 0 and percentage > 0:
level = 10
icon_name = f"battery-{level:03d}.svg"
self.icon.set_from_file(get_relative_path(f"../../../config/assets/icons/battery/{icon_name}"))
self.icon.set_from_file(
get_relative_path(f"../../../config/assets/icons/battery/{icon_name}")
)
self.set_visible(True)
return True
+6 -8
View File
@@ -8,10 +8,10 @@ from fabric.utils import exec_shell_command
class MenuActionHandler:
def __init__(self):
pass
def execute_action(self, action):
try:
# Window menu actions
@@ -34,16 +34,13 @@ class MenuActionHandler:
elif action == "Quit":
Hyprland.send_command("dispatch killactive")
# Go menu actions
elif action == "Back":
Hyprland.send_command("dispatch workspace e-1")
elif action == "Forward":
Hyprland.send_command("dispatch workspace e+1")
# Edit menu actions
# View menu actions
elif action == "Full Screen":
@@ -63,7 +60,7 @@ class MenuActionHandler:
elif action == "Report a Bug...":
exec_shell_command("xdg-open https://github.com/S4NKALP/Modus/issues")
# System menu actions
# System menu actions
elif action == "About This PC":
exec_shell_command("gnome-system-monitor")
elif action == "Force Quit":
@@ -77,13 +74,14 @@ class MenuActionHandler:
elif action == "Lock":
exec_shell_command("hyprlock")
# App menu actions (for hiding apps)
elif action.startswith("Hide ") and not action == "Hide Others":
action.replace("Hide ", "")
Hyprland.send_command("dispatch movetoworkspacesilent special:hidden")
elif action == "Hide Others":
Hyprland.send_command("dispatch movetoworkspacesilent special:hidden,^(activewindow)")
Hyprland.send_command(
"dispatch movetoworkspacesilent special:hidden,^(activewindow)"
)
elif action == "Show All":
Hyprland.send_command("dispatch movetoworkspace e+0,special:hidden")
elif action.startswith("Quit "):
+14 -26
View File
@@ -6,19 +6,10 @@ This file contains all the menu items and their organization.
def get_default_menu_contents():
return {
"Hyprland": [
],
"File": [
],
"Edit": [
],
"View": [
"Full Screen",
"---",
"Zoom In",
"Zoom Out",
"Actual Size"
],
"Hyprland": [],
"File": [],
"Edit": [],
"View": ["Full Screen", "---", "Zoom In", "Zoom Out", "Actual Size"],
"Go": [
"Back",
"Forward",
@@ -33,7 +24,7 @@ def get_default_menu_contents():
"Center",
"Group",
"Pin",
"Quit"
"Quit",
],
"Help": [
"---",
@@ -41,33 +32,30 @@ def get_default_menu_contents():
"Arch Wiki",
"Keyboard Shortcuts",
"---",
"Report a Bug..."
]
"Report a Bug...",
],
}
def get_app_menu_template():
return [
"About {app_name}",
"---",
"Hide {app_name}",
"---",
"Quit {app_name}"
]
return ["About {app_name}", "---", "Hide {app_name}", "---", "Quit {app_name}"]
def create_app_menu(app_name):
template = get_app_menu_template()
return [item.format(app_name=app_name) if "{app_name}" in item else item for item in template]
return [
item.format(app_name=app_name) if "{app_name}" in item else item
for item in template
]
def get_menu_contents_for_app(app_name):
contents = get_default_menu_contents().copy()
if app_name and app_name != "Hyprland":
# Replace the Hyprland menu with app-specific menu
contents["Hyprland"] = create_app_menu(app_name)
return contents
+46 -44
View File
@@ -13,13 +13,13 @@ class DropdownWindow(WaylandWindow):
super().__init__(
name="dropdown-window",
title="Menu Dropdown",
layer="overlay",
anchor="top left",
exclusivity="none",
margin=f"{y}px 0px 0px {x}px",
visible=False,
all_visible=True,
**kwargs
layer="overlay",
anchor="top left",
exclusivity="none",
margin=f"{y}px 0px 0px {x}px",
visible=False,
all_visible=True,
**kwargs,
)
# Store reference to parent menubar for closing dropdown
@@ -51,14 +51,16 @@ class DropdownWindow(WaylandWindow):
if event.detail != 2: # 2 = Gdk.NotifyType.INFERIOR (child widget)
# Start auto-close timer when mouse leaves
if not self.auto_close_timer:
self.auto_close_timer = GLib.timeout_add(300, self.auto_close_callback) # 300ms delay
self.auto_close_timer = GLib.timeout_add(
300, self.auto_close_callback
) # 300ms delay
return False
def auto_close_callback(self):
if self.parent_menubar:
self.parent_menubar.hide_dropdown()
self.auto_close_timer = None
return False
return False
class MenuBar(Box):
@@ -82,38 +84,32 @@ class MenuBar(Box):
self.action_handler = MenuActionHandler()
# Menu items in macOS style order (excluding the first item which will be dynamic)
self.static_menu_items = [
"File",
"Edit",
"View",
"Go",
"Window",
"Help"
]
self.static_menu_items = ["File", "Edit", "View", "Go", "Window", "Help"]
self.menu_contents = get_default_menu_contents()
self.app_button = Button(
name="menubar-button",
child=Label(
name="activewindow-label",
label="Hyprland"
),
child=Label(name="activewindow-label", label="Hyprland"),
)
self.app_button.connect(
"clicked", lambda widget: self.toggle_dropdown("Hyprland", widget)
)
self.app_button.connect("clicked", lambda widget: self.toggle_dropdown("Hyprland", widget))
self.add(self.app_button)
self.menu_buttons = []
for item in self.static_menu_items:
button = Button(
name="menubar-button",
child=Label(
name="menubar-label",
label=item
),
child=Label(name="menubar-label", label=item),
)
button.connect("clicked", lambda widget, menu_item=item: self.on_menu_button_clicked(menu_item, widget))
button.connect(
"clicked",
lambda widget, menu_item=item: self.on_menu_button_clicked(
menu_item, widget
),
)
self.menu_buttons.append(button)
self.add(button)
@@ -145,9 +141,9 @@ class MenuBar(Box):
if menu_item in context_dependent_menus:
# Only allow if there's an active window (not just showing "Hyprland")
has_active_window = (
self.current_window_title and
self.current_window_title.strip() and
self.app_button.get_child().get_label() != "Hyprland"
self.current_window_title
and self.current_window_title.strip()
and self.app_button.get_child().get_label() != "Hyprland"
)
return has_active_window
@@ -171,9 +167,11 @@ class MenuBar(Box):
def toggle_dropdown(self, menu_item, button):
# If same menu is clicked and dropdown is visible, hide it
if (self.current_dropdown == menu_item and
self.dropdown_window and
self.dropdown_window.get_visible()):
if (
self.current_dropdown == menu_item
and self.dropdown_window
and self.dropdown_window.get_visible()
):
self.hide_dropdown()
return
@@ -209,7 +207,7 @@ class MenuBar(Box):
separator = Box(
name="menu-separator",
size=(150, 1),
style="background-color: #444; margin: 2px 0;"
style="background-color: #444; margin: 2px 0;",
)
self.dropdown_window.content_box.add(separator)
else:
@@ -219,9 +217,9 @@ class MenuBar(Box):
child=Label(
name="dropdown-label",
label=item,
h_align="start" # Align text to the left
h_align="start", # Align text to the left
),
on_clicked=lambda *_, action=item: self.on_menu_action(action)
on_clicked=lambda *_, action=item: self.on_menu_action(action),
)
self.dropdown_window.content_box.add(menu_button)
@@ -236,7 +234,9 @@ class MenuBar(Box):
# Get button allocation
allocation = button.get_allocation()
print(f"Button allocation: x={allocation.x}, y={allocation.y}, width={allocation.width}, height={allocation.height}")
print(
f"Button allocation: x={allocation.x}, y={allocation.y}, width={allocation.width}, height={allocation.height}"
)
# Use the exact button position for perfect alignment
dropdown_x = allocation.x
@@ -291,9 +291,11 @@ class MenuBar(Box):
def show_system_dropdown(self, button):
# If same system dropdown is open, hide it (toggle behavior)
if (self.current_dropdown == "System" and
self.dropdown_window and
self.dropdown_window.get_visible()):
if (
self.current_dropdown == "System"
and self.dropdown_window
and self.dropdown_window.get_visible()
):
self.hide_dropdown()
return
@@ -324,7 +326,7 @@ class MenuBar(Box):
"Shutdown",
"Restart",
"Sleep",
"Lock"
"Lock",
]
# Add menu items to dropdown
@@ -334,7 +336,7 @@ class MenuBar(Box):
separator = Box(
name="menu-separator",
size=(150, 1),
style="background-color: #444; margin: 2px 0;"
style="background-color: #444; margin: 2px 0;",
)
self.dropdown_window.content_box.add(separator)
else:
@@ -344,9 +346,9 @@ class MenuBar(Box):
child=Label(
name="dropdown-label",
label=item,
h_align="start" # Align text to the left
h_align="start", # Align text to the left
),
on_clicked=lambda *_, action=item: self.on_menu_action(action)
on_clicked=lambda *_, action=item: self.on_menu_action(action),
)
self.dropdown_window.content_box.add(menu_button)
@@ -0,0 +1,77 @@
from fabric.widgets.button import Button
from fabric.widgets.label import Label
import time
import subprocess
from fabric.widgets.box import Box
from gi.repository import GLib
from fabric.utils import get_relative_path
from fabric.widgets.svg import Svg
class RecordingIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="panel-button", visible=False, **kwargs)
self.script_path = get_relative_path("../../../scripts/screen-capture.sh")
self.recording_start_time = None
self.recording_icon = Svg(
name="indicators-icon",
size=24,
svg_file=get_relative_path("../../../config/assets/icons/media-record.svg"),
)
self.time_label = Label(name="recording-time-label", markup="00:00")
self.recording_box = Box(
orientation="h",
spacing=2,
children=self.recording_icon,
)
self.add(self.recording_box)
self.connect("clicked", self.on_stop_recording)
self.hide()
GLib.timeout_add(1, self._delayed_init)
def check_recording_status(self):
try:
result = subprocess.run(
[self.script_path, "status"], capture_output=True, text=True, timeout=2
)
is_recording = result.stdout.strip() == "true"
if is_recording:
if not self.get_visible():
self.show()
# Get the recording start time if we don't have it
if self.recording_start_time is None:
self.recording_start_time = self.get_recording_start_time()
# Update the recording time display
if self.recording_start_time:
elapsed_seconds = int(time.time() - self.recording_start_time)
minutes = elapsed_seconds // 60
seconds = elapsed_seconds % 60
time_text = f"{minutes:02d}:{seconds:02d}"
self.time_label.set_markup(time_text)
self.set_tooltip_text(
f"Recording in progress ({minutes:02d}:{
seconds:02d}) - Click to stop"
)
else:
self.set_tooltip_text("Recording in progress - Click to stop")
else:
if self.get_visible():
self.hide()
self.recording_start_time = None
except Exception:
# If we can't check status, hide the indicator
if self.get_visible():
self.hide()
self.recording_start_time = None
return True # Continue the timeout
+6 -3
View File
@@ -7,10 +7,13 @@ from fabric.widgets.datetime import DateTime
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.svg import Svg
from utils.wayland import WaylandWindow as Window
from modules.panel.components.enhanced_system_tray import (
apply_enhanced_system_tray,
) # ignore
from modules.panel.components.indicators import Indicators
from modules.panel.components.menubar import MenuBar
from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray
from utils.wayland import WaylandWindow as Window
# Apply enhanced system tray icon handling
apply_enhanced_system_tray()
@@ -131,4 +134,4 @@ class Panel(Window):
else:
self.chevron_button.get_child().set_from_file(
get_relative_path("../../config/assets/icons/chevron-right.svg")
)
)
+5 -3
View File
@@ -1,13 +1,12 @@
import json
import config.data as data
from fabric.hyprland.widgets import get_hyprland_connection
from fabric.widgets.box import Box
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from gi.repository import Gdk
import config.data as data
from utils.icon_resolver import IconResolver
from utils.wayland import WaylandWindow as Window
@@ -102,7 +101,10 @@ class ApplicationSwitcher(Window):
if c.get("hidden", False):
continue
# Skip clients in special workspaces if the setting is enabled
if data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS and self._is_special_workspace(c):
if (
data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS
and self._is_special_workspace(c)
):
continue
filtered_windows.append(c)
+1 -1
View File
@@ -48,7 +48,7 @@ class Battery(Service):
@Property(str, "readable")
def time_to_empty(self):
if self._use_psutil_fallback:
if self._psutil_battery and hasattr(self._psutil_battery, 'secsleft'):
if self._psutil_battery and hasattr(self._psutil_battery, "secsleft"):
return self.seconds_to_hours_minutes(self._psutil_battery.secsleft)
return "N/A"
return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0))
+19 -11
View File
@@ -55,7 +55,11 @@ class PlayerService(Service):
return self._player.get_position()
except Exception:
# Player is no longer available, stop the fabricator
if hasattr(self, 'pos_fabricator') and self.pos_fabricator and self._fabricator_running:
if (
hasattr(self, "pos_fabricator")
and self.pos_fabricator
and self._fabricator_running
):
try:
self.pos_fabricator.stop()
self._fabricator_running = False
@@ -69,7 +73,7 @@ class PlayerService(Service):
return # Already cleaned up
try:
if hasattr(self, 'pos_fabricator') and self.pos_fabricator:
if hasattr(self, "pos_fabricator") and self.pos_fabricator:
if self._fabricator_running:
self.pos_fabricator.stop()
self._fabricator_running = False
@@ -79,9 +83,11 @@ class PlayerService(Service):
print(f"[DEBUG] Error during PlayerService cleanup: {e}")
def on_seeked(self, player, position):
if (not self._cleanup_done and
self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and
not self._fabricator_running):
if (
not self._cleanup_done
and self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
and not self._fabricator_running
):
try:
self.pos_fabricator.start()
self._fabricator_running = True
@@ -128,7 +134,7 @@ class PlayerService(Service):
return
# Validate player object
if not self._player or not hasattr(self._player, 'get_position'):
if not self._player or not hasattr(self._player, "get_position"):
return
# Get position safely
@@ -147,9 +153,11 @@ class PlayerService(Service):
# Get duration safely from multiple sources
dur = 0.0 # Default to 0
if (hasattr(self._player, 'props') and
hasattr(self._player.props, 'metadata') and
self._player.props.metadata):
if (
hasattr(self._player, "props")
and hasattr(self._player.props, "metadata")
and self._player.props.metadata
):
metadata = self._player.props.metadata
@@ -173,7 +181,7 @@ class PlayerService(Service):
# Try to get duration from player directly (some players support this)
if dur <= 0:
try:
if hasattr(self._player, 'get_metadata'):
if hasattr(self._player, "get_metadata"):
player_metadata = self._player.get_metadata()
if player_metadata:
raw_dur = player_metadata["mpris:length"]
@@ -241,4 +249,4 @@ class PlayerManager(Service):
self.new_player(player)
def _on_player_vanished(self, sender, player, manager):
self.player_vanish(player)
self.player_vanish(player)
+5 -3
View File
@@ -510,9 +510,11 @@ class Wifi(Service):
device,
None,
None,
lambda c, r: logger.info(f"Connected to '{ssid}'")
if c
else logger.error(f"Failed to connect to '{ssid}'"),
lambda c, r: (
logger.info(f"Connected to '{ssid}'")
if c
else logger.error(f"Failed to connect to '{ssid}'")
),
)
except Exception as e:
logger.error(f"Failed to add connection: {e}")
+12 -4
View File
@@ -32,8 +32,12 @@ class CircleImage(Gtk.DrawingArea, Widget):
style: str | None = None,
tooltip_text: str | None = None,
tooltip_markup: str | None = None,
h_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None,
v_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None,
h_align: (
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
) = None,
v_align: (
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
) = None,
h_expand: bool = False,
v_expand: bool = False,
size: int | None = None,
@@ -57,7 +61,9 @@ class CircleImage(Gtk.DrawingArea, Widget):
)
self.size = size if size is not None else 100 # Default size if not provided
self._angle = 0
self._orig_image: GdkPixbuf.Pixbuf | None = None # Original image for reprocessing
self._orig_image: GdkPixbuf.Pixbuf | None = (
None # Original image for reprocessing
)
self._image: GdkPixbuf.Pixbuf | None = None
if image_file:
pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
@@ -79,7 +85,9 @@ class CircleImage(Gtk.DrawingArea, Widget):
else:
square_size = width
if square_size != self.size:
pixbuf = pixbuf.scale_simple(self.size, self.size, GdkPixbuf.InterpType.BILINEAR)
pixbuf = pixbuf.scale_simple(
self.size, self.size, GdkPixbuf.InterpType.BILINEAR
)
return pixbuf
def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
+4 -3
View File
@@ -556,9 +556,10 @@ class Conversion:
to_type = self.clean_type(to_type)
if from_type1 == from_type2:
return self.convert(
value1 + value2, from_type1, to_type
), to_type + addition
return (
self.convert(value1 + value2, from_type1, to_type),
to_type + addition,
)
else:
res = 0
res += self.convert(value1, from_type1, to_type)
+6 -2
View File
@@ -16,7 +16,9 @@ if not os.path.exists(data.CACHE_DIR):
class IconResolver:
def __init__(self, default_applicaiton_icon: str = "application-x-executable-symbolic"):
def __init__(
self, default_applicaiton_icon: str = "application-x-executable-symbolic"
):
if os.path.exists(ICON_CACHE_FILE):
with open(ICON_CACHE_FILE) as f:
try:
@@ -79,7 +81,9 @@ class IconResolver:
data_dir = os.path.join(data_dir, "applications")
if os.path.exists(data_dir):
files = os.listdir(data_dir)
matching = [s for s in files if "".join(app_id.lower().split()) in s.lower()]
matching = [
s for s in files if "".join(app_id.lower().split()) in s.lower()
]
if matching:
return os.path.join(data_dir, matching[0])
for word in list(filter(None, re.split(r"-|\.|_|\s", app_id))):
-143
View File
@@ -1,143 +0,0 @@
from fabric.widgets.box import Box
from utils.wayland import WaylandWindow
from config.c import c
import gi # type: ignore
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import Gtk, GtkLayerShell # type: ignore
class PopupWindow(WaylandWindow):
def __init__(
self,
parent: WaylandWindow,
pointing_to: Gtk.Widget | None = None,
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
**kwargs,
):
super().__init__(**kwargs)
self.exclusivity = "none"
self._parent = parent
self._pointing_widget = pointing_to
self._base_margin = self.extract_margin(margin)
self.margin = self._base_margin.values()
self.connect("notify::visible", self.do_update_handlers)
def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore
return 0, 0
allocation = widget.get_allocation()
x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (
0,
0,
)
return round(x / 2), round(y / 2)
def set_pointing_to(self, widget: Gtk.Widget | None):
if self._pointing_widget:
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
self._pointing_widget = widget
return self.do_update_handlers()
def do_update_handlers(self, *_):
if not self._pointing_widget:
return
if not self.get_visible():
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
self.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
return
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
self.connect("size-allocate", self.do_handle_size_allocate)
return self.do_handle_size_allocate()
def do_handle_size_allocate(self, *_):
return self.do_reposition(self.do_calculate_edges())
def do_calculate_edges(self):
move_axe = "x"
parent_anchor = self._parent.anchor
if len(parent_anchor) != 3:
return move_axe
if (
GtkLayerShell.Edge.LEFT in parent_anchor
and GtkLayerShell.Edge.RIGHT in parent_anchor
):
# horizontal -> move on x-axies
move_axe = "x"
if GtkLayerShell.Edge.TOP in parent_anchor:
self.anchor = "left top"
else:
self.anchor = "left bottom"
elif (
GtkLayerShell.Edge.TOP in parent_anchor
and GtkLayerShell.Edge.BOTTOM in parent_anchor
):
# vertical -> move on y-axies
move_axe = "y"
if GtkLayerShell.Edge.RIGHT in parent_anchor:
self.anchor = "top right"
else:
self.anchor = "top left"
return move_axe
def do_reposition(self, move_axe: str):
parent_margin = self._parent.margin
parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
height = self.get_allocated_height()
width = self.get_allocated_width()
if self._pointing_widget:
coords = self.get_coords_for_widget(self._pointing_widget)
if coords[0] + width > int(c.get_rule("Display.resolution").split("x")[0]):
coords_centered = (
round(coords[0] - (width - self._pointing_widget.get_allocated_width())),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(coords[0]),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(self._parent.get_allocated_width() / 2),
round(self._parent.get_allocated_height() / 2),
)
self.margin = tuple(
a + b
for a, b in zip(
(
(
0,
0,
0,
round((parent_x_margin + coords_centered[0])),
)
if move_axe == "x"
else (
round((parent_y_margin + coords_centered[1]) - (height / 2)),
0,
0,
0,
)
),
self._base_margin.values(),
)
)
+31 -21
View File
@@ -132,13 +132,15 @@ class WaylandWindow(Window):
@keyboard_mode.setter
def keyboard_mode(
self,
value: Literal[
"none",
"exclusive",
"on-demand",
"entry-number",
]
| KeyboardMode,
value: (
Literal[
"none",
"exclusive",
"on-demand",
"entry-number",
]
| KeyboardMode
),
):
self._keyboard_mode = get_enum_member(
KeyboardMode, value, default=KeyboardMode.NONE
@@ -226,10 +228,12 @@ class WaylandWindow(Window):
layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP,
anchor: str = "",
margin: str | Iterable[int] = "0px 0px 0px 0px",
exclusivity: Literal["auto", "normal", "none"]
| WaylandWindowExclusivity = WaylandWindowExclusivity.NONE,
keyboard_mode: Literal["none", "exclusive", "on-demand"]
| KeyboardMode = KeyboardMode.NONE,
exclusivity: (
Literal["auto", "normal", "none"] | WaylandWindowExclusivity
) = WaylandWindowExclusivity.NONE,
keyboard_mode: (
Literal["none", "exclusive", "on-demand"] | KeyboardMode
) = KeyboardMode.NONE,
pass_through: bool = False,
monitor: int | Gdk.Monitor | None = None,
title: str = "fabric",
@@ -242,12 +246,12 @@ class WaylandWindow(Window):
style_classes: Iterable[str] | str | None = None,
tooltip_text: str | None = None,
tooltip_markup: str | None = None,
h_align: Literal["fill", "start", "end", "center", "baseline"]
| Gtk.Align
| None = None,
v_align: Literal["fill", "start", "end", "center", "baseline"]
| Gtk.Align
| None = None,
h_align: (
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
) = None,
v_align: (
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
) = None,
h_expand: bool = False,
v_expand: bool = False,
size: Iterable[int] | int | None = None,
@@ -292,7 +296,11 @@ class WaylandWindow(Window):
self.keyboard_mode = keyboard_mode
self.exclusivity = exclusivity
self.pass_through = pass_through
self.show_all() if all_visible is True else self.show() if visible is True else None
(
self.show_all()
if all_visible is True
else self.show() if visible is True else None
)
def steal_input(self) -> None:
return GtkLayerShell.set_keyboard_interactivity(self, True)
@@ -347,9 +355,11 @@ class WaylandWindow(Window):
margins = (
extract_css_values(input.lower())
if isinstance(input, str)
else input
if isinstance(input, (tuple, list)) and len(input) == 4
else (0, 0, 0, 0)
else (
input
if isinstance(input, (tuple, list)) and len(input) == 4
else (0, 0, 0, 0)
)
)
return {
Edge.TOP: margins[0],
-174
View File
@@ -1,174 +0,0 @@
import math
from fabric.core.service import Service, Signal
from gi.repository import Gtk, Gdk, GLib
import gi
gi.require_version("Gtk", "3.0")
class WigglyWidget(Gtk.DrawingArea, Service):
@Signal
def on_seek(self, ratio: float) -> None: ...
def __init__(self):
super().__init__(name="wiggle-bar")
self.phase = 0
self.set_size_request(-1, 20)
self.connect("draw", self.on_draw)
# mouse events
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
)
self.connect("button-press-event", self.on_button_press)
self.connect("motion-notify-event", self.on_motion)
self.connect("button-release-event", self.on_button_release)
GLib.timeout_add(16, self.update) # 60 FPS
self.value = 0.0
self.amplitude = 2
self.dragging = False
self.pause = False
self.show_all()
def animate_amplitude_to(self):
if abs(self.amplitude - self.amplitude_target) < 0.01:
self.amplitude = self.amplitude_target
self.queue_draw()
return False
else:
self.amplitude += self.amplitude_step
self.queue_draw()
return True
def update_amplitude(self, decrease):
if decrease:
self.amplitude_target = 0
self.amplitude_step = (0 - self.amplitude) / 10.0
else:
self.amplitude_target = 2
self.amplitude_step = (2 - self.amplitude) / 10.0
# animation stops when False is returned btw
GLib.timeout_add(16, self.animate_amplitude_to)
def update_value_from_x(self, x):
width = self.get_allocated_width()
self.value = max(0.0, min(1.0, x / width))
self.on_seek(self.value)
self.queue_draw()
def on_button_press(self, widget, event):
self.dragging = True
self.update_amplitude(True)
self.update_value_from_x(event.x)
return True
# def animate_progress_to(self):
# print("frame")
# if self.value < self.value_target:
# self.value = self.value_target
# print(self.value)
# self.queue_draw()
# return False
# else:
# self.value += self.value_step
# self.queue_draw()
# return True
# def update_value_from_signal(self, new_value):
# self.value_target = min(1.0, new_value)
# steps = 60 # 1s / 16ms ≈ 60 frames
# self.value_step = (self.value_target - self.value) / steps
# GLib.timeout_add(16, self.animate_progress_to)
def update_value_from_signal(self, new_value):
self.value = min(1.0, new_value)
def on_motion(self, widget, event):
if self.dragging and not self.pause:
self.update_value_from_x(event.x)
cursor = Gdk.Cursor.new(Gdk.CursorType.HAND1)
widget.get_window().set_cursor(cursor)
return True
def on_button_release(self, widget, event):
if self.pause == False:
self.dragging = False
self.update_amplitude(False)
return True
def update(self):
if self.dragging == False:
self.phase += 0.1
# if (self.value<1):
# self.value += 0.001
self.queue_draw()
return True
def on_draw(self, widget, cr):
alloc_width = self.get_allocated_width()
height = self.get_allocated_height()
center_y = height / 2
# self.amplitude = 2
frequency = 0.3
slider_diameter = 6
width = int(alloc_width * self.value) - slider_diameter + 1
cr.set_source_rgb(1, 1, 1) # color here btw
cr.set_line_width(2)
last_x = 0 # fallback values in case the slider gets dragged out of range
last_y = 0
cr.move_to(0, center_y)
for x in range(width):
y = center_y + self.amplitude * math.sin((x * frequency) + self.phase)
cr.line_to(x, y)
last_x, last_y = x, y
cr.stroke()
# Set the color using CSS - the widget should have CSS styling applied
# that sets the color to var(--primary). For now, use a fallback approach.
style_context = self.get_style_context()
try:
# Get the foreground color which should be styled with CSS
color = style_context.get_color(Gtk.StateFlags.NORMAL)
cr.set_source_rgba(color.red, color.green, color.blue, color.alpha)
except:
# Fallback to a nice blue color that matches the primary theme
cr.set_source_rgb(0.2, 0.6, 1.0)
rect_width = 6
arc_radius = slider_diameter / 2
self.draw_rounded_rect(cr, last_x, height, rect_width, rect_width, arc_radius)
cr.fill()
cr.stroke()
cr.set_source_rgb(1, 1, 1)
cr.set_line_width(1)
# This is for anti aliasing. When line width becomes 1px and the position is not an integer, two lines of opacity 50% is drawn on two pixel rows.
# This moves the line to make it pixel perfect hence not needing anti aliasing
cr.translate(0, 0.5)
cr.move_to(last_x + 2 * arc_radius, height / 2)
cr.line_to(alloc_width, height / 2)
cr.stroke()
def draw_rounded_rect(self, cr, x, y, width, _height, radius):
cr.new_sub_path()
cr.arc(x + width - radius, radius, radius, -math.pi, 0)
cr.arc(x + width - radius, y - radius, radius, 0, -math.pi)
cr.close_path()