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:
9 files changed
+1230
-533
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Reference in new issue
Block a user