204 files changed
+51505
No files matched your search
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Modus services package.
|
||||
Contains background services and utilities for the shell.
|
||||
"""
|
||||
@@ -0,0 +1,180 @@
|
||||
from typing import cast
|
||||
|
||||
from gi.repository import GLib, Gtk
|
||||
|
||||
from fabric import Property, Service, Signal
|
||||
|
||||
|
||||
class Animator(Service):
|
||||
@Signal
|
||||
def finished(self) -> None: ...
|
||||
|
||||
@Property(tuple[float, float, float, float], "read-write")
|
||||
def bezier_curve(self) -> tuple[float, float, float, float]:
|
||||
return self._bezier_curve
|
||||
|
||||
@bezier_curve.setter
|
||||
def bezier_curve(self, value: tuple[float, float, float, float]):
|
||||
self._bezier_curve = value
|
||||
return
|
||||
|
||||
@Property(float, "read-write")
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: float):
|
||||
self._value = value
|
||||
return
|
||||
|
||||
@Property(float, "read-write")
|
||||
def max_value(self):
|
||||
return self._max_value
|
||||
|
||||
@max_value.setter
|
||||
def max_value(self, value: float):
|
||||
self._max_value = value
|
||||
return
|
||||
|
||||
@Property(float, "read-write")
|
||||
def min_value(self):
|
||||
return self._min_value
|
||||
|
||||
@min_value.setter
|
||||
def min_value(self, value: float):
|
||||
self._min_value = value
|
||||
return
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def playing(self):
|
||||
return self._playing
|
||||
|
||||
@playing.setter
|
||||
def playing(self, value: bool):
|
||||
self._playing = value
|
||||
return
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def repeat(self):
|
||||
return self._repeat
|
||||
|
||||
@repeat.setter
|
||||
def repeat(self, value: bool):
|
||||
self._repeat = value
|
||||
return
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bezier_curve: tuple[float, float, float, float],
|
||||
duration: float,
|
||||
min_value: float = 0.0,
|
||||
max_value: float = 1.0,
|
||||
repeat: bool = False,
|
||||
tick_widget: Gtk.Widget | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._bezier_curve = (1, 0, 1, 1)
|
||||
self._duration = 5
|
||||
self._value = 0.0
|
||||
self._min_value = 0.0
|
||||
self._max_value = 1.0
|
||||
self._repeat = False
|
||||
|
||||
self.bezier_curve = bezier_curve
|
||||
self.duration = duration
|
||||
self.value = min_value
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.repeat = repeat
|
||||
|
||||
self.playing = False
|
||||
self._start_time = None
|
||||
self._tick_handler = None
|
||||
self._timeline_pos = 0
|
||||
self._tick_widget = tick_widget
|
||||
|
||||
def do_get_time_now(self):
|
||||
return GLib.get_monotonic_time() / 1_000_000
|
||||
|
||||
def do_lerp(self, start: float, end: float, time: float) -> float:
|
||||
return start + (end - start) * time
|
||||
|
||||
def do_interpolate_cubic_bezier(self, time: float) -> float:
|
||||
y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1)
|
||||
return (
|
||||
(1 - time) ** 3 * y_points[0]
|
||||
+ 3 * (1 - time) ** 2 * time * y_points[1]
|
||||
+ 3 * (1 - time) * time**2 * y_points[2]
|
||||
+ time**3 * y_points[3]
|
||||
)
|
||||
|
||||
def do_ease(self, time: float) -> float:
|
||||
return self.do_lerp(
|
||||
self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time)
|
||||
)
|
||||
|
||||
def do_update_value(self, delta_time: float):
|
||||
if not self.playing:
|
||||
return
|
||||
|
||||
elapsed_time = delta_time - cast(float, self._start_time)
|
||||
|
||||
self._timeline_pos = min(1, elapsed_time / self.duration)
|
||||
|
||||
self.value = self.do_ease(self._timeline_pos)
|
||||
|
||||
if not self._timeline_pos >= 1:
|
||||
return
|
||||
|
||||
if not self.repeat:
|
||||
self.value = self.max_value
|
||||
self.finished()
|
||||
self.pause()
|
||||
return
|
||||
|
||||
self._start_time = delta_time
|
||||
self._timeline_pos = 0
|
||||
return
|
||||
|
||||
def do_handle_tick(self, *_):
|
||||
current_time = self.do_get_time_now()
|
||||
self.do_update_value(current_time)
|
||||
return True
|
||||
|
||||
def do_remove_tick_handlers(self):
|
||||
if self._tick_handler:
|
||||
if self._tick_widget:
|
||||
self._tick_widget.remove_tick_callback(self._tick_handler)
|
||||
else:
|
||||
GLib.source_remove(self._tick_handler)
|
||||
self._tick_handler = None
|
||||
return
|
||||
|
||||
def play(self):
|
||||
if self.playing:
|
||||
return
|
||||
|
||||
self._start_time = self.do_get_time_now()
|
||||
|
||||
if not self._tick_handler:
|
||||
if self._tick_widget:
|
||||
self._tick_handler = self._tick_widget.add_tick_callback(
|
||||
self.do_handle_tick
|
||||
)
|
||||
else:
|
||||
self._tick_handler = GLib.timeout_add(16, self.do_handle_tick)
|
||||
|
||||
self.playing = True
|
||||
return
|
||||
|
||||
def pause(self):
|
||||
self.playing = False
|
||||
return self.do_remove_tick_handlers()
|
||||
|
||||
def stop(self):
|
||||
if not self._tick_handler:
|
||||
self._timeline_pos = 0
|
||||
self.playing = False
|
||||
return
|
||||
return self.do_remove_tick_handlers()
|
||||
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
from utils.roam import modus_service
|
||||
|
||||
|
||||
class AppName:
|
||||
def __init__(self, path="/usr/share/applications"):
|
||||
self.files = os.listdir(path)
|
||||
self.path = path
|
||||
|
||||
def get_app_name(self, wmclass, format_=False):
|
||||
desktop_file = ""
|
||||
for f in self.files:
|
||||
if f.startswith(wmclass + ".desktop"):
|
||||
desktop_file = f
|
||||
|
||||
desktop_app_name = wmclass
|
||||
|
||||
if desktop_file == "":
|
||||
return wmclass
|
||||
with open(os.path.join(self.path, desktop_file), "r") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines:
|
||||
if line.startswith("Name="):
|
||||
desktop_app_name = line.split("=")[1].strip()
|
||||
break
|
||||
return desktop_app_name
|
||||
|
||||
def get_app_exec(self, wmclass, format_=False):
|
||||
desktop_file = ""
|
||||
for f in self.files:
|
||||
if f.startswith(wmclass + ".desktop"):
|
||||
desktop_file = f
|
||||
|
||||
desktop_app_name = wmclass
|
||||
|
||||
if desktop_file == "":
|
||||
return wmclass
|
||||
with open(os.path.join(self.path, desktop_file), "r") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines:
|
||||
if line.startswith("Exec="):
|
||||
desktop_app_name = line.split("=")[1].strip()
|
||||
break
|
||||
return desktop_app_name
|
||||
|
||||
def get_desktop_file(self, wmclass):
|
||||
desktop_file = ""
|
||||
for f in self.files:
|
||||
if f.startswith(wmclass + ".desktop"):
|
||||
desktop_file = f
|
||||
return desktop_file
|
||||
|
||||
def format_app_name(self, title, wmclass, update=False):
|
||||
# Handle case when both title and wmclass are empty (no active window)
|
||||
if not title and not wmclass:
|
||||
name = "Finder"
|
||||
else:
|
||||
name = wmclass
|
||||
if name == "":
|
||||
name = title
|
||||
|
||||
# Try to get the proper app name from desktop file only if wmclass is not empty
|
||||
if wmclass:
|
||||
name = self.get_app_name(wmclass=wmclass)
|
||||
|
||||
# Smart title formatting (capitalize first letter)
|
||||
name = str(name).title()
|
||||
if "." in name:
|
||||
name = name.split(".")[-1]
|
||||
|
||||
if update:
|
||||
modus_service.current_active_app_name = name
|
||||
return name
|
||||
|
||||
|
||||
# Create a global instance for use across modules
|
||||
app_name_resolver = AppName()
|
||||
|
||||
|
||||
def format_window(title, wmclass):
|
||||
# Handle the case when HyprlandActiveWindow passes "unknown" instead of empty strings
|
||||
if (not title or title == "unknown") and (not wmclass or wmclass == "unknown"):
|
||||
return "Finder"
|
||||
|
||||
# Clean up "unknown" values
|
||||
if title == "unknown":
|
||||
title = ""
|
||||
if wmclass == "unknown":
|
||||
wmclass = ""
|
||||
|
||||
name = app_name_resolver.format_app_name(title, wmclass, True)
|
||||
return name
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class CurrencyCache:
|
||||
"""Thread-safe currency exchange rate cache with background updates."""
|
||||
|
||||
def __init__(self):
|
||||
# {base_currency: {rates_data, timestamp}}
|
||||
self._cache: Dict[str, Dict] = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cache_ttl = 3600 # 1 hour in seconds
|
||||
self._request_timeout = 2 # 2 seconds for faster response
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=2, thread_name_prefix="currency"
|
||||
)
|
||||
self._pending_requests: Dict[str, threading.Event] = {}
|
||||
|
||||
def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]:
|
||||
"""
|
||||
Get exchange rate from cache or fetch if needed.
|
||||
Returns (rate, cache_age_seconds) or None if unavailable.
|
||||
"""
|
||||
from_lower = from_code.lower()
|
||||
to_lower = to_code.lower()
|
||||
|
||||
if from_lower == to_lower:
|
||||
return 1.0, 0.0
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
with self._cache_lock:
|
||||
# Check if we have fresh cached data
|
||||
if from_lower in self._cache:
|
||||
cache_entry = self._cache[from_lower]
|
||||
cache_age = current_time - cache_entry["timestamp"]
|
||||
|
||||
if cache_age < self._cache_ttl and to_lower in cache_entry["rates"]:
|
||||
rate = cache_entry["rates"][to_lower]["rate"]
|
||||
return rate, cache_age
|
||||
|
||||
# Check if we're already fetching this currency
|
||||
if from_lower in self._pending_requests:
|
||||
# Don't wait, return cached data if available (even if stale)
|
||||
if (
|
||||
from_lower in self._cache
|
||||
and to_lower in self._cache[from_lower]["rates"]
|
||||
):
|
||||
cache_entry = self._cache[from_lower]
|
||||
cache_age = current_time - cache_entry["timestamp"]
|
||||
rate = cache_entry["rates"][to_lower]["rate"]
|
||||
return rate, cache_age
|
||||
return None
|
||||
|
||||
# Start background fetch
|
||||
self._pending_requests[from_lower] = threading.Event()
|
||||
|
||||
# Submit background task to fetch rates
|
||||
self._executor.submit(self._fetch_rates_background, from_lower)
|
||||
|
||||
# Return stale cached data if available
|
||||
with self._cache_lock:
|
||||
if (
|
||||
from_lower in self._cache
|
||||
and to_lower in self._cache[from_lower]["rates"]
|
||||
):
|
||||
cache_entry = self._cache[from_lower]
|
||||
cache_age = current_time - cache_entry["timestamp"]
|
||||
rate = cache_entry["rates"][to_lower]["rate"]
|
||||
return rate, cache_age
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_rates_background(self, from_code: str):
|
||||
"""Fetch exchange rates in background thread."""
|
||||
try:
|
||||
url = f"https://www.floatrates.com/daily/{from_code}.json"
|
||||
response = requests.get(url, timeout=self._request_timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
rates_data = response.json()
|
||||
current_time = time.time()
|
||||
|
||||
with self._cache_lock:
|
||||
self._cache[from_code] = {
|
||||
"rates": rates_data,
|
||||
"timestamp": current_time,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Background currency fetch failed for {from_code}: {e}")
|
||||
finally:
|
||||
# Mark request as complete
|
||||
with self._cache_lock:
|
||||
if from_code in self._pending_requests:
|
||||
self._pending_requests[from_code].set()
|
||||
del self._pending_requests[from_code]
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup resources."""
|
||||
self._executor.shutdown(wait=False)
|
||||
with self._cache_lock:
|
||||
self._cache.clear()
|
||||
self._pending_requests.clear()
|
||||
|
||||
|
||||
# Global currency cache instance
|
||||
_currency_cache = CurrencyCache()
|
||||
|
||||
|
||||
class Units:
|
||||
def __init__(self):
|
||||
self.WEIGHT_CHART: dict[str, tuple[float, float]] = {
|
||||
"kilogram": (1, 1),
|
||||
"kg": (1, 1),
|
||||
"tonne": (1000, 0.001),
|
||||
"ton": (1000, 0.001),
|
||||
"gram": (1e-3, 1e3),
|
||||
"g": (1e-3, 1e3),
|
||||
"milligram": (1e-6, 1e6),
|
||||
"mg": (1e-6, 1e6),
|
||||
"metric-ton": (1000, 0.001),
|
||||
"metric-tonne": (1000, 0.001),
|
||||
"long-ton": (1016.04608, 0.0009842073),
|
||||
"short-ton": (907.184, 0.0011023122),
|
||||
"pound": (0.453592, 2.2046244202),
|
||||
"lb": (0.453592, 2.2046244202),
|
||||
"stone": (6.35029, 0.1574731728),
|
||||
"st": (6.35029, 0.1574731728),
|
||||
"ounce": (0.0283495, 35.273990723),
|
||||
"oz": (0.0283495, 35.273990723),
|
||||
"carrat": (0.0002, 5000),
|
||||
"ct": (0.0002, 5000),
|
||||
"atomic-mass-unit": (1.660540199e-27, 6.022136652e26),
|
||||
}
|
||||
|
||||
self.LENGTH_CHART: dict[str, float] = {
|
||||
# meter
|
||||
"m": 1,
|
||||
"M": 1,
|
||||
"meter": 1,
|
||||
# kilometer
|
||||
"km": 1e3,
|
||||
"KM": 1e3,
|
||||
"kilometer": 1e3,
|
||||
# centimeter
|
||||
"cm": 1e-2,
|
||||
"CM": 1e-2,
|
||||
"centimeter": 1e-2,
|
||||
# millimeter
|
||||
"mm": 1e-3,
|
||||
"MM": 1e-3,
|
||||
"millimeter": 1e-3,
|
||||
# micrometer
|
||||
"um": 1e-6,
|
||||
"UM": 1e-6,
|
||||
"micrometer": 1e-6,
|
||||
# nanometer
|
||||
"nm": 1e-9,
|
||||
"NM": 1e-9,
|
||||
"nanometer": 1e-9,
|
||||
# mile
|
||||
"mi": 1609.344,
|
||||
"MI": 1609.344,
|
||||
"mile": 1609.344,
|
||||
# yard
|
||||
"yd": 0.9144,
|
||||
"YD": 0.9144,
|
||||
"yard": 0.9144,
|
||||
# foot
|
||||
"ft": 0.3048,
|
||||
"FT": 0.3048,
|
||||
"foot": 0.3048,
|
||||
"feet": 0.3048,
|
||||
# inch
|
||||
"in": 0.0254,
|
||||
"IN": 0.0254,
|
||||
"inch": 0.0254,
|
||||
"inches": 0.0254,
|
||||
# nautical mile
|
||||
"nmi": 1852,
|
||||
"NMI": 1852,
|
||||
"nautical-mile": 1852,
|
||||
}
|
||||
|
||||
self.STORAGE_TYPE_CHART: dict[str, float] = {
|
||||
"bit": 1,
|
||||
"byte": 8,
|
||||
"B": 8,
|
||||
"kilobyte": 8192,
|
||||
"KB": 8192,
|
||||
"megabyte": 8388608,
|
||||
"MB": 8388608,
|
||||
"gigabyte": 8589934592,
|
||||
"GB": 8589934592,
|
||||
"terabyte": 8796093022208,
|
||||
"TB": 8796093022208,
|
||||
"petabyte": 9007199254740992,
|
||||
"PB": 9007199254740992,
|
||||
"exabyte": 9223372036854775808,
|
||||
"EB": 9223372036854775808,
|
||||
}
|
||||
|
||||
self.TEMPERATURE_CHART = {
|
||||
"celsius": (lambda v: v + 273.15, lambda v: v - 273.15),
|
||||
"c": (lambda v: v + 273.15, lambda v: v - 273.15),
|
||||
"fahrenheit": (
|
||||
lambda v: (v - 32) * 5 / 9 + 273.15,
|
||||
lambda v: (v - 273.15) * 9 / 5 + 32,
|
||||
),
|
||||
"f": (
|
||||
lambda v: (v - 32) * 5 / 9 + 273.15,
|
||||
lambda v: (v - 273.15) * 9 / 5 + 32,
|
||||
),
|
||||
"kelvin": (lambda v: v, lambda v: v),
|
||||
"k": (lambda v: v, lambda v: v),
|
||||
"rankine": (lambda v: v * 5 / 9, lambda v: v * 9 / 5),
|
||||
"reaumur": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5),
|
||||
}
|
||||
|
||||
self.TIME_CHART: dict[str, float] = {
|
||||
"second": 1,
|
||||
"s": 1,
|
||||
"minute": 60,
|
||||
"min": 60,
|
||||
"m": 60,
|
||||
"hour": 3600,
|
||||
"h": 3600,
|
||||
"milisecond": 1e-3,
|
||||
"ms": 1e-3,
|
||||
"day": 86400,
|
||||
"d": 86400,
|
||||
"week": 604800,
|
||||
"w": 604800,
|
||||
"fortnight": 1209600,
|
||||
"month": 2628000, # Approximation (30.44 days)
|
||||
"mo": 2628000, # Approximation (30.44 days)
|
||||
"year": 31536000, # Approximation (365 days)
|
||||
"yr": 31536000, # Approximation (365 days)
|
||||
"decade": 315360000, # Approximation (10 years)
|
||||
"dec": 315360000, # Approximation (10 years)
|
||||
"century": 3153600000, # Approximation (100 years)
|
||||
"cent": 3153600000, # Approximation (100 years)
|
||||
"millennium": 31536000000, # Approximation (1000 years)
|
||||
"millenia": 31536000000, # Approximation (1000 years)
|
||||
}
|
||||
|
||||
self.LIQUID_VOLUME_CHART: dict[str, float] = {
|
||||
"liter": 1,
|
||||
"l": 1,
|
||||
"milliliter": 1e-3,
|
||||
"ml": 1e-3,
|
||||
"gallon": 3.78541,
|
||||
"quart": 0.946353,
|
||||
"pint": 0.473176,
|
||||
"fluid-ounce": 0.0295735,
|
||||
"fl-oz": 0.0295735,
|
||||
"oz": 0.0295735,
|
||||
"ounce": 0.0295735,
|
||||
"cup": 0.236588,
|
||||
"tablespoon": 0.0147868,
|
||||
"tbsp": 0.0147868,
|
||||
"teaspoon": 0.00492892,
|
||||
"tsp": 0.00492892,
|
||||
}
|
||||
|
||||
self.ANGLE_CHART: dict[str, float] = {
|
||||
"degree": 1,
|
||||
"deg": 1,
|
||||
"radian": 57.2958,
|
||||
"rad": 57.2958,
|
||||
"gradian": 0.9,
|
||||
"gon": 0.9,
|
||||
}
|
||||
|
||||
self.ENERGY_CHART: dict[str, float] = {
|
||||
"joule": 1,
|
||||
"j": 1,
|
||||
"kilojoule": 1000,
|
||||
"kj": 1000,
|
||||
"calorie": 4.184,
|
||||
"cal": 4.184,
|
||||
"kilocalorie": 4184,
|
||||
"kcal": 4184,
|
||||
"watt-hour": 3600,
|
||||
"wh": 3600,
|
||||
"kilowatt-hour": 3.6e6,
|
||||
"kwh": 3.6e6,
|
||||
}
|
||||
|
||||
self.SPEED_CHART: dict[str, float] = {
|
||||
"mps": 1,
|
||||
"kmph": 0.277778,
|
||||
"mph": 0.44704,
|
||||
"fps": 0.3048,
|
||||
"knot": 0.514444,
|
||||
}
|
||||
|
||||
self.PRESSURE_CHART: dict[str, float] = {
|
||||
"pascal": 1,
|
||||
"Pa": 1,
|
||||
"bar": 100000,
|
||||
"atm": 101325,
|
||||
"torr": 133.322,
|
||||
"mmHg": 133.322,
|
||||
"psi": 6894.76,
|
||||
}
|
||||
|
||||
self.FORCE_CHART: dict[str, float] = {
|
||||
"newton": 1,
|
||||
"N": 1,
|
||||
"kilonewton": 1000,
|
||||
"kN": 1000,
|
||||
"pound-force": 4.44822,
|
||||
"lbf": 4.44822,
|
||||
"dyne": 1e-5,
|
||||
}
|
||||
|
||||
self.POWER_CHART: dict[str, float] = {
|
||||
"watt": 1,
|
||||
"W": 1,
|
||||
"kilowatt": 1000,
|
||||
"kW": 1000,
|
||||
"horsepower": 745.7,
|
||||
"hp": 745.7,
|
||||
"megawatt": 1e6,
|
||||
"MW": 1e6,
|
||||
}
|
||||
|
||||
self.VOLTAGE_CHART: dict[str, float] = {
|
||||
"volt": 1,
|
||||
"V": 1,
|
||||
"millivolt": 1e-3,
|
||||
"mV": 1e-3,
|
||||
"kilovolt": 1000,
|
||||
"kV": 1000,
|
||||
"megavolt": 1e6,
|
||||
"MV": 1e6,
|
||||
}
|
||||
|
||||
self.CURRENT_CHART: dict[str, float] = {
|
||||
"ampere": 1,
|
||||
"A": 1,
|
||||
"milliampere": 1e-3,
|
||||
"mA": 1e-3,
|
||||
"microampere": 1e-6,
|
||||
"μA": 1e-6,
|
||||
}
|
||||
|
||||
self.RESISTANCE_CHART: dict[str, float] = {
|
||||
"ohm": 1,
|
||||
"Ω": 1,
|
||||
"kilohm": 1000,
|
||||
"kΩ": 1000,
|
||||
"megohm": 1e6,
|
||||
"MΩ": 1e6,
|
||||
}
|
||||
|
||||
self.CAPACITANCE_CHART: dict[str, float] = {
|
||||
"farad": 1,
|
||||
"F": 1,
|
||||
"millifarad": 1e-3,
|
||||
"mF": 1e-3,
|
||||
"microfarad": 1e-6,
|
||||
"μF": 1e-6,
|
||||
"nanofarad": 1e-9,
|
||||
"nF": 1e-9,
|
||||
}
|
||||
|
||||
self.INDUCTANCE_CHART: dict[str, float] = {
|
||||
"henry": 1,
|
||||
"H": 1,
|
||||
"millihenry": 1e-3,
|
||||
"mH": 1e-3,
|
||||
"microhenry": 1e-6,
|
||||
"μH": 1e-6,
|
||||
"nanohenry": 1e-9,
|
||||
"nH": 1e-9,
|
||||
}
|
||||
|
||||
self.FREQUENCY_CHART: dict[str, float] = {
|
||||
"hertz": 1,
|
||||
"Hz": 1,
|
||||
"kilohertz": 1e3,
|
||||
"kHz": 1e3,
|
||||
"megahertz": 1e6,
|
||||
"MHz": 1e6,
|
||||
"gigahertz": 1e9,
|
||||
"GHz": 1e9,
|
||||
}
|
||||
|
||||
self.LUMINANCE_CHART: dict[str, float] = {
|
||||
"candela": 1,
|
||||
"cd": 1,
|
||||
"lumen": 1,
|
||||
"lm": 1,
|
||||
"lux": 1,
|
||||
"lx": 1,
|
||||
}
|
||||
|
||||
self.AREA_CHART: dict[str, float] = {
|
||||
"square-meter": 1,
|
||||
"m2": 1,
|
||||
"square-kilometer": 1e6,
|
||||
"km2": 1e6,
|
||||
"hectare": 1e4,
|
||||
"ha": 1e4,
|
||||
"are": 1e2,
|
||||
"a": 1e2,
|
||||
"square-centimeter": 1e-4,
|
||||
"cm2": 1e-4,
|
||||
"square-millimeter": 1e-6,
|
||||
"mm2": 1e-6,
|
||||
}
|
||||
|
||||
# We no longer use currency_converter here.
|
||||
|
||||
|
||||
class Conversion:
|
||||
def __init__(self):
|
||||
self.units = Units()
|
||||
self.currency_cache = _currency_cache
|
||||
|
||||
def convert(self, value: float, from_type: str, to_type: str):
|
||||
"""
|
||||
Generalized conversion function that works with all categories,
|
||||
including currency via floatrates.com.
|
||||
"""
|
||||
# Collection of all non-currency charts
|
||||
charts = {
|
||||
"WEIGHT_CHART": self.units.WEIGHT_CHART,
|
||||
"LENGTH_CHART": self.units.LENGTH_CHART,
|
||||
"TEMPERATURE_CHART": self.units.TEMPERATURE_CHART,
|
||||
"TIME_CHART": self.units.TIME_CHART,
|
||||
"LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART,
|
||||
"STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART,
|
||||
"ANGLE_CHART": self.units.ANGLE_CHART,
|
||||
"ENERGY_CHART": self.units.ENERGY_CHART,
|
||||
"SPEED_CHART": self.units.SPEED_CHART,
|
||||
"PRESSURE_CHART": self.units.PRESSURE_CHART,
|
||||
"FORCE_CHART": self.units.FORCE_CHART,
|
||||
"POWER_CHART": self.units.POWER_CHART,
|
||||
"VOLTAGE_CHART": self.units.VOLTAGE_CHART,
|
||||
"CURRENT_CHART": self.units.CURRENT_CHART,
|
||||
"RESISTANCE_CHART": self.units.RESISTANCE_CHART,
|
||||
"CAPACITANCE_CHART": self.units.CAPACITANCE_CHART,
|
||||
"INDUCTANCE_CHART": self.units.INDUCTANCE_CHART,
|
||||
"FREQUENCY_CHART": self.units.FREQUENCY_CHART,
|
||||
"LUMINANCE_CHART": self.units.LUMINANCE_CHART,
|
||||
"AREA_CHART": self.units.AREA_CHART,
|
||||
}
|
||||
|
||||
# 1) Check if it's in any of the charts (non-currency)
|
||||
for chart_name, chart in charts.items():
|
||||
if from_type in chart and to_type in chart:
|
||||
# Temperatures use lambdas
|
||||
if chart_name == "TEMPERATURE_CHART":
|
||||
if from_type == to_type:
|
||||
return value
|
||||
to_kelvin = chart[from_type][0]
|
||||
from_kelvin = chart[to_type][1]
|
||||
return from_kelvin(to_kelvin(value))
|
||||
|
||||
# Handle WEIGHT_CHART separately (tuple values)
|
||||
if chart_name == "WEIGHT_CHART":
|
||||
if from_type == to_type:
|
||||
return value
|
||||
to_kg = chart[from_type][0]
|
||||
from_kg = chart[to_type][1]
|
||||
return value * to_kg * from_kg
|
||||
|
||||
# Any other numeric chart
|
||||
if from_type == to_type:
|
||||
return value
|
||||
return value * (chart[from_type] / chart[to_type])
|
||||
|
||||
# 2) If both are currency codes (e.g. “USD”, “ARS”)
|
||||
# we assume they are uppercase and have 3 letters.
|
||||
if (
|
||||
len(from_type) == 3
|
||||
and len(to_type) == 3
|
||||
and from_type.isalpha()
|
||||
and to_type.isalpha()
|
||||
):
|
||||
result = self._convert_currency_fast(value, from_type, to_type)
|
||||
if result is not None:
|
||||
return result
|
||||
# Fallback to slow method if fast method fails
|
||||
return self._convert_currency_via_floatrates(value, from_type, to_type)
|
||||
|
||||
# 3) If it doesn't fall into any case, error.
|
||||
raise ValueError(f"Unsupported conversion: {from_type} to {to_type}")
|
||||
|
||||
def _convert_currency_fast(
|
||||
self, value: float, from_code: str, to_code: str
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Fast currency conversion using cached exchange rates.
|
||||
Returns None if rate is not available in cache.
|
||||
"""
|
||||
rate_info = self.currency_cache.get_rate(from_code, to_code)
|
||||
if rate_info is not None:
|
||||
rate, _ = rate_info # cache_age not needed here
|
||||
return value * rate
|
||||
return None
|
||||
|
||||
def _convert_currency_via_floatrates(
|
||||
self, value: float, from_code: str, to_code: str
|
||||
) -> float:
|
||||
"""
|
||||
Converts using the JSON from floatrates.com:
|
||||
- Makes GET to https://www.floatrates.com/daily/{from_lower}.json
|
||||
- Takes the rate from the to_lower key and multiplies.
|
||||
"""
|
||||
from_lower = from_code.lower()
|
||||
to_lower = to_code.lower()
|
||||
|
||||
if from_lower == to_lower:
|
||||
return value
|
||||
|
||||
url = f"https://www.floatrates.com/daily/{from_lower}.json"
|
||||
resp = requests.get(url, timeout=5)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Error getting data from floatrates for {from_code}")
|
||||
|
||||
data = resp.json()
|
||||
if to_lower not in data:
|
||||
raise ValueError(
|
||||
f"Target currency '{to_code}' not found in floatrates response for '{
|
||||
from_code
|
||||
}'"
|
||||
)
|
||||
|
||||
rate = data[to_lower]["rate"]
|
||||
return value * rate
|
||||
|
||||
def parse_input_and_convert(self, input: str):
|
||||
parts = input.split()
|
||||
addition = "s" if parts[-1].endswith("s") else ""
|
||||
|
||||
if "and" in parts: # value unit1 and value2 unit2 _ to target_unit
|
||||
parts.remove("and")
|
||||
if len(parts) != 6:
|
||||
raise ValueError(
|
||||
"Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'"
|
||||
)
|
||||
|
||||
value1, from_type1, value2, from_type2, _, to_type = parts
|
||||
value1, value2 = float(value1), float(value2)
|
||||
from_type1 = self.clean_type(from_type1)
|
||||
from_type2 = self.clean_type(from_type2)
|
||||
to_type = self.clean_type(to_type)
|
||||
|
||||
if from_type1 == from_type2:
|
||||
return (
|
||||
self.convert(value1 + value2, from_type1, to_type),
|
||||
to_type + addition,
|
||||
)
|
||||
else:
|
||||
res = 0
|
||||
res += self.convert(value1, from_type1, to_type)
|
||||
res += self.convert(value2, from_type2, to_type)
|
||||
return res, to_type + addition
|
||||
else:
|
||||
if len(parts) != 4:
|
||||
raise ValueError(
|
||||
"Invalid format. Expected: 'value from_type _ to_type'"
|
||||
)
|
||||
value, from_type, _, to_type = parts
|
||||
value = float(value)
|
||||
from_type = self.clean_type(from_type)
|
||||
to_type = self.clean_type(to_type)
|
||||
return self.convert(value, from_type, to_type), to_type + addition
|
||||
|
||||
def clean_type(self, type: str) -> str:
|
||||
"""
|
||||
If it's currency (3 letters), convert to uppercase.
|
||||
If it ends in 's' (and is not 'celsius'), remove the 's' for
|
||||
other units."""
|
||||
if len(type) == 3 and type.isalpha():
|
||||
return type.upper()
|
||||
if type.endswith("s") and type.lower() != "celsius":
|
||||
# For tables that have singular/plural
|
||||
singular = type[:-1].lower()
|
||||
# If it exists in STORAGE_TYPE_CHART, we use it;
|
||||
# if not, we return singular in lowercase for other charts.
|
||||
if singular in self.units.STORAGE_TYPE_CHART:
|
||||
return singular
|
||||
return singular.lower()
|
||||
return type
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup resources."""
|
||||
self.currency_cache.cleanup()
|
||||
|
||||
def get_currency_cache_info(
|
||||
self, from_code: str, to_code: str
|
||||
) -> Optional[Tuple[bool, float]]:
|
||||
"""
|
||||
Get currency cache information for UI display.
|
||||
Returns (is_fresh, cache_age_seconds) or None if not cached.
|
||||
"""
|
||||
rate_info = self.currency_cache.get_rate(from_code, to_code)
|
||||
if rate_info is not None:
|
||||
_, cache_age = rate_info # rate not needed here
|
||||
is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old
|
||||
return is_fresh, cache_age
|
||||
return None
|
||||
|
||||
|
||||
# Quick usage example:
|
||||
if __name__ == "__main__":
|
||||
conv = Conversion()
|
||||
# Convert 10 USD to ARS:
|
||||
result, suffix = conv.parse_input_and_convert("10 USD _ ARS")
|
||||
print(f"{result:.2f} {suffix}") # Ex: "10 USD _ ARS" -> "38754.23 ARS"
|
||||
@@ -0,0 +1,174 @@
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Threading helper functions
|
||||
|
||||
|
||||
def thread(target, *args, **kwargs) -> threading.Thread:
|
||||
"""
|
||||
Simply run the given function in a thread.
|
||||
The provided args and kwargs will be passed to the function.
|
||||
"""
|
||||
th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
|
||||
th.start()
|
||||
return th
|
||||
|
||||
|
||||
def run_in_thread(func):
|
||||
"""
|
||||
Decorator to run the decorated function in a thread.
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return thread(func, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@run_in_thread
|
||||
def write_json_file(data: Dict, path: str):
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write json: {e}")
|
||||
|
||||
|
||||
def read_json_file(file_path: str) -> Optional[List]:
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"JSON file {file_path} does not exist.")
|
||||
return None
|
||||
|
||||
with open(file_path, "r") as file:
|
||||
try:
|
||||
return json.load(file)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to read JSON file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_wifi_icon_for_strength(strength: int) -> str:
|
||||
"""
|
||||
Get the appropriate WiFi icon based on signal strength.
|
||||
|
||||
Args:
|
||||
strength: Signal strength from 0-100
|
||||
|
||||
Returns:
|
||||
Absolute path to the appropriate WiFi icon
|
||||
"""
|
||||
# Get the current directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Get the project root (parent of utils directory)
|
||||
project_root = os.path.dirname(current_dir)
|
||||
|
||||
if strength >= 80:
|
||||
icon_name = "network-wireless-100.svg"
|
||||
elif strength >= 60:
|
||||
icon_name = "network-wireless-80.svg"
|
||||
elif strength >= 40:
|
||||
icon_name = "network-wireless-60.svg"
|
||||
elif strength >= 20:
|
||||
icon_name = "network-wireless-40.svg"
|
||||
elif strength > 0:
|
||||
icon_name = "network-wireless-20.svg"
|
||||
else:
|
||||
icon_name = "network-wireless-0.svg"
|
||||
|
||||
return os.path.join(project_root, "config", "assets", "icons", "wifi", icon_name)
|
||||
|
||||
|
||||
def get_wifi_connecting_icon() -> str:
|
||||
"""
|
||||
Get the WiFi connecting icon path.
|
||||
|
||||
Returns:
|
||||
Absolute path to the WiFi connecting icon
|
||||
"""
|
||||
# Get the current directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Get the project root (parent of utils directory)
|
||||
project_root = os.path.dirname(current_dir)
|
||||
|
||||
return os.path.join(project_root, "config", "assets", "icons", "wifi", "wifi-connecting.svg")
|
||||
|
||||
|
||||
def is_special_workspace_id(ws_id) -> bool:
|
||||
"""
|
||||
Check if a workspace ID represents a special workspace.
|
||||
|
||||
Args:
|
||||
ws_id: Workspace ID (can be int, string, or other types)
|
||||
|
||||
Returns:
|
||||
True if the workspace is special, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Convert to int if it's a string
|
||||
workspace_id = int(ws_id)
|
||||
# Special workspaces have negative IDs
|
||||
return workspace_id < 0
|
||||
except (ValueError, TypeError):
|
||||
# If it's a string, check if it starts with "special:"
|
||||
if isinstance(ws_id, str) and ws_id.startswith("special:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_special_workspace(client: dict) -> bool:
|
||||
"""
|
||||
Check if a client is in a special workspace.
|
||||
|
||||
Args:
|
||||
client: Client data dictionary from Hyprland
|
||||
|
||||
Returns:
|
||||
True if the client is in a special workspace, False otherwise
|
||||
"""
|
||||
if "workspace" not in client:
|
||||
return False
|
||||
|
||||
workspace = client["workspace"]
|
||||
|
||||
# Check workspace name first
|
||||
if "name" in workspace:
|
||||
workspace_name = str(workspace["name"])
|
||||
# Special workspaces typically start with "special:" or have negative IDs
|
||||
if workspace_name.startswith("special:"):
|
||||
return True
|
||||
|
||||
# Check workspace ID
|
||||
if "id" in workspace:
|
||||
workspace_id = workspace["id"]
|
||||
# Special workspaces have negative IDs
|
||||
if workspace_id < 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def escape_markup_text(text: str) -> str:
|
||||
"""
|
||||
Escape special characters in text to make it safe for Pango markup.
|
||||
|
||||
Args:
|
||||
text: Raw text that may contain special characters
|
||||
|
||||
Returns:
|
||||
Escaped text safe for use in Pango markup
|
||||
"""
|
||||
if not text or not isinstance(text, str):
|
||||
return ""
|
||||
|
||||
# Use html.escape to escape XML/HTML special characters
|
||||
# This handles &, <, >, and quotes
|
||||
return html.escape(text)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GLib, Gtk
|
||||
from loguru import logger
|
||||
|
||||
import config.data as data
|
||||
|
||||
ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json"
|
||||
if not os.path.exists(data.CACHE_DIR):
|
||||
os.makedirs(data.CACHE_DIR)
|
||||
|
||||
|
||||
class IconResolver:
|
||||
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:
|
||||
self._icon_dict = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
logger.info("[ICONS] Cache file does not exist or is corrupted")
|
||||
self._icon_dict = {}
|
||||
else:
|
||||
self._icon_dict = {}
|
||||
|
||||
self.default_applicaiton_icon = default_applicaiton_icon
|
||||
|
||||
def get_icon_name(self, app_id: str):
|
||||
if app_id in self._icon_dict:
|
||||
return self._icon_dict[app_id]
|
||||
new_icon = self._compositor_find_icon(app_id)
|
||||
logger.info(
|
||||
f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..."
|
||||
)
|
||||
self._store_new_icon(app_id, new_icon)
|
||||
return new_icon
|
||||
|
||||
def get_icon_pixbuf(self, app_id: str, size: int = 16):
|
||||
icon_theme = Gtk.IconTheme.get_default()
|
||||
icon_name = self.get_icon_name(app_id)
|
||||
try:
|
||||
# Try to load the resolved icon.
|
||||
return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE)
|
||||
except GLib.Error as primary_error:
|
||||
logger.warning(
|
||||
f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}"
|
||||
)
|
||||
try:
|
||||
# Fallback to the default application icon.
|
||||
return icon_theme.load_icon(
|
||||
self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE
|
||||
)
|
||||
except GLib.Error as fallback_error:
|
||||
logger.error(
|
||||
f"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _store_new_icon(self, app_id: str, icon: str):
|
||||
self._icon_dict[app_id] = icon
|
||||
with open(ICON_CACHE_FILE, "w") as f:
|
||||
json.dump(self._icon_dict, f)
|
||||
|
||||
def _get_icon_from_desktop_file(self, desktop_file_path: str):
|
||||
# Retrieve the icon specified in the [Desktop Entry] section.
|
||||
with open(desktop_file_path) as f:
|
||||
for line in f.readlines():
|
||||
if "Icon=" in line:
|
||||
return "".join(line[5:].split())
|
||||
return self.default_applicaiton_icon
|
||||
|
||||
def _get_desktop_file(self, app_id: str) -> str | None:
|
||||
data_dirs = GLib.get_system_data_dirs()
|
||||
for data_dir in data_dirs:
|
||||
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()
|
||||
]
|
||||
if matching:
|
||||
return os.path.join(data_dir, matching[0])
|
||||
for word in list(filter(None, re.split(r"-|\.|_|\s", app_id))):
|
||||
matching = [s for s in files if word.lower() in s.lower()]
|
||||
if matching:
|
||||
return os.path.join(data_dir, matching[0])
|
||||
return None
|
||||
|
||||
def _compositor_find_icon(self, app_id: str):
|
||||
icon_theme = Gtk.IconTheme.get_default()
|
||||
if icon_theme.has_icon(app_id):
|
||||
return app_id
|
||||
if icon_theme.has_icon(app_id + "-desktop"):
|
||||
return app_id + "-desktop"
|
||||
desktop_file = self._get_desktop_file(app_id)
|
||||
return (
|
||||
self._get_icon_from_desktop_file(desktop_file)
|
||||
if desktop_file
|
||||
else self.default_applicaiton_icon
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
# From https://github.com/stwa/wayland-idle-inhibitor
|
||||
# License: WTFPL Version 2
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
from threading import Event, Timer
|
||||
|
||||
import setproctitle
|
||||
from pywayland.client.display import Display
|
||||
from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (
|
||||
ZwpIdleInhibitManagerV1,
|
||||
)
|
||||
from pywayland.protocol.wayland.wl_compositor import WlCompositor
|
||||
from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
|
||||
from pywayland.protocol.wayland.wl_surface import WlSurface
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobalRegistry:
|
||||
surface: WlSurface | None = None
|
||||
inhibit_manager: ZwpIdleInhibitManagerV1 | None = None
|
||||
|
||||
|
||||
def parse_duration(duration_str: str) -> int:
|
||||
"""Parse duration string into seconds.
|
||||
Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off'
|
||||
"""
|
||||
try:
|
||||
if duration_str.lower() in ["on", "off"]:
|
||||
return 0
|
||||
elif duration_str.endswith("h"):
|
||||
return int(float(duration_str[:-1]) * 3600)
|
||||
elif duration_str.endswith("m"):
|
||||
return int(float(duration_str[:-1]) * 60)
|
||||
elif duration_str.endswith("s"):
|
||||
return int(float(duration_str[:-1]))
|
||||
else:
|
||||
return int(duration_str)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
"Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc."
|
||||
)
|
||||
|
||||
|
||||
def kill_existing_inhibit_processes():
|
||||
"""Kill any existing modus-inhibit processes."""
|
||||
try:
|
||||
# Find all modus-inhibit processes except the current one
|
||||
output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True)
|
||||
pids = output.strip().split("\n")
|
||||
current_pid = str(os.getpid())
|
||||
|
||||
killed_count = 0
|
||||
for pid in pids:
|
||||
pid = pid.strip()
|
||||
if pid and pid != current_pid:
|
||||
try:
|
||||
subprocess.run(["kill", pid], check=True)
|
||||
killed_count += 1
|
||||
except subprocess.CalledProcessError:
|
||||
pass # Process might have already exited
|
||||
|
||||
if killed_count > 0:
|
||||
print(f"Stopped {killed_count} existing inhibit process(es)")
|
||||
else:
|
||||
print("No existing inhibit processes were running")
|
||||
return killed_count
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
# No processes found
|
||||
print("No existing inhibit processes were running")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error stopping existing processes: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def handle_registry_global(
|
||||
wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int
|
||||
) -> None:
|
||||
global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()
|
||||
|
||||
if iface_name == "wl_compositor":
|
||||
compositor = wl_registry.bind(id_num, WlCompositor, version)
|
||||
global_registry.surface = compositor.create_surface() # type: ignore
|
||||
elif iface_name == "zwp_idle_inhibit_manager_v1":
|
||||
global_registry.inhibit_manager = wl_registry.bind(
|
||||
id_num, ZwpIdleInhibitManagerV1, version
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inhibit system idle for a specified duration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"duration",
|
||||
nargs="?",
|
||||
default="0",
|
||||
help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle "off" argument - kill existing processes and exit
|
||||
if args.duration.lower() == "off":
|
||||
kill_existing_inhibit_processes()
|
||||
sys.exit(0)
|
||||
|
||||
done = Event()
|
||||
signal(SIGINT, lambda _, __: done.set())
|
||||
signal(SIGTERM, lambda _, __: done.set())
|
||||
|
||||
global_registry = GlobalRegistry()
|
||||
|
||||
try:
|
||||
display = Display()
|
||||
display.connect()
|
||||
|
||||
registry = display.get_registry() # type: ignore
|
||||
registry.user_data = global_registry
|
||||
registry.dispatcher["global"] = handle_registry_global
|
||||
|
||||
def shutdown() -> None:
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
display.disconnect()
|
||||
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
|
||||
if global_registry.surface is None:
|
||||
print("Error: Failed to create Wayland surface.")
|
||||
shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
if global_registry.inhibit_manager is None:
|
||||
print("Error: Your Wayland compositor does not support idle inhibition.")
|
||||
print("This usually means either:")
|
||||
print(
|
||||
"1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol"
|
||||
)
|
||||
print("2. The protocol is not enabled in your compositor")
|
||||
print("\nFor Hyprland, you can enable it by adding to your config:")
|
||||
print("misc:disable_autoreload = true")
|
||||
print("misc:enable_wayland_protocols = true")
|
||||
shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
|
||||
global_registry.surface
|
||||
)
|
||||
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
|
||||
duration = parse_duration(args.duration)
|
||||
if duration > 0:
|
||||
print(f"Inhibiting idle for {args.duration}...")
|
||||
# Set up timer to release inhibition
|
||||
timer = Timer(duration, lambda: done.set())
|
||||
timer.start()
|
||||
else:
|
||||
print("Inhibiting idle indefinitely...")
|
||||
|
||||
done.wait()
|
||||
print("Shutting down...")
|
||||
|
||||
inhibitor.destroy()
|
||||
if duration > 0:
|
||||
timer.cancel()
|
||||
|
||||
shutdown()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
print("Make sure you're running this under a Wayland session.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
setproctitle.setproctitle("modus-inhibit")
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import warnings
|
||||
from typing import Dict
|
||||
import time
|
||||
from fabric.hyprland import Hyprland
|
||||
from gi.repository import Gdk
|
||||
from functools import lru_cache
|
||||
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
|
||||
def ttl_lru_cache(seconds_to_live: int, maxsize: int = 128):
|
||||
def wrapper(func):
|
||||
@lru_cache(maxsize)
|
||||
def inner(__ttl, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return lambda *args, **kwargs: inner(
|
||||
time.time() // seconds_to_live, *args, **kwargs
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class HyprlandWithMonitors(Hyprland):
|
||||
"""A Hyprland class with additional monitor common."""
|
||||
|
||||
instance = None
|
||||
|
||||
@staticmethod
|
||||
def get_default():
|
||||
if HyprlandWithMonitors.instance is None:
|
||||
HyprlandWithMonitors.instance = HyprlandWithMonitors()
|
||||
|
||||
return HyprlandWithMonitors.instance
|
||||
|
||||
def __init__(self, commands_only: bool = False, **kwargs):
|
||||
self.display: Gdk.Display = Gdk.Display.get_default()
|
||||
super().__init__(commands_only, **kwargs)
|
||||
|
||||
@ttl_lru_cache(100, 5)
|
||||
def get_all_monitors(self) -> Dict:
|
||||
monitors = json.loads(self.send_command("j/monitors").reply)
|
||||
return {monitor["id"]: monitor["name"] for monitor in monitors}
|
||||
|
||||
def get_gdk_monitor_id_from_name(self, plug_name: str) -> int | None:
|
||||
for i in range(self.display.get_n_monitors()):
|
||||
if self.display.get_default_screen().get_monitor_plug_name(i) == plug_name:
|
||||
return i
|
||||
return None
|
||||
|
||||
def get_gdk_monitor_id(self, hyprland_id: int) -> int | None:
|
||||
monitors = self.get_all_monitors()
|
||||
if hyprland_id in monitors:
|
||||
return self.get_gdk_monitor_id_from_name(monitors[hyprland_id])
|
||||
return None
|
||||
|
||||
def get_current_gdk_monitor_id(self) -> int | None:
|
||||
active_workspace = json.loads(self.send_command("j/activeworkspace").reply)
|
||||
return self.get_gdk_monitor_id_from_name(active_workspace["monitor"])
|
||||
@@ -0,0 +1,140 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import config.data as data
|
||||
|
||||
|
||||
def get_current_workspace():
|
||||
"""
|
||||
Get the current workspace ID using hyprctl.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "activeworkspace"], capture_output=True, text=True
|
||||
)
|
||||
# Assume the output similar to: "ID <number>"
|
||||
# Extracting the number from the output
|
||||
parts = result.stdout.split()
|
||||
for i, part in enumerate(parts):
|
||||
if part == "ID" and i + 1 < len(parts):
|
||||
return int(parts[i + 1])
|
||||
except Exception as e:
|
||||
print(f"Error getting current workspace: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
def get_screen_dimensions():
|
||||
"""
|
||||
Get screen dimensions from hyprctl.
|
||||
|
||||
Returns:
|
||||
tuple: (width, height) of the monitor containing the current workspace
|
||||
"""
|
||||
try:
|
||||
# Get current workspace
|
||||
workspace_id = get_current_workspace()
|
||||
|
||||
# Get monitor information
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "monitors"], capture_output=True, text=True
|
||||
)
|
||||
monitors = json.loads(result.stdout)
|
||||
|
||||
# Find the monitor containing our workspace
|
||||
for monitor in monitors:
|
||||
if monitor.get("activeWorkspace", {}).get("id") == workspace_id:
|
||||
return monitor.get("width", data.CURRENT_WIDTH), monitor.get(
|
||||
"height", data.CURRENT_HEIGHT
|
||||
)
|
||||
|
||||
# Fallback to first monitor
|
||||
if monitors:
|
||||
return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get(
|
||||
"height", data.CURRENT_HEIGHT
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error getting screen dimensions: {e}")
|
||||
|
||||
# Default fallback values
|
||||
return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
|
||||
|
||||
|
||||
def check_occlusion(occlusion_region, workspace=None):
|
||||
"""
|
||||
Check if a region is occupied by any window on a given workspace.
|
||||
|
||||
Parameters:
|
||||
occlusion_region: Can be one of:
|
||||
- tuple (side, size): where side is "top", "bottom", "left", or "right"
|
||||
and size is the pixel width of the region
|
||||
- tuple (x, y, width, height): The full region coordinates (legacy format)
|
||||
workspace (int, optional): The workspace ID to check. If None, the current workspace is used.
|
||||
|
||||
Returns:
|
||||
bool: True if any window overlaps with the occlusion region, False otherwise.
|
||||
"""
|
||||
if workspace is None:
|
||||
workspace = get_current_workspace()
|
||||
|
||||
# Handle simplified side-based format
|
||||
if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:
|
||||
side, size = occlusion_region
|
||||
if isinstance(side, str):
|
||||
# Convert side-based format to coordinates
|
||||
screen_width, screen_height = get_screen_dimensions()
|
||||
|
||||
if side.lower() == "bottom":
|
||||
occlusion_region = (0, screen_height - size, screen_width, size)
|
||||
elif side.lower() == "top":
|
||||
occlusion_region = (0, 0, screen_width, size)
|
||||
elif side.lower() == "left":
|
||||
occlusion_region = (0, 0, size, screen_height)
|
||||
elif side.lower() == "right":
|
||||
occlusion_region = (screen_width - size, 0, size, screen_height)
|
||||
|
||||
# Ensure occlusion_region is in the correct format (x, y, width, height)
|
||||
if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
|
||||
print(f"Invalid occlusion region format: {occlusion_region}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "clients"], capture_output=True, text=True
|
||||
)
|
||||
clients = json.loads(result.stdout)
|
||||
except Exception as e:
|
||||
print(f"Error retrieving client windows: {e}")
|
||||
return False
|
||||
|
||||
occ_x, occ_y, occ_width, occ_height = occlusion_region
|
||||
occ_x2 = occ_x + occ_width
|
||||
occ_y2 = occ_y + occ_height
|
||||
|
||||
for client in clients:
|
||||
# Check if client is mapped
|
||||
if not client.get("mapped", False):
|
||||
continue
|
||||
|
||||
# Ensure client has proper workspace information and matches the workspace
|
||||
client_workspace = client.get("workspace", {})
|
||||
if client_workspace.get("id") != workspace:
|
||||
continue
|
||||
|
||||
# Ensure client has position and size info
|
||||
position = client.get("at")
|
||||
size = client.get("size")
|
||||
if not position or not size:
|
||||
continue
|
||||
|
||||
x, y = position
|
||||
width, height = size
|
||||
win_x1, win_y1 = x, y
|
||||
win_x2, win_y2 = x + width, y + height
|
||||
|
||||
# Check for intersection between the window and occlusion region
|
||||
if not (
|
||||
win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2
|
||||
):
|
||||
return True # Occlusion region is occupied
|
||||
|
||||
return False # No window overlaps the occlusion region
|
||||
@@ -0,0 +1,41 @@
|
||||
from loguru import logger
|
||||
|
||||
from fabric.audio import Audio
|
||||
|
||||
from services.modus import ModusService, notification_service as notification_service_instance
|
||||
|
||||
global modus_service
|
||||
try:
|
||||
modus_service = ModusService()
|
||||
except Exception as e:
|
||||
logger.error(f"[Main] Failed to create ModusService: {e}")
|
||||
modus_service = None
|
||||
|
||||
if modus_service is None:
|
||||
logger.warning(
|
||||
"[Main] ModusService was not initialized. Functionality may be limited."
|
||||
)
|
||||
|
||||
global notification_service
|
||||
try:
|
||||
notification_service = notification_service_instance
|
||||
except Exception as e:
|
||||
logger.error(f"[Main] Failed to create NotificationService: {e}")
|
||||
notification_service = None
|
||||
|
||||
if notification_service is None:
|
||||
logger.warning(
|
||||
"[Main] NotificationService was not initialized. Notifications may not work."
|
||||
)
|
||||
|
||||
global audio_service
|
||||
try:
|
||||
audio_service = Audio()
|
||||
except Exception as e:
|
||||
logger.error(f"[Main] Failed to create AudioService: {e}")
|
||||
audio_service = None
|
||||
|
||||
if audio_service is None:
|
||||
logger.warning(
|
||||
"[Main] AudioService was not initialized. Audio features may not work."
|
||||
)
|
||||
Reference in new issue
Block a user