feat(src/shared): add flat scale widget and smooth-switch styling
Implement animated flat scale widget with CSS styling and smooth-switch UI enhancements
This commit is contained in:
9 files changed
+1230
-533
No files matched your search
@@ -0,0 +1,101 @@
|
||||
from fabric.utils import GLib
|
||||
from fabric.widgets.eventbox import EventBox # noqa: E402
|
||||
from fabric.widgets.wayland import WaylandWindow as Window # noqa: E402
|
||||
|
||||
from shared.window.popup_win import PopupWindow # noqa: E402
|
||||
|
||||
|
||||
class DismissLayer(Window):
|
||||
def __init__(self, on_dismiss, **kwargs):
|
||||
self.event_box = EventBox()
|
||||
super().__init__(
|
||||
anchor="left right top bottom",
|
||||
layer="top",
|
||||
exclusivity="none",
|
||||
keyboard_mode="none",
|
||||
child=self.event_box,
|
||||
visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.event_box.connect("button-press-event", lambda *_: on_dismiss())
|
||||
|
||||
|
||||
class AppletWindow(PopupWindow):
|
||||
_active_popup = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._is_open = False
|
||||
self._hide_timeout_id = None
|
||||
self.dismiss_layer = DismissLayer(on_dismiss=self.toggle)
|
||||
kwargs["keyboard_mode"] = "on-demand"
|
||||
super().__init__(**kwargs)
|
||||
self.add_keybinding("escape", lambda *_: self.toggle())
|
||||
|
||||
def toggle(self, *_):
|
||||
if self._is_open:
|
||||
self.close_applet()
|
||||
else:
|
||||
self.open_applet()
|
||||
|
||||
def open_applet(self):
|
||||
if self._is_open:
|
||||
return
|
||||
|
||||
self._is_open = True
|
||||
|
||||
active = AppletWindow._active_popup
|
||||
if active and active != self:
|
||||
try:
|
||||
if hasattr(active, "close_applet"):
|
||||
active.close_applet()
|
||||
else:
|
||||
active.hide()
|
||||
except Exception as e:
|
||||
print(f"[AppletWindow] Warning: failed to close active popup: {e}")
|
||||
|
||||
AppletWindow._active_popup = self
|
||||
|
||||
if self._hide_timeout_id is not None:
|
||||
GLib.source_remove(self._hide_timeout_id)
|
||||
self._hide_timeout_id = None
|
||||
|
||||
child = self.get_child()
|
||||
if child:
|
||||
child.set_opacity(1.0)
|
||||
|
||||
if hasattr(self, "dismiss_layer"):
|
||||
self.dismiss_layer.show()
|
||||
|
||||
self.set_visible(True)
|
||||
self.set_focus(None)
|
||||
|
||||
def close_applet(self):
|
||||
if not self._is_open:
|
||||
return
|
||||
|
||||
self._is_open = False
|
||||
|
||||
if self._hide_timeout_id is not None:
|
||||
GLib.source_remove(self._hide_timeout_id)
|
||||
self._hide_timeout_id = None
|
||||
|
||||
if hasattr(self, "dismiss_layer"):
|
||||
self.dismiss_layer.hide()
|
||||
|
||||
self.set_visible(False)
|
||||
|
||||
if AppletWindow._active_popup == self:
|
||||
AppletWindow._active_popup = None
|
||||
|
||||
def show(self):
|
||||
self.open_applet()
|
||||
|
||||
def hide(self):
|
||||
self.close_applet()
|
||||
|
||||
def destroy(self):
|
||||
if hasattr(self, "dismiss_layer"):
|
||||
self.dismiss_layer.destroy()
|
||||
if self._hide_timeout_id is not None:
|
||||
GLib.source_remove(self._hide_timeout_id)
|
||||
super().destroy()
|
||||
@@ -7,7 +7,7 @@ from fabric.widgets.label import Label
|
||||
from fabric.widgets.separator import Separator
|
||||
from 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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
from fabric.utils import Any, Gdk, cairo, GLib
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.widgets.widget import Widget
|
||||
from gi.repository import GtkLayerShell # type: ignore
|
||||
|
||||
from utils.monitors import HyprlandWithMonitors
|
||||
from utils.roam import modus_service
|
||||
|
||||
|
||||
class MouseCapture(Window):
|
||||
"""A background overlay that captures outside clicks without blocking child window interactions"""
|
||||
|
||||
def __init__(self, layer: str, child_window: Window, **kwargs):
|
||||
super().__init__(
|
||||
layer=layer, # Use the passed layer
|
||||
anchor="top bottom left right",
|
||||
exclusivity="auto",
|
||||
title="modus",
|
||||
name="MouseCapture",
|
||||
keyboard_mode="none", # Don't steal keyboard
|
||||
all_visible=False,
|
||||
visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
GtkLayerShell.set_exclusive_zone(self, -1)
|
||||
|
||||
self.child_window = child_window
|
||||
|
||||
# Ensure child window is on overlay layer to be above this capture
|
||||
if hasattr(self.child_window, "layer"):
|
||||
self.child_window.layer = "overlay"
|
||||
|
||||
if hasattr(self.child_window, "_init_mousecapture"):
|
||||
self.child_window._init_mousecapture(self)
|
||||
|
||||
# Create transparent event box that captures clicks
|
||||
self.event_box = EventBox(
|
||||
events=[
|
||||
"button-press-event",
|
||||
"button-release-event",
|
||||
"pointer-motion-mask", # Listen for mouse movement
|
||||
"enter-notify-mask",
|
||||
],
|
||||
all_visible=True,
|
||||
)
|
||||
self.event_box.connect("button-press-event", self.on_overlay_click)
|
||||
self.event_box.connect("motion-notify-event", self.on_mouse_motion)
|
||||
self.event_box.connect("enter-notify-event", self.on_mouse_motion)
|
||||
self.children = [self.event_box]
|
||||
|
||||
self._hyprland = HyprlandWithMonitors()
|
||||
self._cursor_pointer = Gdk.Cursor.new_from_name(
|
||||
Gdk.Display.get_default(), "pointer"
|
||||
)
|
||||
self._cursor_default = None # Resets to default arrow
|
||||
|
||||
# Make the overlay transparent
|
||||
self.set_app_paintable(True)
|
||||
self.connect("draw", self.on_draw)
|
||||
self.connect("size-allocate", lambda *_: self.update_input_region())
|
||||
self.connect("map", lambda *_: self.update_input_region())
|
||||
self.connect("notify::visible", lambda *_: self.update_input_region())
|
||||
|
||||
# Add escape key binding to child window
|
||||
if hasattr(self.child_window, "add_keybinding"):
|
||||
self.child_window.add_keybinding("Escape", self.hide_child_window)
|
||||
|
||||
def on_draw(self, _widget, cr):
|
||||
"""Make overlay transparent"""
|
||||
cr.set_source_rgba(0, 0, 0, 0) # Fully transparent
|
||||
cr.set_operator(cairo.OPERATOR_SOURCE)
|
||||
cr.paint()
|
||||
return False
|
||||
|
||||
def _get_child_window_bounds(self) -> tuple[int, int, int, int]:
|
||||
"""Calculates absolute screen bounds for the child window using Gdk origin"""
|
||||
try:
|
||||
window = self.child_window.get_window()
|
||||
if not window:
|
||||
# Fallback to allocation if window isn't mapped yet
|
||||
alloc = self.child_window.get_allocation()
|
||||
return 0, 0, alloc.width, alloc.height
|
||||
|
||||
# get_origin provides absolute screen coordinates
|
||||
success, x, y = window.get_origin()
|
||||
if not success:
|
||||
alloc = self.child_window.get_allocation()
|
||||
return 0, 0, alloc.width, alloc.height
|
||||
|
||||
alloc = self.child_window.get_allocation()
|
||||
return x, y, alloc.width, alloc.height
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating child window bounds: {e}")
|
||||
alloc = self.child_window.get_allocation()
|
||||
return 0, 0, alloc.width, alloc.height
|
||||
|
||||
def _get_widget_absolute_bounds(self, widget: Widget) -> tuple[int, int, int, int]:
|
||||
"""Calculates absolute screen bounds for a widget in another window"""
|
||||
try:
|
||||
toplevel = widget.get_toplevel()
|
||||
if not toplevel or not toplevel.get_window():
|
||||
return 0, 0, 0, 0
|
||||
|
||||
# Get the origin of the window containing the widget
|
||||
success, wx, wy = toplevel.get_window().get_origin()
|
||||
if not success:
|
||||
return 0, 0, 0, 0
|
||||
|
||||
# Get relative position within toplevel
|
||||
alloc = widget.get_allocation()
|
||||
rx, ry = widget.translate_coordinates(toplevel, 0, 0) or (0, 0)
|
||||
|
||||
return wx + rx, wy + ry, alloc.width, alloc.height
|
||||
except Exception as e:
|
||||
print(f"Error calculating widget absolute bounds: {e}")
|
||||
return 0, 0, 0, 0
|
||||
|
||||
def update_input_region(self):
|
||||
"""Punches holes in the input region for the trigger button and child window"""
|
||||
window = self.get_window()
|
||||
if not window:
|
||||
return
|
||||
|
||||
try:
|
||||
# Full screen region
|
||||
alloc = self.get_allocation()
|
||||
region = cairo.Region(cairo.RectangleInt(0, 0, alloc.width, alloc.height))
|
||||
|
||||
# Punch hole for trigger button (relative to this overlay window)
|
||||
pointing_widget = getattr(self.child_window, "_pointing_widget", None)
|
||||
if pointing_widget:
|
||||
# get_widget_absolute_bounds returns screen-absolute coords.
|
||||
# Since overlay is full screen on its monitor, we just need to subtract its monitor's X/Y
|
||||
px, py, pw, ph = self._get_widget_absolute_bounds(pointing_widget)
|
||||
monitor = self._hyprland.display.get_monitor_at_window(window)
|
||||
if monitor:
|
||||
mx, my = monitor.get_geometry().x, monitor.get_geometry().y
|
||||
region.subtract(cairo.RectangleInt(px - mx, py - my, pw, ph))
|
||||
|
||||
# Punch hole for child window
|
||||
cx, cy, cw, ch = self._get_child_window_bounds()
|
||||
monitor = self._hyprland.display.get_monitor_at_window(window)
|
||||
if monitor:
|
||||
mx, my = monitor.get_geometry().x, monitor.get_geometry().y
|
||||
region.subtract(cairo.RectangleInt(cx - mx, cy - my, cw, ch))
|
||||
|
||||
window.input_shape_combine_region(region, 0, 0)
|
||||
except Exception as e:
|
||||
print(f"Error updating input region: {e}")
|
||||
|
||||
def on_mouse_motion(self, _widget, event):
|
||||
"""Update cursor feedback based on position"""
|
||||
# (This is mostly redundant now with input regions but kept for extra safety)
|
||||
if not self.child_window.is_visible():
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def on_overlay_click(self, _widget, event):
|
||||
"""Handle overlay clicks - check if click is outside child window"""
|
||||
if not self.child_window.is_visible():
|
||||
return False
|
||||
|
||||
# Support double and triple clicks too
|
||||
if event.type not in [
|
||||
Gdk.EventType.BUTTON_PRESS,
|
||||
Gdk.EventType._2BUTTON_PRESS,
|
||||
Gdk.EventType._3BUTTON_PRESS,
|
||||
]:
|
||||
return False
|
||||
|
||||
# Use window-local coordinates for robust detection
|
||||
click_x = event.x
|
||||
click_y = event.y
|
||||
|
||||
# Get window position relative to monitor for absolute -> local conversion
|
||||
window = self.get_window()
|
||||
mx, my = 0, 0
|
||||
if window:
|
||||
monitor = self._hyprland.display.get_monitor_at_window(window)
|
||||
if monitor:
|
||||
geom = monitor.get_geometry()
|
||||
mx, my = geom.x, geom.y
|
||||
|
||||
# Check if click is on the "trigger" (pointing) widget
|
||||
pointing_widget = getattr(self.child_window, "_pointing_widget", None)
|
||||
if pointing_widget:
|
||||
px_abs, py_abs, pw, ph = self._get_widget_absolute_bounds(pointing_widget)
|
||||
px, py = px_abs - mx, py_abs - my
|
||||
if px <= click_x <= px + pw and py <= click_y <= py + ph:
|
||||
# Clicked the button that opened us - hide and consume
|
||||
self.hide_child_window()
|
||||
return True
|
||||
|
||||
# Get child window bounds (monitor-relative)
|
||||
cx_abs, cy_abs, cw, ch = self._get_child_window_bounds()
|
||||
cx, cy = cx_abs - mx, cy_abs - my
|
||||
|
||||
# Check if click is inside child window bounds
|
||||
inside_child = cx <= click_x <= cx + cw and cy <= click_y <= cy + ch
|
||||
|
||||
if not inside_child:
|
||||
# Click is outside child window - hide it immediately
|
||||
# No delay to avoid race conditions with toggle buttons
|
||||
self.hide_child_window()
|
||||
return True # Consume the event
|
||||
|
||||
# Click is inside child window - don't consume event
|
||||
return False
|
||||
|
||||
def show_child_window(self, widget: Widget = None, event: Any = None) -> None:
|
||||
self.set_child_window_visible(True)
|
||||
|
||||
def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
|
||||
|
||||
self.set_child_window_visible(False)
|
||||
|
||||
def set_child_window_visible(self, visible: bool) -> None:
|
||||
if visible:
|
||||
# Connect to child window size changes to keep input region in sync
|
||||
self._size_handler = self.child_window.connect(
|
||||
"size-allocate", lambda *_: self.update_input_region()
|
||||
)
|
||||
self.child_window.show()
|
||||
self.show()
|
||||
# Force update with a small delay to ensure window is mapped
|
||||
GLib.timeout_add(50, self.update_input_region)
|
||||
else:
|
||||
if hasattr(self, "_size_handler") and self._size_handler:
|
||||
self.child_window.disconnect(self._size_handler)
|
||||
self._size_handler = None
|
||||
self.child_window.hide()
|
||||
self.hide()
|
||||
|
||||
# Update styling on the trigger button
|
||||
pointing_widget = getattr(self.child_window, "_pointing_widget", None)
|
||||
if pointing_widget:
|
||||
if visible:
|
||||
pointing_widget.add_style_class("active")
|
||||
else:
|
||||
pointing_widget.remove_style_class("active")
|
||||
|
||||
if hasattr(self.child_window, "_set_mousecapture"):
|
||||
self.child_window._set_mousecapture(visible)
|
||||
|
||||
def toggle_mousecapture(self, *_) -> None:
|
||||
if self.is_visible():
|
||||
self.set_child_window_visible(False)
|
||||
else:
|
||||
self.set_child_window_visible(True)
|
||||
|
||||
|
||||
class DropDownMouseCapture(MouseCapture):
|
||||
"""A specialized MouseCapture for dropdown menus with service integration"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed)
|
||||
|
||||
def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
|
||||
"""Hide child window and update dropdown service state"""
|
||||
# Update service state before hiding to prevent conflicts
|
||||
if hasattr(self.child_window, "id"):
|
||||
if str(modus_service.current_dropdown) == str(self.child_window.id):
|
||||
modus_service.current_dropdown = None
|
||||
super().hide_child_window(widget, event)
|
||||
|
||||
def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None:
|
||||
"""Handle dropdown service hide changes"""
|
||||
if hasattr(self.child_window, "id"):
|
||||
if modus_service.current_dropdown == self.child_window.id:
|
||||
return
|
||||
return self.hide_child_window(widget, event)
|
||||
|
||||
def destroy(self):
|
||||
"""Clean up signal connections"""
|
||||
try:
|
||||
modus_service.disconnect_by_func(self.dropdowns_hide_changed)
|
||||
except Exception:
|
||||
pass
|
||||
super().destroy()
|
||||
|
||||
|
||||
def add_destroy_to_mousecapture():
|
||||
# Base MouseCapture
|
||||
def mc_destroy(self):
|
||||
# Child window should be destroyed by its owner, but we should null references
|
||||
self.child_window = None
|
||||
Window.destroy(self)
|
||||
|
||||
MouseCapture.destroy = mc_destroy
|
||||
|
||||
|
||||
# Apply destroy to base class
|
||||
add_destroy_to_mousecapture()
|
||||
@@ -0,0 +1,190 @@
|
||||
from fabric.utils import Gdk, GLib, Gtk
|
||||
from fabric.widgets.wayland import WaylandWindow
|
||||
from gi.repository import GtkLayerShell
|
||||
|
||||
EDGE_MARGIN = 0
|
||||
|
||||
|
||||
def _get_monitor_geometry(widget: Gtk.Widget) -> tuple[int, int]:
|
||||
screen = Gdk.Screen.get_default()
|
||||
window = widget.get_toplevel().get_window()
|
||||
|
||||
if window is None:
|
||||
return 0, screen.get_width()
|
||||
|
||||
monitor_index = screen.get_monitor_at_window(window)
|
||||
geo = screen.get_monitor_geometry(monitor_index)
|
||||
|
||||
return geo.x, geo.width
|
||||
|
||||
|
||||
class PopupWindow(WaylandWindow):
|
||||
def __init__(
|
||||
self,
|
||||
parent: WaylandWindow | None = None,
|
||||
pointing_to: Gtk.Widget | None = None,
|
||||
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.exclusivity = "none"
|
||||
|
||||
self._parent = parent
|
||||
self._pointing_widget = pointing_to
|
||||
self._base_margin = self.extract_margin(margin)
|
||||
self.margin = self._base_margin.values()
|
||||
self._initial_position = True
|
||||
|
||||
self.connect("notify::visible", self.do_update_handlers)
|
||||
|
||||
def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
|
||||
if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()):
|
||||
return 0, 0
|
||||
allocation = widget.get_allocation()
|
||||
x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (
|
||||
0,
|
||||
0,
|
||||
)
|
||||
return round(x / 2), round(y / 2)
|
||||
|
||||
def set_pointing_to(self, widget: Gtk.Widget | None):
|
||||
if self._pointing_widget:
|
||||
try:
|
||||
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
|
||||
except Exception:
|
||||
pass
|
||||
self._pointing_widget = widget
|
||||
return self.do_update_handlers()
|
||||
|
||||
def get_parent_window(self):
|
||||
if not self._parent:
|
||||
return None
|
||||
if hasattr(self._parent, "anchor"):
|
||||
return self._parent
|
||||
toplevel = self._parent.get_toplevel()
|
||||
if hasattr(toplevel, "anchor"):
|
||||
return toplevel
|
||||
return None
|
||||
|
||||
def do_update_handlers(self, *_):
|
||||
if not self._pointing_widget:
|
||||
return
|
||||
|
||||
if not self.get_visible():
|
||||
try:
|
||||
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
|
||||
self.disconnect_by_func(self.do_handle_size_allocate)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
|
||||
self.connect("size-allocate", self.do_handle_size_allocate)
|
||||
|
||||
GLib.timeout_add(10, self._do_delayed_reposition)
|
||||
|
||||
def _do_delayed_reposition(self):
|
||||
self._initial_position = True
|
||||
self.do_reposition(self.do_calculate_edges())
|
||||
return False
|
||||
|
||||
def do_handle_size_allocate(self, *_):
|
||||
if self.get_visible() and not self._initial_position:
|
||||
return
|
||||
return self.do_reposition(self.do_calculate_edges())
|
||||
|
||||
def do_calculate_edges(self):
|
||||
move_axe = "x"
|
||||
parent_window = self.get_parent_window()
|
||||
if not parent_window:
|
||||
return move_axe
|
||||
|
||||
parent_anchor = parent_window.anchor
|
||||
|
||||
if len(parent_anchor) == 1:
|
||||
if GtkLayerShell.Edge.TOP in parent_anchor:
|
||||
self.anchor = "left top"
|
||||
else:
|
||||
self.anchor = "left bottom"
|
||||
return move_axe
|
||||
|
||||
if len(parent_anchor) != 3:
|
||||
return move_axe
|
||||
|
||||
if (
|
||||
GtkLayerShell.Edge.LEFT in parent_anchor
|
||||
and GtkLayerShell.Edge.RIGHT in parent_anchor
|
||||
):
|
||||
move_axe = "x"
|
||||
if GtkLayerShell.Edge.TOP in parent_anchor:
|
||||
self.anchor = "left top"
|
||||
else:
|
||||
self.anchor = "left bottom"
|
||||
elif (
|
||||
GtkLayerShell.Edge.TOP in parent_anchor
|
||||
and GtkLayerShell.Edge.BOTTOM in parent_anchor
|
||||
):
|
||||
move_axe = "y"
|
||||
if GtkLayerShell.Edge.RIGHT in parent_anchor:
|
||||
self.anchor = "top right"
|
||||
else:
|
||||
self.anchor = "top left"
|
||||
|
||||
return move_axe
|
||||
|
||||
def do_reposition(self, move_axe: str):
|
||||
parent_window = self.get_parent_window()
|
||||
if not parent_window:
|
||||
return
|
||||
|
||||
parent_margin = parent_window.margin
|
||||
parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
|
||||
|
||||
height = self.get_allocated_height()
|
||||
width = self.get_allocated_width()
|
||||
|
||||
monitor_x, monitor_width = _get_monitor_geometry(parent_window)
|
||||
|
||||
if self._pointing_widget:
|
||||
coords = self.get_coords_for_widget(self._pointing_widget)
|
||||
coords_centered = (
|
||||
round(coords[0] + self._pointing_widget.get_allocated_width() / 2),
|
||||
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
|
||||
)
|
||||
else:
|
||||
coords_centered = (
|
||||
round(parent_window.get_allocated_width() / 2),
|
||||
round(parent_window.get_allocated_height() / 2),
|
||||
)
|
||||
|
||||
if move_axe == "x":
|
||||
if len(parent_window.anchor) == 1:
|
||||
bar_width = parent_window.get_allocated_width()
|
||||
bar_left = (monitor_width - bar_width) // 2
|
||||
raw_margin = round((bar_left + coords_centered[0]) - (width / 2))
|
||||
else:
|
||||
raw_margin = round((parent_x_margin + coords_centered[0]) - (width / 2))
|
||||
min_margin = EDGE_MARGIN
|
||||
max_margin = monitor_width - width - EDGE_MARGIN
|
||||
clamped = max(min_margin, min(raw_margin, max_margin))
|
||||
position_margins = (0, 0, 0, clamped)
|
||||
else:
|
||||
raw_margin = round((parent_y_margin + coords_centered[1]) - (height / 2))
|
||||
monitor_height = Gdk.Screen.get_default().get_height()
|
||||
min_margin = EDGE_MARGIN
|
||||
max_margin = monitor_height - height - EDGE_MARGIN
|
||||
clamped = max(min_margin, min(raw_margin, max_margin))
|
||||
position_margins = (clamped, 0, 0, 0)
|
||||
|
||||
new_margin = tuple(
|
||||
a + b for a, b in zip(position_margins, self._base_margin.values())
|
||||
)
|
||||
new_margin = (
|
||||
max(0, new_margin[0]),
|
||||
new_margin[1],
|
||||
max(0, new_margin[2]),
|
||||
new_margin[3],
|
||||
)
|
||||
|
||||
self.margin = new_margin
|
||||
self._initial_position = False
|
||||
@@ -1,192 +0,0 @@
|
||||
import contextlib
|
||||
|
||||
from fabric.utils import Gdk, Gtk
|
||||
from fabric.widgets.wayland import WaylandWindow
|
||||
from gi.repository import GtkLayerShell
|
||||
|
||||
from utils.monitors import HyprlandWithMonitors
|
||||
|
||||
|
||||
class PopupWindow(WaylandWindow):
|
||||
"""A simple popover window that can point to a widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: WaylandWindow,
|
||||
pointing_to: Gtk.Widget | None = None,
|
||||
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
|
||||
enable_boundary_checking: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.exclusivity = "none"
|
||||
self._is_centered = False
|
||||
self._parent = parent
|
||||
self._pointing_widget = pointing_to
|
||||
self._hyprland = HyprlandWithMonitors()
|
||||
self._base_margin = self.extract_margin(margin)
|
||||
self.margin = self._base_margin.values()
|
||||
self._enable_boundary_checking = enable_boundary_checking
|
||||
|
||||
self.connect("notify::visible", self.do_update_handlers)
|
||||
|
||||
def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
|
||||
if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore
|
||||
return 0, 0
|
||||
allocation = widget.get_allocation()
|
||||
x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (
|
||||
0,
|
||||
0,
|
||||
)
|
||||
return round(x / 2), round(y / 2)
|
||||
|
||||
def set_pointing_to(self, widget: Gtk.Widget | None):
|
||||
if self._pointing_widget:
|
||||
with contextlib.suppress(Exception):
|
||||
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
|
||||
self._pointing_widget = widget
|
||||
return self.do_update_handlers()
|
||||
|
||||
def do_update_handlers(self, *_):
|
||||
if not self._pointing_widget:
|
||||
return
|
||||
|
||||
if not self.get_visible():
|
||||
try:
|
||||
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
|
||||
self.disconnect_by_func(self.do_handle_size_allocate)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
|
||||
self.connect("size-allocate", self.do_handle_size_allocate)
|
||||
|
||||
return self.do_handle_size_allocate()
|
||||
|
||||
def do_handle_size_allocate(self, *_):
|
||||
return self.do_reposition(self.do_calculate_edges())
|
||||
|
||||
def do_calculate_edges(self):
|
||||
move_axe = "x"
|
||||
parent_anchor = self._parent.anchor
|
||||
|
||||
if len(parent_anchor) != 3:
|
||||
self.anchor = "left bottom"
|
||||
self._is_centered = True
|
||||
return move_axe
|
||||
|
||||
if (
|
||||
GtkLayerShell.Edge.LEFT in parent_anchor
|
||||
and GtkLayerShell.Edge.RIGHT in parent_anchor
|
||||
):
|
||||
# horizontal -> move on x-axies
|
||||
move_axe = "x"
|
||||
if GtkLayerShell.Edge.TOP in parent_anchor:
|
||||
self.anchor = "left top"
|
||||
else:
|
||||
self.anchor = "left bottom"
|
||||
elif (
|
||||
GtkLayerShell.Edge.TOP in parent_anchor
|
||||
and GtkLayerShell.Edge.BOTTOM in parent_anchor
|
||||
):
|
||||
# vertical -> move on y-axies
|
||||
move_axe = "y"
|
||||
if GtkLayerShell.Edge.RIGHT in parent_anchor:
|
||||
self.anchor = "top right"
|
||||
else:
|
||||
self.anchor = "top left"
|
||||
|
||||
self._is_centered = False
|
||||
return move_axe
|
||||
|
||||
def do_reposition(self, move_axe: str):
|
||||
parent_margin = self._parent.margin
|
||||
parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
|
||||
|
||||
height = self.get_allocated_height()
|
||||
width = self.get_allocated_width()
|
||||
|
||||
# Get monitor geometry for boundary checking
|
||||
current_monitor_id = self._hyprland.get_current_gdk_monitor_id()
|
||||
if current_monitor_id is not None:
|
||||
monitor = self._hyprland.display.get_monitor(current_monitor_id)
|
||||
monitor_geometry = monitor.get_geometry()
|
||||
monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y
|
||||
monitor_width, monitor_height = (
|
||||
monitor_geometry.width,
|
||||
monitor_geometry.height,
|
||||
)
|
||||
else:
|
||||
# Fallback to default screen
|
||||
screen = Gdk.Screen.get_default()
|
||||
monitor_x, monitor_y = 0, 0
|
||||
monitor_width, monitor_height = screen.get_width(), screen.get_height()
|
||||
|
||||
if self._pointing_widget:
|
||||
coords = self.get_coords_for_widget(self._pointing_widget)
|
||||
coords_centered = (
|
||||
round(coords[0] + self._pointing_widget.get_allocated_width() / 2),
|
||||
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
|
||||
)
|
||||
else:
|
||||
coords_centered = (
|
||||
round(self._parent.get_allocated_width() / 2),
|
||||
round(self._parent.get_allocated_height() / 2),
|
||||
)
|
||||
|
||||
if self._is_centered:
|
||||
# Calculate centered position with boundary checking
|
||||
centered_x = (
|
||||
(monitor_width / 2 - self._parent.get_allocated_width() / 2) - width / 2
|
||||
) + coords_centered[0]
|
||||
|
||||
# Apply boundary checking only if enabled
|
||||
if self._enable_boundary_checking:
|
||||
if centered_x < monitor_x:
|
||||
centered_x = monitor_x
|
||||
elif centered_x + width > monitor_x + monitor_width:
|
||||
centered_x = monitor_x + monitor_width - width
|
||||
|
||||
self.margin = tuple(
|
||||
a + b
|
||||
for a, b in zip(
|
||||
(0, 0, 0, centered_x),
|
||||
self._base_margin.values(),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Calculate position with boundary checking
|
||||
if move_axe == "x":
|
||||
# Horizontal positioning
|
||||
calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2))
|
||||
|
||||
# Apply boundary checking only if enabled
|
||||
if self._enable_boundary_checking:
|
||||
if calculated_x < monitor_x:
|
||||
calculated_x = monitor_x
|
||||
elif calculated_x + width > monitor_x + monitor_width:
|
||||
calculated_x = monitor_x + monitor_width - width
|
||||
|
||||
margin_values = (0, 0, 0, calculated_x)
|
||||
else:
|
||||
# Vertical positioning
|
||||
calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2))
|
||||
|
||||
# Apply boundary checking only if enabled
|
||||
if self._enable_boundary_checking:
|
||||
if calculated_y < monitor_y:
|
||||
calculated_y = monitor_y
|
||||
elif calculated_y + height > monitor_y + monitor_height:
|
||||
calculated_y = monitor_y + monitor_height - height
|
||||
|
||||
margin_values = (calculated_y, 0, 0, 0)
|
||||
|
||||
self.margin = tuple(
|
||||
a + b
|
||||
for a, b in zip(
|
||||
margin_values,
|
||||
self._base_margin.values(),
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user