This commit is contained in:
2026-06-03 21:32:45 +02:00
parent f2328ff319
commit 1e869b49c7
126 changed files with 41986 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
"""
Ax-Shell utilities package.
Contains helper functions and utility classes.
"""
+180
View File
@@ -0,0 +1,180 @@
from typing import cast
import fabric
from fabric import Property, Service, Signal
from gi.repository import GLib, Gtk
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()
+120
View File
@@ -0,0 +1,120 @@
"""
Utility functions for running subprocess operations asynchronously without blocking the UI.
This module provides helper functions to prevent UI freezes when executing external processes.
"""
import subprocess
from typing import Callable, List, Optional, Union
from gi.repository import GLib
def run_async_subprocess(
command: Union[str, List[str]],
on_success: Optional[Callable] = None,
on_error: Optional[Callable[[Exception], None]] = None,
on_complete: Optional[Callable[[], None]] = None,
thread_name: str = "async-subprocess"
) -> None:
"""
Run a subprocess command asynchronously in a background thread.
Args:
command: Command to execute (string or list of strings)
on_success: Callback function to call on successful completion
on_error: Callback function to call when an error occurs (receives exception)
on_complete: Callback function to call when operation completes (success or error)
thread_name: Name for the background thread
"""
def worker_thread(user_data):
"""Background thread worker function"""
try:
if isinstance(command, str):
subprocess.run(command, shell=True, check=True)
else:
subprocess.run(command, check=True)
# Schedule success callback on main thread
if on_success:
GLib.idle_add(lambda: (on_success(), False))
except Exception as e:
# Schedule error callback on main thread
if on_error:
GLib.idle_add(lambda: (on_error(e), False))
finally:
# Schedule completion callback on main thread
if on_complete:
GLib.idle_add(lambda: (on_complete(), False))
GLib.Thread.new(thread_name, worker_thread, None)
def check_process_async(
process_name: str,
on_running: Optional[Callable[[], None]] = None,
on_not_running: Optional[Callable[[], None]] = None,
on_error: Optional[Callable[[Exception], None]] = None,
thread_name: str = "check-process"
) -> None:
"""
Check if a process is running asynchronously.
Args:
process_name: Name of the process to check (used with pgrep)
on_running: Callback function to call if process is running
on_not_running: Callback function to call if process is not running
on_error: Callback function to call when an error occurs
thread_name: Name for the background thread
"""
def worker_thread(user_data):
"""Background thread worker function"""
try:
subprocess.check_output(["pgrep", process_name])
# Process is running
if on_running:
GLib.idle_add(lambda: (on_running(), False))
except subprocess.CalledProcessError:
# Process is not running
if on_not_running:
GLib.idle_add(lambda: (on_not_running(), False))
except Exception as e:
# Other error occurred
if on_error:
GLib.idle_add(lambda: (on_error(e), False))
GLib.Thread.new(thread_name, worker_thread, None)
def run_command_with_output_async(
command: Union[str, List[str]],
on_success: Optional[Callable[[bytes], None]] = None,
on_error: Optional[Callable[[Exception], None]] = None,
thread_name: str = "command-output"
) -> None:
"""
Run a command and capture its output asynchronously.
Args:
command: Command to execute (string or list of strings)
on_success: Callback function to call with command output on success
on_error: Callback function to call when an error occurs
thread_name: Name for the background thread
"""
def worker_thread(user_data):
"""Background thread worker function"""
try:
if isinstance(command, str):
result = subprocess.run(command, shell=True, capture_output=True, check=True)
else:
result = subprocess.run(command, capture_output=True, check=True)
# Schedule success callback with output on main thread
if on_success:
GLib.idle_add(lambda: (on_success(result.stdout), False))
except Exception as e:
# Schedule error callback on main thread
if on_error:
GLib.idle_add(lambda: (on_error(e), False))
GLib.Thread.new(thread_name, worker_thread, None)
+14
View File
@@ -0,0 +1,14 @@
class Colors:
"""Class to define colors for terminal output"""
# Reference: https://stackoverflow.com/questions/287871/print-in-terminal-with-colors-using-python
HEADER = "\033[95m"
INFO = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
ERROR = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
RESET = "\033[0m"
+449
View File
@@ -0,0 +1,449 @@
import requests
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,
"": 1000,
"megohm": 1e6,
"": 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,
}
# Ya no usamos currency_converter aquí.
class Conversion():
def __init__(self):
self.units = Units()
def convert(self, value: float, from_type: str, to_type: str):
"""
Generalized conversion function que funciona con todas las categorías,
incluyendo moneda via floatrates.com.
"""
# Colección de todos los charts no-monedas
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) Revisar si está en alguno de los charts (no monedas)
for chart_name, chart in charts.items():
if from_type in chart and to_type in chart:
# Temperaturas usan 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
# Cualquier otro chart numérico
if from_type == to_type:
return value
return value * (chart[from_type] / chart[to_type])
# 2) Si ambos son códigos de moneda (p. ej. “USD”, “ARS”)
# asumimos que están en mayúsculas y tienen 3 letras.
if len(from_type) == 3 and len(to_type) == 3 and from_type.isalpha() and to_type.isalpha():
return self._convert_currency_via_floatrates(value, from_type, to_type)
# 3) Si no cae en ningún caso, error.
raise ValueError(f"Unsupported conversion: {from_type} to {to_type}")
def _convert_currency_via_floatrates(self, value: float, from_code: str, to_code: str) -> float:
"""
Convierte usando el JSON de floatrates.com:
- Hace GET a https://www.floatrates.com/daily/{from_lower}.json
- Toma el rate de la clave to_lower y multiplica.
"""
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 al obtener datos de floatrates para {from_code}")
data = resp.json()
if to_lower not in data:
raise ValueError(f"Moneda destino '{to_code}' no encontrada en la respuesta de floatrates para '{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: # valor unidad1 and valor2 unidad2 _ a unidad_destino
parts.remove("and")
if len(parts) != 6:
raise ValueError("Formato inválido. Esperado: '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("Formato inválido. Esperado: '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:
"""
Si es moneda (3 letras), lo pasa a mayúsculas.
Si termina en 's' (y no es 'celsius'), le quita la 's' para
las otras unidades. """
if len(type) == 3 and type.isalpha():
return type.upper()
if type.endswith("s") and type.lower() != "celsius":
# Para las tablas que tienen singular/plural
singular = type[:-1].lower()
# Si existe en STORAGE_TYPE_CHART, lo usamos;
# si no, devolvemos singular en minúsculas para otros charts.
if singular in self.units.STORAGE_TYPE_CHART:
return singular
return singular.lower()
return type
# Ejemplo rápido de uso:
if __name__ == "__main__":
conv = Conversion()
# Convierte 10 USD a ARS:
result, suffix = conv.parse_input_and_convert("10 USD _ ARS")
print(f"{result:.2f} {suffix}") # Ej: "10 USD _ ARS" -> "38754.23 ARS"
+235
View File
@@ -0,0 +1,235 @@
import datetime
import os
import shutil
import subprocess
from typing import Dict, List, Literal
import gi
import psutil
from fabric.utils import exec_shell_command, exec_shell_command_async, get_relative_path
from gi.repository import Gdk, GLib, Gtk
from loguru import logger
from .colors import Colors
from .icons import distro_text_icons
gi.require_version("Gtk", "3.0")
class ExecutableNotFoundError(ImportError):
"""Raised when an executable is not found."""
def __init__(self, executable_name: str):
super().__init__(
f"{Colors.ERROR}Executable {Colors.UNDERLINE}{executable_name}{Colors.RESET} not found. Please install it using your package manager." # noqa: E501
)
# Function to escape the markup
def parse_markup(text):
return text
# support for multiple monitors
def for_monitors(widget):
n = Gdk.Display.get_default().get_n_monitors() if Gdk.Display.get_default() else 1
return [widget(i) for i in range(n)]
# Function to get the system icon theme
def copy_theme(theme: str):
destination_file = get_relative_path("../styles/theme.scss")
source_file = get_relative_path(f"../styles/themes/{theme}.scss")
if not os.path.exists(source_file):
logger.warning(
f"{Colors.WARNING}Warning: The theme file '{theme}.scss' was not found. Using default theme." # noqa: E501
)
source_file = get_relative_path("../styles/themes/catpuccin-mocha.scss")
try:
with open(source_file, "r") as source_file:
content = source_file.read()
# Open the destination file in write mode
with open(destination_file, "w") as destination_file:
destination_file.write(content)
logger.info(f"{Colors.INFO}[THEME] '{theme}' applied successfully.")
except FileNotFoundError:
logger.error(
f"{Colors.ERROR}Error: The theme file '{source_file}' was not found."
)
exit(1)
# Merge the parsed data with the default configuration
def merge_defaults(data: dict, defaults: dict):
return {**defaults, **data}
# Validate the widgets
def validate_widgets(parsed_data, default_config):
layout = parsed_data["layout"]
for section in layout:
for widget in layout[section]:
if widget not in default_config:
raise ValueError(
f"Invalid widget {widget} found in section {section}. Please check the widget name." # noqa: E501
)
# Function to exclude keys from a dictionary )
def exclude_keys(d: Dict, keys_to_exclude: List[str]) -> Dict:
return {k: v for k, v in d.items() if k not in keys_to_exclude}
# Function to format time in hours and minutes
def format_time(secs: int):
mm, _ = divmod(secs, 60)
hh, mm = divmod(mm, 60)
return "%d h %02d min" % (hh, mm)
# Function to convert bytes to kilobytes, megabytes, or gigabytes
def convert_bytes(bytes: int, to: Literal["kb", "mb", "gb"], format_spec=".1f"):
multiplier = 1
if to == "mb":
multiplier = 2
elif to == "gb":
multiplier = 3
return f"{format(bytes / (1024**multiplier), format_spec)}{to.upper()}"
# Function to get the system uptime
def uptime():
boot_time = psutil.boot_time()
now = datetime.datetime.now()
diff = now.timestamp() - boot_time
# Convert the difference in seconds to hours and minutes
hours, remainder = divmod(diff, 3600)
minutes, _ = divmod(remainder, 60)
return f"{int(hours):02}:{int(minutes):02}"
# Function to convert seconds to milliseconds
def convert_seconds_to_milliseconds(seconds: int):
return seconds * 1000
# Function to check if an icon exists, otherwise use a fallback icon
def check_icon_exists(icon_name: str, fallback_icon: str) -> str:
if Gtk.IconTheme.get_default().has_icon(icon_name):
return icon_name
return fallback_icon
# Function to execute a shell command asynchronously
def play_sound(file: str):
exec_shell_command_async(f"play {file}", None)
# Function to get the distro icon
def get_distro_icon():
distro_id = GLib.get_os_info("ID")
# Search for the icon in the list
return distro_text_icons.get(distro_id, "")
# Function to check if an executable exists
def executable_exists(executable_name):
executable_path = shutil.which(executable_name)
return bool(executable_path)
def send_notification(
title: str,
body: str,
urgency: Literal["low", "normal", "critical"],
icon=None,
app_name="Application",
timeout=None,
):
"""
Sends a notification using the notify-send command.
:param title: The title of the notification
:param body: The message body of the notification
:param urgency: The urgency of the notification ('low', 'normal', 'critical')
:param icon: Optional icon for the notification
:param app_name: The application name that is sending the notification
:param timeout: Optional timeout in milliseconds (e.g., 5000 for 5 seconds)
"""
# Base command
command = [
"notify-send",
"--urgency",
urgency,
"--app-name",
app_name,
title,
body,
]
# Add icon if provided
if icon:
command.extend(["--icon", icon])
if timeout is not None:
command.extend(["-t", str(timeout)])
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as e:
print(f"Failed to send notification: {e}")
# Function to get the relative time
def get_relative_time(mins: int) -> str:
# Seconds
if mins == 0:
return "now"
# Minutes
if mins < 60:
return f"{mins} minute{'s' if mins > 1 else ''} ago"
# Hours
if mins < 1440:
hours = mins // 60
return f"{hours} hour{'s' if hours > 1 else ''} ago"
# Days
days = mins // 1440
return f"{days} day{'s' if days > 1 else ''} ago"
# Function to get the percentage of a value
def convert_to_percent(
current: int | float, max: int | float, is_int=True
) -> int | float:
if is_int:
return int((current / max) * 100)
else:
return (current / max) * 100
# Function to ensure the directory exists
def ensure_dir_exists(path: str):
if not os.path.exists(path):
os.makedirs(path)
# Function to unique list
def unique_list(lst) -> List:
return list(set(lst))
# Function to check if an app is running
def is_app_running(app_name: str) -> bool:
return len(exec_shell_command(f"pidof {app_name}")) != 0
+254
View File
@@ -0,0 +1,254 @@
from typing import Optional
class GlobalKeybindHandler:
"""
Handler for global keybinds that redirects commands to the focused monitor.
This class provides methods to open notch modules, access widgets, and
perform other actions on the currently focused monitor.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if hasattr(self, '_initialized'):
return
self._initialized = True
self._monitor_manager = None
def set_monitor_manager(self, monitor_manager):
"""Set the monitor manager reference."""
self._monitor_manager = monitor_manager
def open_notch_module(self, module_name: str) -> bool:
"""
Open a notch module on the currently focused monitor.
Args:
module_name: Name of the module to open
Returns:
True if successful, False otherwise
"""
if not self._monitor_manager:
return False
focused_monitor_id = self._monitor_manager.get_focused_monitor_id()
# Close any open notches on other monitors
self._monitor_manager.close_all_notches_except(focused_monitor_id)
# Get notch instance for focused monitor
notch = self._monitor_manager.get_focused_instance('notch')
if notch and hasattr(notch, 'open_module'):
try:
notch.open_module(module_name)
self._monitor_manager.set_notch_state(focused_monitor_id, True, module_name)
return True
except Exception as e:
print(f"GlobalKeybindHandler: Error opening module '{module_name}': {e}")
return False
def toggle_notch(self) -> bool:
"""
Toggle notch on the currently focused monitor.
Returns:
True if successful, False otherwise
"""
if not self._monitor_manager:
return False
focused_monitor_id = self._monitor_manager.get_focused_monitor_id()
is_open = self._monitor_manager.is_notch_open(focused_monitor_id)
notch = self._monitor_manager.get_focused_instance('notch')
if notch:
try:
if is_open:
if hasattr(notch, 'close'):
notch.close()
self._monitor_manager.set_notch_state(focused_monitor_id, False)
else:
if hasattr(notch, 'open'):
notch.open()
self._monitor_manager.set_notch_state(focused_monitor_id, True)
return True
except Exception as e:
print(f"GlobalKeybindHandler: Error toggling notch: {e}")
return False
def get_dashboard_wallpapers_widget(self):
"""
Get the dashboard wallpapers widget from the focused monitor.
Returns:
Wallpapers widget instance or None
"""
if not self._monitor_manager:
return None
notch = self._monitor_manager.get_focused_instance('notch')
if notch and hasattr(notch, 'dashboard'):
dashboard = notch.dashboard
if hasattr(dashboard, 'widgets') and hasattr(dashboard.widgets, 'wallpapers'):
return dashboard.widgets.wallpapers
return None
def get_dashboard_widget(self, widget_name: str):
"""
Get a specific dashboard widget from the focused monitor.
Args:
widget_name: Name of the widget to get
Returns:
Widget instance or None
"""
if not self._monitor_manager:
return None
notch = self._monitor_manager.get_focused_instance('notch')
if notch and hasattr(notch, 'dashboard'):
dashboard = notch.dashboard
if hasattr(dashboard, 'widgets'):
return getattr(dashboard.widgets, widget_name, None)
return None
def open_launcher(self) -> bool:
"""Open launcher on focused monitor."""
return self.open_notch_module('launcher')
def open_overview(self) -> bool:
"""Open overview on focused monitor."""
return self.open_notch_module('overview')
def open_dashboard(self) -> bool:
"""Open dashboard on focused monitor."""
return self.open_notch_module('dashboard')
def open_power_menu(self) -> bool:
"""Open power menu on focused monitor."""
return self.open_notch_module('power')
def open_toolbox(self) -> bool:
"""Open toolbox on focused monitor."""
return self.open_notch_module('tools')
def open_emoji_picker(self) -> bool:
"""Open emoji picker on focused monitor."""
return self.open_notch_module('emoji')
def open_clipboard_history(self) -> bool:
"""Open clipboard history on focused monitor."""
return self.open_notch_module('cliphist')
def get_focused_monitor_info(self) -> Optional[dict]:
"""
Get information about the currently focused monitor.
Returns:
Monitor info dict or None
"""
if not self._monitor_manager:
return None
return self._monitor_manager.get_focused_monitor()
def get_all_monitors_info(self) -> list:
"""
Get information about all monitors.
Returns:
List of monitor info dicts
"""
if not self._monitor_manager:
return []
return self._monitor_manager.get_monitors()
def toggle_bar(self) -> bool:
"""
Toggle bar visibility and force notch/dock to occlusion mode.
Returns:
True if successful, False otherwise
"""
if not self._monitor_manager:
return False
monitors = self._monitor_manager.get_monitors()
for monitor in monitors:
bar = self._monitor_manager.get_instance(monitor['id'], 'bar')
notch = self._monitor_manager.get_instance(monitor['id'], 'notch')
if bar and notch:
try:
current_visibility = bar.get_visible()
bar.set_visible(not current_visibility)
if not current_visibility:
# Bar is being shown - restore from occlusion
notch.restore_from_occlusion()
# Also restore docks on all monitors
try:
from modules.dock import Dock
for dock_instance in Dock._instances:
if hasattr(dock_instance, 'restore_from_occlusion'):
dock_instance.restore_from_occlusion()
except ImportError:
pass
else:
# Bar is being hidden - force occlusion
notch.force_occlusion()
# Also force occlusion on docks on all monitors
try:
from modules.dock import Dock
for dock_instance in Dock._instances:
if hasattr(dock_instance, 'force_occlusion'):
dock_instance.force_occlusion()
except ImportError:
pass
except Exception as e:
print(f"GlobalKeybindHandler: Error toggling bar: {e}")
return False
return True
# Singleton accessor
_global_keybind_handler_instance = None
def get_global_keybind_handler() -> GlobalKeybindHandler:
"""Get the global GlobalKeybindHandler instance."""
global _global_keybind_handler_instance
if _global_keybind_handler_instance is None:
_global_keybind_handler_instance = GlobalKeybindHandler()
return _global_keybind_handler_instance
def init_global_keybind_objects():
"""Initialize global keybind handler with monitor manager."""
try:
from utils.monitor_manager import get_monitor_manager
handler = get_global_keybind_handler()
manager = get_monitor_manager()
handler.set_monitor_manager(manager)
return handler
except ImportError as e:
print(f"Error initializing global keybind objects: {e}")
return None
+56
View File
@@ -0,0 +1,56 @@
import json
from typing import Dict
import gi
import warnings
from fabric.hyprland import Hyprland
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
# IDC, Gdk.Screen.get_monitor_plug_name is deprecated
warnings.filterwarnings("ignore", category=DeprecationWarning)
# Another idea is to use Gdk.Monitor.get_model() however,
# there is no garuntee that this will be unique
# Example: both monitors have the same model number
# (quite common in multi monitor setups)
# Also, using Gdk.Display.get_monitor_at_point(x,y)
# does not work correctly on all wayland setups
# Annoyingly, Gdk 4.0 has a solution to this with
# Gdk.Monitor.get_description() or Gdk.Monitor.get_connector()
# which both can be used to uniquely identify a monitor
class HyprlandWithMonitors(Hyprland):
def __init__(self, commands_only: bool = False, **kwargs):
self.display: Gdk.Display = Gdk.Display.get_default()
super().__init__(commands_only, **kwargs)
# Add new arguments
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"])
+102
View File
@@ -0,0 +1,102 @@
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
)
+550
View File
@@ -0,0 +1,550 @@
common_text_icons = {
"playing": "",
"paused": "",
"power": "",
"cpu": "",
"memory": "",
"storage": "󰋊",
"updates": "󱧘",
"thermometer": "",
}
distro_text_icons = {
"deepin": "",
"fedora": "",
"arch": "",
"nixos": "",
"debian": "",
"opensuse-tumbleweed": "",
"ubuntu": "",
"endeavouros": "",
"manjaro": "",
"popos": "",
"garuda": "",
"zorin": "",
"mxlinux": "",
"arcolinux": "",
"gentoo": "",
"artix": "",
"centos": "",
"hyperbola": "",
"kubuntu": "",
"mandriva": "",
"xerolinux": "",
"parabola": "",
"void": "",
"linuxmint": "",
"archlabs": "",
"devuan": "",
"freebsd": "",
"openbsd": "",
"slackware": "",
}
# sourced from wttr.in
weather_text_icons = {
"113": {"description": "Sunny", "icon": "󰖙"},
"116": {"description": "PartlyCloudy", "icon": "󰖕"},
"119": {"description": "Cloudy", "icon": "󰖐"},
"122": {"description": "VeryCloudy", "icon": "󰖕"},
"143": {"description": "Fog", "icon": "󰖑"},
"176": {"description": "LightShowers", "icon": "󰼳"},
"179": {"description": "LightSleetShowers", "icon": "󰼵"},
"182": {"description": "LightSleet", "icon": "󰙿"},
"185": {"description": "LightSleet", "icon": "󰙿"},
"200": {"description": "ThunderyShowers", "icon": "󰙾"},
"227": {"description": "LightSnow", "icon": "󰼴"},
"230": {"description": "HeavySnow", "icon": "󰼶"},
"248": {"description": "Fog", "icon": "󰖑"},
"260": {"description": "Fog", "icon": "󰖑"},
"263": {"description": "LightShowers", "icon": "󰼳"},
"266": {"description": "LightRain", "icon": "󰼳"},
"281": {"description": "LightSleet", "icon": "󰙿"},
"284": {"description": "LightSleet", "icon": "󰙿"},
"293": {"description": "LightRain", "icon": "󰼳"},
"296": {"description": "LightRain", "icon": "󰼳"},
"299": {"description": "HeavyShowers", "icon": "󰖖"},
"302": {"description": "HeavyRain", "icon": "󰖖"},
"305": {"description": "HeavyShowers", "icon": "󰖖"},
"308": {"description": "HeavyRain", "icon": "󰖖"},
"311": {"description": "LightSleet", "icon": "󰙿"},
"314": {"description": "LightSleet", "icon": "󰙿"},
"317": {"description": "LightSleet", "icon": "󰙿"},
"320": {"description": "LightSnow", "icon": "󰼴"},
"323": {"description": "LightSnowShowers", "icon": "󰼵"},
"326": {"description": "LightSnowShowers", "icon": "󰼵"},
"329": {"description": "HeavySnow", "icon": "󰼶"},
"332": {"description": "HeavySnow", "icon": "󰼶"},
"335": {"description": "HeavySnowShowers", "icon": "󰼵"},
"338": {"description": "HeavySnow", "icon": "󰼶"},
"350": {"description": "LightSleet", "icon": "󰙿"},
"353": {"description": "LightShowers", "icon": "󰼳"},
"356": {"description": "HeavyShowers", "icon": "󰖖"},
"359": {"description": "HeavyRain", "icon": "󰖖"},
"362": {"description": "LightSleetShowers", "icon": "󰼵"},
"365": {"description": "LightSleetShowers", "icon": "󰼵"},
"368": {"description": "LightSnowShowers", "icon": "󰼵"},
"371": {"description": "HeavySnowShowers", "icon": "󰼵"},
"374": {"description": "LightSleetShowers", "icon": "󰼵"},
"377": {"description": "LightSleet", "icon": "󰙿"},
"386": {"description": "ThunderyShowers", "icon": "󰙾"},
"389": {"description": "ThunderyHeavyRain", "icon": "󰙾"},
"392": {"description": "ThunderySnowShowers", "icon": "󰼶"},
"395": {"description": "HeavySnowShowers", "icon": "󰼵"},
}
weather_text_icons_v2 = {
"113": {
"description": "Sunny",
"icon": "󰖙",
"image": "clear-day",
"icon-night": "󰖙",
"image-night": "clear-night",
},
"116": {
"description": "PartlyCloudy",
"icon": "󰖕",
"image": "cloudy",
"icon-night": "󰖕",
"image-night": "cloudy",
},
"119": {
"description": "Cloudy",
"icon": "󰖐",
"image": "cloudy",
"icon-night": "󰖐",
"image-night": "cloudy",
},
"122": {
"description": "VeryCloudy",
"icon": "󰖕",
"image": "cloudy",
"icon-night": "󰖕",
"image-night": "cloudy",
},
"143": {
"description": "Fog",
"icon": "󰖑",
"image": "fog",
"icon-night": "󰖑",
"image-night": "fog",
},
"176": {
"description": "LightShowers",
"icon": "󰼳",
"image": "rain",
"icon-night": "󰼳",
"image-night": "rain",
},
"179": {
"description": "LightSleetShowers",
"icon": "󰼵",
"image": "sleet",
"icon-night": "󰼵",
"image-night": "sleet",
},
"182": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"185": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"200": {
"description": "ThunderyShowers",
"icon": "󰙾",
"image": "thunderstorms",
"icon-night": "󰙾",
"image-night": "thunderstorms",
},
"227": {
"description": "LightSnow",
"icon": "󰼴",
"image": "snow",
"icon-night": "󰼴",
"image-night": "snow",
},
"230": {
"description": "HeavySnow",
"icon": "󰼶",
"image": "snow",
"icon-night": "󰼶",
"image-night": "snow",
},
"248": {
"description": "Fog",
"icon": "󰖑",
"image": "fog",
"icon-night": "󰖑",
"image-night": "fog",
},
"260": {
"description": "Fog",
"icon": "󰖑",
"image": "fog",
"icon-night": "󰖑",
"image-night": "fog",
},
"263": {
"description": "LightShowers",
"icon": "󰼳",
"image": "rain",
"icon-night": "󰼳",
"image-night": "rain",
},
"266": {
"description": "LightRain",
"icon": "󰼳",
"image": "rain",
"icon-night": "󰼳",
"image-night": "rain",
},
"281": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"284": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"293": {
"description": "LightRain",
"icon": "󰼳",
"image": "rain",
"icon-night": "󰼳",
"image-night": "rain",
},
"296": {
"description": "LightRain",
"icon": "󰼳",
"image": "rain",
"icon-night": "󰼳",
"image-night": "rain",
},
"299": {
"description": "HeavyShowers",
"icon": "󰖖",
"image": "rain",
"icon-night": "󰖖",
"image-night": "rain",
},
"302": {
"description": "HeavyRain",
"icon": "󰖖",
"image": "rain",
"icon-night": "󰖖",
"image-night": "rain",
},
"305": {
"description": "HeavyShowers",
"icon": "󰖖",
"image": "rain",
"icon-night": "󰖖",
"image-night": "rain",
},
"308": {
"description": "HeavyRain",
"icon": "󰖖",
"image": "rain",
"icon-night": "󰖖",
"image-night": "rain",
},
"311": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"314": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"317": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"320": {
"description": "LightSnow",
"icon": "󰼴",
"image": "snow",
"icon-night": "󰼴",
"image-night": "snow",
},
"323": {
"description": "LightSnowShowers",
"icon": "󰼵",
"image": "snow",
"icon-night": "󰼵",
"image-night": "snow",
},
"326": {
"description": "LightSnowShowers",
"icon": "󰼵",
"image": "snow",
"icon-night": "󰼵",
"image-night": "snow",
},
"329": {
"description": "HeavySnow",
"icon": "󰼶",
"image": "snow",
"icon-night": "󰼶",
"image-night": "snow",
},
"332": {
"description": "HeavySnow",
"icon": "󰼶",
"image": "snow",
"icon-night": "󰼶",
"image-night": "snow",
},
"335": {
"description": "HeavySnowShowers",
"icon": "󰼵",
"image": "snow",
"icon-night": "󰼵",
"image-night": "snow",
},
"338": {
"description": "HeavySnow",
"icon": "󰼶",
"image": "snow",
"icon-night": "󰼶",
"image-night": "snow",
},
"350": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"353": {
"description": "LightShowers",
"icon": "󰼳",
"image": "rain",
"icon-night": "󰼳",
"image-night": "rain",
},
"356": {
"description": "HeavyShowers",
"icon": "󰖖",
"image": "rain",
"icon-night": "󰖖",
"image-night": "rain",
},
"359": {
"description": "HeavyRain",
"icon": "󰖖",
"image": "rain",
"icon-night": "󰖖",
"image-night": "rain",
},
"362": {
"description": "LightSleetShowers",
"icon": "󰼵",
"image": "sleet",
"icon-night": "󰼵",
"image-night": "sleet",
},
"365": {
"description": "HeavySleetShowers",
"icon": "󰼵",
"image": "sleet",
"icon-night": "󰼵",
"image-night": "sleet",
},
"368": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"371": {
"description": "HeavySleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
"374": {
"description": "HeavySnowShowers",
"icon": "󰼶",
"image": "snow",
"icon-night": "󰼶",
"image-night": "snow",
},
"377": {
"description": "LightSleet",
"icon": "󰙿",
"image": "sleet",
"icon-night": "󰙿",
"image-night": "sleet",
},
}
volume_text_icons = {
"overamplified": "󰕾",
"high": "󰕾",
"medium": "󰖀",
"low": "󰕿",
"muted": "󰝟",
}
volume_text_icons = {
"overamplified": "󰕾",
"high": "󰕾",
"medium": "󰖀",
"low": "󰕿",
"muted": "󰝟",
}
brightness_text_icons = {
"off": "", # lowest brightness
"low": "",
"medium": "",
"high": "", # highest brightness
}
icons = {
"missing": "image-missing-symbolic",
"nix": {
"nix": "nix-snowflake-symbolic",
},
"app": {
"terminal": "terminal-symbolic",
},
"fallback": {
"executable": "application-x-executable",
"notification": "dialog-information-symbolic",
"video": "video-x-generic-symbolic",
"audio": "audio-x-generic-symbolic",
},
"ui": {
"close": "window-close-symbolic",
"colorpicker": "color-select-symbolic",
"info": "info-symbolic",
"link": "external-link-symbolic",
"lock": "system-lock-screen-symbolic",
"menu": "open-menu-symbolic",
"refresh": "view-refresh-symbolic",
"search": "system-search-symbolic",
"settings": "emblem-system-symbolic",
"themes": "preferences-desktop-theme-symbolic",
"tick": "object-select-symbolic",
"time": "hourglass-symbolic",
"toolbars": "toolbars-symbolic",
"warning": "dialog-warning-symbolic",
"avatar": "avatar-default-symbolic",
"arrow": {
"right": "pan-end-symbolic",
"left": "pan-start-symbolic",
"down": "pan-down-symbolic",
"up": "pan-up-symbolic",
},
},
"audio": {
"mic": {
"muted": "microphone-disabled-symbolic",
"low": "microphone-sensitivity-low-symbolic",
"medium": "microphone-sensitivity-medium-symbolic",
"high": "microphone-sensitivity-high-symbolic",
},
"volume": {
"muted": "audio-volume-muted-symbolic",
"low": "audio-volume-low-symbolic",
"medium": "audio-volume-medium-symbolic",
"high": "audio-volume-high-symbolic",
"overamplified": "audio-volume-overamplified-symbolic",
},
"type": {
"headset": "audio-headphones-symbolic",
"speaker": "audio-speakers-symbolic",
"card": "audio-card-symbolic",
},
"mixer": "mixer-symbolic",
},
"powerprofile": {
"balanced": "power-profile-balanced-symbolic",
"power-saver": "power-profile-power-saver-symbolic",
"performance": "power-profile-performance-symbolic",
},
"battery": {
"charging": "battery-flash-symbolic",
"warning": "battery-empty-symbolic",
},
"bluetooth": {
"enabled": "bluetooth-active-symbolic",
"disabled": "bluetooth-disabled-symbolic",
},
"brightness": {
"indicator": "display-brightness-symbolic",
"keyboard": "keyboard-brightness-symbolic",
"screen": "display-brightness-symbolic",
},
"powermenu": {
"sleep": "weather-clear-night-symbolic",
"reboot": "system-reboot-symbolic",
"logout": "system-log-out-symbolic",
"shutdown": "system-shutdown-symbolic",
},
"recorder": {
"recording": "media-record-symbolic",
"stopped": "media-record-symbolic",
},
"notifications": {
"noisy": "org.gnome.Settings-notifications-symbolic",
"silent": "notifications-disabled-symbolic",
"message": "chat-bubbles-symbolic",
},
"trash": {
"full": "user-trash-full-symbolic",
"empty": "user-trash-symbolic",
},
"mpris": {
"shuffle": {
"enabled": "media-playlist-shuffle-symbolic",
"disabled": "media-playlist-consecutive-symbolic",
},
"loop": {
"none": "media-playlist-repeat-symbolic",
"track": "media-playlist-repeat-song-symbolic",
"playlist": "media-playlist-repeat-symbolic",
},
"playing": "media-playback-pause-symbolic",
"paused": "media-playback-start-symbolic",
"stopped": "media-playback-start-symbolic",
"prev": "media-skip-backward-symbolic",
"next": "media-skip-forward-symbolic",
},
"system": {
"cpu": "org.gnome.SystemMonitor-symbolic",
"ram": "drive-harddisk-solidstate-symbolic",
"temp": "temperature-symbolic",
},
"color": {
"dark": "dark-mode-symbolic",
"light": "light-mode-symbolic",
},
}
+334
View File
@@ -0,0 +1,334 @@
import json
import subprocess
from typing import Dict, List, Optional, Tuple
import gi
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
class Signal:
"""Simple signal implementation for monitor manager."""
def __init__(self):
self._callbacks = []
def connect(self, callback):
"""Connect a callback to this signal."""
self._callbacks.append(callback)
def emit(self, *args, **kwargs):
"""Emit the signal to all connected callbacks."""
for callback in self._callbacks:
try:
callback(*args, **kwargs)
except Exception as e:
print(f"Error in signal callback: {e}")
class MonitorManager:
"""
Centralized monitor management for Ax-Shell multi-monitor support.
Manages monitor detection, workspace paging, notch states, and component instances.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if hasattr(self, '_initialized'):
return
self._initialized = True
self._monitors: List[Dict] = []
self._focused_monitor_id: int = 0
self._notch_states: Dict[int, bool] = {}
self._current_notch_module: Dict[int, Optional[str]] = {}
self._monitor_instances: Dict[int, Dict] = {}
self._monitor_focus_service = None
# Signals
self.monitor_changed = Signal()
self.notch_focus_changed = Signal()
self.refresh_monitors()
def set_monitor_focus_service(self, service):
"""Set the monitor focus service reference."""
self._monitor_focus_service = service
if service:
service.monitor_focused.connect(self._on_monitor_focused)
def _get_gtk_monitor_info(self) -> List[Dict]:
"""Get monitor information using GTK/GDK including scale factors."""
gtk_monitors = []
try:
display = Gdk.Display.get_default()
if display and hasattr(display, 'get_n_monitors'):
n_monitors = display.get_n_monitors()
for i in range(n_monitors):
monitor = display.get_monitor(i)
if monitor:
geometry = monitor.get_geometry()
scale_factor = monitor.get_scale_factor()
model = monitor.get_model() or f'monitor-{i}'
gtk_monitors.append({
'id': i,
'name': model,
'width': geometry.width,
'height': geometry.height,
'x': geometry.x,
'y': geometry.y,
'scale': scale_factor
})
except Exception as e:
print(f"Error getting GTK monitor info: {e}")
return gtk_monitors
def refresh_monitors(self) -> List[Dict]:
"""
Detect monitors using Hyprland API for accurate info, with GTK for scale detection.
Returns:
List of monitor dictionaries with id, name, width, height, x, y, scale
"""
self._monitors = []
try:
# Try Hyprland first for primary info (more accurate)
result = subprocess.run(
["hyprctl", "monitors", "-j"],
capture_output=True,
text=True,
check=True
)
hypr_monitors = json.loads(result.stdout)
for i, monitor in enumerate(hypr_monitors):
monitor_name = monitor.get('name', f'monitor-{i}')
# Get scale directly from Hyprland (more reliable)
hypr_scale = monitor.get('scale', 1.0)
self._monitors.append({
'id': i,
'name': monitor_name,
'width': monitor.get('width', 1920),
'height': monitor.get('height', 1080),
'x': monitor.get('x', 0),
'y': monitor.get('y', 0),
'focused': monitor.get('focused', False),
'scale': hypr_scale
})
# Initialize states for new monitors
if i not in self._notch_states:
self._notch_states[i] = False
self._current_notch_module[i] = None
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
# Fallback to GTK only if Hyprland fails
self._fallback_to_gtk()
# Ensure we have at least one monitor
if not self._monitors:
self._monitors = [{
'id': 0,
'name': 'default',
'width': 1920,
'height': 1080,
'x': 0,
'y': 0,
'focused': True,
'scale': 1.0
}]
self._notch_states[0] = False
self._current_notch_module[0] = None
# Update focused monitor
for monitor in self._monitors:
if monitor.get('focused', False):
self._focused_monitor_id = monitor['id']
break
self.monitor_changed.emit(self._monitors)
return self._monitors
def _fallback_to_gtk(self):
"""Fallback monitor detection using GTK with scale information."""
try:
display = Gdk.Display.get_default()
if display and hasattr(display, 'get_n_monitors'):
n_monitors = display.get_n_monitors()
for i in range(n_monitors):
monitor = display.get_monitor(i)
geometry = monitor.get_geometry()
scale_factor = monitor.get_scale_factor()
self._monitors.append({
'id': i,
'name': monitor.get_model() or f'monitor-{i}',
'width': geometry.width,
'height': geometry.height,
'x': geometry.x,
'y': geometry.y,
'focused': i == 0, # Assume first monitor is focused
'scale': scale_factor
})
if i not in self._notch_states:
self._notch_states[i] = False
self._current_notch_module[i] = None
except Exception:
pass
def get_monitors(self) -> List[Dict]:
"""Get list of all monitors."""
return self._monitors.copy()
def get_monitor_by_id(self, monitor_id: int) -> Optional[Dict]:
"""Get monitor by ID."""
for monitor in self._monitors:
if monitor['id'] == monitor_id:
return monitor.copy()
return None
def get_focused_monitor_id(self) -> int:
"""Get currently focused monitor ID."""
return self._focused_monitor_id
def get_focused_monitor(self) -> Optional[Dict]:
"""Get currently focused monitor."""
return self.get_monitor_by_id(self._focused_monitor_id)
def get_workspace_range_for_monitor(self, monitor_id: int) -> Tuple[int, int]:
"""
Get workspace range for a monitor (10 workspaces per monitor).
Args:
monitor_id: Monitor ID
Returns:
Tuple of (start_workspace, end_workspace)
"""
start = (monitor_id * 10) + 1
end = start + 9
return (start, end)
def get_monitor_for_workspace(self, workspace_id: int) -> int:
"""
Get monitor ID for a workspace.
Args:
workspace_id: Workspace number
Returns:
Monitor ID
"""
if workspace_id <= 0:
return 0
return (workspace_id - 1) // 10
def get_monitor_scale(self, monitor_id: int) -> float:
"""
Get scale factor for a monitor.
Args:
monitor_id: Monitor ID
Returns:
Scale factor (default 1.0 if not found)
"""
monitor = self.get_monitor_by_id(monitor_id)
return monitor.get('scale', 1.0) if monitor else 1.0
def is_notch_open(self, monitor_id: int) -> bool:
"""Check if notch is open on a monitor."""
return self._notch_states.get(monitor_id, False)
def set_notch_state(self, monitor_id: int, is_open: bool, module: Optional[str] = None):
"""Set notch state for a monitor."""
self._notch_states[monitor_id] = is_open
self._current_notch_module[monitor_id] = module if is_open else None
def get_current_notch_module(self, monitor_id: int) -> Optional[str]:
"""Get current notch module for a monitor."""
return self._current_notch_module.get(monitor_id)
def close_all_notches_except(self, except_monitor_id: int):
"""Close all notches except on specified monitor."""
for monitor_id in self._notch_states:
if monitor_id != except_monitor_id and self._notch_states[monitor_id]:
self.set_notch_state(monitor_id, False)
# Get notch instance and close it
instances = self._monitor_instances.get(monitor_id, {})
notch = instances.get('notch')
if notch and hasattr(notch, 'close_notch'):
notch.close_notch()
def register_monitor_instances(self, monitor_id: int, instances: Dict):
"""
Register component instances for a monitor.
Args:
monitor_id: Monitor ID
instances: Dict with 'bar', 'notch', 'dock', 'corners' keys
"""
self._monitor_instances[monitor_id] = instances
def get_monitor_instances(self, monitor_id: int) -> Dict:
"""Get component instances for a monitor."""
return self._monitor_instances.get(monitor_id, {})
def get_instance(self, monitor_id: int, component: str):
"""Get specific component instance for a monitor."""
instances = self._monitor_instances.get(monitor_id, {})
return instances.get(component)
def get_focused_instance(self, component: str):
"""Get component instance from focused monitor."""
return self.get_instance(self._focused_monitor_id, component)
def _on_monitor_focused(self, monitor_name: str, monitor_id: int, workspace_id: int):
"""Handle monitor focus change."""
old_focused = self._focused_monitor_id
self._focused_monitor_id = monitor_id
# Handle notch focus switching
if old_focused != monitor_id:
self._handle_notch_focus_switch(old_focused, monitor_id)
def _handle_notch_focus_switch(self, old_monitor: int, new_monitor: int):
"""Handle notch switching between monitors."""
# Close notch on old monitor if open
if self.is_notch_open(old_monitor):
old_module = self.get_current_notch_module(old_monitor)
self.close_all_notches_except(-1) # Close all
# Open notch on new monitor with same module
if old_module:
new_instances = self.get_monitor_instances(new_monitor)
notch = new_instances.get('notch')
if notch and hasattr(notch, 'open_module'):
notch.open_module(old_module)
self.notch_focus_changed.emit(old_monitor, new_monitor)
# Singleton accessor
_monitor_manager_instance = None
def get_monitor_manager() -> MonitorManager:
"""Get the global MonitorManager instance."""
global _monitor_manager_instance
if _monitor_manager_instance is None:
_monitor_manager_instance = MonitorManager()
return _monitor_manager_instance
+146
View File
@@ -0,0 +1,146 @@
import subprocess
import json
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
# Get screen dimensions for fullscreen check
screen_width, screen_height = get_screen_dimensions()
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 fullscreen windows (size matches screen and positioned at 0,0)
if (width, height) == (screen_width, screen_height) and (x, y) == (0, 0):
# For fullscreen windows, check if occlusion region is the top area
if occ_y == 0 and occ_height > 0: # Top region
return True # Consider fullscreen as occluding the top
# 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