"""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()