✨ feat: implement core functionality and UI for Modus shell

This commit is contained in:
sankalp committed 2025-07-30 09:28:32 +05:45
1 parent 5789013e2e
commit acec6bdda7
129 files changed
+33424 -226

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
"""
Modus services package.
Contains background services and utilities for the shell.
"""
+179
View File
@@ -0,0 +1,179 @@
from typing import cast
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()
+118
View File
@@ -0,0 +1,118 @@
import math
from typing import Literal
import cairo
import gi
from fabric.core.service import Property
from fabric.widgets.widget import Widget
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
class CircleImage(Gtk.DrawingArea, Widget):
"""A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
@Property(int, "read-write")
def angle(self) -> int:
return self._angle
@angle.setter
def angle(self, value: int):
self._angle = value % 360
self.queue_draw()
def __init__(
self,
image_file: str | None = None,
pixbuf: GdkPixbuf.Pixbuf | None = None,
name: str | None = None,
visible: bool = True,
all_visible: bool = False,
style: str | None = None,
tooltip_text: str | None = None,
tooltip_markup: str | None = None,
h_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None,
v_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None,
h_expand: bool = False,
v_expand: bool = False,
size: int | None = None,
**kwargs,
):
Gtk.DrawingArea.__init__(self)
Widget.__init__(
self,
name=name,
visible=visible,
all_visible=all_visible,
style=style,
tooltip_text=tooltip_text,
tooltip_markup=tooltip_markup,
h_align=h_align,
v_align=v_align,
h_expand=h_expand,
v_expand=v_expand,
size=size,
**kwargs,
)
self.size = size if size is not None else 100 # Default size if not provided
self._angle = 0
self._orig_image: GdkPixbuf.Pixbuf | None = None # Original image for reprocessing
self._image: GdkPixbuf.Pixbuf | None = None
if image_file:
pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
self._orig_image = pix
self._image = self._process_image(pix)
elif pixbuf:
self._orig_image = pixbuf
self._image = self._process_image(pixbuf)
self.connect("draw", self.on_draw)
def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
"""Crop the image to a centered square and scale it to the widget’s size."""
width, height = pixbuf.get_width(), pixbuf.get_height()
if width != height:
square_size = min(width, height)
x_offset = (width - square_size) // 2
y_offset = (height - square_size) // 2
pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
else:
square_size = width
if square_size != self.size:
pixbuf = pixbuf.scale_simple(self.size, self.size, GdkPixbuf.InterpType.BILINEAR)
return pixbuf
def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
if self._image:
ctx.save()
# Create a circular clipping path
ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
ctx.clip()
# Rotate around the center of the square image
ctx.translate(self.size / 2, self.size / 2)
ctx.rotate(self._angle * math.pi / 180.0)
ctx.translate(-self.size / 2, -self.size / 2)
Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
ctx.paint()
ctx.restore()
def set_image_from_file(self, new_image_file: str):
if not new_image_file:
return
pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
self._orig_image = pixbuf
self._image = self._process_image(pixbuf)
self.queue_draw()
def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
if not pixbuf:
return
self._orig_image = pixbuf
self._image = self._process_image(pixbuf)
self.queue_draw()
def set_image_size(self, size: int):
self.size = size
if self._orig_image:
self._image = self._process_image(self._orig_image)
self.queue_draw()
+619
View File
@@ -0,0 +1,619 @@
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"
+38
View File
@@ -0,0 +1,38 @@
import math
from typing import cast
import cairo
from fabric.widgets.image import Image
from gi.repository import Gtk
class CustomImage(Image):
def do_render_rectangle(
self, cr: cairo.Context, width: int, height: int, radius: int = 0
):
cr.move_to(radius, 0)
cr.line_to(width - radius, 0)
cr.arc(width - radius, radius, radius, -(math.pi / 2), 0)
cr.line_to(width, height - radius)
cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2))
cr.line_to(radius, height)
cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi)
cr.line_to(0, radius)
cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2)))
cr.close_path()
def do_draw(self, cr: cairo.Context):
context = self.get_style_context()
width, height = self.get_allocated_width(), self.get_allocated_height()
cr.save()
self.do_render_rectangle(
cr,
width,
height,
cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)),
)
cr.clip()
Image.do_draw(self, cr)
cr.restore()
+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
)
+234
View File
@@ -0,0 +1,234 @@
# Parameters
font_family: str = "tabler-icons"
font_weight: str = "normal"
span: str = f"<span font-family='{font_family}' font-weight='{font_weight}'>"
# Panels
apps: str = "&#xf1fd;"
dashboard: str = "&#xea87;"
chat: str = "&#xf59f;"
windows: str = "&#xefe6;"
# Bar
colorpicker: str = "&#xebe6;"
media: str = "&#xf00d;"
# Toolbox
toolbox: str = "&#xebca;"
ssfull: str = "&#xec3c;"
ssregion: str = "&#xf201;"
sswindow: str = "&#xeaea;"
screenshots: str = "&#xeb0a;"
screenrecord: str = "&#xf692;"
recordings: str = "&#xeafa;"
ocr: str = "&#xfcc3;"
gamemode: str = "&#xf026;"
gamemode_off: str = "&#xf111;"
close: str = "&#xeb55;"
# Circles
temp: str = "&#xeb38;"
disk: str = "&#xea88;"
battery: str = "&#xea38;"
memory: str = "&#xfa97;"
cpu: str = "&#xef8e;"
gpu: str = "&#xf233;"
swap: str = "&#xeb31;"
# AIchat
reload: str = "&#xf3ae;"
detach: str = "&#xea99;"
# Wallpapers
add: str = "&#xeb0b;"
sort: str = "&#xeb5a;"
circle: str = "&#xf671;"
# Chevrons
chevron_up: str = "&#xea62;"
chevron_down: str = "&#xea5f;"
chevron_left: str = "&#xea60;"
chevron_right: str = "&#xea61;"
# Power
lock: str = "&#xeae2;"
suspend: str = "&#xece7;"
logout: str = "&#xeba8;"
reboot: str = "&#xeb13;"
shutdown: str = "&#xeb0d;"
# Power Manager
power_saving: str = "&#xed4f;"
power_balanced: str = "&#xfa77;"
power_performance: str = "&#xec45;"
charging: str = "&#xefef;"
discharging: str = "&#xefe9;"
alert: str = "&#xea06;"
bat_charging: str = "&#xeeca;"
bat_discharging: str = "&#xf0a1;"
bat_low: str = "&#xff1d;"
bat_full: str = "&#xea38;"
# Applets
# Applets
wifi_0: str = "&#xeba3;"
wifi_1: str = "&#xeba4;"
wifi_2: str = "&#xeba5;"
wifi_3: str = "&#xeb52;"
world: str = "&#xeb54;"
world_off: str = "&#xf1ca;"
bluetooth: str = "&#xea37;"
night: str = "&#xeaf8;"
coffee: str = "&#xef0e;"
notifications: str = "&#xea35;"
notifications_dots: str = "&#xede3;"
wifi_off: str = "&#xecfa;"
bluetooth_off: str = "&#xeceb;"
night_off: str = "&#xf162;"
notifications_off: str = "&#xece9;"
notifications_clear: str = "&#xf814;"
download: str = "&#xea96;"
upload: str = "&#xeb47;"
# Bluetooth
bluetooth_connected: str = "&#xecea;"
bluetooth_disconnected: str = "&#xf081;"
# Player
pause: str = "&#xf690;"
play: str = "&#xf691;"
stop: str = "&#xf695;"
skip_back: str = "&#xf693;"
skip_forward: str = "&#xf694;"
prev: str = "&#xf697;"
next: str = "&#xf696;"
shuffle: str = "&#xf000;"
repeat: str = "&#xeb72;"
music: str = "&#xeafc;"
rewind_backward_5: str = "&#xfabf;"
rewind_forward_5: str = "&#xfac7;"
next_fill: str = chr(0xF04AD)
previous_fill: str = chr(0xF04AE)
# Volume
vol_off: str = "&#xf1c3;"
vol_mute: str = "&#xeb50;"
vol_medium: str = "&#xeb4f;"
vol_high: str = "&#xeb51;"
mic: str = "&#xeaf0;"
mic_mute: str = "&#xed16;"
speaker: str = "&#x10045;"
headphones: str = "&#xfa3c;"
mic_filled: str = "&#xfe0f;"
# Overview
circle_plus: str = "&#xea69;"
# Pins
paperclip: str = "&#xeb02;"
# Clipboard Manager
clipboard: str = "&#xea6f;"
clip_text: str = "&#xf089;"
# Confirm
accept: str = "&#xea5e;"
cancel: str = "&#xeb55;"
trash: str = "&#xeb41;"
# Config
config: str = "&#xeb20;"
# Icons
firefox: str = "&#xecfd;"
chromium: str = "&#xec18;"
spotify: str = "&#xfe86;"
disc: str = "&#x1003e;"
disc_off: str = "&#xf118;"
# Brightness
brightness_low: str = "&#xeb7d;"
brightness_medium: str = "&#xeb7e;"
brightness_high: str = "&#xeb30;"
brightness: str = "&#xee1a;"
# Dashboard
widgets: str = "&#xf02c;"
pins: str = "&#xec9c;"
kanban: str = "&#xec3f;"
wallpapers: str = "&#xeb61;"
sparkles: str = "&#xf6d7;"
# Misc
dot: str = "&#xf698;"
palette: str = "&#xeb01;"
cloud_off: str = "&#xed3e;"
loader: str = "&#xeca3;"
radar: str = "&#xf017;"
emoji: str = "&#xeaf7;"
keyboard: str = "&#xebd6;"
terminal: str = "&#xebef;"
timer_off: str = "&#xf146;"
timer_on: str = "&#xf756;"
spy: str = "&#xf227;"
calculator: str = "&#xeb80;"
file: str = "&#xeaa4;"
calendar: str = "&#xea53;"
connect: str = "&#xeade;"
disconnect: str = "&#xfd16;"
auth: str = "&#xeca0;"
bookmark: str = "&#xea3a;"
star: str = "&#xeb2e;"
process: str = "&#xf292;"
music: str = "&#xf00d;"
# otp
check: str = "&#xea5e;"
bulb: str = "&#xea51;"
key: str = "&#xeac7;"
magnifier: str = "&#xeb1c;"
plus: str = "&#xeb0b;"
info: str = "&#xeac5;"
# Dice
dice_1: str = "&#xf08b;"
dice_2: str = "&#xf08c;"
dice_3: str = "&#xf08d;"
dice_4: str = "&#xf08e;"
dice_5: str = "&#xf08f;"
dice_6: str = "&#xf090;"
# search engine
reddit: str = "&#xec8e;"
youtube: str = "&#xec90;"
duckduckgo: str = "&#xec8f;"
github: str = "&#xec1c;"
stackoverflow: str = "&#xef58;"
google: str = "&#xec1f;"
linkedin: str = "&#xec8c;"
wikipedia: str = "&#xfa79;"
exceptions: list[str] = ["font_family", "font_weight", "span"]
def apply_span() -> None:
global_dict = globals()
for key in global_dict:
if key not in exceptions and not key.startswith("__"):
global_dict[key] = f"{span}{global_dict[key]}</span>"
apply_span()
+186
View File
@@ -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()
+140
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
from fabric.widgets.box import Box
from utils.wayland import WaylandWindow
from config.c import c
import gi # type: ignore
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import Gtk, GtkLayerShell # type: ignore
class PopupWindow(WaylandWindow):
def __init__(
self,
parent: WaylandWindow,
pointing_to: Gtk.Widget | None = None,
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
**kwargs,
):
super().__init__(**kwargs)
self.exclusivity = "none"
self._parent = parent
self._pointing_widget = pointing_to
self._base_margin = self.extract_margin(margin)
self.margin = self._base_margin.values()
self.connect("notify::visible", self.do_update_handlers)
def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore
return 0, 0
allocation = widget.get_allocation()
x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (
0,
0,
)
return round(x / 2), round(y / 2)
def set_pointing_to(self, widget: Gtk.Widget | None):
if self._pointing_widget:
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
self._pointing_widget = widget
return self.do_update_handlers()
def do_update_handlers(self, *_):
if not self._pointing_widget:
return
if not self.get_visible():
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
self.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
return
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
self.connect("size-allocate", self.do_handle_size_allocate)
return self.do_handle_size_allocate()
def do_handle_size_allocate(self, *_):
return self.do_reposition(self.do_calculate_edges())
def do_calculate_edges(self):
move_axe = "x"
parent_anchor = self._parent.anchor
if len(parent_anchor) != 3:
return move_axe
if (
GtkLayerShell.Edge.LEFT in parent_anchor
and GtkLayerShell.Edge.RIGHT in parent_anchor
):
# horizontal -> move on x-axies
move_axe = "x"
if GtkLayerShell.Edge.TOP in parent_anchor:
self.anchor = "left top"
else:
self.anchor = "left bottom"
elif (
GtkLayerShell.Edge.TOP in parent_anchor
and GtkLayerShell.Edge.BOTTOM in parent_anchor
):
# vertical -> move on y-axies
move_axe = "y"
if GtkLayerShell.Edge.RIGHT in parent_anchor:
self.anchor = "top right"
else:
self.anchor = "top left"
return move_axe
def do_reposition(self, move_axe: str):
parent_margin = self._parent.margin
parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
height = self.get_allocated_height()
width = self.get_allocated_width()
if self._pointing_widget:
coords = self.get_coords_for_widget(self._pointing_widget)
if coords[0] + width > int(c.get_rule("Display.resolution").split("x")[0]):
coords_centered = (
round(coords[0] - (width - self._pointing_widget.get_allocated_width())),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(coords[0]),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(self._parent.get_allocated_width() / 2),
round(self._parent.get_allocated_height() / 2),
)
self.margin = tuple(
a + b
for a, b in zip(
(
(
0,
0,
0,
round((parent_x_margin + coords_centered[0])),
)
if move_axe == "x"
else (
round((parent_y_margin + coords_centered[1]) - (height / 2)),
0,
0,
0,
)
),
self._base_margin.values(),
)
)
+359
View File
@@ -0,0 +1,359 @@
import re
from collections.abc import Iterable
from enum import Enum
from typing import Literal, cast
import cairo
import gi
from fabric.core.service import Property
from fabric.utils.helpers import extract_css_values, get_enum_member
from fabric.widgets.window import Window
from gi.repository import Gdk, GObject, Gtk
from loguru import logger
gi.require_version("Gtk", "3.0")
try:
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import GtkLayerShell
except:
raise ImportError(
"looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)"
)
class WaylandWindowExclusivity(Enum):
NONE = 1
NORMAL = 2
AUTO = 3
class Layer(GObject.GEnum):
BACKGROUND = 0
BOTTOM = 1
TOP = 2
OVERLAY = 3
ENTRY_NUMBER = 4
class KeyboardMode(GObject.GEnum):
NONE = 0
EXCLUSIVE = 1
ON_DEMAND = 2
ENTRY_NUMBER = 3
class Edge(GObject.GEnum):
LEFT = 0
RIGHT = 1
TOP = 2
BOTTOM = 3
ENTRY_NUMBER = 4
class WaylandWindow(Window):
@Property(
Layer,
flags="read-write",
default_value=Layer.TOP,
)
def layer(self) -> Layer: # type: ignore
return self._layer # type: ignore
@layer.setter
def layer(
self,
value: Literal["background", "bottom", "top", "overlay"] | Layer,
) -> None:
self._layer = get_enum_member(Layer, value, default=Layer.TOP)
return GtkLayerShell.set_layer(self, self._layer)
@Property(int, "read-write")
def monitor(self) -> int:
if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))):
return -1
display = monitor.get_display() or Gdk.Display.get_default()
for i in range(0, display.get_n_monitors()):
if display.get_monitor(i) is monitor:
return i
return -1
@monitor.setter
def monitor(self, monitor: int | Gdk.Monitor) -> bool:
if isinstance(monitor, int):
display = Gdk.Display().get_default()
monitor = display.get_monitor(monitor)
return (
(GtkLayerShell.set_monitor(self, monitor), True)[1]
if monitor is not None
else False
)
@Property(WaylandWindowExclusivity, "read-write")
def exclusivity(self) -> WaylandWindowExclusivity:
return self._exclusivity
@exclusivity.setter
def exclusivity(
self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity
) -> None:
value = get_enum_member(
WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE
)
self._exclusivity = value
match value:
case WaylandWindowExclusivity.NORMAL:
return GtkLayerShell.set_exclusive_zone(self, True)
case WaylandWindowExclusivity.AUTO:
return GtkLayerShell.auto_exclusive_zone_enable(self)
case _:
return GtkLayerShell.set_exclusive_zone(self, False)
@Property(bool, "read-write", default_value=False)
def pass_through(self) -> bool:
return self._pass_through
@pass_through.setter
def pass_through(self, pass_through: bool = False):
self._pass_through = pass_through
region = cairo.Region() if pass_through is True else None
self.input_shape_combine_region(region)
del region
return
@Property(
KeyboardMode,
"read-write",
default_value=KeyboardMode.NONE,
)
def keyboard_mode(self) -> KeyboardMode:
return self._keyboard_mode
@keyboard_mode.setter
def keyboard_mode(
self,
value: Literal[
"none",
"exclusive",
"on-demand",
"entry-number",
]
| KeyboardMode,
):
self._keyboard_mode = get_enum_member(
KeyboardMode, value, default=KeyboardMode.NONE
)
return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode)
@Property(tuple[Edge, ...], "read-write")
def anchor(self):
return tuple(
x
for x in [
Edge.TOP,
Edge.RIGHT,
Edge.BOTTOM,
Edge.LEFT,
]
if GtkLayerShell.get_anchor(self, x)
)
@anchor.setter
def anchor(self, value: str | Iterable[Edge]) -> None:
self._anchor = value
if isinstance(value, (list, tuple)) and all(
isinstance(edge, Edge) for edge in value
):
for edge in [
Edge.TOP,
Edge.RIGHT,
Edge.BOTTOM,
Edge.LEFT,
]:
if edge not in value:
GtkLayerShell.set_anchor(self, edge, False)
GtkLayerShell.set_anchor(self, edge, True)
return
elif isinstance(value, str):
for edge, anchored in WaylandWindow.extract_edges_from_string(
value
).items():
GtkLayerShell.set_anchor(self, edge, anchored)
return
@Property(tuple[int, ...], flags="read-write")
def margin(self) -> tuple[int, ...]:
return tuple(
GtkLayerShell.get_margin(self, x)
for x in [
Edge.TOP,
Edge.RIGHT,
Edge.BOTTOM,
Edge.LEFT,
]
)
@margin.setter
def margin(self, value: str | Iterable[int]) -> None:
for edge, mrgv in WaylandWindow.extract_margin(value).items():
GtkLayerShell.set_margin(self, edge, mrgv)
return
@Property(object, "read-write")
def keyboard_mode(self):
kb_mode = GtkLayerShell.get_keyboard_mode(self)
if GtkLayerShell.get_keyboard_interactivity(self):
kb_mode = KeyboardMode.EXCLUSIVE
return kb_mode
@keyboard_mode.setter
def keyboard_mode(
self,
value: Literal["none", "exclusive", "on-demand"] | KeyboardMode,
):
return GtkLayerShell.set_keyboard_mode(
self,
get_enum_member(
KeyboardMode,
value,
default=KeyboardMode.NONE,
),
)
def __init__(
self,
layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP,
anchor: str = "",
margin: str | Iterable[int] = "0px 0px 0px 0px",
exclusivity: Literal["auto", "normal", "none"]
| WaylandWindowExclusivity = WaylandWindowExclusivity.NONE,
keyboard_mode: Literal["none", "exclusive", "on-demand"]
| KeyboardMode = KeyboardMode.NONE,
pass_through: bool = False,
monitor: int | Gdk.Monitor | None = None,
title: str = "fabric",
type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
child: Gtk.Widget | None = None,
name: str | None = None,
visible: bool = True,
all_visible: bool = False,
style: str | None = None,
style_classes: Iterable[str] | str | None = None,
tooltip_text: str | None = None,
tooltip_markup: str | None = None,
h_align: Literal["fill", "start", "end", "center", "baseline"]
| Gtk.Align
| None = None,
v_align: Literal["fill", "start", "end", "center", "baseline"]
| Gtk.Align
| None = None,
h_expand: bool = False,
v_expand: bool = False,
size: Iterable[int] | int | None = None,
**kwargs,
):
Window.__init__(
self,
title=title,
type=type,
child=child,
name=name,
visible=False,
all_visible=False,
style=style,
style_classes=style_classes,
tooltip_text=tooltip_text,
tooltip_markup=tooltip_markup,
h_align=h_align,
v_align=v_align,
h_expand=h_expand,
v_expand=v_expand,
size=size,
**kwargs,
)
self._layer = Layer.ENTRY_NUMBER
self._keyboard_mode = KeyboardMode.NONE
self._anchor = anchor
self._exclusivity = WaylandWindowExclusivity.NONE
self._pass_through = pass_through
GtkLayerShell.init_for_window(self)
GtkLayerShell.set_namespace(self, title)
self.connect(
"notify::title",
lambda *_: GtkLayerShell.set_namespace(self, self.get_title()),
)
if monitor is not None:
self.monitor = monitor
self.layer = layer
self.anchor = anchor
self.margin = margin
self.keyboard_mode = keyboard_mode
self.exclusivity = exclusivity
self.pass_through = pass_through
self.show_all() if all_visible is True else self.show() if visible is True else None
def steal_input(self) -> None:
return GtkLayerShell.set_keyboard_interactivity(self, True)
def return_input(self) -> None:
return GtkLayerShell.set_keyboard_interactivity(self, False)
# custom overrides
def show(self) -> None:
super().show()
return self.do_handle_post_show_request()
def show_all(self) -> None:
super().show_all()
return self.do_handle_post_show_request()
def do_handle_post_show_request(self) -> None:
if not self.get_children():
logger.warning(
"[WaylandWindow] showing an empty window is not recommended, some compositors might freak out."
)
self.pass_through = self._pass_through
return
@staticmethod
def extract_anchor_values(string: str) -> tuple[str, ...]:
"""
extracts the geometry values from a given geometry string.
:param string: the string containing the geometry values.
:type string: str
:return: a list of unique directions extracted from the geometry string.
:rtype: list
"""
direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"}
pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE)
matches = pattern.findall(string)
return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches)))
@staticmethod
def extract_edges_from_string(string: str) -> dict["Edge", bool]:
anchor_values = WaylandWindow.extract_anchor_values(string.lower())
return {
Edge.TOP: "top" in anchor_values,
Edge.RIGHT: "right" in anchor_values,
Edge.BOTTOM: "bottom" in anchor_values,
Edge.LEFT: "left" in anchor_values,
}
@staticmethod
def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]:
margins = (
extract_css_values(input.lower())
if isinstance(input, str)
else input
if isinstance(input, (tuple, list)) and len(input) == 4
else (0, 0, 0, 0)
)
return {
Edge.TOP: margins[0],
Edge.RIGHT: margins[1],
Edge.BOTTOM: margins[2],
Edge.LEFT: margins[3],
}
+174
View File
@@ -0,0 +1,174 @@
import math
from fabric.core.service import Service, Signal
from gi.repository import Gtk, Gdk, GLib
import gi
gi.require_version("Gtk", "3.0")
class WigglyWidget(Gtk.DrawingArea, Service):
@Signal
def on_seek(self, ratio: float) -> None: ...
def __init__(self):
super().__init__(name="wiggle-bar")
self.phase = 0
self.set_size_request(-1, 20)
self.connect("draw", self.on_draw)
# mouse events
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
)
self.connect("button-press-event", self.on_button_press)
self.connect("motion-notify-event", self.on_motion)
self.connect("button-release-event", self.on_button_release)
GLib.timeout_add(16, self.update) # 60 FPS
self.value = 0.0
self.amplitude = 2
self.dragging = False
self.pause = False
self.show_all()
def animate_amplitude_to(self):
if abs(self.amplitude - self.amplitude_target) < 0.01:
self.amplitude = self.amplitude_target
self.queue_draw()
return False
else:
self.amplitude += self.amplitude_step
self.queue_draw()
return True
def update_amplitude(self, decrease):
if decrease:
self.amplitude_target = 0
self.amplitude_step = (0 - self.amplitude) / 10.0
else:
self.amplitude_target = 2
self.amplitude_step = (2 - self.amplitude) / 10.0
# animation stops when False is returned btw
GLib.timeout_add(16, self.animate_amplitude_to)
def update_value_from_x(self, x):
width = self.get_allocated_width()
self.value = max(0.0, min(1.0, x / width))
self.on_seek(self.value)
self.queue_draw()
def on_button_press(self, widget, event):
self.dragging = True
self.update_amplitude(True)
self.update_value_from_x(event.x)
return True
# def animate_progress_to(self):
# print("frame")
# if self.value < self.value_target:
# self.value = self.value_target
# print(self.value)
# self.queue_draw()
# return False
# else:
# self.value += self.value_step
# self.queue_draw()
# return True
# def update_value_from_signal(self, new_value):
# self.value_target = min(1.0, new_value)
# steps = 60 # 1s / 16ms ≈ 60 frames
# self.value_step = (self.value_target - self.value) / steps
# GLib.timeout_add(16, self.animate_progress_to)
def update_value_from_signal(self, new_value):
self.value = min(1.0, new_value)
def on_motion(self, widget, event):
if self.dragging and not self.pause:
self.update_value_from_x(event.x)
cursor = Gdk.Cursor.new(Gdk.CursorType.HAND1)
widget.get_window().set_cursor(cursor)
return True
def on_button_release(self, widget, event):
if self.pause == False:
self.dragging = False
self.update_amplitude(False)
return True
def update(self):
if self.dragging == False:
self.phase += 0.1
# if (self.value<1):
# self.value += 0.001
self.queue_draw()
return True
def on_draw(self, widget, cr):
alloc_width = self.get_allocated_width()
height = self.get_allocated_height()
center_y = height / 2
# self.amplitude = 2
frequency = 0.3
slider_diameter = 6
width = int(alloc_width * self.value) - slider_diameter + 1
cr.set_source_rgb(1, 1, 1) # color here btw
cr.set_line_width(2)
last_x = 0 # fallback values in case the slider gets dragged out of range
last_y = 0
cr.move_to(0, center_y)
for x in range(width):
y = center_y + self.amplitude * math.sin((x * frequency) + self.phase)
cr.line_to(x, y)
last_x, last_y = x, y
cr.stroke()
# Set the color using CSS - the widget should have CSS styling applied
# that sets the color to var(--primary). For now, use a fallback approach.
style_context = self.get_style_context()
try:
# Get the foreground color which should be styled with CSS
color = style_context.get_color(Gtk.StateFlags.NORMAL)
cr.set_source_rgba(color.red, color.green, color.blue, color.alpha)
except:
# Fallback to a nice blue color that matches the primary theme
cr.set_source_rgb(0.2, 0.6, 1.0)
rect_width = 6
arc_radius = slider_diameter / 2
self.draw_rounded_rect(cr, last_x, height, rect_width, rect_width, arc_radius)
cr.fill()
cr.stroke()
cr.set_source_rgb(1, 1, 1)
cr.set_line_width(1)
# This is for anti aliasing. When line width becomes 1px and the position is not an integer, two lines of opacity 50% is drawn on two pixel rows.
# This moves the line to make it pixel perfect hence not needing anti aliasing
cr.translate(0, 0.5)
cr.move_to(last_x + 2 * arc_radius, height / 2)
cr.line_to(alloc_width, height / 2)
cr.stroke()
def draw_rounded_rect(self, cr, x, y, width, _height, radius):
cr.new_sub_path()
cr.arc(x + width - radius, radius, radius, -math.pi, 0)
cr.arc(x + width - radius, y - radius, radius, 0, -math.pi)
cr.close_path()