archive
This commit is contained in:
182 files changed
+18062
-277
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# ruff: noqa: F401, F403
|
||||
|
||||
|
||||
from .animatedscrollable import AnimatedScrollable
|
||||
from .animator import *
|
||||
from .clippingbox import ClippingBox
|
||||
|
||||
# from .corners import MyCorner, corners
|
||||
from .functions import *
|
||||
|
||||
# from .icon_resolver import IconResolver
|
||||
from .occlusion import *
|
||||
@@ -0,0 +1,76 @@
|
||||
# Author: Yousef EL-Darsh
|
||||
# License (SPDX): CC-BY-NC-ND-4.0
|
||||
|
||||
from functools import partial
|
||||
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
|
||||
from utils.animator import Animator, cubic_bezier
|
||||
|
||||
|
||||
class AnimatedScrollable(ScrolledWindow):
|
||||
def __init__(
|
||||
self,
|
||||
bezier_curve: tuple[float, float, float, float] = (0.2, 1, 0.8, 1.0),
|
||||
duration: float = 0.3,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._last_req = -1
|
||||
self._last_height = -1
|
||||
self._is_visible_state = False
|
||||
_, min_height = self.min_content_size
|
||||
_, max_height = self.max_content_size
|
||||
|
||||
self.height_animator = Animator(
|
||||
duration=duration,
|
||||
timing_function=partial(cubic_bezier, *bezier_curve),
|
||||
min_value=min_height,
|
||||
max_value=max_height,
|
||||
notify_value=self.on_animator_change,
|
||||
tick_widget=self,
|
||||
)
|
||||
|
||||
def on_animator_change(self, animator: Animator, *_):
|
||||
value = round(animator.value)
|
||||
|
||||
if value == self._last_height:
|
||||
return
|
||||
|
||||
self._last_height = value
|
||||
|
||||
if value < 1:
|
||||
if self._is_visible_state:
|
||||
self.hide()
|
||||
self._is_visible_state = False
|
||||
else:
|
||||
if not self._is_visible_state:
|
||||
self.show()
|
||||
self._is_visible_state = True
|
||||
|
||||
self.set_min_content_height(value)
|
||||
|
||||
def do_animate(
|
||||
self,
|
||||
from_height: int = 0,
|
||||
to_height: int = -1,
|
||||
):
|
||||
if to_height == -1:
|
||||
return
|
||||
|
||||
self._last_height = from_height
|
||||
|
||||
self.height_animator.pause()
|
||||
self.height_animator.min_value = from_height
|
||||
self.height_animator.max_value = to_height
|
||||
self.height_animator.play()
|
||||
return
|
||||
|
||||
def animate_size(self, height: int = -1):
|
||||
self._last_req = height
|
||||
return self.do_animate(self.min_content_size[1], height)
|
||||
|
||||
def do_get_preferred_height(self):
|
||||
value = self.height_animator.value
|
||||
value = 0 if value < 0 else value
|
||||
return value, value
|
||||
@@ -0,0 +1,263 @@
|
||||
# Author: Yousef EL-Darsh
|
||||
# License (SPDX): AGPL-3.0-or-later
|
||||
|
||||
from functools import cache
|
||||
from typing import Protocol, cast
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import GLib, Gtk, clamp
|
||||
|
||||
|
||||
@cache
|
||||
def lerp(start: float, end: float, progress: float) -> float:
|
||||
return start + (end - start) * progress
|
||||
|
||||
|
||||
@cache
|
||||
def steps(n: int, progress: float, start_jump: bool = False) -> float:
|
||||
if start_jump:
|
||||
return min(int(progress * n), n - 1) / (n - 1) if n > 1 else 0.0
|
||||
return min(int(progress * n + 1e-10), n) / n
|
||||
|
||||
|
||||
@cache
|
||||
def cubic_bezier(
|
||||
x1: float, y1: float, x2: float, y2: float, progress: float, epsilon=1e-6
|
||||
) -> float:
|
||||
# implementation yanked off of the internet, don't blame me about anything.
|
||||
# Fast-path boundaries to avoid overshoot and unnecessary work
|
||||
if progress <= 0.0 or progress >= 1.0:
|
||||
return clamp(progress, 0.0, 1.0)
|
||||
|
||||
t_guess = progress
|
||||
for _ in range(8):
|
||||
t = t_guess
|
||||
t_sq = t * t
|
||||
omt = 1.0 - t
|
||||
omt_sq = omt * omt
|
||||
|
||||
x = 3 * x1 * omt_sq * t + 3 * x2 * omt * t_sq + t * t_sq
|
||||
dx = 3 * x1 * omt_sq + 6 * (x2 - x1) * omt * t + 3 * (1 - x2) * t_sq
|
||||
|
||||
if abs(dx) < epsilon:
|
||||
break
|
||||
|
||||
delta = (x - progress) / dx
|
||||
t_guess -= delta
|
||||
t_guess = clamp(t_guess, 0.0, 1.0)
|
||||
|
||||
if abs(delta) < epsilon:
|
||||
break
|
||||
|
||||
t = clamp(t_guess, 0.0, 1.0)
|
||||
t_sq = t * t
|
||||
omt = 1.0 - t
|
||||
return 3 * y1 * omt * omt * t + 3 * y2 * omt * t_sq + t * t_sq
|
||||
|
||||
|
||||
def ease_linear(progress: float) -> float:
|
||||
return cubic_bezier(1, 1, 0, 0, progress)
|
||||
|
||||
|
||||
def ease_in(progress: float) -> float:
|
||||
return cubic_bezier(0.4, 0, 1, 1, progress)
|
||||
|
||||
|
||||
def ease_out(progress: float) -> float:
|
||||
return cubic_bezier(0, 0, 0.2, 1, progress)
|
||||
|
||||
|
||||
def ease_in_out(progress: float) -> float:
|
||||
return cubic_bezier(0.4, 0, 0.2, 1, progress)
|
||||
|
||||
|
||||
class TimingFunctionCallback(Protocol):
|
||||
def __call__(self, progress: float, *args, **kwargs) -> float: ...
|
||||
|
||||
|
||||
class Animator(Service):
|
||||
"""
|
||||
An animator is a simple way for animating a value on
|
||||
a set timeline based on a given timing function
|
||||
"""
|
||||
|
||||
@Signal
|
||||
def finished(self) -> None: ...
|
||||
|
||||
@Property(TimingFunctionCallback, "read-write")
|
||||
def timing_function(self) -> TimingFunctionCallback:
|
||||
return self._timing_function
|
||||
|
||||
@timing_function.setter
|
||||
def timing_function(self, value: TimingFunctionCallback):
|
||||
self._timing_function = value
|
||||
return
|
||||
|
||||
@Property(float, "read-write")
|
||||
def duration(self):
|
||||
return self._duration
|
||||
|
||||
@duration.setter
|
||||
def duration(self, value: float):
|
||||
if value <= 0.0:
|
||||
raise ValueError("duration can't be smaller than or equal to 0.0")
|
||||
|
||||
self._duration = 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): # this setter is intended for internal usage only
|
||||
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,
|
||||
duration: float = 0.8,
|
||||
timing_function: TimingFunctionCallback = ease_linear,
|
||||
value: float = 0.0,
|
||||
min_value: float = 0.0,
|
||||
max_value: float = 1.0,
|
||||
repeat: bool = False,
|
||||
tick_widget: Gtk.Widget | None = None,
|
||||
tick_interval: int = 16,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._playing = False
|
||||
self._value = value
|
||||
self._min_value = 0.0
|
||||
self._max_value = 1.0
|
||||
self._repeat = False
|
||||
self._duration = 0.8
|
||||
self._timing_function = timing_function
|
||||
self._tick_widget = tick_widget
|
||||
self._tick_interval = tick_interval
|
||||
|
||||
self.timing_function = timing_function
|
||||
self.repeat = repeat
|
||||
self.duration = duration
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.value = value
|
||||
self.playing = False
|
||||
|
||||
self._start_time = None
|
||||
self._tick_handler = None
|
||||
self._timeline_pos = 0.0
|
||||
|
||||
def do_get_time_now(self):
|
||||
return GLib.get_monotonic_time() / 1_000_000
|
||||
|
||||
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.0, elapsed_time / self._duration)
|
||||
|
||||
self.value = lerp(
|
||||
self._min_value,
|
||||
self._max_value,
|
||||
self._timing_function(progress=self._timeline_pos),
|
||||
)
|
||||
|
||||
if not self._timeline_pos >= 1.0:
|
||||
return
|
||||
|
||||
if not self._repeat:
|
||||
# all done..
|
||||
self.value = self._max_value
|
||||
self.finished()
|
||||
self.pause()
|
||||
return
|
||||
|
||||
self._start_time = delta_time
|
||||
self._timeline_pos = 0.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 not self._tick_handler:
|
||||
return
|
||||
|
||||
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.playing = True
|
||||
self._start_time = self.do_get_time_now()
|
||||
|
||||
if self._tick_handler:
|
||||
return
|
||||
|
||||
if self._tick_widget:
|
||||
self._tick_handler = self._tick_widget.add_tick_callback(
|
||||
self.do_handle_tick
|
||||
)
|
||||
return
|
||||
|
||||
self._tick_handler = GLib.timeout_add(self._tick_interval, self.do_handle_tick)
|
||||
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,40 @@
|
||||
from typing import cast
|
||||
|
||||
from fabric.utils import cairo, math
|
||||
from fabric.widgets.box import Box
|
||||
|
||||
|
||||
class ClippingBox(Box):
|
||||
@staticmethod
|
||||
def render_shape(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)))
|
||||
|
||||
return cr.close_path()
|
||||
|
||||
def do_draw(self, cr: cairo.Context):
|
||||
cr.save()
|
||||
ClippingBox.render_shape(
|
||||
cr,
|
||||
self.get_allocated_width(),
|
||||
self.get_allocated_height(),
|
||||
cast(
|
||||
int,
|
||||
self.get_style_context().get_property(
|
||||
"border-radius", self.get_state_flags()
|
||||
),
|
||||
),
|
||||
)
|
||||
cr.clip()
|
||||
|
||||
Box.do_draw(self, cr)
|
||||
|
||||
cr.restore()
|
||||
return True
|
||||
@@ -0,0 +1,50 @@
|
||||
DEFAULT = {
|
||||
"debug": {"enabled": False},
|
||||
"wallpaper_dir": {"path": "~/Pictures/Wallpapers/"},
|
||||
"corners": {"enabled": True},
|
||||
"keyboard_layout": {"layouts": ["us"]},
|
||||
"panel": {
|
||||
"enabled": True,
|
||||
"position": "top",
|
||||
"autohide": True,
|
||||
"systray": {"enabled": True, "hide_items": []},
|
||||
"network": {"enabled": True},
|
||||
"bluetooth": {"enabled": True},
|
||||
"workspace": {"enabled": True, "hide_special_ws": True},
|
||||
"datetime": {"enabled": True, "12hrs": True},
|
||||
"taskbar": {
|
||||
"enabled": True,
|
||||
"hide_empty": True,
|
||||
"hide_special": True,
|
||||
"icon_size": 20,
|
||||
"group_apps": False,
|
||||
},
|
||||
"battery": {
|
||||
"enabled": True,
|
||||
"label": True,
|
||||
"hide_on_full": True,
|
||||
"icon_size": 28,
|
||||
},
|
||||
},
|
||||
"osd": {
|
||||
"enabled": True,
|
||||
"position": "bottom",
|
||||
"audio": True,
|
||||
"brightness": True,
|
||||
"capslock": True,
|
||||
"kb_layout": True,
|
||||
"network": True,
|
||||
"microphone": True,
|
||||
"battery": True,
|
||||
"cooldown_ms": 200,
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": True,
|
||||
"timeout": "10s",
|
||||
"image_size": 64,
|
||||
"width": 360,
|
||||
"buttons_per_row": 3,
|
||||
"corner_size": 16,
|
||||
"revealer_duration": 400,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from functools import partial
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.shapes import Corner
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from config.data import DATA
|
||||
from services.config_watcher import has_config_changed, on_config_change
|
||||
|
||||
bake_corner = partial(Corner, name="corner")
|
||||
|
||||
# Corner with defaults for standalone corner windows (fixed size, centered, non-expanding)
|
||||
CornerBase = partial(
|
||||
bake_corner,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
size=20,
|
||||
)
|
||||
|
||||
|
||||
def expanded_corner(orientation: str, **kwargs):
|
||||
return bake_corner(orientation=orientation, v_expand=True, h_expand=True, **kwargs)
|
||||
|
||||
|
||||
class MyCorner(Box):
|
||||
def __init__(self, corner):
|
||||
super().__init__(
|
||||
name="corner-container",
|
||||
children=CornerBase(orientation=corner),
|
||||
)
|
||||
|
||||
|
||||
class CornerWindow(Window):
|
||||
def __init__(self, corner: str):
|
||||
anchor = {
|
||||
"top-left": "top left",
|
||||
"top-right": "top right",
|
||||
"bottom-left": "bottom left",
|
||||
"bottom-right": "bottom right",
|
||||
}[corner]
|
||||
|
||||
super().__init__(
|
||||
layer="bottom",
|
||||
anchor=anchor,
|
||||
exclusivity="normal",
|
||||
pass_through=True,
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
)
|
||||
|
||||
# Size to content by not expanding and only containing the single corner widget
|
||||
content = Box(
|
||||
children=MyCorner(corner),
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
)
|
||||
|
||||
self.add(content)
|
||||
self.show_all()
|
||||
|
||||
|
||||
# Create all corner windows
|
||||
corners = [
|
||||
CornerWindow("top-left"),
|
||||
CornerWindow("top-right"),
|
||||
CornerWindow("bottom-left"),
|
||||
CornerWindow("bottom-right"),
|
||||
]
|
||||
|
||||
|
||||
def update_corners_visibility():
|
||||
data = DATA()
|
||||
corners_visible = data["corners"]
|
||||
for corner in corners:
|
||||
corner.set_visible(corners_visible)
|
||||
|
||||
|
||||
def _on_config_change(new_config, old_config):
|
||||
if has_config_changed(old_config, new_config, "corners.enabled"):
|
||||
update_corners_visibility()
|
||||
|
||||
|
||||
update_corners_visibility()
|
||||
on_config_change(_on_config_change)
|
||||
@@ -0,0 +1,76 @@
|
||||
from fabric.utils import Gio, GLib
|
||||
|
||||
|
||||
class GioDBusHelper:
|
||||
"""A helper class for interacting with D-Bus using the Gio library."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_name,
|
||||
object_path,
|
||||
interface_name,
|
||||
bus_type=Gio.BusType.SYSTEM,
|
||||
):
|
||||
self.bus = Gio.bus_get_sync(bus_type, None)
|
||||
|
||||
self.bus_name = bus_name
|
||||
self.object_path = object_path
|
||||
self.proxy = Gio.DBusProxy.new_sync(
|
||||
self.bus,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
bus_name,
|
||||
object_path,
|
||||
interface_name,
|
||||
None,
|
||||
)
|
||||
|
||||
def call_method(
|
||||
self,
|
||||
bus_name,
|
||||
object_path,
|
||||
interface_name,
|
||||
method_name,
|
||||
parameters=None,
|
||||
timeout=-1,
|
||||
):
|
||||
if parameters is None:
|
||||
parameters = GLib.Variant("()", ())
|
||||
result = self.bus.call_sync(
|
||||
bus_name,
|
||||
object_path,
|
||||
interface_name,
|
||||
method_name,
|
||||
parameters,
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
timeout,
|
||||
None,
|
||||
)
|
||||
return result.unpack()
|
||||
|
||||
def listen_signal(
|
||||
self, member, callback, interface_name="org.freedesktop.DBus.Properties"
|
||||
):
|
||||
"""Register a signal listener (conn, sender, path, iface, signal, parameters)"""
|
||||
self.bus.signal_subscribe(
|
||||
self.bus_name,
|
||||
interface_name,
|
||||
member,
|
||||
self.object_path,
|
||||
arg0=None,
|
||||
flags=Gio.DBusSignalFlags.NONE,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
def set_property(self, interface_name, property_name, value_variant):
|
||||
"""Sets a D-Bus property using the standard D-Bus Properties interface."""
|
||||
return self.call_method(
|
||||
bus_name=self.bus_name,
|
||||
object_path=self.object_path,
|
||||
interface_name="org.freedesktop.DBus.Properties",
|
||||
method_name="Set",
|
||||
parameters=GLib.Variant(
|
||||
"(ssv)", (interface_name, property_name, value_variant)
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
import ctypes
|
||||
import json
|
||||
import subprocess
|
||||
from collections.abc import Callable, Iterable
|
||||
from functools import reduce
|
||||
from typing import NamedTuple, TypeVar, cast
|
||||
|
||||
from fabric.utils import (
|
||||
Gdk,
|
||||
GdkPixbuf,
|
||||
GLib,
|
||||
Gtk,
|
||||
cairo,
|
||||
exec_shell_command,
|
||||
invoke_repeater,
|
||||
logger,
|
||||
os,
|
||||
time,
|
||||
)
|
||||
from fabric.widgets.box import Box
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Rectangle(NamedTuple):
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
def get_children_height_limit(
|
||||
viewport: Box,
|
||||
max_n_children: int,
|
||||
transform_func: Callable[[Gtk.Widget], cairo.RectangleInt] | None = None,
|
||||
) -> int:
|
||||
spacing: int = viewport.get_spacing()
|
||||
|
||||
children = viewport.children
|
||||
children_len = len(viewport.children)
|
||||
|
||||
if children_len < 1:
|
||||
return 0
|
||||
|
||||
if children_len > max_n_children:
|
||||
children_len = max_n_children
|
||||
|
||||
# calculate the new height
|
||||
# ( <the spacing for each child combined, last child doesn't have spacing> ) + ( <the total height of all the children> )
|
||||
return (spacing * (children_len - 1)) + reduce(
|
||||
lambda x, y: x + y,
|
||||
(
|
||||
(
|
||||
transform_func(children[i])
|
||||
if transform_func
|
||||
else cast(
|
||||
cairo.RectangleInt,
|
||||
children[i].get_preferred_size().minimum_size, # type: ignore
|
||||
)
|
||||
).height # type: ignore
|
||||
for i in range(children_len)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Function to set the process name
|
||||
def set_process_name(name: str):
|
||||
libc = ctypes.CDLL("libc.so.6")
|
||||
libc.prctl(15, name.encode("utf-8"), 0, 0, 0)
|
||||
|
||||
|
||||
def write_json_file(data: dict, path: str):
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write json: {e}")
|
||||
|
||||
|
||||
def read_json_file(file_path: str) -> dict | None:
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"JSON file {file_path} does not exist.")
|
||||
return None
|
||||
|
||||
with open(file_path) as file:
|
||||
try:
|
||||
return json.load(file)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to read JSON file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Function to check if a workspace ID is special
|
||||
def is_special_workspace_id(ws_id) -> bool:
|
||||
try:
|
||||
workspace_id = int(ws_id)
|
||||
return workspace_id < 0
|
||||
except (ValueError, TypeError):
|
||||
return bool(isinstance(ws_id, str) and ws_id.startswith("special:"))
|
||||
|
||||
|
||||
# Function to check if a client is on a special workspace
|
||||
def is_special_workspace(client: dict) -> bool:
|
||||
if "workspace" not in client:
|
||||
return False
|
||||
|
||||
workspace = client["workspace"]
|
||||
if "name" in workspace:
|
||||
workspace_name = workspace["name"]
|
||||
if is_special_workspace_id(workspace_name):
|
||||
return True
|
||||
if "id" in workspace:
|
||||
workspace_id = workspace["id"]
|
||||
if is_special_workspace_id(workspace_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def add_style_class_lazy(widget: Gtk.Widget, class_name: str | Iterable[str]) -> int:
|
||||
return invoke_repeater(
|
||||
50, lambda: widget.add_style_class(class_name), initial_call=False
|
||||
)
|
||||
|
||||
|
||||
def is_app_running(app_name: str) -> bool:
|
||||
return len(exec_shell_command(f"pidof {app_name}")) != 0
|
||||
|
||||
|
||||
def copy_text(text: str) -> bool:
|
||||
try:
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
clipboard.set_text(text, -1)
|
||||
clipboard.store()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def copy_image(image_path: str) -> bool:
|
||||
try:
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
image = GdkPixbuf.Pixbuf.new_from_file(image_path)
|
||||
clipboard.set_image(image)
|
||||
clipboard.store()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Terminal helpers
|
||||
def terminal_exec_command(terminal: str, command: str, title: str | None = None) -> str:
|
||||
title_flag = title or ""
|
||||
mapping = {
|
||||
"kitty": f"kitty{f" --title='{title_flag}'" if title_flag else ''} -e {command}",
|
||||
"alacritty": f"alacritty{f" --title '{title_flag}'" if title_flag else ''} -e {command}",
|
||||
"gnome-terminal": f"gnome-terminal{f" --title='{title_flag}'" if title_flag else ''} -- {command}",
|
||||
"xterm": f"xterm{f" -title '{title_flag}'" if title_flag else ''} -e {command}",
|
||||
"foot": f"foot {command}",
|
||||
"konsole": f"konsole -e {command}",
|
||||
"xfce4-terminal": f"xfce4-terminal -e '{command}'",
|
||||
}
|
||||
return mapping.get(terminal, f"{terminal} -e {command}")
|
||||
|
||||
|
||||
def terminal_open_in_directory_command(
|
||||
terminal: str,
|
||||
directory: str,
|
||||
title: str | None = None,
|
||||
inner_command: str | None = None,
|
||||
) -> str:
|
||||
title_segment = title or ""
|
||||
directory = directory
|
||||
if inner_command:
|
||||
mapping = {
|
||||
"kitty": f"kitty{f" --title='{title_segment}'" if title_segment else ''} --directory {directory} {inner_command}",
|
||||
"alacritty": f"alacritty{f" --title '{title_segment}'" if title_segment else ''} --working-directory {directory} -e {inner_command}",
|
||||
"gnome-terminal": f"gnome-terminal{f" --title='{title_segment}'" if title_segment else ''} --working-directory {directory} -- {inner_command}",
|
||||
"foot": f"foot --working-directory {directory} {inner_command}",
|
||||
"xfce4-terminal": f"xfce4-terminal --default-working-directory {directory} -e '{inner_command}'",
|
||||
}
|
||||
return mapping.get(
|
||||
terminal,
|
||||
f"{terminal} --working-directory {directory} -e {inner_command}",
|
||||
)
|
||||
|
||||
mapping = {
|
||||
"kitty": f"kitty{f" --title='{title_segment}'" if title_segment else ''} --directory {directory}",
|
||||
"alacritty": f"alacritty{f" --title '{title_segment}'" if title_segment else ''} --working-directory {directory}",
|
||||
"gnome-terminal": f"gnome-terminal{f" --title='{title_segment}'" if title_segment else ''} --working-directory {directory}",
|
||||
"foot": f"foot --working-directory {directory}",
|
||||
"xfce4-terminal": f"xfce4-terminal --default-working-directory {directory}",
|
||||
}
|
||||
return mapping.get(terminal, f"{terminal} --working-directory {directory}")
|
||||
|
||||
|
||||
# Subprocess helper with structured result and timeouts
|
||||
class CommandResult(NamedTuple):
|
||||
returncode: int
|
||||
stdout: bytes | str
|
||||
stderr: bytes | str
|
||||
|
||||
|
||||
def run_command(
|
||||
args: list[str],
|
||||
timeout: float | None = None,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
*,
|
||||
input: bytes | str | None = None,
|
||||
text: bool = True,
|
||||
) -> CommandResult:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=text,
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
input=input,
|
||||
)
|
||||
return CommandResult(result.returncode, result.stdout, result.stderr)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return CommandResult(
|
||||
-1,
|
||||
(e.stdout or b"" if not text else e.stdout or ""),
|
||||
(e.stderr or (b"timeout" if not text else "timeout")),
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return CommandResult(127, "", str(e))
|
||||
except Exception as e:
|
||||
return CommandResult(1, "", str(e))
|
||||
|
||||
|
||||
def trigger_paste_shortcut(delay_ms: int = 50) -> bool:
|
||||
time.sleep(delay_ms / 1000)
|
||||
result = run_command(["wtype", "-M", "ctrl", "v", "-m", "ctrl"], timeout=1)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def debounce(ms: int):
|
||||
"""
|
||||
Debounce a function.
|
||||
Useful for preventing UI flickering during fast typing.
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
timer_id_attr = f"_debounce_timer_{func.__name__}"
|
||||
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if existing_timer := getattr(self, timer_id_attr, None):
|
||||
GLib.source_remove(existing_timer)
|
||||
|
||||
def timeout_cb():
|
||||
setattr(self, timer_id_attr, 0)
|
||||
func(self, *args, **kwargs)
|
||||
return False
|
||||
|
||||
setattr(self, timer_id_attr, GLib.timeout_add(ms, timeout_cb))
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,107 @@
|
||||
from fabric.utils import GdkPixbuf, GLib, Gtk, os, re
|
||||
|
||||
from config.data import ICON_CACHE_FILE
|
||||
from utils.functions import read_json_file, write_json_file
|
||||
|
||||
|
||||
class IconResolver:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if os.path.exists(ICON_CACHE_FILE):
|
||||
self._icon_dict = read_json_file(ICON_CACHE_FILE) or {}
|
||||
else:
|
||||
self._icon_dict = {}
|
||||
|
||||
self.default_application_icon = "application-x-executable"
|
||||
|
||||
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)
|
||||
self._store_new_icon(app_id, new_icon)
|
||||
return new_icon
|
||||
|
||||
def resolve_icon(self, pixmap, icon_name: str, app_id: str, icon_size: int = 16):
|
||||
try:
|
||||
return (
|
||||
pixmap.as_pixbuf(icon_size, GdkPixbuf.InterpType.HYPER)
|
||||
if pixmap is not None
|
||||
else Gtk.IconTheme()
|
||||
.get_default()
|
||||
.load_icon(
|
||||
icon_name,
|
||||
icon_size,
|
||||
Gtk.IconLookupFlags.FORCE_SIZE,
|
||||
)
|
||||
)
|
||||
except GLib.GError:
|
||||
return self.get_icon_pixbuf(app_id, icon_size)
|
||||
|
||||
def get_icon_pixbuf(self, app_id: str, size: int = 16):
|
||||
icon_name = self.get_icon_name(app_id)
|
||||
try:
|
||||
return Gtk.IconTheme.get_default().load_icon(
|
||||
icon_name,
|
||||
size,
|
||||
Gtk.IconLookupFlags.FORCE_SIZE,
|
||||
)
|
||||
except GLib.GError:
|
||||
return (
|
||||
Gtk.IconTheme()
|
||||
.get_default()
|
||||
.load_icon(
|
||||
"image-missing",
|
||||
size,
|
||||
Gtk.IconLookupFlags.FORCE_SIZE,
|
||||
)
|
||||
)
|
||||
|
||||
def _store_new_icon(self, app_id: str, icon: str):
|
||||
self._icon_dict[app_id] = icon
|
||||
write_json_file(self._icon_dict, ICON_CACHE_FILE)
|
||||
|
||||
def _get_icon_from_desktop_file(self, desktop_file_path: str):
|
||||
with open(desktop_file_path) as f:
|
||||
for line in f.readlines():
|
||||
if "Icon=" in line:
|
||||
return "".join(line[5:].split())
|
||||
|
||||
return self.default_application_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 = data_dir + "/applications/"
|
||||
if os.path.exists(data_dir):
|
||||
# Do name resolving here
|
||||
files = os.listdir(data_dir)
|
||||
matching = [
|
||||
s for s in files if "".join(app_id.lower().split()) in s.lower()
|
||||
]
|
||||
if matching:
|
||||
return 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 data_dir + matching[0]
|
||||
|
||||
return None
|
||||
|
||||
def _compositor_find_icon(self, app_id: str):
|
||||
if Gtk.IconTheme.get_default().has_icon(app_id):
|
||||
return app_id
|
||||
if Gtk.IconTheme.get_default().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_application_icon
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
|
||||
from fabric.utils import logger
|
||||
|
||||
import config.data as data
|
||||
from utils.functions import run_command
|
||||
|
||||
|
||||
def get_current_workspace():
|
||||
"""
|
||||
Get the current workspace ID using hyprctl.
|
||||
"""
|
||||
try:
|
||||
result = run_command(["hyprctl", "activeworkspace"], timeout=5)
|
||||
# Assume the output similar to: "ID <number>"
|
||||
# Extracting the number from the output
|
||||
parts = (result.stdout or "").split() if isinstance(result.stdout, str) else []
|
||||
for i, part in enumerate(parts):
|
||||
if part == "ID" and i + 1 < len(parts):
|
||||
return int(parts[i + 1])
|
||||
except Exception as e:
|
||||
logger.error(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 = run_command(["hyprctl", "-j", "monitors"], timeout=5)
|
||||
monitors = json.loads(result.stdout) if isinstance(result.stdout, str) else []
|
||||
|
||||
# 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:
|
||||
logger.error(f"Error getting screen dimensions: {e}")
|
||||
|
||||
# Default fallback values
|
||||
return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
|
||||
|
||||
|
||||
def resolve_region(region):
|
||||
"""
|
||||
Convert a side-based region (side, size) to coordinates (x, y, w, h).
|
||||
"""
|
||||
if isinstance(region, tuple) and len(region) == 2:
|
||||
side, size = region
|
||||
if isinstance(side, str):
|
||||
screen_width, screen_height = get_screen_dimensions()
|
||||
if side.lower() == "bottom":
|
||||
return (0, screen_height - size, screen_width, size)
|
||||
elif side.lower() == "top":
|
||||
return (0, 0, screen_width, size)
|
||||
elif side.lower() == "left":
|
||||
return (0, 0, size, screen_height)
|
||||
elif side.lower() == "right":
|
||||
return (screen_width - size, 0, size, screen_height)
|
||||
return region
|
||||
|
||||
|
||||
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
|
||||
occlusion_region = resolve_region(occlusion_region)
|
||||
|
||||
# Ensure occlusion_region is in the correct format (x, y, width, height)
|
||||
if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
|
||||
logger.warning(f"Invalid occlusion region format: {occlusion_region}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = run_command(["hyprctl", "-j", "clients"], timeout=2)
|
||||
if result.returncode != 0:
|
||||
logger.warning("hyprctl command failed, assuming no occlusion")
|
||||
return False
|
||||
clients = json.loads(result.stdout) if isinstance(result.stdout, str) else []
|
||||
except Exception as e:
|
||||
logger.warning(f"Error retrieving client windows: {e}, assuming no occlusion")
|
||||
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
|
||||
|
||||
|
||||
def get_cursor_pos():
|
||||
"""
|
||||
Get the current cursor position from hyprctl.
|
||||
Returns:
|
||||
tuple: (x, y) coordinates
|
||||
"""
|
||||
try:
|
||||
result = run_command(["hyprctl", "cursorpos"], timeout=2)
|
||||
if result.stdout:
|
||||
# Output format is "x, y"
|
||||
coords = (
|
||||
result.stdout.strip().split(",")
|
||||
if isinstance(result.stdout, str)
|
||||
else []
|
||||
)
|
||||
if len(coords) == 2:
|
||||
return int(coords[0].strip()), int(coords[1].strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting cursor position: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_mouse_in_region(region):
|
||||
"""
|
||||
Check if the cursor is within a given region (x, y, width, height).
|
||||
"""
|
||||
cursor = get_cursor_pos()
|
||||
if not cursor:
|
||||
return False
|
||||
|
||||
# Handle side-based format
|
||||
region = resolve_region(region)
|
||||
|
||||
if not isinstance(region, tuple) or len(region) != 4:
|
||||
return False
|
||||
|
||||
cx, cy = cursor
|
||||
rx, ry, rw, rh = region
|
||||
|
||||
return rx <= cx <= rx + rw and ry <= cy <= ry + rh
|
||||
Reference in new issue
Block a user