This commit is contained in:
sankalp committed 2026-07-12 18:56:10 +05:45
1 parent 6b62051f93
commit dc60e20bd7
182 files changed
+18062 -277

No files matched your search

+12
View File
@@ -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 *
+76
View File
@@ -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
+263
View File
@@ -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()
+40
View File
@@ -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
+50
View File
@@ -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,
},
}
+86
View File
@@ -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)
+76
View File
@@ -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)
),
)
+264
View File
@@ -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
+107
View File
@@ -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
)
+187
View File
@@ -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