204 files changed
+51505
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from fabric.core.service import Property
|
||||
from fabric.widgets.widget import Widget
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||
|
||||
|
||||
class CircleImage(Gtk.DrawingArea, Widget):
|
||||
"""A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
|
||||
|
||||
@Property(int, "read-write")
|
||||
def angle(self) -> int:
|
||||
return self._angle
|
||||
|
||||
@angle.setter
|
||||
def angle(self, value: int):
|
||||
self._angle = value % 360
|
||||
self.queue_draw()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_file: str | None = None,
|
||||
pixbuf: GdkPixbuf.Pixbuf | None = None,
|
||||
name: str | None = None,
|
||||
visible: bool = True,
|
||||
all_visible: bool = False,
|
||||
style: 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: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
Gtk.DrawingArea.__init__(self)
|
||||
Widget.__init__(
|
||||
self,
|
||||
name=name,
|
||||
visible=visible,
|
||||
all_visible=all_visible,
|
||||
style=style,
|
||||
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.size = size if size is not None else 100 # Default size if not provided
|
||||
self._angle = 0
|
||||
# Original image for reprocessing
|
||||
self._orig_image: GdkPixbuf.Pixbuf | None = None
|
||||
self._image: GdkPixbuf.Pixbuf | None = None
|
||||
if image_file:
|
||||
pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
|
||||
self._orig_image = pix
|
||||
self._image = self._process_image(pix)
|
||||
elif pixbuf:
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.connect("draw", self.on_draw)
|
||||
|
||||
def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
|
||||
"""Crop the image to a centered square and scale it to the widget’s size."""
|
||||
width, height = pixbuf.get_width(), pixbuf.get_height()
|
||||
if width != height:
|
||||
square_size = min(width, height)
|
||||
x_offset = (width - square_size) // 2
|
||||
y_offset = (height - square_size) // 2
|
||||
pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
|
||||
else:
|
||||
square_size = width
|
||||
if square_size != self.size:
|
||||
pixbuf = pixbuf.scale_simple(
|
||||
self.size, self.size, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
return pixbuf
|
||||
|
||||
def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
|
||||
if self._image:
|
||||
ctx.save()
|
||||
# Create a circular clipping path
|
||||
ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
|
||||
ctx.clip()
|
||||
# Rotate around the center of the square image
|
||||
ctx.translate(self.size / 2, self.size / 2)
|
||||
ctx.rotate(self._angle * math.pi / 180.0)
|
||||
ctx.translate(-self.size / 2, -self.size / 2)
|
||||
Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
|
||||
ctx.paint()
|
||||
ctx.restore()
|
||||
|
||||
def set_image_from_file(self, new_image_file: str):
|
||||
if not new_image_file:
|
||||
return
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.queue_draw()
|
||||
|
||||
def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
|
||||
if not pixbuf:
|
||||
return
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.queue_draw()
|
||||
|
||||
def set_image_size(self, size: int):
|
||||
self.size = size
|
||||
if self._orig_image:
|
||||
self._image = self._process_image(self._orig_image)
|
||||
self.queue_draw()
|
||||
@@ -0,0 +1,39 @@
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
import cairo
|
||||
from gi.repository import Gtk
|
||||
|
||||
from fabric.widgets.image import Image
|
||||
|
||||
|
||||
class CustomImage(Image):
|
||||
def do_render_rectangle(
|
||||
self, cr: cairo.Context, width: int, height: int, radius: int = 0
|
||||
):
|
||||
cr.move_to(radius, 0)
|
||||
cr.line_to(width - radius, 0)
|
||||
cr.arc(width - radius, radius, radius, -(math.pi / 2), 0)
|
||||
cr.line_to(width, height - radius)
|
||||
cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2))
|
||||
cr.line_to(radius, height)
|
||||
cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi)
|
||||
cr.line_to(0, radius)
|
||||
cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2)))
|
||||
cr.close_path()
|
||||
|
||||
def do_draw(self, cr: cairo.Context):
|
||||
context = self.get_style_context()
|
||||
width, height = self.get_allocated_width(), self.get_allocated_height()
|
||||
cr.save()
|
||||
|
||||
self.do_render_rectangle(
|
||||
cr,
|
||||
width,
|
||||
height,
|
||||
cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)),
|
||||
)
|
||||
cr.clip()
|
||||
Image.do_draw(self, cr)
|
||||
|
||||
cr.restore()
|
||||
@@ -0,0 +1,320 @@
|
||||
from gi.repository import GLib, Gtk
|
||||
import gi
|
||||
import math
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
# TODO: UsE BETTER APPROACH IF POSSIBLE
|
||||
|
||||
|
||||
class AnimationManager:
|
||||
_instance = None
|
||||
_animating_widgets = set()
|
||||
_timer_id = None
|
||||
_containers_to_redraw = set()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def add_widget(self, widget):
|
||||
self._animating_widgets.add(widget)
|
||||
if self._timer_id is None:
|
||||
# Use 120 FPS for ultra-smooth animations like macOS
|
||||
self._timer_id = GLib.timeout_add(8, self._animate_all) # 120 FPS
|
||||
|
||||
|
||||
def remove_widget(self, widget):
|
||||
self._animating_widgets.discard(widget)
|
||||
if not self._animating_widgets and self._timer_id:
|
||||
# Stop timer when no widgets are animating
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
# Clear any pending redraws
|
||||
self._containers_to_redraw.clear()
|
||||
|
||||
def _animate_all(self):
|
||||
# Clear previous frame's redraw queue
|
||||
self._containers_to_redraw.clear()
|
||||
|
||||
widgets_to_remove = []
|
||||
|
||||
# Process all animations in a single frame
|
||||
for widget in list(self._animating_widgets):
|
||||
if not widget._calculate_position():
|
||||
widgets_to_remove.append(widget)
|
||||
else:
|
||||
container = widget._get_container_for_redraw()
|
||||
if container:
|
||||
self._containers_to_redraw.add(container)
|
||||
|
||||
# Apply all position changes at once to prevent conflicts
|
||||
for widget in self._animating_widgets:
|
||||
widget._apply_position()
|
||||
|
||||
# Batch redraw calls to minimize performance impact
|
||||
for container in self._containers_to_redraw:
|
||||
container.queue_draw()
|
||||
|
||||
# Remove completed animations
|
||||
for widget in widgets_to_remove:
|
||||
self.remove_widget(widget)
|
||||
|
||||
return len(self._animating_widgets) > 0 # Continue if widgets remain
|
||||
|
||||
def get_active_widget_count(self):
|
||||
"""Return the number of currently animating widgets"""
|
||||
return len(self._animating_widgets)
|
||||
|
||||
def _get_optimal_interval(self):
|
||||
"""Keep consistent 120 FPS for macOS-like smoothness"""
|
||||
return 8 # 120 FPS
|
||||
|
||||
|
||||
def _start_timer(self):
|
||||
interval = self._get_optimal_interval()
|
||||
self._timer_id = GLib.timeout_add(interval, self._animate_all)
|
||||
|
||||
def _adjust_frame_rate(self):
|
||||
# No need to adjust frame rate anymore - keep it consistent
|
||||
pass
|
||||
|
||||
|
||||
class MacOSEasing:
|
||||
"""macOS-style easing functions for natural motion"""
|
||||
|
||||
@staticmethod
|
||||
def ease_out_expo(t):
|
||||
"""Exponential ease out - fast start, slow end"""
|
||||
return 1 - math.pow(2, -10 * t) if t != 1 else 1
|
||||
|
||||
@staticmethod
|
||||
def ease_in_out_quart(t):
|
||||
"""Quartic ease in-out for smooth acceleration/deceleration"""
|
||||
return 8 * t * t * t * t if t < 0.5 else 1 - math.pow(-2 * t + 2, 4) / 2
|
||||
|
||||
@staticmethod
|
||||
def ease_out_back(t):
|
||||
"""Back ease out for slight overshoot effect"""
|
||||
c1 = 1.70158
|
||||
c3 = c1 + 1
|
||||
return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2)
|
||||
|
||||
@staticmethod
|
||||
def ease_out_cubic_bezier(t):
|
||||
"""Custom cubic bezier similar to macOS default (0.25, 0.1, 0.25, 1.0)"""
|
||||
# Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0)
|
||||
return t * t * t * (t * (6 * t - 15) + 10)
|
||||
|
||||
@staticmethod
|
||||
def ease_in_cubic(t):
|
||||
"""Cubic ease in for smooth acceleration"""
|
||||
return t * t * t
|
||||
|
||||
@staticmethod
|
||||
def ease_out_quint(t):
|
||||
"""Quintic ease out for very smooth deceleration"""
|
||||
return 1 - math.pow(1 - t, 5)
|
||||
|
||||
|
||||
class SlideRevealer(Gtk.Overlay):
|
||||
def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None):
|
||||
super().__init__()
|
||||
|
||||
self.child = child
|
||||
self.direction = direction
|
||||
self.duration = duration # Slightly faster for snappier feel
|
||||
self.fixed_size = size
|
||||
self._revealed = False
|
||||
self._animating = False
|
||||
self._start_time = None
|
||||
self._show_animation = False
|
||||
self._pending_position = None
|
||||
self._current_position = (0.0, 0.0) # Use float for sub-pixel positioning
|
||||
self._animation_id = None # Track individual animation instances
|
||||
|
||||
self._fixed = Gtk.Fixed()
|
||||
self._fixed.set_has_window(False)
|
||||
self._fixed.add(child)
|
||||
self.add_overlay(self._fixed)
|
||||
|
||||
if self.fixed_size:
|
||||
self.set_size_request(self.fixed_size[0], self.fixed_size[1])
|
||||
child.hide()
|
||||
self.show_all()
|
||||
else:
|
||||
child.connect("size-allocate", self._on_size_allocate)
|
||||
child.hide()
|
||||
self.show_all()
|
||||
|
||||
def _on_size_allocate(self, _widget, allocation):
|
||||
if not self.fixed_size:
|
||||
current_req = self.get_size_request()
|
||||
if (
|
||||
current_req[0] != allocation.width
|
||||
or current_req[1] != allocation.height
|
||||
):
|
||||
self.set_size_request(allocation.width, allocation.height)
|
||||
|
||||
def set_reveal_child(self, reveal: bool):
|
||||
if reveal:
|
||||
self.reveal()
|
||||
else:
|
||||
self.hide()
|
||||
|
||||
def reveal(self):
|
||||
if self._revealed and not self._animating:
|
||||
return
|
||||
self._revealed = True
|
||||
|
||||
# Ensure widget is properly laid out before starting animation
|
||||
if self.get_realized():
|
||||
self._start_animation(show=True)
|
||||
else:
|
||||
# Wait for widget to be realized
|
||||
def on_realize(*_):
|
||||
self._start_animation(show=True)
|
||||
self.disconnect_by_func(on_realize)
|
||||
self.connect("realize", on_realize)
|
||||
|
||||
def hide(self):
|
||||
if not self._revealed and not self._animating:
|
||||
return
|
||||
self._revealed = False
|
||||
self._start_animation(show=False)
|
||||
|
||||
def _start_animation(self, show: bool):
|
||||
# Stop any existing animation for this widget
|
||||
if self._animating:
|
||||
AnimationManager.get_instance().remove_widget(self)
|
||||
|
||||
self._cached_dimensions = self._get_dimensions()
|
||||
if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0:
|
||||
self._animating = False
|
||||
return
|
||||
|
||||
# Use high-precision monotonic time for smooth animations
|
||||
self._start_time = GLib.get_monotonic_time()
|
||||
self._animating = True
|
||||
self._show_animation = show
|
||||
self._animation_id = id(self) # Unique ID for this animation instance
|
||||
|
||||
if show:
|
||||
self.child.show()
|
||||
pos = self._get_offscreen_pos_cached()
|
||||
self._current_position = (float(pos[0]), float(pos[1]))
|
||||
self._fixed.move(self.child, int(pos[0]), int(pos[1]))
|
||||
|
||||
def start_with_dimensions():
|
||||
AnimationManager.get_instance().add_widget(self)
|
||||
return False
|
||||
|
||||
# Use idle_add to ensure layout is complete
|
||||
GLib.idle_add(start_with_dimensions)
|
||||
|
||||
def _calculate_position(self):
|
||||
if not self._animating:
|
||||
return False
|
||||
|
||||
elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000
|
||||
t = min(elapsed / self.duration, 1.0)
|
||||
|
||||
# Use different easing functions for better smoothness
|
||||
if self._show_animation:
|
||||
# Use quintic ease out for very smooth revealing
|
||||
eased = MacOSEasing.ease_out_quint(t)
|
||||
else:
|
||||
# Use cubic ease in for smooth hiding
|
||||
eased = MacOSEasing.ease_in_cubic(t)
|
||||
|
||||
self._pending_position = self._get_position_at_progress_cached(eased)
|
||||
|
||||
if t >= 1.0:
|
||||
self._animating = False
|
||||
self._cached_dimensions = None
|
||||
self._animation_id = None
|
||||
if not self._show_animation:
|
||||
GLib.idle_add(lambda: self.child.hide())
|
||||
return False
|
||||
return True
|
||||
|
||||
def _apply_position(self):
|
||||
if self._pending_position:
|
||||
x, y = self._pending_position
|
||||
# Use sub-pixel positioning for smoother motion
|
||||
self._current_position = (x, y)
|
||||
# Round to nearest pixel for actual positioning
|
||||
pixel_x, pixel_y = int(round(x)), int(round(y))
|
||||
self._fixed.move(self.child, pixel_x, pixel_y)
|
||||
self._pending_position = None
|
||||
|
||||
def _get_container_for_redraw(self):
|
||||
return self
|
||||
|
||||
def _get_dimensions(self):
|
||||
if self.fixed_size:
|
||||
return self.fixed_size
|
||||
else:
|
||||
alloc = self.child.get_allocation()
|
||||
return alloc.width, alloc.height
|
||||
|
||||
def _get_offscreen_pos_cached(self):
|
||||
w, h = self._cached_dimensions
|
||||
if self.direction == "left":
|
||||
return -w, 0
|
||||
elif self.direction == "right":
|
||||
return w, 0
|
||||
elif self.direction == "top":
|
||||
return 0, -h
|
||||
elif self.direction == "bottom":
|
||||
return 0, h
|
||||
return 0, 0
|
||||
|
||||
def _get_position_at_progress_cached(self, progress):
|
||||
w, h = self._cached_dimensions
|
||||
if self._show_animation:
|
||||
# Showing animation: slide from offscreen to onscreen (0,0)
|
||||
if self.direction == "left":
|
||||
return -w + w * progress, 0.0
|
||||
elif self.direction == "right":
|
||||
return w - w * progress, 0.0
|
||||
elif self.direction == "top":
|
||||
return 0.0, -h + h * progress
|
||||
elif self.direction == "bottom":
|
||||
return 0.0, h - h * progress
|
||||
else:
|
||||
# Hiding animation: slide from onscreen (0,0) to offscreen
|
||||
if self.direction == "left":
|
||||
return -w * progress, 0.0 # Slide left (negative x)
|
||||
elif self.direction == "right":
|
||||
return w * progress, 0.0 # Slide right (positive x)
|
||||
elif self.direction == "top":
|
||||
return 0.0, -h * progress # Slide up (negative y)
|
||||
elif self.direction == "bottom":
|
||||
return 0.0, h * progress # Slide down (positive y)
|
||||
return 0.0, 0.0
|
||||
|
||||
def set_slide_direction(self, direction):
|
||||
self.direction = direction
|
||||
|
||||
def is_revealed(self):
|
||||
return self._revealed
|
||||
|
||||
def is_animating(self):
|
||||
return self._animating
|
||||
|
||||
def get_child_revealed(self):
|
||||
return self._revealed
|
||||
|
||||
def stop_animation(self):
|
||||
if self._animating:
|
||||
AnimationManager.get_instance().remove_widget(self)
|
||||
self._animating = False
|
||||
self._cached_dimensions = None
|
||||
self._animation_id = None
|
||||
|
||||
def destroy(self):
|
||||
self.stop_animation()
|
||||
super().destroy()
|
||||
@@ -0,0 +1,91 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from utils.roam import modus_service
|
||||
from widgets.popup_window import PopupWindow
|
||||
|
||||
dropdowns = []
|
||||
|
||||
|
||||
def dropdown_divider(comment):
|
||||
return Box(
|
||||
children=[Box(name="dropdown-divider", h_expand=True)],
|
||||
name="dropdown-divider-box",
|
||||
h_align="fill",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
|
||||
class ModusDropdown(PopupWindow):
|
||||
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
|
||||
|
||||
modus_service.connect("dropdowns-hide-changed", self.hide_dropdown)
|
||||
|
||||
self.dropdown = Box(
|
||||
children=dropdown_children or [],
|
||||
h_expand=True,
|
||||
name="dropdown-options",
|
||||
orientation="vertical",
|
||||
)
|
||||
|
||||
self.child_box = CenterBox(start_children=[self.dropdown])
|
||||
|
||||
self.event_box = EventBox(
|
||||
events=["enter-notify-event", "leave-notify-event"],
|
||||
child=self.child_box,
|
||||
all_visible=True,
|
||||
)
|
||||
|
||||
self.children = [self.event_box]
|
||||
self.connect("button-press-event", self.hide_dropdown)
|
||||
self.add_keybinding("Escape", self.hide_dropdown)
|
||||
|
||||
def toggle_dropdown(self, button, parent=None):
|
||||
self.set_visible(not self.is_visible())
|
||||
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):
|
||||
if self.is_visible():
|
||||
self.hide()
|
||||
if 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
|
||||
|
||||
def on_cursor_enter(self, *_):
|
||||
self.set_visible(True)
|
||||
|
||||
def on_cursor_leave(self, *_):
|
||||
if self.is_hovered():
|
||||
return
|
||||
self.set_visible(False)
|
||||
modus_service.dropdowns_hide = not modus_service.dropdowns_hide
|
||||
@@ -0,0 +1,142 @@
|
||||
from typing import Any
|
||||
|
||||
import cairo
|
||||
from gi.repository import GLib, GtkLayerShell # type: ignore
|
||||
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.widget import Widget
|
||||
from utils.roam import modus_service
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
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="top", # Use top layer to capture events
|
||||
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"],
|
||||
all_visible=True,
|
||||
)
|
||||
self.event_box.connect("button-press-event", self.on_overlay_click)
|
||||
self.children = [self.event_box]
|
||||
|
||||
# Make the overlay transparent
|
||||
self.set_app_paintable(True)
|
||||
self.connect("draw", self.on_draw)
|
||||
|
||||
# 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 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
|
||||
|
||||
# Get click coordinates
|
||||
click_x = event.x_root
|
||||
click_y = event.y_root
|
||||
|
||||
# Get child window bounds
|
||||
try:
|
||||
child_x, child_y = self.child_window.get_position()
|
||||
child_allocation = self.child_window.get_allocation()
|
||||
|
||||
# Check if click is inside child window bounds
|
||||
inside_child = (
|
||||
child_x <= click_x <= child_x + child_allocation.width
|
||||
and child_y <= click_y <= child_y + child_allocation.height
|
||||
)
|
||||
|
||||
if not inside_child:
|
||||
# Click is outside child window - hide it with delay
|
||||
GLib.timeout_add(
|
||||
50, lambda: self.hide_child_window(None, None) or False
|
||||
)
|
||||
return True # Consume the event
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking click position: {e}")
|
||||
# If we can't determine position, hide child window to be safe
|
||||
GLib.timeout_add(50, lambda: self.hide_child_window(None, None) or False)
|
||||
return True
|
||||
|
||||
# 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:
|
||||
self.child_window.show()
|
||||
self.show()
|
||||
else:
|
||||
self.child_window.hide()
|
||||
self.hide()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,191 @@
|
||||
import contextlib
|
||||
import gi # type: ignore
|
||||
from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore
|
||||
|
||||
from widgets.wayland import WaylandWindow
|
||||
from utils.monitors import HyprlandWithMonitors
|
||||
|
||||
gi.require_version("GtkLayerShell", "0.1")
|
||||
|
||||
|
||||
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(),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,370 @@
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from typing import Literal, cast
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from gi.repository import Gdk, GObject, Gtk
|
||||
from loguru import logger
|
||||
|
||||
from fabric.core.service import Property
|
||||
from fabric.utils.helpers import extract_css_values, get_enum_member
|
||||
from fabric.widgets.window import Window
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
try:
|
||||
gi.require_version("GtkLayerShell", "0.1")
|
||||
from gi.repository import GtkLayerShell
|
||||
except:
|
||||
raise ImportError(
|
||||
"looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)"
|
||||
)
|
||||
|
||||
|
||||
class WaylandWindowExclusivity(Enum):
|
||||
NONE = 1
|
||||
NORMAL = 2
|
||||
AUTO = 3
|
||||
|
||||
|
||||
class Layer(GObject.GEnum):
|
||||
BACKGROUND = 0
|
||||
BOTTOM = 1
|
||||
TOP = 2
|
||||
OVERLAY = 3
|
||||
ENTRY_NUMBER = 4
|
||||
|
||||
|
||||
class KeyboardMode(GObject.GEnum):
|
||||
NONE = 0
|
||||
EXCLUSIVE = 1
|
||||
ON_DEMAND = 2
|
||||
ENTRY_NUMBER = 3
|
||||
|
||||
|
||||
class Edge(GObject.GEnum):
|
||||
LEFT = 0
|
||||
RIGHT = 1
|
||||
TOP = 2
|
||||
BOTTOM = 3
|
||||
ENTRY_NUMBER = 4
|
||||
|
||||
|
||||
class WaylandWindow(Window):
|
||||
@Property(
|
||||
Layer,
|
||||
flags="read-write",
|
||||
default_value=Layer.TOP,
|
||||
)
|
||||
def layer(self) -> Layer: # type: ignore
|
||||
return self._layer # type: ignore
|
||||
|
||||
@layer.setter
|
||||
def layer(
|
||||
self,
|
||||
value: Literal["background", "bottom", "top", "overlay"] | Layer,
|
||||
) -> None:
|
||||
self._layer = get_enum_member(Layer, value, default=Layer.TOP)
|
||||
return GtkLayerShell.set_layer(self, self._layer)
|
||||
|
||||
@Property(int, "read-write")
|
||||
def monitor(self) -> int:
|
||||
if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))):
|
||||
return -1
|
||||
display = monitor.get_display() or Gdk.Display.get_default()
|
||||
for i in range(0, display.get_n_monitors()):
|
||||
if display.get_monitor(i) is monitor:
|
||||
return i
|
||||
return -1
|
||||
|
||||
@monitor.setter
|
||||
def monitor(self, monitor: int | Gdk.Monitor) -> bool:
|
||||
if isinstance(monitor, int):
|
||||
display = Gdk.Display().get_default()
|
||||
monitor = display.get_monitor(monitor)
|
||||
return (
|
||||
(GtkLayerShell.set_monitor(self, monitor), True)[1]
|
||||
if monitor is not None
|
||||
else False
|
||||
)
|
||||
|
||||
@Property(WaylandWindowExclusivity, "read-write")
|
||||
def exclusivity(self) -> WaylandWindowExclusivity:
|
||||
return self._exclusivity
|
||||
|
||||
@exclusivity.setter
|
||||
def exclusivity(
|
||||
self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity
|
||||
) -> None:
|
||||
value = get_enum_member(
|
||||
WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE
|
||||
)
|
||||
self._exclusivity = value
|
||||
match value:
|
||||
case WaylandWindowExclusivity.NORMAL:
|
||||
return GtkLayerShell.set_exclusive_zone(self, True)
|
||||
case WaylandWindowExclusivity.AUTO:
|
||||
return GtkLayerShell.auto_exclusive_zone_enable(self)
|
||||
case _:
|
||||
return GtkLayerShell.set_exclusive_zone(self, False)
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def pass_through(self) -> bool:
|
||||
return self._pass_through
|
||||
|
||||
@pass_through.setter
|
||||
def pass_through(self, pass_through: bool = False):
|
||||
self._pass_through = pass_through
|
||||
region = cairo.Region() if pass_through is True else None
|
||||
self.input_shape_combine_region(region)
|
||||
del region
|
||||
return
|
||||
|
||||
@Property(
|
||||
KeyboardMode,
|
||||
"read-write",
|
||||
default_value=KeyboardMode.NONE,
|
||||
)
|
||||
def keyboard_mode(self) -> KeyboardMode:
|
||||
return self._keyboard_mode
|
||||
|
||||
@keyboard_mode.setter
|
||||
def keyboard_mode(
|
||||
self,
|
||||
value: (
|
||||
Literal[
|
||||
"none",
|
||||
"exclusive",
|
||||
"on-demand",
|
||||
"entry-number",
|
||||
]
|
||||
| KeyboardMode
|
||||
),
|
||||
):
|
||||
self._keyboard_mode = get_enum_member(
|
||||
KeyboardMode, value, default=KeyboardMode.NONE
|
||||
)
|
||||
return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode)
|
||||
|
||||
@Property(tuple[Edge, ...], "read-write")
|
||||
def anchor(self):
|
||||
return tuple(
|
||||
x
|
||||
for x in [
|
||||
Edge.TOP,
|
||||
Edge.RIGHT,
|
||||
Edge.BOTTOM,
|
||||
Edge.LEFT,
|
||||
]
|
||||
if GtkLayerShell.get_anchor(self, x)
|
||||
)
|
||||
|
||||
@anchor.setter
|
||||
def anchor(self, value: str | Iterable[Edge]) -> None:
|
||||
self._anchor = value
|
||||
if isinstance(value, (list, tuple)) and all(
|
||||
isinstance(edge, Edge) for edge in value
|
||||
):
|
||||
for edge in [
|
||||
Edge.TOP,
|
||||
Edge.RIGHT,
|
||||
Edge.BOTTOM,
|
||||
Edge.LEFT,
|
||||
]:
|
||||
if edge not in value:
|
||||
GtkLayerShell.set_anchor(self, edge, False)
|
||||
GtkLayerShell.set_anchor(self, edge, True)
|
||||
return
|
||||
elif isinstance(value, str):
|
||||
for edge, anchored in WaylandWindow.extract_edges_from_string(
|
||||
value
|
||||
).items():
|
||||
GtkLayerShell.set_anchor(self, edge, anchored)
|
||||
|
||||
return
|
||||
|
||||
@Property(tuple[int, ...], flags="read-write")
|
||||
def margin(self) -> tuple[int, ...]:
|
||||
return tuple(
|
||||
GtkLayerShell.get_margin(self, x)
|
||||
for x in [
|
||||
Edge.TOP,
|
||||
Edge.RIGHT,
|
||||
Edge.BOTTOM,
|
||||
Edge.LEFT,
|
||||
]
|
||||
)
|
||||
|
||||
@margin.setter
|
||||
def margin(self, value: str | Iterable[int]) -> None:
|
||||
for edge, mrgv in WaylandWindow.extract_margin(value).items():
|
||||
GtkLayerShell.set_margin(self, edge, mrgv)
|
||||
return
|
||||
|
||||
@Property(object, "read-write")
|
||||
def keyboard_mode(self):
|
||||
kb_mode = GtkLayerShell.get_keyboard_mode(self)
|
||||
if GtkLayerShell.get_keyboard_interactivity(self):
|
||||
kb_mode = KeyboardMode.EXCLUSIVE
|
||||
return kb_mode
|
||||
|
||||
@keyboard_mode.setter
|
||||
def keyboard_mode(
|
||||
self,
|
||||
value: Literal["none", "exclusive", "on-demand"] | KeyboardMode,
|
||||
):
|
||||
return GtkLayerShell.set_keyboard_mode(
|
||||
self,
|
||||
get_enum_member(
|
||||
KeyboardMode,
|
||||
value,
|
||||
default=KeyboardMode.NONE,
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP,
|
||||
anchor: str = "",
|
||||
margin: str | Iterable[int] = "0px 0px 0px 0px",
|
||||
exclusivity: (
|
||||
Literal["auto", "normal", "none"] | WaylandWindowExclusivity
|
||||
) = WaylandWindowExclusivity.NONE,
|
||||
keyboard_mode: (
|
||||
Literal["none", "exclusive", "on-demand"] | KeyboardMode
|
||||
) = KeyboardMode.NONE,
|
||||
pass_through: bool = False,
|
||||
monitor: int | Gdk.Monitor | None = None,
|
||||
title: str = "fabric",
|
||||
type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
|
||||
child: Gtk.Widget | 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,
|
||||
):
|
||||
Window.__init__(
|
||||
self,
|
||||
title=title,
|
||||
type=type,
|
||||
child=child,
|
||||
name=name,
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
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._layer = Layer.ENTRY_NUMBER
|
||||
self._keyboard_mode = KeyboardMode.NONE
|
||||
self._anchor = anchor
|
||||
self._exclusivity = WaylandWindowExclusivity.NONE
|
||||
self._pass_through = pass_through
|
||||
|
||||
GtkLayerShell.init_for_window(self)
|
||||
GtkLayerShell.set_namespace(self, title)
|
||||
self.connect(
|
||||
"notify::title",
|
||||
lambda *_: GtkLayerShell.set_namespace(self, self.get_title()),
|
||||
)
|
||||
if monitor is not None:
|
||||
self.monitor = monitor
|
||||
self.layer = layer
|
||||
self.anchor = anchor
|
||||
self.margin = margin
|
||||
self.keyboard_mode = keyboard_mode
|
||||
self.exclusivity = exclusivity
|
||||
self.pass_through = pass_through
|
||||
(
|
||||
self.show_all()
|
||||
if all_visible is True
|
||||
else self.show() if visible is True else None
|
||||
)
|
||||
|
||||
def steal_input(self) -> None:
|
||||
return GtkLayerShell.set_keyboard_interactivity(self, True)
|
||||
|
||||
def return_input(self) -> None:
|
||||
return GtkLayerShell.set_keyboard_interactivity(self, False)
|
||||
|
||||
# custom overrides
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
return self.do_handle_post_show_request()
|
||||
|
||||
def show_all(self) -> None:
|
||||
super().show_all()
|
||||
return self.do_handle_post_show_request()
|
||||
|
||||
def do_handle_post_show_request(self) -> None:
|
||||
if not self.get_children():
|
||||
logger.warning(
|
||||
"[WaylandWindow] showing an empty window is not recommended, some compositors might freak out."
|
||||
)
|
||||
self.pass_through = self._pass_through
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def extract_anchor_values(string: str) -> tuple[str, ...]:
|
||||
"""
|
||||
extracts the geometry values from a given geometry string.
|
||||
|
||||
:param string: the string containing the geometry values.
|
||||
:type string: str
|
||||
:return: a list of unique directions extracted from the geometry string.
|
||||
:rtype: list
|
||||
"""
|
||||
direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"}
|
||||
pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE)
|
||||
matches = pattern.findall(string)
|
||||
return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches)))
|
||||
|
||||
@staticmethod
|
||||
def extract_edges_from_string(string: str) -> dict["Edge", bool]:
|
||||
anchor_values = WaylandWindow.extract_anchor_values(string.lower())
|
||||
return {
|
||||
Edge.TOP: "top" in anchor_values,
|
||||
Edge.RIGHT: "right" in anchor_values,
|
||||
Edge.BOTTOM: "bottom" in anchor_values,
|
||||
Edge.LEFT: "left" in anchor_values,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]:
|
||||
margins = (
|
||||
extract_css_values(input.lower())
|
||||
if isinstance(input, str)
|
||||
else (
|
||||
input
|
||||
if isinstance(input, (tuple, list)) and len(input) == 4
|
||||
else (0, 0, 0, 0)
|
||||
)
|
||||
)
|
||||
return {
|
||||
Edge.TOP: margins[0],
|
||||
Edge.RIGHT: margins[1],
|
||||
Edge.BOTTOM: margins[2],
|
||||
Edge.LEFT: margins[3],
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import gi
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
# from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from fabric.widgets.window import Window
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
|
||||
class WiFiPasswordDialog(Window):
|
||||
def __init__(
|
||||
self,
|
||||
ssid: str,
|
||||
on_connect_callback=None,
|
||||
on_cancel_callback=None,
|
||||
on_dialog_closed=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
title="modus",
|
||||
layer="overlay",
|
||||
anchor="center",
|
||||
keyboard_mode="on-demand",
|
||||
visible=False,
|
||||
name="wifi-password-dialog",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.ssid = ssid
|
||||
self.on_connect_callback = on_connect_callback
|
||||
self.on_cancel_callback = on_cancel_callback
|
||||
# self.set_size_request(400, 300)
|
||||
self.set_resizable(False)
|
||||
self.on_dialog_closed = on_dialog_closed
|
||||
self.is_connecting = False
|
||||
self.connection_timeout_id = None
|
||||
|
||||
self._create_dialog_content()
|
||||
self.connect("key-press-event", self._on_key_press)
|
||||
self.connect("notify::visible", self._on_visibility_changed)
|
||||
|
||||
def _create_dialog_content(self):
|
||||
self.wifi_icon = Image(
|
||||
icon_name="network-wireless-symbolic", size=20, name="wifi-dialog-icon"
|
||||
)
|
||||
|
||||
self.title_label = Label(
|
||||
label=f'The Wi-Fi network "{self.ssid}" requires a WPA2 password.',
|
||||
name="wifi-dialog-title",
|
||||
h_align="start",
|
||||
wrap=True,
|
||||
max_width_chars=40,
|
||||
)
|
||||
|
||||
self.title_container = Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
children=[self.wifi_icon, self.title_label],
|
||||
name="wifi-dialog-title-container",
|
||||
h_align="start",
|
||||
)
|
||||
|
||||
self.error_label = Label(
|
||||
label="Incorrect password. Please try again.",
|
||||
name="wifi-dialog-error",
|
||||
h_align="center",
|
||||
visible=False,
|
||||
)
|
||||
|
||||
self.password_label = Label(
|
||||
label="Password:", name="wifi-dialog-password-label", h_align="start"
|
||||
)
|
||||
|
||||
self.password_entry = Entry(
|
||||
placeholder_text="Enter password",
|
||||
name="wifi-dialog-password-entry",
|
||||
visibility=False,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
)
|
||||
|
||||
self.password_entry.connect("activate", lambda *_: self._on_join_clicked())
|
||||
self.password_entry.connect("changed", self._on_password_changed)
|
||||
|
||||
self.password_visible = False
|
||||
self.show_password_button = Button(
|
||||
image=Image(icon_name="view-conceal-symbolic", size=16),
|
||||
name="wifi-dialog-show-password-button",
|
||||
on_clicked=self._on_show_password_clicked,
|
||||
)
|
||||
|
||||
self.show_password_label = Label(
|
||||
label="Show password", name="wifi-dialog-show-password-label"
|
||||
)
|
||||
|
||||
self.show_password_box = Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
children=[self.show_password_button, self.show_password_label],
|
||||
name="wifi-dialog-show-password-box",
|
||||
)
|
||||
|
||||
self.cancel_button = Button(
|
||||
label="Cancel",
|
||||
name="wifi-dialog-cancel-button",
|
||||
on_clicked=self._on_cancel_clicked,
|
||||
)
|
||||
|
||||
self.join_button = Button(
|
||||
label="Join",
|
||||
name="wifi-dialog-join-button",
|
||||
on_clicked=self._on_join_clicked,
|
||||
)
|
||||
|
||||
self.button_box = Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
h_expand=True,
|
||||
children=[self.cancel_button, self.join_button],
|
||||
name="wifi-dialog-button-box",
|
||||
h_align="end",
|
||||
)
|
||||
|
||||
self.password_container = Box(
|
||||
orientation="v",
|
||||
h_expand=True,
|
||||
spacing=6,
|
||||
children=[self.password_label, self.password_entry, self.show_password_box],
|
||||
name="wifi-dialog-password-container",
|
||||
)
|
||||
|
||||
self.content_box = Box(
|
||||
orientation="v",
|
||||
h_expand=True,
|
||||
spacing=12,
|
||||
children=[
|
||||
self.title_container,
|
||||
self.error_label,
|
||||
self.password_container,
|
||||
self.button_box,
|
||||
],
|
||||
name="wifi-dialog-content",
|
||||
h_align="fill",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.dialog_background = Box(
|
||||
children=[self.content_box],
|
||||
name="wifi-dialog-background",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.children = self.dialog_background
|
||||
|
||||
self._update_join_button_state()
|
||||
|
||||
def _on_password_changed(self, entry):
|
||||
self._update_join_button_state()
|
||||
|
||||
def _update_join_button_state(self):
|
||||
password = self.password_entry.get_text().strip()
|
||||
has_password = len(password) > 0
|
||||
|
||||
if has_password:
|
||||
self.join_button.set_opacity(1.0)
|
||||
self.join_button.remove_style_class("disabled")
|
||||
else:
|
||||
self.join_button.set_opacity(0.5)
|
||||
self.join_button.add_style_class("disabled")
|
||||
|
||||
def _on_show_password_clicked(self, *args):
|
||||
self.password_visible = not self.password_visible
|
||||
self.password_entry.set_visibility(self.password_visible)
|
||||
|
||||
icon_name = (
|
||||
"view-reveal-symbolic" if self.password_visible else "view-conceal-symbolic"
|
||||
)
|
||||
self.show_password_button.get_image().set_property("icon-name", icon_name)
|
||||
|
||||
def _on_key_press(self, widget, event):
|
||||
keyval = event.keyval
|
||||
|
||||
if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:
|
||||
self._on_join_clicked()
|
||||
return True
|
||||
elif keyval == Gdk.KEY_Escape:
|
||||
self._on_cancel_clicked()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _on_visibility_changed(self, widget, *args):
|
||||
"""Handle visibility changes"""
|
||||
if self.get_visible():
|
||||
GLib.timeout_add(100, lambda: self.password_entry.grab_focus())
|
||||
|
||||
def _on_cancel_clicked(self, *args):
|
||||
self.hide()
|
||||
if self.on_cancel_callback:
|
||||
self.on_cancel_callback()
|
||||
if self.on_dialog_closed:
|
||||
self.on_dialog_closed()
|
||||
|
||||
def _on_join_clicked(self, *args):
|
||||
if self.is_connecting:
|
||||
return
|
||||
|
||||
password = self.password_entry.get_text().strip()
|
||||
if not password:
|
||||
self.password_entry.grab_focus()
|
||||
return
|
||||
|
||||
self.is_connecting = True
|
||||
self.join_button.set_sensitive(False)
|
||||
|
||||
self.connection_timeout_id = GLib.timeout_add(5000, self._connection_timeout)
|
||||
self.error_label.set_visible(False)
|
||||
|
||||
self.hide()
|
||||
if self.on_connect_callback:
|
||||
self.on_connect_callback(self.ssid, password)
|
||||
if self.on_dialog_closed:
|
||||
self.on_dialog_closed()
|
||||
|
||||
def _connection_timeout(self):
|
||||
if self.is_connecting:
|
||||
self.is_connecting = False
|
||||
self.join_button.set_sensitive(True)
|
||||
self.show_error("Connection timeout. Please try again.")
|
||||
return False
|
||||
|
||||
def show_dialog(self):
|
||||
if self.connection_timeout_id:
|
||||
GLib.source_remove(self.connection_timeout_id)
|
||||
self.connection_timeout_id = None
|
||||
|
||||
self.show_all()
|
||||
self.password_entry.set_text("")
|
||||
self.error_label.set_visible(False)
|
||||
|
||||
self.password_visible = False
|
||||
self.password_entry.set_visibility(False)
|
||||
self.show_password_button.get_image().set_property(
|
||||
"icon-name", "view-conceal-symbolic"
|
||||
)
|
||||
|
||||
self.is_connecting = False
|
||||
self.join_button.set_sensitive(True)
|
||||
|
||||
self._update_join_button_state()
|
||||
|
||||
def show_error(self, message="Incorrect password. Please try again."):
|
||||
if self.connection_timeout_id:
|
||||
GLib.source_remove(self.connection_timeout_id)
|
||||
self.connection_timeout_id = None
|
||||
|
||||
self.is_connecting = False
|
||||
self.join_button.set_sensitive(True)
|
||||
|
||||
if not self.get_visible():
|
||||
self.error_label.set_text(message)
|
||||
self.error_label.set_visible(True)
|
||||
self.show_all()
|
||||
GLib.timeout_add(10, lambda: self._focus_and_select_password())
|
||||
else:
|
||||
self.error_label.set_text(message)
|
||||
self.error_label.set_visible(True)
|
||||
self._focus_and_select_password()
|
||||
|
||||
def _focus_and_select_password(self):
|
||||
try:
|
||||
self.password_entry.grab_focus()
|
||||
self.password_entry.select_region(0, -1)
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_password(self):
|
||||
return self.password_entry.get_text()
|
||||
|
||||
def destroy_dialog(self):
|
||||
self.hide()
|
||||
self.destroy()
|
||||
Reference in new issue
Block a user