Dotfiles/desktopenvs/hyprdrive/astro-menu/ui/quadgrid.py

192 lines
8.4 KiB
Python

"""The 2x2 quad region and its expand-over-the-others behaviour.
A Gtk.Overlay stacks two things in the same space:
* base : a 2x2 Gtk.Grid of QuadCards
* overlay: a Revealer that, when a quad expands, fills the whole region (the full
content width, covering all four cells) with that module's expanded
view wrapped in a small header carrying a collapse button.
Because the overlay fills the region exactly, the expanded quad is as wide as the
appdrawer below it, and the outer letterbox margins (applied further up the tree)
are untouched — so letterboxing stays identical in every state.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
from lib.border import bordered
from module_base import ModuleSpec
from ui.quadcard import QuadCard
class QuadGrid(Gtk.Overlay):
def __init__(self, specs: list[ModuleSpec], settings, services):
super().__init__()
self.add_css_class("quad-region")
self.set_vexpand(True)
self.settings = settings
self._expanded_id: str | None = None
self.grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True,
column_spacing=10, row_spacing=10)
self.grid.add_css_class("quad-grid")
self.set_child(self.grid)
self.cards: dict[str, QuadCard] = {}
for index, spec in enumerate(specs[:4]):
card = QuadCard(spec, settings, services,
self.request_expand, self.request_collapse)
self.cards[spec.id] = card
self.grid.attach(bordered(card, radius=16, fill_bg=True),
index % 2, index // 2, 1, 1)
# overlay used for the expanded quad
self._expand_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
transition_duration=180, reveal_child=False)
self._expand_reveal.set_halign(Gtk.Align.FILL)
self._expand_reveal.set_valign(Gtk.Align.FILL)
self._expand_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._expand_holder.add_css_class("quad-expanded")
self._expand_card = bordered(self._expand_holder, radius=16, fill_bg=True)
self._expand_reveal.set_child(self._expand_card)
self.add_overlay(self._expand_reveal)
# The overlay's holder is opaque; keep the whole overlay hidden unless a quad
# is actually expanded, otherwise it paints over the 2x2 grid.
self._expand_reveal.set_visible(False)
# A second overlay: an external "takeover" widget (the taskbar's workspace/
# window panel) that, when shown, covers the whole quad region.
self._takeover_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
transition_duration=180, reveal_child=False)
self._takeover_reveal.set_halign(Gtk.Align.FILL)
self._takeover_reveal.set_valign(Gtk.Align.FILL)
self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._takeover_holder.add_css_class("quad-expanded")
self._takeover_card = bordered(self._takeover_holder, radius=16, fill_bg=True)
self._takeover_reveal.set_child(self._takeover_card)
self.add_overlay(self._takeover_reveal)
self._takeover_reveal.set_visible(False)
settings.subscribe(self._on_settings_changed)
# -- external takeover (taskbar panel) ---------------------------------
def set_takeover_widget(self, widget: Gtk.Widget, on_back) -> None:
"""Mount an external widget (once) that will cover the quad region when shown.
A Back row is prepended so the covered quads can be restored."""
header = Gtk.CenterBox()
header.add_css_class("expanded-header")
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
back.add_css_class("quad-action")
back.set_tooltip_text("Collapse")
back.connect("clicked", lambda *_: on_back())
header.set_start_widget(back)
self._takeover_holder.append(header)
widget.set_vexpand(True)
self._takeover_holder.append(widget)
def show_takeover(self) -> None:
self._takeover_reveal.set_visible(True)
self._takeover_card.add_css_class("quad-pop") # same bouncy scale as a quad
GLib.timeout_add(340, lambda: (self._takeover_card.remove_css_class("quad-pop"),
GLib.SOURCE_REMOVE)[1])
self._takeover_reveal.set_reveal_child(True)
self.grid.add_css_class("dimmed")
def hide_takeover(self) -> None:
self._takeover_reveal.set_reveal_child(False)
self._takeover_reveal.set_visible(False)
self.grid.remove_css_class("dimmed")
# -- expansion ---------------------------------------------------------
def request_expand(self, module_id: str) -> None:
card = self.cards.get(module_id)
if not card or card.instance is None or card.instance.expanded is None:
return
# A module opts into expansion by supplying a distinct expanded widget
# (a separate instance), so no reparenting of the compact cell is needed.
content = card.instance.expanded
# reparent content into the expanded holder
self._clear_expand_holder()
self._expand_reveal.set_visible(True)
self._expand_holder.append(self._expanded_header(card.spec.title))
if card.instance.scroll_expanded:
wrap = Gtk.ScrolledWindow(vexpand=True,
hscrollbar_policy=Gtk.PolicyType.NEVER)
wrap.set_child(content)
self._expand_holder.append(wrap)
else:
content.set_vexpand(True)
self._expand_holder.append(content)
self._expanded_id = module_id
self._expand_reveal.set_reveal_child(True)
# bouncy scale pop (CSS @keyframes). Keep the class only for the animation's
# duration, then drop it: while it's applied, any re-layout of the card (module
# content streaming in, the grid updating underneath) restarts the transform
# animation, which made the pop loop forever.
self._expand_card.add_css_class("quad-pop")
GLib.timeout_add(340, self._clear_pop)
self.grid.add_css_class("dimmed")
card.on_show()
def _clear_pop(self) -> bool:
self._expand_card.remove_css_class("quad-pop")
return GLib.SOURCE_REMOVE
def request_collapse(self) -> None:
self._expand_reveal.set_reveal_child(False)
self._expand_reveal.set_visible(False)
self._expand_card.remove_css_class("quad-pop") # reset so the pop replays
self._expanded_id = None
self.grid.remove_css_class("dimmed")
# Drop the reference to the reparented widget so the card can reuse it.
self._clear_expand_holder()
def _clear_expand_holder(self) -> None:
child = self._expand_holder.get_first_child()
while child:
# detach any ScrolledWindow's child so it survives for the card
if isinstance(child, Gtk.ScrolledWindow):
inner = child.get_child()
if inner:
child.set_child(None)
self._expand_holder.remove(child)
child = self._expand_holder.get_first_child()
def _expanded_header(self, title: str) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("expanded-header")
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
back.add_css_class("quad-action")
back.set_tooltip_text("Collapse")
back.connect("clicked", lambda *_: self.request_collapse())
header.set_start_widget(back)
lbl = Gtk.Label(label=title)
lbl.add_css_class("quad-title")
header.set_center_widget(lbl)
return header
@property
def is_expanded(self) -> bool:
return self._expanded_id is not None
# -- lifecycle ---------------------------------------------------------
def _on_settings_changed(self) -> None:
for card in self.cards.values():
card.on_settings_changed()
def on_show(self) -> None:
for card in self.cards.values():
card.on_show()
def on_hide(self) -> None:
if self.is_expanded:
self.request_collapse()
for card in self.cards.values():
card.on_hide()