diff --git a/src/shared/styles/widgets.css b/src/shared/styles/widgets.css index 6523dc8..81d9004 100644 --- a/src/shared/styles/widgets.css +++ b/src/shared/styles/widgets.css @@ -269,4 +269,37 @@ #component-name { font-size: 10px; -} \ No newline at end of file +} +/* 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; +} diff --git a/src/shared/widgets/flat_scale.py b/src/shared/widgets/flat_scale.py new file mode 100644 index 0000000..d8e2514 --- /dev/null +++ b/src/shared/widgets/flat_scale.py @@ -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() diff --git a/src/shared/widgets/smooth_switch.py b/src/shared/widgets/smooth_switch.py new file mode 100644 index 0000000..d97a45a --- /dev/null +++ b/src/shared/widgets/smooth_switch.py @@ -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 diff --git a/src/shared/window/applet_window.py b/src/shared/window/applet_window.py new file mode 100644 index 0000000..4ab9287 --- /dev/null +++ b/src/shared/window/applet_window.py @@ -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() diff --git a/src/shared/window/battery_widget.py b/src/shared/window/battery_widget.py index beba25a..e4da3f1 100644 --- a/src/shared/window/battery_widget.py +++ b/src/shared/window/battery_widget.py @@ -7,7 +7,7 @@ from fabric.widgets.label import Label from fabric.widgets.separator import Separator 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.utils import svg_file @@ -18,7 +18,7 @@ class EnergyModeButton(Box): profile_name: str, display_name: str, icon_name: str, - battery_service: BatteryService, + battery_service: Battery, parent, **kwargs, ): @@ -60,7 +60,9 @@ class EnergyModeButton(Box): self.update_state() 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: # Update all profile buttons in parent self.parent.update_energy_mode_buttons() @@ -72,7 +74,9 @@ class EnergyModeButton(Box): return False # Remove timeout 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 if is_active: self.mode_icon.add_style_class("connected") @@ -153,7 +157,7 @@ class BatteryControl(Box): self.set_size_request(354, -1) self.parent = parent - self.battery_service = BatteryService() + self.battery_service = Battery() self.energy_mode_buttons = [] self.battery_widget = Box( @@ -250,7 +254,13 @@ class BatteryControl(Box): self.add(self.battery_widget) 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 self.update_battery_info() @@ -268,7 +278,11 @@ class BatteryControl(Box): self.energy_mode_buttons.clear() # 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: no_profiles_label = Label( @@ -333,7 +347,7 @@ class BatteryControl(Box): return format_duration(seconds) def update_battery_info(self): - is_present = bool(self.battery_service.get_property("IsPresent")) + is_present = self.battery_service.available if not is_present: self.battery_percentage_label.set_label("No Battery") self.power_source_label.set_label("Power Source: Not Present") @@ -341,16 +355,22 @@ class BatteryControl(Box): return # 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}%") # Update power source and charging info - state_code = self.battery_service.get_property("State") - state = DeviceState.get(state_code, "UNKNOWN") + if self.battery_service.charging: + 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"]: 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) if time_to_full != "N/A" and time_to_full != "0m": self.charging_time_label.set_label( @@ -363,9 +383,7 @@ class BatteryControl(Box): self.charging_time_label.set_label("Fully Charged") elif state in ["DISCHARGING", "PENDING_DISCHARGE"]: self.power_source_label.set_label("Power Source: Battery") - seconds_to_empty = int( - self.battery_service.get_property("TimeToEmpty") or 0 - ) + seconds_to_empty = self.battery_service.time_remaining time_to_empty = self._format_time(seconds_to_empty) if time_to_empty != "N/A" and not time_to_empty.startswith( "4553h" diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py index 2b83887..e74fbf2 100644 --- a/src/shared/window/dropdown.py +++ b/src/shared/window/dropdown.py @@ -1,8 +1,9 @@ from fabric.widgets.box import Box from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox +from gi.repository import GLib from utils.roam import modus_service -from shared.window.popup_window import PopupWindow +from shared.window.applet_window import AppletWindow 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): super().__init__( layer="top", exclusivity="auto", name="dropdown-menu", title="modus", - keyboard_mode="none", visible=False, **kwargs, ) self.id = dropdown_id or str(len(dropdowns)) 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) self.dropdown = Box( @@ -51,42 +51,32 @@ class ModusDropdown(PopupWindow): ) 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): - self.set_visible(not self.is_visible()) + self.toggle() modus_service.current_dropdown = self.id if self.is_visible() else None - def _init_mousecapture(self, mousecapture): - """Store reference to mousecapture parent for hiding""" - self._mousecapture_parent = mousecapture - - def hide_dropdown(self, widget, event): + def hide_dropdown(self, *_): if self.is_visible(): - self.hide() - if str(modus_service.current_dropdown) == str(self.id): - modus_service.current_dropdown = None + GLib.idle_add(lambda: self.hide()) + + 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): - """Hide dropdown via mousecapture parent""" - 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 + self.hide() def on_cursor_enter(self, *_): - self.set_visible(True) + self.show() def on_cursor_leave(self, *_): if self.is_hovered(): return - self.set_visible(False) + self.hide() modus_service.dropdowns_hide = not modus_service.dropdowns_hide def destroy(self): diff --git a/src/shared/window/mousecapture.py b/src/shared/window/mousecapture.py deleted file mode 100644 index 5ef5181..0000000 --- a/src/shared/window/mousecapture.py +++ /dev/null @@ -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() diff --git a/src/shared/window/popup_win.py b/src/shared/window/popup_win.py new file mode 100644 index 0000000..61f8f9d --- /dev/null +++ b/src/shared/window/popup_win.py @@ -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 diff --git a/src/shared/window/popup_window.py b/src/shared/window/popup_window.py deleted file mode 100644 index be1df90..0000000 --- a/src/shared/window/popup_window.py +++ /dev/null @@ -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(), - ) - )