Files
Modus/widgets/mousecapture.py
T
sankalp 38de0c66ed feat(widgets): enhance mouse capture handling and dropdown escape keybinding
Why: Refactor mouse capture logic to use robust input‑region handling, add support for multi‑click events, and align layer usage with the passed layer parameter; update dropdown to use an Escape keybinding instead of a button‑press hide.

New Features:
- Support button‑press, double‑click, and triple‑click events.
- Introduce `_get_child_window_bounds` helper and window‑local coordinate detection for accurate click placement.
- Add cursor‑feedback styling updates on the trigger widget.

Enhancements:
- Make overlay layer configurable via the passed `layer` argument.
- Replace direct `button-press-event` connection with an Escape keybinding in the dropdown.
- Update imports to use `Gdk` and new utilities (`HyprlandWithMonitors`, `modus_service`).

Bug Fixes:
- Resolve race condition that could hide child windows on external clicks.
- Ensure immediate hide when clicking outside the child window to avoid timing issues.
- Correct click‑inside detection logic and properly consume events.
2026-04-10 01:13:23 +05:45

305 lines
12 KiB
Python

from typing import Any
import cairo
from fabric.utils import Gdk
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 Layer Shell properties"""
try:
# Get monitor geometry
monitor = self._hyprland.display.get_monitor_at_window(
self.child_window.get_window()
)
if not monitor:
monitor = self._hyprland.display.get_default_monitor()
geom = monitor.get_geometry()
mx, my, mw, mh = geom.x, geom.y, geom.width, geom.height
# Get child window properties
alloc = self.child_window.get_allocation()
ww, wh = alloc.width, alloc.height
# fabric's WaylandWindow usually has anchor and margin accessible
anchor = self.child_window.anchor
margin = self.child_window.margin # (top, right, bottom, left)
# Calculation logic based on Layer Shell anchors
# X coordinate
if GtkLayerShell.Edge.LEFT in anchor and GtkLayerShell.Edge.RIGHT in anchor:
x = mx + margin[3]
elif GtkLayerShell.Edge.LEFT in anchor:
x = mx + margin[3]
elif GtkLayerShell.Edge.RIGHT in anchor:
x = mx + mw - ww - margin[1]
else: # Centered horizontally
x = mx + (mw - ww) // 2 + margin[3] - margin[1]
# Y coordinate
if GtkLayerShell.Edge.TOP in anchor and GtkLayerShell.Edge.BOTTOM in anchor:
y = my + margin[0]
elif GtkLayerShell.Edge.TOP in anchor:
y = my + margin[0]
elif GtkLayerShell.Edge.BOTTOM in anchor:
y = my + mh - wh - margin[2]
else: # Centered vertically
y = my + (mh - wh) // 2 + margin[0] - margin[2]
return x, y, ww, wh
except Exception as e:
print(f"Error calculating child window bounds: {e}")
# Fallback to allocation if possible, though likely (0,0)
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:
return 0, 0, 0, 0
# Get monitor geometry
monitor = self._hyprland.display.get_monitor_at_window(
toplevel.get_window()
)
if not monitor:
monitor = self._hyprland.display.get_default_monitor()
geom = monitor.get_geometry()
mx, my = geom.x, geom.y
# Get relative position within toplevel
alloc = widget.get_allocation()
x, y = widget.translate_coordinates(toplevel, 0, 0) or (0, 0)
# If the toplevel is a Layer Shell window, its (0,0) is monitor relative
# but we need to account for its own margin/anchor if it's not full screen.
# However, for the Panel (which is usually top-anchored), (0,0) is (monitor_x, monitor_y).
return mx + x, my + y, 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:
self.child_window.show()
self.show()
else:
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)