✨ feat: implement core functionality and UI for Modus shell
This commit is contained in:
1 parent
5789013e2e
commit
acec6bdda7
129 files changed
+33424
-226
No files matched your search
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Modus services package.
|
||||
Contains background services and utilities for the shell.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 = ""
|
||||
dashboard: str = ""
|
||||
chat: str = ""
|
||||
windows: str = ""
|
||||
|
||||
# Bar
|
||||
colorpicker: str = ""
|
||||
media: str = ""
|
||||
|
||||
# Toolbox
|
||||
|
||||
toolbox: str = ""
|
||||
ssfull: str = ""
|
||||
ssregion: str = ""
|
||||
sswindow: str = ""
|
||||
screenshots: str = ""
|
||||
screenrecord: str = ""
|
||||
recordings: str = ""
|
||||
ocr: str = "ﳃ"
|
||||
gamemode: str = ""
|
||||
gamemode_off: str = ""
|
||||
close: str = ""
|
||||
|
||||
# Circles
|
||||
temp: str = ""
|
||||
disk: str = ""
|
||||
battery: str = ""
|
||||
memory: str = "流"
|
||||
cpu: str = ""
|
||||
gpu: str = ""
|
||||
swap: str = ""
|
||||
|
||||
|
||||
# AIchat
|
||||
reload: str = ""
|
||||
detach: str = ""
|
||||
|
||||
# Wallpapers
|
||||
add: str = ""
|
||||
sort: str = ""
|
||||
circle: str = ""
|
||||
|
||||
# Chevrons
|
||||
chevron_up: str = ""
|
||||
chevron_down: str = ""
|
||||
chevron_left: str = ""
|
||||
chevron_right: str = ""
|
||||
|
||||
# Power
|
||||
lock: str = ""
|
||||
suspend: str = ""
|
||||
logout: str = ""
|
||||
reboot: str = ""
|
||||
shutdown: str = ""
|
||||
|
||||
# Power Manager
|
||||
power_saving: str = ""
|
||||
power_balanced: str = "勺"
|
||||
power_performance: str = ""
|
||||
charging: str = ""
|
||||
discharging: str = ""
|
||||
alert: str = ""
|
||||
bat_charging: str = ""
|
||||
bat_discharging: str = ""
|
||||
bat_low: str = "="
|
||||
bat_full: str = ""
|
||||
|
||||
|
||||
# Applets
|
||||
|
||||
# Applets
|
||||
wifi_0: str = ""
|
||||
wifi_1: str = ""
|
||||
wifi_2: str = ""
|
||||
wifi_3: str = ""
|
||||
world: str = ""
|
||||
world_off: str = ""
|
||||
bluetooth: str = ""
|
||||
night: str = ""
|
||||
coffee: str = ""
|
||||
notifications: str = ""
|
||||
notifications_dots: str = ""
|
||||
|
||||
wifi_off: str = ""
|
||||
bluetooth_off: str = ""
|
||||
night_off: str = ""
|
||||
notifications_off: str = ""
|
||||
|
||||
notifications_clear: str = ""
|
||||
|
||||
download: str = ""
|
||||
upload: str = ""
|
||||
|
||||
# Bluetooth
|
||||
bluetooth_connected: str = ""
|
||||
bluetooth_disconnected: str = ""
|
||||
|
||||
# Player
|
||||
pause: str = ""
|
||||
play: str = ""
|
||||
stop: str = ""
|
||||
skip_back: str = ""
|
||||
skip_forward: str = ""
|
||||
prev: str = ""
|
||||
next: str = ""
|
||||
shuffle: str = ""
|
||||
repeat: str = ""
|
||||
music: str = ""
|
||||
rewind_backward_5: str = "謹"
|
||||
rewind_forward_5: str = "難"
|
||||
|
||||
next_fill: str = chr(0xF04AD)
|
||||
previous_fill: str = chr(0xF04AE)
|
||||
|
||||
# Volume
|
||||
vol_off: str = ""
|
||||
vol_mute: str = ""
|
||||
vol_medium: str = ""
|
||||
vol_high: str = ""
|
||||
|
||||
mic: str = ""
|
||||
mic_mute: str = ""
|
||||
|
||||
speaker: str = "𐁅"
|
||||
headphones: str = "屮"
|
||||
mic_filled: str = "️"
|
||||
|
||||
# Overview
|
||||
circle_plus: str = ""
|
||||
|
||||
# Pins
|
||||
paperclip: str = ""
|
||||
|
||||
# Clipboard Manager
|
||||
clipboard: str = ""
|
||||
clip_text: str = ""
|
||||
|
||||
# Confirm
|
||||
accept: str = ""
|
||||
cancel: str = ""
|
||||
trash: str = ""
|
||||
|
||||
# Config
|
||||
config: str = ""
|
||||
|
||||
# Icons
|
||||
firefox: str = ""
|
||||
chromium: str = ""
|
||||
spotify: str = "ﺆ"
|
||||
disc: str = "𐀾"
|
||||
disc_off: str = ""
|
||||
|
||||
# Brightness
|
||||
brightness_low: str = ""
|
||||
brightness_medium: str = ""
|
||||
brightness_high: str = ""
|
||||
|
||||
brightness: str = ""
|
||||
|
||||
# Dashboard
|
||||
widgets: str = ""
|
||||
pins: str = ""
|
||||
kanban: str = ""
|
||||
wallpapers: str = ""
|
||||
sparkles: str = ""
|
||||
|
||||
# Misc
|
||||
dot: str = ""
|
||||
palette: str = ""
|
||||
cloud_off: str = ""
|
||||
loader: str = ""
|
||||
radar: str = ""
|
||||
emoji: str = ""
|
||||
keyboard: str = ""
|
||||
terminal: str = ""
|
||||
timer_off: str = ""
|
||||
timer_on: str = ""
|
||||
spy: str = ""
|
||||
calculator: str = ""
|
||||
file: str = ""
|
||||
calendar: str = ""
|
||||
connect: str = ""
|
||||
disconnect: str = "ﴖ"
|
||||
auth: str = ""
|
||||
bookmark: str = ""
|
||||
star: str = ""
|
||||
process: str = ""
|
||||
|
||||
|
||||
music: str = ""
|
||||
# otp
|
||||
check: str = ""
|
||||
bulb: str = ""
|
||||
key: str = ""
|
||||
magnifier: str = ""
|
||||
plus: str = ""
|
||||
info: str = ""
|
||||
|
||||
# Dice
|
||||
dice_1: str = ""
|
||||
dice_2: str = ""
|
||||
dice_3: str = ""
|
||||
dice_4: str = ""
|
||||
dice_5: str = ""
|
||||
dice_6: str = ""
|
||||
|
||||
# search engine
|
||||
reddit: str = ""
|
||||
youtube: str = ""
|
||||
duckduckgo: str = ""
|
||||
github: str = ""
|
||||
stackoverflow: str = ""
|
||||
google: str = ""
|
||||
linkedin: str = ""
|
||||
wikipedia: str = "啕"
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,186 @@
|
||||
# From https://github.com/stwa/wayland-idle-inhibitor
|
||||
# License: WTFPL Version 2
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
from threading import Event, Timer
|
||||
|
||||
import setproctitle
|
||||
from pywayland.client.display import Display
|
||||
from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (
|
||||
ZwpIdleInhibitManagerV1,
|
||||
)
|
||||
from pywayland.protocol.wayland.wl_compositor import WlCompositor
|
||||
from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
|
||||
from pywayland.protocol.wayland.wl_surface import WlSurface
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobalRegistry:
|
||||
surface: WlSurface | None = None
|
||||
inhibit_manager: ZwpIdleInhibitManagerV1 | None = None
|
||||
|
||||
|
||||
def parse_duration(duration_str: str) -> int:
|
||||
"""Parse duration string into seconds.
|
||||
Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off'
|
||||
"""
|
||||
try:
|
||||
if duration_str.lower() in ["on", "off"]:
|
||||
return 0
|
||||
elif duration_str.endswith("h"):
|
||||
return int(float(duration_str[:-1]) * 3600)
|
||||
elif duration_str.endswith("m"):
|
||||
return int(float(duration_str[:-1]) * 60)
|
||||
elif duration_str.endswith("s"):
|
||||
return int(float(duration_str[:-1]))
|
||||
else:
|
||||
return int(duration_str)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
"Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc."
|
||||
)
|
||||
|
||||
|
||||
def kill_existing_inhibit_processes():
|
||||
"""Kill any existing modus-inhibit processes."""
|
||||
try:
|
||||
# Find all modus-inhibit processes except the current one
|
||||
output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True)
|
||||
pids = output.strip().split("\n")
|
||||
current_pid = str(os.getpid())
|
||||
|
||||
killed_count = 0
|
||||
for pid in pids:
|
||||
pid = pid.strip()
|
||||
if pid and pid != current_pid:
|
||||
try:
|
||||
subprocess.run(["kill", pid], check=True)
|
||||
killed_count += 1
|
||||
except subprocess.CalledProcessError:
|
||||
pass # Process might have already exited
|
||||
|
||||
if killed_count > 0:
|
||||
print(f"Stopped {killed_count} existing inhibit process(es)")
|
||||
else:
|
||||
print("No existing inhibit processes were running")
|
||||
return killed_count
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
# No processes found
|
||||
print("No existing inhibit processes were running")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error stopping existing processes: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def handle_registry_global(
|
||||
wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int
|
||||
) -> None:
|
||||
global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()
|
||||
|
||||
if iface_name == "wl_compositor":
|
||||
compositor = wl_registry.bind(id_num, WlCompositor, version)
|
||||
global_registry.surface = compositor.create_surface() # type: ignore
|
||||
elif iface_name == "zwp_idle_inhibit_manager_v1":
|
||||
global_registry.inhibit_manager = wl_registry.bind(
|
||||
id_num, ZwpIdleInhibitManagerV1, version
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inhibit system idle for a specified duration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"duration",
|
||||
nargs="?",
|
||||
default="0",
|
||||
help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle "off" argument - kill existing processes and exit
|
||||
if args.duration.lower() == "off":
|
||||
kill_existing_inhibit_processes()
|
||||
sys.exit(0)
|
||||
|
||||
done = Event()
|
||||
signal(SIGINT, lambda _, __: done.set())
|
||||
signal(SIGTERM, lambda _, __: done.set())
|
||||
|
||||
global_registry = GlobalRegistry()
|
||||
|
||||
try:
|
||||
display = Display()
|
||||
display.connect()
|
||||
|
||||
registry = display.get_registry() # type: ignore
|
||||
registry.user_data = global_registry
|
||||
registry.dispatcher["global"] = handle_registry_global
|
||||
|
||||
def shutdown() -> None:
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
display.disconnect()
|
||||
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
|
||||
if global_registry.surface is None:
|
||||
print("Error: Failed to create Wayland surface.")
|
||||
shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
if global_registry.inhibit_manager is None:
|
||||
print("Error: Your Wayland compositor does not support idle inhibition.")
|
||||
print("This usually means either:")
|
||||
print(
|
||||
"1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol"
|
||||
)
|
||||
print("2. The protocol is not enabled in your compositor")
|
||||
print("\nFor Hyprland, you can enable it by adding to your config:")
|
||||
print("misc:disable_autoreload = true")
|
||||
print("misc:enable_wayland_protocols = true")
|
||||
shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
|
||||
global_registry.surface
|
||||
)
|
||||
|
||||
display.dispatch()
|
||||
display.roundtrip()
|
||||
|
||||
duration = parse_duration(args.duration)
|
||||
if duration > 0:
|
||||
print(f"Inhibiting idle for {args.duration}...")
|
||||
# Set up timer to release inhibition
|
||||
timer = Timer(duration, lambda: done.set())
|
||||
timer.start()
|
||||
else:
|
||||
print("Inhibiting idle indefinitely...")
|
||||
|
||||
done.wait()
|
||||
print("Shutting down...")
|
||||
|
||||
inhibitor.destroy()
|
||||
if duration > 0:
|
||||
timer.cancel()
|
||||
|
||||
shutdown()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
print("Make sure you're running this under a Wayland session.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
setproctitle.setproctitle("modus-inhibit")
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import config.data as data
|
||||
|
||||
|
||||
def get_current_workspace():
|
||||
"""
|
||||
Get the current workspace ID using hyprctl.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "activeworkspace"], capture_output=True, text=True
|
||||
)
|
||||
# Assume the output similar to: "ID <number>"
|
||||
# Extracting the number from the output
|
||||
parts = result.stdout.split()
|
||||
for i, part in enumerate(parts):
|
||||
if part == "ID" and i + 1 < len(parts):
|
||||
return int(parts[i + 1])
|
||||
except Exception as e:
|
||||
print(f"Error getting current workspace: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
def get_screen_dimensions():
|
||||
"""
|
||||
Get screen dimensions from hyprctl.
|
||||
|
||||
Returns:
|
||||
tuple: (width, height) of the monitor containing the current workspace
|
||||
"""
|
||||
try:
|
||||
# Get current workspace
|
||||
workspace_id = get_current_workspace()
|
||||
|
||||
# Get monitor information
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "monitors"], capture_output=True, text=True
|
||||
)
|
||||
monitors = json.loads(result.stdout)
|
||||
|
||||
# Find the monitor containing our workspace
|
||||
for monitor in monitors:
|
||||
if monitor.get("activeWorkspace", {}).get("id") == workspace_id:
|
||||
return monitor.get("width", data.CURRENT_WIDTH), monitor.get(
|
||||
"height", data.CURRENT_HEIGHT
|
||||
)
|
||||
|
||||
# Fallback to first monitor
|
||||
if monitors:
|
||||
return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get(
|
||||
"height", data.CURRENT_HEIGHT
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error getting screen dimensions: {e}")
|
||||
|
||||
# Default fallback values
|
||||
return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
|
||||
|
||||
|
||||
def check_occlusion(occlusion_region, workspace=None):
|
||||
"""
|
||||
Check if a region is occupied by any window on a given workspace.
|
||||
|
||||
Parameters:
|
||||
occlusion_region: Can be one of:
|
||||
- tuple (side, size): where side is "top", "bottom", "left", or "right"
|
||||
and size is the pixel width of the region
|
||||
- tuple (x, y, width, height): The full region coordinates (legacy format)
|
||||
workspace (int, optional): The workspace ID to check. If None, the current workspace is used.
|
||||
|
||||
Returns:
|
||||
bool: True if any window overlaps with the occlusion region, False otherwise.
|
||||
"""
|
||||
if workspace is None:
|
||||
workspace = get_current_workspace()
|
||||
|
||||
# Handle simplified side-based format
|
||||
if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:
|
||||
side, size = occlusion_region
|
||||
if isinstance(side, str):
|
||||
# Convert side-based format to coordinates
|
||||
screen_width, screen_height = get_screen_dimensions()
|
||||
|
||||
if side.lower() == "bottom":
|
||||
occlusion_region = (0, screen_height - size, screen_width, size)
|
||||
elif side.lower() == "top":
|
||||
occlusion_region = (0, 0, screen_width, size)
|
||||
elif side.lower() == "left":
|
||||
occlusion_region = (0, 0, size, screen_height)
|
||||
elif side.lower() == "right":
|
||||
occlusion_region = (screen_width - size, 0, size, screen_height)
|
||||
|
||||
# Ensure occlusion_region is in the correct format (x, y, width, height)
|
||||
if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
|
||||
print(f"Invalid occlusion region format: {occlusion_region}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "clients"], capture_output=True, text=True
|
||||
)
|
||||
clients = json.loads(result.stdout)
|
||||
except Exception as e:
|
||||
print(f"Error retrieving client windows: {e}")
|
||||
return False
|
||||
|
||||
occ_x, occ_y, occ_width, occ_height = occlusion_region
|
||||
occ_x2 = occ_x + occ_width
|
||||
occ_y2 = occ_y + occ_height
|
||||
|
||||
for client in clients:
|
||||
# Check if client is mapped
|
||||
if not client.get("mapped", False):
|
||||
continue
|
||||
|
||||
# Ensure client has proper workspace information and matches the workspace
|
||||
client_workspace = client.get("workspace", {})
|
||||
if client_workspace.get("id") != workspace:
|
||||
continue
|
||||
|
||||
# Ensure client has position and size info
|
||||
position = client.get("at")
|
||||
size = client.get("size")
|
||||
if not position or not size:
|
||||
continue
|
||||
|
||||
x, y = position
|
||||
width, height = size
|
||||
win_x1, win_y1 = x, y
|
||||
win_x2, win_y2 = x + width, y + height
|
||||
|
||||
# Check for intersection between the window and occlusion region
|
||||
if not (
|
||||
win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2
|
||||
):
|
||||
return True # Occlusion region is occupied
|
||||
|
||||
return False # No window overlaps the occlusion region
|
||||
@@ -0,0 +1,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(),
|
||||
)
|
||||
)
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user