feat(src/shared): add flat scale widget and smooth-switch styling

Implement animated flat scale widget with CSS styling and smooth-switch UI enhancements
This commit is contained in:
sankalp committed 2026-06-17 01:46:19 +05:45
1 parent 687c5d6bcf
commit 87884eef09
9 files changed
+1230 -533

No files matched your search

+34 -1
View File
@@ -269,4 +269,37 @@
#component-name { #component-name {
font-size: 10px; font-size: 10px;
} }
/* FlatScale styling */
.flat-scale {
margin: 4px 0px;
}
.flat-scale slider {
background-color: @text-main;
min-height: 24px;
min-width: 24px;
border-radius: apply(radius-full);
}
.flat-scale trough {
background-color: alpha(#666, 0.5);
min-height: 25px;
border-radius: apply(radius-full);
border: 1px solid alpha(#444, 0.3);
}
.flat-scale highlight {
background-color: alpha(@text-main, 0.8);
min-height: 25px;
border-radius: apply(radius-full);
}
/* SmoothSwitch styling */
.smooth-switch {
min-width: 44px;
min-height: 24px;
background-color: alpha(#666, 0.5);
color: @text-main;
}
.smooth-switch.checked {
background-color: alpha(@text-main, 0.8);
color: @background;
}
+710
View File
@@ -0,0 +1,710 @@
import math
from typing import Callable, Iterable, Literal, TypedDict
import cairo
from fabric.utils import Gdk, GObject, Gtk
from fabric.utils.helpers import clamp
from fabric.widgets.widget import Widget
from gi.repository import Pango, PangoCairo # noqa: E402
from shared.widgets.animator import Animator, ease_in, ease_in_out, ease_out
# ------------------------------------------------------------------ #
# Helpers #
# ------------------------------------------------------------------ #
def _ease_out_expo(t: float) -> float:
if t >= 1.0:
return 1.0
return 1.0 - pow(2.0, -10.0 * t)
def _ease_in_expo(t: float) -> float:
if t <= 0.0:
return 0.0
return pow(2.0, 10.0 * t - 10.0)
def _draw_rounded_rect(cr, x, y, w, h, r):
r = min(r, w / 2, h / 2)
if r <= 0:
cr.rectangle(x, y, w, h)
return
cr.new_sub_path()
cr.move_to(x + r, y)
cr.line_to(x + w - r, y)
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
cr.line_to(x + w, y + h - r)
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
cr.line_to(x + r, y + h)
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
cr.line_to(x, y + r)
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
cr.close_path()
# ------------------------------------------------------------------ #
# TypedDict #
# ------------------------------------------------------------------ #
class FlatScaleStyle(TypedDict):
slider_color: Gdk.RGBA
slider_height: float
slider_thickness: float
top_gap: float
bottom_gap: float
left_gap: float
right_gap: float
corner_radius: float
progress_color: Gdk.RGBA
progress_thickness: float
trough_color: Gdk.RGBA
trough_thickness: float
background_color: Gdk.RGBA
# ------------------------------------------------------------------ #
# Widget #
# ------------------------------------------------------------------ #
class FlatScale(Gtk.DrawingArea, Widget):
"""
A Cairo-drawn linear scale widget for Fabric.
Styled via CSS sub-nodes (slider, trough, highlight), mirroring
CircularScale's gadget context approach. Use margin-top/margin-bottom
on the slider node to create a gap between the slider and the arcs.
While dragging, a compact percentage pill animates above (or beside, for
vertical) the slider handle. The pill is small enough that only a few
pixels of headroom are needed above the widget.
Example CSS:
.flat-scale slider {
background-color: var(--primary);
min-height: 16px;
min-width: 16px;
border-radius: 8px;
margin-top: 4px;
margin-bottom: 4px;
}
.flat-scale trough {
background-color: alpha(var(--primary), 0.3);
border-width: 6px;
}
.flat-scale trough highlight {
background-color: var(--primary);
border-width: 6px;
}
"""
__gsignals__ = {
"value-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
}
# Mini bubble constants — kept deliberately compact
_BUBBLE_GAP = -5 # px between top of handle and bottom of pill
_BUBBLE_FONT_SIZE = 8 # pt — smaller than body text
_BUBBLE_PAD_X = 4 # horizontal padding inside pill
_BUBBLE_PAD_Y = -1 # vertical padding inside pill
_BUBBLE_RADIUS = 10 # corner radius of pill
_BUBBLE_SCALE_FROM = 0.55 # scale at start of open animation
def __init__(
self,
value: float = 0.5,
min_value: float = 0.0,
max_value: float = 1.0,
step: float = 0.05,
orientation: Literal["horizontal", "vertical"]
| Gtk.Orientation = Gtk.Orientation.HORIZONTAL,
on_value_changed: Callable[["FlatScale", float], None] | None = None,
value_formatter: Callable[[float], str] | 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,
):
Gtk.DrawingArea.__init__(self)
Widget.__init__(
self,
name=name,
visible=visible,
all_visible=all_visible,
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._value = clamp(value, min_value, max_value)
self._min_value = min_value
self._max_value = max_value
self._step = step
self._dragging = False
self._hovering = False
self._cached_style: FlatScaleStyle | None = None
self._gadget_classes: dict[Gtk.StyleContext, frozenset[str] | None] = {}
self._anim_initialized = False
self._scroll_accumulator = 0.0
self._value_formatter = value_formatter or (
lambda val: f"{round(self.do_normalize_value() * 100)}%"
)
# Slider press/hover size animator
self._anim_press = Animator(
timing_function=ease_in_out,
duration=0.1,
min_value=16.0,
max_value=16.0,
tick_widget=self,
)
self._anim_press.connect("notify::value", lambda *_: self.queue_draw())
# Bubble open animator (0 → 1)
self._anim_bubble_open = Animator(
timing_function=ease_out,
duration=0.18,
min_value=0.0,
max_value=1.0,
tick_widget=self,
)
self._anim_bubble_open.connect("notify::value", lambda *_: self.queue_draw())
# Bubble close animator (0 → 1, mapped as 1 - value)
self._anim_bubble_close = Animator(
timing_function=ease_in,
duration=0.12,
min_value=0.0,
max_value=1.0,
tick_widget=self,
)
self._anim_bubble_close.connect("notify::value", lambda *_: self.queue_draw())
self._bubble_progress = 0.0 # 0.0 = hidden, 1.0 = fully visible
self._bubble_closing = False
if isinstance(orientation, str):
self._orientation = (
Gtk.Orientation.HORIZONTAL
if orientation == "horizontal"
else Gtk.Orientation.VERTICAL
)
else:
self._orientation = orientation
self._highlight_ctx = self.do_create_gadget_context("highlight")
self._trough_ctx = self.do_create_gadget_context("trough")
self._slider_ctx = self.do_create_gadget_context("slider")
self.get_style_context().add_class("flat-scale")
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.SMOOTH_SCROLL_MASK
| Gdk.EventMask.ENTER_NOTIFY_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.connect("button-press-event", self._on_button_press)
self.connect("button-release-event", self._on_button_release)
self.connect("motion-notify-event", self._on_motion)
self.connect("scroll-event", self._on_scroll)
self.connect("enter-notify-event", self._on_enter)
self.connect("leave-notify-event", self._on_leave)
self.connect("realize", self._on_realize)
self.show_all()
if on_value_changed:
self.connect("value-changed", on_value_changed)
# ------------------------------------------------------------------ #
# Value #
# ------------------------------------------------------------------ #
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, v: float) -> None:
self.set_value(v)
def set_value(self, value: float) -> None:
new = clamp(value, self._min_value, self._max_value)
if new != self._value:
self._value = new
self.emit("value-changed", self._value)
self.queue_draw()
def get_value(self) -> float:
return self._value
# ------------------------------------------------------------------ #
# Realize / gadget contexts #
# ------------------------------------------------------------------ #
def _on_realize(self, _widget) -> None:
state = self.get_state_flags()
self._gadget_classes[self._slider_ctx] = None
self.do_update_gadget_path(self._slider_ctx, "slider")
default_size = self._slider_ctx.get_property("min-height", state)
self._anim_press.min_value = default_size
self._anim_press.max_value = default_size
self._anim_press.value = default_size
self._anim_initialized = True
def do_create_gadget_context(self, node_name: str) -> Gtk.StyleContext:
ctx = Gtk.StyleContext()
ctx.set_parent(self.get_style_context())
ctx.set_screen(self.get_screen())
self._gadget_classes[ctx] = None
ctx.connect("changed", lambda *_: self.do_update_gadget_path(ctx, node_name))
self.do_update_gadget_path(ctx, node_name)
return ctx
def do_update_gadget_path(self, context: Gtk.StyleContext, node_name: str) -> None:
parent_ctx = self.get_style_context()
current_classes = frozenset(parent_ctx.list_classes())
if current_classes != self._gadget_classes[context]:
self._gadget_classes[context] = current_classes
new_path = parent_ctx.get_path().copy()
for cls in list(new_path.iter_list_classes(-1)):
if cls not in current_classes:
new_path.iter_remove_class(-1, cls)
for cls in current_classes:
if not new_path.iter_has_class(-1, cls):
new_path.iter_add_class(-1, cls)
new_path.append_type(GObject.TYPE_NONE)
new_path.iter_set_object_name(-1, node_name)
context.set_path(new_path)
context.set_state(self.get_state_flags())
self._cached_style = None
self.queue_draw()
# ------------------------------------------------------------------ #
# Style resolution #
# ------------------------------------------------------------------ #
def do_get_border_width(
self, context: Gtk.StyleContext, state: Gtk.StateFlags
) -> float:
border = context.get_border(state)
return max(
border.top,
border.bottom,
border.left,
border.right,
context.get_property("min-width", state),
context.get_property("min-height", state),
)
def do_resolve_style(self) -> FlatScaleStyle:
if self._cached_style is not None:
return self._cached_style
state = self.get_state_flags()
self._cached_style = FlatScaleStyle(
slider_color=self._slider_ctx.get_background_color(state),
slider_height=self._slider_ctx.get_property("min-height", state),
slider_thickness=self._slider_ctx.get_property("min-width", state),
top_gap=self._slider_ctx.get_property("margin-top", state),
bottom_gap=self._slider_ctx.get_property("margin-bottom", state),
left_gap=self._slider_ctx.get_property("margin-left", state),
right_gap=self._slider_ctx.get_property("margin-right", state),
corner_radius=self._slider_ctx.get_property("border-radius", state),
progress_color=self._highlight_ctx.get_background_color(state),
progress_thickness=self.do_get_border_width(self._highlight_ctx, state),
trough_color=self._trough_ctx.get_background_color(state),
trough_thickness=self.do_get_border_width(self._trough_ctx, state),
background_color=self.get_style_context().get_background_color(state),
)
return self._cached_style
def do_normalize_value(self) -> float:
value_range = self._max_value - self._min_value
if value_range == 0:
return 0.0
return clamp((self._value - self._min_value) / value_range, 0.0, 1.0)
def _is_horizontal(self) -> bool:
return self._orientation == Gtk.Orientation.HORIZONTAL
def _value_from_coords(self, x: float, y: float) -> float:
width = self.get_allocated_width()
height = self.get_allocated_height()
if self._is_horizontal():
ratio = clamp(x / width, 0.0, 1.0)
else:
ratio = clamp(1.0 - (y / height), 0.0, 1.0)
return self._min_value + ratio * (self._max_value - self._min_value)
# ------------------------------------------------------------------ #
# Bubble animation helpers #
# ------------------------------------------------------------------ #
def _bubble_open(self):
self._bubble_closing = False
self._anim_bubble_close.pause()
p = self._bubble_progress
self._anim_bubble_open.pause()
self._anim_bubble_open.min_value = p
self._anim_bubble_open.max_value = 1.0
self._anim_bubble_open.value = p
self._anim_bubble_open._start_time = None
self._anim_bubble_open.play()
def _bubble_close(self):
self._bubble_closing = True
self._anim_bubble_open.pause()
start = 1.0 - self._bubble_progress
self._anim_bubble_close.pause()
self._anim_bubble_close.min_value = start
self._anim_bubble_close.max_value = 1.0
self._anim_bubble_close.value = start
self._anim_bubble_close._start_time = None
self._anim_bubble_close.play()
def _sync_bubble_progress(self):
"""Pull latest progress from whichever animator is active."""
if self._bubble_closing:
self._bubble_progress = max(0.0, 1.0 - self._anim_bubble_close.value)
else:
self._bubble_progress = min(1.0, self._anim_bubble_open.value)
# ------------------------------------------------------------------ #
# Drawing helpers #
# ------------------------------------------------------------------ #
def do_draw_rounded_rect(self, cr, x, y, width, height, radius):
_draw_rounded_rect(cr, x, y, width, height, radius)
def _draw_bubble(
self,
cr: cairo.Context,
slider_cx: float,
slider_cy: float,
max_slider_thickness: float,
styles: FlatScaleStyle,
):
"""
Draws the percentage pill scaling elegantly out from its anchor point
(bottom-center for horizontal, right-center for vertical).
"""
self._sync_bubble_progress()
p = self._bubble_progress
if p <= 0.0:
return
text = self._value_formatter(self._value)
state = self.get_state_flags()
widget_w = self.get_allocated_width()
widget_h = self.get_allocated_height()
# Font setup (Keep native sizes static; Cairo will handle the scaling matrix)
font_desc = self.get_style_context().get_font(state).copy()
font_desc.set_size(self._BUBBLE_FONT_SIZE * Pango.SCALE)
font_desc.set_weight(Pango.Weight.SEMIBOLD)
layout = self.create_pango_layout(text)
layout.set_font_description(font_desc)
text_w, text_h = layout.get_pixel_size()
# Target full dimensions at 100% scale
pill_w = text_w + self._BUBBLE_PAD_X * 2
pill_h = text_h + self._BUBBLE_PAD_Y * 2
edge_margin = 2.0
# Calculate scale factor (maps smoothly from start scale to 1.0)
current_scale = self._BUBBLE_SCALE_FROM + (1.0 - self._BUBBLE_SCALE_FROM) * p
cr.save()
static_slider_half = max_slider_thickness / 2
if self._is_horizontal():
ideal_x = slider_cx - pill_w / 2
pill_x = clamp(ideal_x, edge_margin, widget_w - pill_w - edge_margin)
fixed_top_edge = slider_cy - static_slider_half
pill_y = fixed_top_edge - self._BUBBLE_GAP - pill_h
# Anchor point for scaling: Bottom-Center of the pill
anchor_x = pill_x + (pill_w / 2)
anchor_y = pill_y + pill_h
else:
# Vertical layout
fixed_left_edge = slider_cy - static_slider_half
pill_x = fixed_left_edge - self._BUBBLE_GAP - pill_w
ideal_y = slider_cx - pill_h / 2
pill_y = clamp(ideal_y, edge_margin, widget_h - pill_h - edge_margin)
# Anchor point for scaling: Right-Center of the pill
anchor_x = pill_x + pill_w
anchor_y = pill_y + (pill_h / 2)
# Apply Matrix Transformations for Scaling from the Anchor
cr.translate(anchor_x, anchor_y)
cr.scale(current_scale, current_scale)
cr.translate(-anchor_x, -anchor_y)
# Draw the pill background using full alpha or a subtle fade
fg = self._slider_ctx.get_color(state)
bg = styles["slider_color"]
cr.set_source_rgba(bg.red, bg.green, bg.blue, bg.alpha * min(1.0, p * 1.5))
_draw_rounded_rect(cr, pill_x, pill_y, pill_w, pill_h, self._BUBBLE_RADIUS)
cr.fill()
# Render Text
# Because the matrix scales everything, text won't warp or clip layout boundaries
cr.set_source_rgba(fg.red, fg.green, fg.blue, fg.alpha * min(1.0, p * 2.0))
text_offset_y = (pill_h - text_h) / 2
cr.move_to(pill_x + self._BUBBLE_PAD_X, pill_y + text_offset_y)
PangoCairo.show_layout(cr, layout)
cr.restore()
# ------------------------------------------------------------------ #
# Main draw #
# ------------------------------------------------------------------ #
def do_draw(self, cr: cairo.Context) -> bool:
styles = self.do_resolve_style()
width = self.get_allocated_width()
height = self.get_allocated_height()
trough_thickness = styles["trough_thickness"]
progress_thickness = styles["progress_thickness"]
slider_height = self._anim_press.value
slider_thickness = self._anim_press.value
left_gap = styles["left_gap"]
right_gap = styles["right_gap"]
top_gap = styles["top_gap"]
bottom_gap = styles["bottom_gap"]
corner_radius = styles["corner_radius"]
normalized = self.do_normalize_value()
if self._is_horizontal():
track_y = height / 2
track_start = 0.0
track_end = float(width)
track_length = track_end - track_start
slider_half = slider_height / 2
slider_cx = (
track_start + slider_half + normalized * (track_length - slider_height)
)
slider_cy = track_y
# Trough
trough_x = slider_cx + slider_half + right_gap
trough_w = max(track_end - trough_x, 0.0)
if trough_w > 0:
Gdk.cairo_set_source_rgba(cr, styles["trough_color"])
self.do_draw_rounded_rect(
cr,
trough_x,
track_y - trough_thickness / 2,
trough_w,
trough_thickness,
trough_thickness / 2,
)
cr.fill()
# Progress
progress_w = max(slider_cx - slider_half - left_gap - track_start, 0.0)
if progress_w > 0:
Gdk.cairo_set_source_rgba(cr, styles["progress_color"])
self.do_draw_rounded_rect(
cr,
track_start,
track_y - progress_thickness / 2,
progress_w,
progress_thickness,
progress_thickness / 2,
)
cr.fill()
# Slider handle
Gdk.cairo_set_source_rgba(cr, styles["slider_color"])
self.do_draw_rounded_rect(
cr,
slider_cx - slider_half,
slider_cy - slider_thickness / 2,
slider_height,
slider_thickness,
corner_radius,
)
cr.fill()
max_thickness = max(self._anim_press.min_value, self._anim_press.max_value)
self._draw_bubble(cr, slider_cx, slider_cy, max_thickness, styles)
else:
track_x = width / 2
track_start = 0.0
track_end = float(height)
track_length = track_end - track_start
slider_half = slider_height / 2
slider_cy = (
track_end - slider_half - normalized * (track_length - slider_height)
)
slider_cx = track_x
trough_y = track_start
trough_h = max(slider_cy - slider_half - top_gap - track_start, 0.0)
if trough_h > 0:
Gdk.cairo_set_source_rgba(cr, styles["trough_color"])
self.do_draw_rounded_rect(
cr,
track_x - trough_thickness / 2,
trough_y,
trough_thickness,
trough_h,
trough_thickness / 2,
)
cr.fill()
progress_y = slider_cy + slider_half + bottom_gap
progress_h = max(track_end - progress_y, 0.0)
if progress_h > 0:
Gdk.cairo_set_source_rgba(cr, styles["progress_color"])
self.do_draw_rounded_rect(
cr,
track_x - progress_thickness / 2,
progress_y,
progress_thickness,
progress_h,
progress_thickness / 2,
)
cr.fill()
Gdk.cairo_set_source_rgba(cr, styles["slider_color"])
self.do_draw_rounded_rect(
cr,
slider_cx - slider_thickness / 2,
slider_cy - slider_half,
slider_thickness,
slider_height,
corner_radius,
)
cr.fill()
# Percentage bubble (drag only) — pass slider_left_x as "slider_top_y"
slider_left_x = slider_cx - slider_thickness / 2
self._draw_bubble(cr, slider_cy, slider_left_x, styles)
return False
# ------------------------------------------------------------------ #
# Input events #
# ------------------------------------------------------------------ #
def _on_button_press(self, _widget, event: Gdk.EventButton) -> bool:
if event.button == 1:
self._dragging = True
self._update_state_flags()
self.set_value(self._value_from_coords(event.x, event.y))
self._bubble_open()
return True
def _on_button_release(self, _widget, event: Gdk.EventButton) -> bool:
if event.button == 1:
self._dragging = False
self._update_state_flags()
self._bubble_close()
return False
def _on_motion(self, _widget, event: Gdk.EventMotion) -> bool:
if self._dragging:
self.set_value(self._value_from_coords(event.x, event.y))
return True
def _on_scroll(self, _widget, event: Gdk.EventScroll) -> bool:
if event.direction == Gdk.ScrollDirection.UP:
self.set_value(self._value + self._step)
elif event.direction == Gdk.ScrollDirection.DOWN:
self.set_value(self._value - self._step)
elif event.direction == Gdk.ScrollDirection.SMOOTH:
self._scroll_accumulator += event.delta_y * -10
if abs(self._scroll_accumulator) >= 1.0:
steps = int(self._scroll_accumulator)
self.set_value(self._value + steps * self._step)
self._scroll_accumulator -= steps
return True
def _on_enter(self, _widget, _event) -> bool:
self._hovering = True
self._update_state_flags()
return False
def _on_leave(self, _widget, _event) -> bool:
self._hovering = False
self._dragging = False
self._update_state_flags()
# Close bubble if pointer leaves mid-drag
if self._bubble_progress > 0.0:
self._bubble_close()
return False
def _update_state_flags(self) -> None:
ctx = self.get_style_context()
if self._hovering:
ctx.add_class("hover")
else:
ctx.remove_class("hover")
if self._dragging:
ctx.add_class("active")
else:
ctx.remove_class("active")
self._gadget_classes[self._slider_ctx] = None
self.do_update_gadget_path(self._slider_ctx, "slider")
self._cached_style = None
state = self.get_state_flags()
target = self._slider_ctx.get_property("min-height", state)
if self._anim_initialized:
self._anim_press.min_value = self._anim_press.value
self._anim_press.max_value = target
self._anim_press.play()
self.queue_draw()
+145
View File
@@ -0,0 +1,145 @@
import cairo
from fabric.utils import Gdk, Gtk
from shared.widgets.animator import Animator, ease_in_out
class SmoothSwitch(Gtk.DrawingArea):
"""
Cairo-drawn switch with animated thumb, drop-in replacement for Switch.
Emits 'user-toggled' (bool) only on actual user clicks.
"""
def __init__(
self,
active: bool = False,
width: int = 44,
height: int = 24,
on_user_toggle=None,
style_classes: list | None = None,
v_align=None,
v_expand: bool = False,
h_expand: bool = False,
**kwargs,
):
super().__init__(**kwargs)
self._active = active
self._width = width
self._height = height
self._on_user_toggle = on_user_toggle
self._anim_value = 1.0 if active else 0.0
self.set_size_request(width, height)
self.set_hexpand(h_expand)
self.set_vexpand(v_expand)
if v_align is not None:
if isinstance(v_align, str):
v_align = {
"fill": Gtk.Align.FILL,
"start": Gtk.Align.START,
"end": Gtk.Align.END,
"center": Gtk.Align.CENTER,
"baseline": Gtk.Align.BASELINE,
}[v_align]
self.set_valign(v_align)
if style_classes:
ctx = self.get_style_context()
for cls in style_classes:
ctx.add_class(cls)
self._animator = Animator(
timing_function=ease_in_out,
duration=0.2,
min_value=0.0,
max_value=1.0,
tick_widget=self,
)
self._animator.connect("notify::value", lambda *_: self.queue_draw())
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.ENTER_NOTIFY_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.connect("button-press-event", self._on_click)
self.connect("draw", self._on_draw)
self.connect("enter-notify-event", lambda *_: self.queue_draw())
self.connect("leave-notify-event", lambda *_: self.queue_draw())
self.show_all()
def get_active(self) -> bool:
return self._active
def set_active(self, value: bool):
"""Programmatic set — does NOT emit user-toggled."""
if value == self._active:
return
self._active = value
self._animate_to(1.0 if value else 0.0)
def _on_click(self, _, event):
if event.button != 1:
return False
self._active = not self._active
self._animate_to(1.0 if self._active else 0.0)
if self._on_user_toggle:
self._on_user_toggle(self._active)
return True
def _animate_to(self, target: float):
self._animator.pause()
self._animator.min_value = self._animator.value
self._animator.max_value = target
self._animator.value = self._animator.min_value
self._animator.play()
def _on_draw(self, _, cr: cairo.Context):
w, h = self._width, self._height
r = h / 2
margin = 2
t = self._animator.value
style = self.get_style_context()
if self._active or t > 0.0:
style.add_class("checked")
else:
style.remove_class("checked")
off_color = style.get_background_color(Gtk.StateFlags.NORMAL)
on_color = style.get_background_color(Gtk.StateFlags.CHECKED)
if self._active or t > 0.5:
thumb_color = style.get_color(Gtk.StateFlags.CHECKED)
else:
thumb_color = style.get_color(Gtk.StateFlags.NORMAL)
track_r = off_color.red + (on_color.red - off_color.red) * t
track_g = off_color.green + (on_color.green - off_color.green) * t
track_b = off_color.blue + (on_color.blue - off_color.blue) * t
track_a = off_color.alpha + (on_color.alpha - off_color.alpha) * t
cr.new_sub_path()
cr.arc(r, r, r, 0.5 * 3.14159, 1.5 * 3.14159)
cr.arc(w - r, r, r, -0.5 * 3.14159, 0.5 * 3.14159)
cr.close_path()
cr.set_source_rgba(track_r, track_g, track_b, track_a)
cr.fill()
thumb_r = r - margin
travel = w - 2 * r
thumb_x = r + travel * t
thumb_y = r
cr.arc(thumb_x, thumb_y, thumb_r, 0, 2 * 3.14159)
cr.set_source_rgba(
thumb_color.red,
thumb_color.green,
thumb_color.blue,
thumb_color.alpha,
)
cr.fill()
return False
+101
View File
@@ -0,0 +1,101 @@
from fabric.utils import GLib
from fabric.widgets.eventbox import EventBox # noqa: E402
from fabric.widgets.wayland import WaylandWindow as Window # noqa: E402
from shared.window.popup_win import PopupWindow # noqa: E402
class DismissLayer(Window):
def __init__(self, on_dismiss, **kwargs):
self.event_box = EventBox()
super().__init__(
anchor="left right top bottom",
layer="top",
exclusivity="none",
keyboard_mode="none",
child=self.event_box,
visible=False,
**kwargs,
)
self.event_box.connect("button-press-event", lambda *_: on_dismiss())
class AppletWindow(PopupWindow):
_active_popup = None
def __init__(self, **kwargs):
self._is_open = False
self._hide_timeout_id = None
self.dismiss_layer = DismissLayer(on_dismiss=self.toggle)
kwargs["keyboard_mode"] = "on-demand"
super().__init__(**kwargs)
self.add_keybinding("escape", lambda *_: self.toggle())
def toggle(self, *_):
if self._is_open:
self.close_applet()
else:
self.open_applet()
def open_applet(self):
if self._is_open:
return
self._is_open = True
active = AppletWindow._active_popup
if active and active != self:
try:
if hasattr(active, "close_applet"):
active.close_applet()
else:
active.hide()
except Exception as e:
print(f"[AppletWindow] Warning: failed to close active popup: {e}")
AppletWindow._active_popup = self
if self._hide_timeout_id is not None:
GLib.source_remove(self._hide_timeout_id)
self._hide_timeout_id = None
child = self.get_child()
if child:
child.set_opacity(1.0)
if hasattr(self, "dismiss_layer"):
self.dismiss_layer.show()
self.set_visible(True)
self.set_focus(None)
def close_applet(self):
if not self._is_open:
return
self._is_open = False
if self._hide_timeout_id is not None:
GLib.source_remove(self._hide_timeout_id)
self._hide_timeout_id = None
if hasattr(self, "dismiss_layer"):
self.dismiss_layer.hide()
self.set_visible(False)
if AppletWindow._active_popup == self:
AppletWindow._active_popup = None
def show(self):
self.open_applet()
def hide(self):
self.close_applet()
def destroy(self):
if hasattr(self, "dismiss_layer"):
self.dismiss_layer.destroy()
if self._hide_timeout_id is not None:
GLib.source_remove(self._hide_timeout_id)
super().destroy()
+33 -15
View File
@@ -7,7 +7,7 @@ from fabric.widgets.label import Label
from fabric.widgets.separator import Separator from fabric.widgets.separator import Separator
from services.gamemode import check_gamemode, toggle_gamemode from services.gamemode import check_gamemode, toggle_gamemode
from services.battery import BatteryService, DeviceState from services.battery import Battery
from utils.functions import clear_children, format_duration from utils.functions import clear_children, format_duration
from utils.utils import svg_file from utils.utils import svg_file
@@ -18,7 +18,7 @@ class EnergyModeButton(Box):
profile_name: str, profile_name: str,
display_name: str, display_name: str,
icon_name: str, icon_name: str,
battery_service: BatteryService, battery_service: Battery,
parent, parent,
**kwargs, **kwargs,
): ):
@@ -60,7 +60,9 @@ class EnergyModeButton(Box):
self.update_state() self.update_state()
def on_clicked(self, *args): def on_clicked(self, *args):
success = self.battery_service.set_power_profile(self.profile_name) success = False
if hasattr(self.battery_service, "set_power_profile"):
success = self.battery_service.set_power_profile(self.profile_name)
if success: if success:
# Update all profile buttons in parent # Update all profile buttons in parent
self.parent.update_energy_mode_buttons() self.parent.update_energy_mode_buttons()
@@ -72,7 +74,9 @@ class EnergyModeButton(Box):
return False # Remove timeout return False # Remove timeout
def update_state(self): def update_state(self):
current_profile = self.battery_service.get_power_profile() current_profile = None
if hasattr(self.battery_service, "get_power_profile"):
current_profile = self.battery_service.get_power_profile()
is_active = current_profile == self.profile_name is_active = current_profile == self.profile_name
if is_active: if is_active:
self.mode_icon.add_style_class("connected") self.mode_icon.add_style_class("connected")
@@ -153,7 +157,7 @@ class BatteryControl(Box):
self.set_size_request(354, -1) self.set_size_request(354, -1)
self.parent = parent self.parent = parent
self.battery_service = BatteryService() self.battery_service = Battery()
self.energy_mode_buttons = [] self.energy_mode_buttons = []
self.battery_widget = Box( self.battery_widget = Box(
@@ -250,7 +254,13 @@ class BatteryControl(Box):
self.add(self.battery_widget) self.add(self.battery_widget)
self.battery_service.connect("changed", self.on_battery_changed) self.battery_service.connect("changed", self.on_battery_changed)
self.battery_service.connect("power_profile_changed", self.on_profile_changed) if hasattr(self.battery_service, "connect"):
try:
self.battery_service.connect(
"power_profile_changed", self.on_profile_changed
)
except TypeError:
pass # signal does not exist
# Initialize display # Initialize display
self.update_battery_info() self.update_battery_info()
@@ -268,7 +278,11 @@ class BatteryControl(Box):
self.energy_mode_buttons.clear() self.energy_mode_buttons.clear()
# Get available profiles # Get available profiles
available_profiles = self.battery_service.get_available_power_profiles() or [] available_profiles = []
if hasattr(self.battery_service, "get_available_power_profiles"):
available_profiles = (
self.battery_service.get_available_power_profiles() or []
)
if not available_profiles: if not available_profiles:
no_profiles_label = Label( no_profiles_label = Label(
@@ -333,7 +347,7 @@ class BatteryControl(Box):
return format_duration(seconds) return format_duration(seconds)
def update_battery_info(self): def update_battery_info(self):
is_present = bool(self.battery_service.get_property("IsPresent")) is_present = self.battery_service.available
if not is_present: if not is_present:
self.battery_percentage_label.set_label("No Battery") self.battery_percentage_label.set_label("No Battery")
self.power_source_label.set_label("Power Source: Not Present") self.power_source_label.set_label("Power Source: Not Present")
@@ -341,16 +355,22 @@ class BatteryControl(Box):
return return
# Update percentage in header # Update percentage in header
percentage = int(self.battery_service.get_property("Percentage") or 0) percentage = int(self.battery_service.percent)
self.battery_percentage_label.set_label(f"{percentage}%") self.battery_percentage_label.set_label(f"{percentage}%")
# Update power source and charging info # Update power source and charging info
state_code = self.battery_service.get_property("State") if self.battery_service.charging:
state = DeviceState.get(state_code, "UNKNOWN") state = "CHARGING"
elif self.battery_service.discharging:
state = "DISCHARGING"
elif self.battery_service.charged:
state = "FULLY_CHARGED"
else:
state = "UNKNOWN"
if state in ["CHARGING", "PENDING_CHARGE"]: if state in ["CHARGING", "PENDING_CHARGE"]:
self.power_source_label.set_label("Power Source: Power Adapter") self.power_source_label.set_label("Power Source: Power Adapter")
seconds_to_full = int(self.battery_service.get_property("TimeToFull") or 0) seconds_to_full = self.battery_service.time_to_full
time_to_full = self._format_time(seconds_to_full) time_to_full = self._format_time(seconds_to_full)
if time_to_full != "N/A" and time_to_full != "0m": if time_to_full != "N/A" and time_to_full != "0m":
self.charging_time_label.set_label( self.charging_time_label.set_label(
@@ -363,9 +383,7 @@ class BatteryControl(Box):
self.charging_time_label.set_label("Fully Charged") self.charging_time_label.set_label("Fully Charged")
elif state in ["DISCHARGING", "PENDING_DISCHARGE"]: elif state in ["DISCHARGING", "PENDING_DISCHARGE"]:
self.power_source_label.set_label("Power Source: Battery") self.power_source_label.set_label("Power Source: Battery")
seconds_to_empty = int( seconds_to_empty = self.battery_service.time_remaining
self.battery_service.get_property("TimeToEmpty") or 0
)
time_to_empty = self._format_time(seconds_to_empty) time_to_empty = self._format_time(seconds_to_empty)
if time_to_empty != "N/A" and not time_to_empty.startswith( if time_to_empty != "N/A" and not time_to_empty.startswith(
"4553h" "4553h"
+17 -27
View File
@@ -1,8 +1,9 @@
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox from fabric.widgets.eventbox import EventBox
from gi.repository import GLib
from utils.roam import modus_service from utils.roam import modus_service
from shared.window.popup_window import PopupWindow from shared.window.applet_window import AppletWindow
dropdowns = [] dropdowns = []
@@ -17,22 +18,21 @@ def dropdown_divider(comment):
) )
class ModusDropdown(PopupWindow): class ModusDropdown(AppletWindow):
def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs): def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs):
super().__init__( super().__init__(
layer="top", layer="top",
exclusivity="auto", exclusivity="auto",
name="dropdown-menu", name="dropdown-menu",
title="modus", title="modus",
keyboard_mode="none",
visible=False, visible=False,
**kwargs, **kwargs,
) )
self.id = dropdown_id or str(len(dropdowns)) self.id = dropdown_id or str(len(dropdowns))
dropdowns.append(self) dropdowns.append(self)
self._mousecapture_parent = None # Will be set by mousecapture
self.connect("notify::visible", self._on_visible_changed)
modus_service.connect("dropdowns-hide-changed", self.hide_dropdown) modus_service.connect("dropdowns-hide-changed", self.hide_dropdown)
self.dropdown = Box( self.dropdown = Box(
@@ -51,42 +51,32 @@ class ModusDropdown(PopupWindow):
) )
self.children = [self.event_box] self.children = [self.event_box]
self.add_keybinding("Escape", self.hide_dropdown) self.add_keybinding("escape", self.hide_dropdown)
def toggle_dropdown(self, button, parent=None): def toggle_dropdown(self, button, parent=None):
self.set_visible(not self.is_visible()) self.toggle()
modus_service.current_dropdown = self.id if self.is_visible() else None modus_service.current_dropdown = self.id if self.is_visible() else None
def _init_mousecapture(self, mousecapture): def hide_dropdown(self, *_):
"""Store reference to mousecapture parent for hiding"""
self._mousecapture_parent = mousecapture
def hide_dropdown(self, widget, event):
if self.is_visible(): if self.is_visible():
self.hide() GLib.idle_add(lambda: self.hide())
if str(modus_service.current_dropdown) == str(self.id):
modus_service.current_dropdown = None def _on_visible_changed(self, *_):
if self.is_visible():
modus_service.current_dropdown = self.id
elif str(modus_service.current_dropdown) == str(self.id):
modus_service.current_dropdown = None
def hide_via_mousecapture(self): def hide_via_mousecapture(self):
"""Hide dropdown via mousecapture parent""" self.hide()
if self._mousecapture_parent:
self._mousecapture_parent.hide_child_window()
def _set_mousecapture(self, visible: bool) -> None:
self.set_visible(visible)
if visible:
modus_service.current_dropdown = self.id
else:
if str(modus_service.current_dropdown) == str(self.id):
modus_service.current_dropdown = None
def on_cursor_enter(self, *_): def on_cursor_enter(self, *_):
self.set_visible(True) self.show()
def on_cursor_leave(self, *_): def on_cursor_leave(self, *_):
if self.is_hovered(): if self.is_hovered():
return return
self.set_visible(False) self.hide()
modus_service.dropdowns_hide = not modus_service.dropdowns_hide modus_service.dropdowns_hide = not modus_service.dropdowns_hide
def destroy(self): def destroy(self):
-298
View File
@@ -1,298 +0,0 @@
from fabric.utils import Any, Gdk, cairo, GLib
from fabric.widgets.eventbox import EventBox
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.widgets.widget import Widget
from gi.repository import GtkLayerShell # type: ignore
from utils.monitors import HyprlandWithMonitors
from utils.roam import modus_service
class MouseCapture(Window):
"""A background overlay that captures outside clicks without blocking child window interactions"""
def __init__(self, layer: str, child_window: Window, **kwargs):
super().__init__(
layer=layer, # Use the passed layer
anchor="top bottom left right",
exclusivity="auto",
title="modus",
name="MouseCapture",
keyboard_mode="none", # Don't steal keyboard
all_visible=False,
visible=False,
**kwargs,
)
GtkLayerShell.set_exclusive_zone(self, -1)
self.child_window = child_window
# Ensure child window is on overlay layer to be above this capture
if hasattr(self.child_window, "layer"):
self.child_window.layer = "overlay"
if hasattr(self.child_window, "_init_mousecapture"):
self.child_window._init_mousecapture(self)
# Create transparent event box that captures clicks
self.event_box = EventBox(
events=[
"button-press-event",
"button-release-event",
"pointer-motion-mask", # Listen for mouse movement
"enter-notify-mask",
],
all_visible=True,
)
self.event_box.connect("button-press-event", self.on_overlay_click)
self.event_box.connect("motion-notify-event", self.on_mouse_motion)
self.event_box.connect("enter-notify-event", self.on_mouse_motion)
self.children = [self.event_box]
self._hyprland = HyprlandWithMonitors()
self._cursor_pointer = Gdk.Cursor.new_from_name(
Gdk.Display.get_default(), "pointer"
)
self._cursor_default = None # Resets to default arrow
# Make the overlay transparent
self.set_app_paintable(True)
self.connect("draw", self.on_draw)
self.connect("size-allocate", lambda *_: self.update_input_region())
self.connect("map", lambda *_: self.update_input_region())
self.connect("notify::visible", lambda *_: self.update_input_region())
# Add escape key binding to child window
if hasattr(self.child_window, "add_keybinding"):
self.child_window.add_keybinding("Escape", self.hide_child_window)
def on_draw(self, _widget, cr):
"""Make overlay transparent"""
cr.set_source_rgba(0, 0, 0, 0) # Fully transparent
cr.set_operator(cairo.OPERATOR_SOURCE)
cr.paint()
return False
def _get_child_window_bounds(self) -> tuple[int, int, int, int]:
"""Calculates absolute screen bounds for the child window using Gdk origin"""
try:
window = self.child_window.get_window()
if not window:
# Fallback to allocation if window isn't mapped yet
alloc = self.child_window.get_allocation()
return 0, 0, alloc.width, alloc.height
# get_origin provides absolute screen coordinates
success, x, y = window.get_origin()
if not success:
alloc = self.child_window.get_allocation()
return 0, 0, alloc.width, alloc.height
alloc = self.child_window.get_allocation()
return x, y, alloc.width, alloc.height
except Exception as e:
print(f"Error calculating child window bounds: {e}")
alloc = self.child_window.get_allocation()
return 0, 0, alloc.width, alloc.height
def _get_widget_absolute_bounds(self, widget: Widget) -> tuple[int, int, int, int]:
"""Calculates absolute screen bounds for a widget in another window"""
try:
toplevel = widget.get_toplevel()
if not toplevel or not toplevel.get_window():
return 0, 0, 0, 0
# Get the origin of the window containing the widget
success, wx, wy = toplevel.get_window().get_origin()
if not success:
return 0, 0, 0, 0
# Get relative position within toplevel
alloc = widget.get_allocation()
rx, ry = widget.translate_coordinates(toplevel, 0, 0) or (0, 0)
return wx + rx, wy + ry, alloc.width, alloc.height
except Exception as e:
print(f"Error calculating widget absolute bounds: {e}")
return 0, 0, 0, 0
def update_input_region(self):
"""Punches holes in the input region for the trigger button and child window"""
window = self.get_window()
if not window:
return
try:
# Full screen region
alloc = self.get_allocation()
region = cairo.Region(cairo.RectangleInt(0, 0, alloc.width, alloc.height))
# Punch hole for trigger button (relative to this overlay window)
pointing_widget = getattr(self.child_window, "_pointing_widget", None)
if pointing_widget:
# get_widget_absolute_bounds returns screen-absolute coords.
# Since overlay is full screen on its monitor, we just need to subtract its monitor's X/Y
px, py, pw, ph = self._get_widget_absolute_bounds(pointing_widget)
monitor = self._hyprland.display.get_monitor_at_window(window)
if monitor:
mx, my = monitor.get_geometry().x, monitor.get_geometry().y
region.subtract(cairo.RectangleInt(px - mx, py - my, pw, ph))
# Punch hole for child window
cx, cy, cw, ch = self._get_child_window_bounds()
monitor = self._hyprland.display.get_monitor_at_window(window)
if monitor:
mx, my = monitor.get_geometry().x, monitor.get_geometry().y
region.subtract(cairo.RectangleInt(cx - mx, cy - my, cw, ch))
window.input_shape_combine_region(region, 0, 0)
except Exception as e:
print(f"Error updating input region: {e}")
def on_mouse_motion(self, _widget, event):
"""Update cursor feedback based on position"""
# (This is mostly redundant now with input regions but kept for extra safety)
if not self.child_window.is_visible():
return False
return False
def on_overlay_click(self, _widget, event):
"""Handle overlay clicks - check if click is outside child window"""
if not self.child_window.is_visible():
return False
# Support double and triple clicks too
if event.type not in [
Gdk.EventType.BUTTON_PRESS,
Gdk.EventType._2BUTTON_PRESS,
Gdk.EventType._3BUTTON_PRESS,
]:
return False
# Use window-local coordinates for robust detection
click_x = event.x
click_y = event.y
# Get window position relative to monitor for absolute -> local conversion
window = self.get_window()
mx, my = 0, 0
if window:
monitor = self._hyprland.display.get_monitor_at_window(window)
if monitor:
geom = monitor.get_geometry()
mx, my = geom.x, geom.y
# Check if click is on the "trigger" (pointing) widget
pointing_widget = getattr(self.child_window, "_pointing_widget", None)
if pointing_widget:
px_abs, py_abs, pw, ph = self._get_widget_absolute_bounds(pointing_widget)
px, py = px_abs - mx, py_abs - my
if px <= click_x <= px + pw and py <= click_y <= py + ph:
# Clicked the button that opened us - hide and consume
self.hide_child_window()
return True
# Get child window bounds (monitor-relative)
cx_abs, cy_abs, cw, ch = self._get_child_window_bounds()
cx, cy = cx_abs - mx, cy_abs - my
# Check if click is inside child window bounds
inside_child = cx <= click_x <= cx + cw and cy <= click_y <= cy + ch
if not inside_child:
# Click is outside child window - hide it immediately
# No delay to avoid race conditions with toggle buttons
self.hide_child_window()
return True # Consume the event
# Click is inside child window - don't consume event
return False
def show_child_window(self, widget: Widget = None, event: Any = None) -> None:
self.set_child_window_visible(True)
def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
self.set_child_window_visible(False)
def set_child_window_visible(self, visible: bool) -> None:
if visible:
# Connect to child window size changes to keep input region in sync
self._size_handler = self.child_window.connect(
"size-allocate", lambda *_: self.update_input_region()
)
self.child_window.show()
self.show()
# Force update with a small delay to ensure window is mapped
GLib.timeout_add(50, self.update_input_region)
else:
if hasattr(self, "_size_handler") and self._size_handler:
self.child_window.disconnect(self._size_handler)
self._size_handler = None
self.child_window.hide()
self.hide()
# Update styling on the trigger button
pointing_widget = getattr(self.child_window, "_pointing_widget", None)
if pointing_widget:
if visible:
pointing_widget.add_style_class("active")
else:
pointing_widget.remove_style_class("active")
if hasattr(self.child_window, "_set_mousecapture"):
self.child_window._set_mousecapture(visible)
def toggle_mousecapture(self, *_) -> None:
if self.is_visible():
self.set_child_window_visible(False)
else:
self.set_child_window_visible(True)
class DropDownMouseCapture(MouseCapture):
"""A specialized MouseCapture for dropdown menus with service integration"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed)
def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
"""Hide child window and update dropdown service state"""
# Update service state before hiding to prevent conflicts
if hasattr(self.child_window, "id"):
if str(modus_service.current_dropdown) == str(self.child_window.id):
modus_service.current_dropdown = None
super().hide_child_window(widget, event)
def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None:
"""Handle dropdown service hide changes"""
if hasattr(self.child_window, "id"):
if modus_service.current_dropdown == self.child_window.id:
return
return self.hide_child_window(widget, event)
def destroy(self):
"""Clean up signal connections"""
try:
modus_service.disconnect_by_func(self.dropdowns_hide_changed)
except Exception:
pass
super().destroy()
def add_destroy_to_mousecapture():
# Base MouseCapture
def mc_destroy(self):
# Child window should be destroyed by its owner, but we should null references
self.child_window = None
Window.destroy(self)
MouseCapture.destroy = mc_destroy
# Apply destroy to base class
add_destroy_to_mousecapture()
+190
View File
@@ -0,0 +1,190 @@
from fabric.utils import Gdk, GLib, Gtk
from fabric.widgets.wayland import WaylandWindow
from gi.repository import GtkLayerShell
EDGE_MARGIN = 0
def _get_monitor_geometry(widget: Gtk.Widget) -> tuple[int, int]:
screen = Gdk.Screen.get_default()
window = widget.get_toplevel().get_window()
if window is None:
return 0, screen.get_width()
monitor_index = screen.get_monitor_at_window(window)
geo = screen.get_monitor_geometry(monitor_index)
return geo.x, geo.width
class PopupWindow(WaylandWindow):
def __init__(
self,
parent: WaylandWindow | None = None,
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._initial_position = True
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()):
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 get_parent_window(self):
if not self._parent:
return None
if hasattr(self._parent, "anchor"):
return self._parent
toplevel = self._parent.get_toplevel()
if hasattr(toplevel, "anchor"):
return toplevel
return None
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)
GLib.timeout_add(10, self._do_delayed_reposition)
def _do_delayed_reposition(self):
self._initial_position = True
self.do_reposition(self.do_calculate_edges())
return False
def do_handle_size_allocate(self, *_):
if self.get_visible() and not self._initial_position:
return
return self.do_reposition(self.do_calculate_edges())
def do_calculate_edges(self):
move_axe = "x"
parent_window = self.get_parent_window()
if not parent_window:
return move_axe
parent_anchor = parent_window.anchor
if len(parent_anchor) == 1:
if GtkLayerShell.Edge.TOP in parent_anchor:
self.anchor = "left top"
else:
self.anchor = "left bottom"
return move_axe
if len(parent_anchor) != 3:
return move_axe
if (
GtkLayerShell.Edge.LEFT in parent_anchor
and GtkLayerShell.Edge.RIGHT in parent_anchor
):
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
):
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_window = self.get_parent_window()
if not parent_window:
return
parent_margin = parent_window.margin
parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
height = self.get_allocated_height()
width = self.get_allocated_width()
monitor_x, monitor_width = _get_monitor_geometry(parent_window)
if self._pointing_widget:
coords = self.get_coords_for_widget(self._pointing_widget)
coords_centered = (
round(coords[0] + self._pointing_widget.get_allocated_width() / 2),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(parent_window.get_allocated_width() / 2),
round(parent_window.get_allocated_height() / 2),
)
if move_axe == "x":
if len(parent_window.anchor) == 1:
bar_width = parent_window.get_allocated_width()
bar_left = (monitor_width - bar_width) // 2
raw_margin = round((bar_left + coords_centered[0]) - (width / 2))
else:
raw_margin = round((parent_x_margin + coords_centered[0]) - (width / 2))
min_margin = EDGE_MARGIN
max_margin = monitor_width - width - EDGE_MARGIN
clamped = max(min_margin, min(raw_margin, max_margin))
position_margins = (0, 0, 0, clamped)
else:
raw_margin = round((parent_y_margin + coords_centered[1]) - (height / 2))
monitor_height = Gdk.Screen.get_default().get_height()
min_margin = EDGE_MARGIN
max_margin = monitor_height - height - EDGE_MARGIN
clamped = max(min_margin, min(raw_margin, max_margin))
position_margins = (clamped, 0, 0, 0)
new_margin = tuple(
a + b for a, b in zip(position_margins, self._base_margin.values())
)
new_margin = (
max(0, new_margin[0]),
new_margin[1],
max(0, new_margin[2]),
new_margin[3],
)
self.margin = new_margin
self._initial_position = False
-192
View File
@@ -1,192 +0,0 @@
import contextlib
from fabric.utils import Gdk, Gtk
from fabric.widgets.wayland import WaylandWindow
from gi.repository import GtkLayerShell
from utils.monitors import HyprlandWithMonitors
class PopupWindow(WaylandWindow):
"""A simple popover window that can point to a widget."""
def __init__(
self,
parent: WaylandWindow,
pointing_to: Gtk.Widget | None = None,
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
enable_boundary_checking: bool = True,
**kwargs,
):
super().__init__(**kwargs)
self.exclusivity = "none"
self._is_centered = False
self._parent = parent
self._pointing_widget = pointing_to
self._hyprland = HyprlandWithMonitors()
self._base_margin = self.extract_margin(margin)
self.margin = self._base_margin.values()
self._enable_boundary_checking = enable_boundary_checking
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:
with contextlib.suppress(Exception):
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
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:
self.anchor = "left bottom"
self._is_centered = True
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"
self._is_centered = False
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()
# Get monitor geometry for boundary checking
current_monitor_id = self._hyprland.get_current_gdk_monitor_id()
if current_monitor_id is not None:
monitor = self._hyprland.display.get_monitor(current_monitor_id)
monitor_geometry = monitor.get_geometry()
monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y
monitor_width, monitor_height = (
monitor_geometry.width,
monitor_geometry.height,
)
else:
# Fallback to default screen
screen = Gdk.Screen.get_default()
monitor_x, monitor_y = 0, 0
monitor_width, monitor_height = screen.get_width(), screen.get_height()
if self._pointing_widget:
coords = self.get_coords_for_widget(self._pointing_widget)
coords_centered = (
round(coords[0] + self._pointing_widget.get_allocated_width() / 2),
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),
)
if self._is_centered:
# Calculate centered position with boundary checking
centered_x = (
(monitor_width / 2 - self._parent.get_allocated_width() / 2) - width / 2
) + coords_centered[0]
# Apply boundary checking only if enabled
if self._enable_boundary_checking:
if centered_x < monitor_x:
centered_x = monitor_x
elif centered_x + width > monitor_x + monitor_width:
centered_x = monitor_x + monitor_width - width
self.margin = tuple(
a + b
for a, b in zip(
(0, 0, 0, centered_x),
self._base_margin.values(),
)
)
return
# Calculate position with boundary checking
if move_axe == "x":
# Horizontal positioning
calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2))
# Apply boundary checking only if enabled
if self._enable_boundary_checking:
if calculated_x < monitor_x:
calculated_x = monitor_x
elif calculated_x + width > monitor_x + monitor_width:
calculated_x = monitor_x + monitor_width - width
margin_values = (0, 0, 0, calculated_x)
else:
# Vertical positioning
calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2))
# Apply boundary checking only if enabled
if self._enable_boundary_checking:
if calculated_y < monitor_y:
calculated_y = monitor_y
elif calculated_y + height > monitor_y + monitor_height:
calculated_y = monitor_y + monitor_height - height
margin_values = (calculated_y, 0, 0, 0)
self.margin = tuple(
a + b
for a, b in zip(
margin_values,
self._base_margin.values(),
)
)