Huge updates: ControlCenter, DropDown Menu

This commit is contained in:
sankalp committed 2025-07-31 20:46:02 +05:45
1 parent 1f2d0a33f4
commit 9d83d960ef
34 files changed
+3261 -1440

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
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
+142
View File
@@ -0,0 +1,142 @@
from typing import Any
import cairo
from gi.repository import GtkLayerShell, GLib # type: ignore
from fabric.widgets.eventbox import EventBox
from widgets.wayland import WaylandWindow as Window
from fabric.widgets.widget import Widget
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="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)
+145
View File
@@ -0,0 +1,145 @@
import gi # type: ignore
from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore
from widgets.wayland import WaylandWindow
gi.require_version("GtkLayerShell", "0.1")
class PopupWindow(WaylandWindow):
def __init__(
self,
parent: WaylandWindow,
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.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:
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 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:
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_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()
screen = Gdk.Screen.get_default()
screen_width = screen.get_width()
if self._pointing_widget:
coords = self.get_coords_for_widget(self._pointing_widget)
if coords[0] + width > screen_width:
coords_centered = (
round(
coords[0]
- (width - self._pointing_widget.get_allocated_width())
),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(coords[0]),
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),
)
self.margin = tuple(
a + b
for a, b in zip(
(
(
0,
0,
0,
round((parent_x_margin + coords_centered[0])),
)
if move_axe == "x"
else (
round((parent_y_margin + coords_centered[1]) - (height / 2)),
0,
0,
0,
)
),
self._base_margin.values(),
)
)
+369
View File
@@ -0,0 +1,369 @@
import re
from collections.abc import Iterable
from enum import Enum
from typing import Literal, cast
import cairo
import gi
from fabric.core.service import Property
from fabric.utils.helpers import extract_css_values, get_enum_member
from fabric.widgets.window import Window
from gi.repository import Gdk, GObject, Gtk
from loguru import logger
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],
}