Dotfiles/desktopenvs/hyprdrive/astro-menu/window.py

244 lines
10 KiB
Python

"""The popup: a content-sized floating layer-shell panel anchored top-centre.
Earlier this was a full-monitor overlay with a dim backdrop, but that blocks the
whole screen — the invisible full-screen surface intercepts every click. Instead the
window now sizes to its own content and only occupies that area, leaving the rest of
the screen usable. It is dismissed with the launcher toggle, Esc, or the ✕ button
(there is no click-outside-to-close, since that would require a blocking full-screen
surface).
Gtk.Window (layer TOP, anchored TOP → horizontally centred, height = content)
└ Gtk.Overlay
main : #panel-root (Taskbar / QuadGrid / AppDrawer, each drawn-bordered)
over : ✕ close button (top-right)
Expanding the app drawer additionally anchors the BOTTOM edge so the panel stretches
down and the drawer fills to the bottom; collapsing removes that anchor.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
import config
from appservices import Services
from lib.border import bordered
from lib.hologram import HologramOverlay
from registry import ordered_specs
from ui.appdrawer import AppDrawer
from ui.quadgrid import QuadGrid
from ui.statsbar import Statsbar
from ui.taskbar import Taskbar
PANEL_WIDTH_FRACTION = 0.5 # of the monitor width...
MAX_PANEL_WIDTH = 1100 # ...clamped to this
EDGE_MARGIN = 28 # gap from the anchored edge
BOTTOM_MARGIN = 34 # gap from the far edge when the drawer is expanded
SIDES = ("top", "bottom", "left", "right")
class MenuWindow(Gtk.ApplicationWindow):
def __init__(self, app, settings, services: Services):
super().__init__(application=app)
self.set_name("menu-window")
self.add_css_class("menu-window")
self.settings = settings
self.services = services
self._drawer_expanded = False
self._side = "top"
self._init_layer_shell()
self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.root.set_name("panel-root")
self.root.add_css_class("panel")
# minimalist system-stats line (full-width, very top)
self.statsbar = Statsbar()
self.statsbar_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
transition_duration=160, reveal_child=True)
self.statsbar_reveal.set_child(bordered(self.statsbar, border=2, radius=12, fill_bg=True))
self.root.append(self.statsbar_reveal)
# taskbar (full-width, top)
self.taskbar = Taskbar(on_activate=self.hide_menu,
on_toggle_panel=self._on_taskbar_panel)
self.taskbar_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
transition_duration=160, reveal_child=True)
self.taskbar_reveal.set_child(bordered(self.taskbar, fill_bg=True))
self.root.append(self.taskbar_reveal)
# quads
specs = list(ordered_specs(settings))
self.grid = QuadGrid(specs, settings, services)
# The taskbar's workspace/window panel is mounted over the quad region so that
# expanding it collapses the taskbar strip and covers the 2x2 quads.
self.grid.set_takeover_widget(self.taskbar.panel_widget,
on_back=self.taskbar.collapse_panel)
self.quad_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_UP,
transition_duration=200, reveal_child=True)
self.quad_reveal.set_child(self.grid)
self.root.append(self.quad_reveal)
# appdrawer
self.appdrawer = AppDrawer(settings, on_launch=self.hide_menu,
on_toggle_expand=self._on_appdrawer_expand)
self.appdrawer_wrap = bordered(self.appdrawer, fill_bg=True)
self.appdrawer_wrap.set_valign(Gtk.Align.FILL)
self.root.append(self.appdrawer_wrap)
overlay = Gtk.Overlay()
overlay.set_child(self.root)
self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root)
overlay.add_overlay(self._hologram.widget)
close = Gtk.Button(label="")
close.add_css_class("close-btn")
close.set_halign(Gtk.Align.END)
close.set_valign(Gtk.Align.START)
close.connect("clicked", lambda *_: self.hide_menu())
overlay.add_overlay(close)
self.set_child(overlay)
key = Gtk.EventControllerKey()
key.connect("key-pressed", self._on_key)
self.add_controller(key)
self._last_tick: float | None = None
self._tick_id: int | None = None
self.connect("map", lambda *_: self._apply_size())
self.set_visible(False)
# -- layer shell / size -----------------------------------------------
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.TOP)
LayerShell.set_namespace(self, "astro-menu")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND)
self._apply_anchor()
def set_side(self, side: str) -> None:
"""Anchor the panel to the given monitor edge ('top'|'bottom'|'left'|'right').
The compositor's layer 'slide' animation then slides it in from that edge, and
the panel sits flush against it. Set before showing so the map animates."""
if side in SIDES:
self._side = side
self._apply_anchor()
def _apply_anchor(self) -> None:
"""Pin the panel to its side edge; while the drawer is expanded, also pin the
perpendicular edges so it fills the screen height."""
E = LayerShell.Edge
for edge in (E.TOP, E.BOTTOM, E.LEFT, E.RIGHT):
LayerShell.set_anchor(self, edge, False)
LayerShell.set_margin(self, edge, 0)
primary = {"top": E.TOP, "bottom": E.BOTTOM, "left": E.LEFT, "right": E.RIGHT}[self._side]
LayerShell.set_anchor(self, primary, True)
LayerShell.set_margin(self, primary, EDGE_MARGIN)
if self._drawer_expanded:
# fill vertically so the expanded drawer reaches top and bottom
for edge in (E.TOP, E.BOTTOM):
LayerShell.set_anchor(self, edge, True)
if edge != primary:
LayerShell.set_margin(self, edge, BOTTOM_MARGIN)
def _monitor_width(self) -> int:
display = Gdk.Display.get_default()
surface = self.get_surface()
mon = display.get_monitor_at_surface(surface) if surface is not None else None
if mon is None:
monitors = display.get_monitors()
mon = monitors.get_item(0) if monitors.get_n_items() else None
return mon.get_geometry().width if mon is not None else 1920
def _apply_size(self) -> None:
width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH)
self.root.set_size_request(width, -1)
# -- taskbar panel expansion ------------------------------------------
def _on_taskbar_panel(self, expanded: bool) -> None:
# Cover the quads with the taskbar's workspace/window panel. Fully collapse the
# taskbar strip (header included) so the expanded panel reads as ONE module
# rather than a thin strip on top plus a detached card. Collapse any quad first.
if expanded and self.grid.is_expanded:
self.grid.request_collapse()
self.taskbar_reveal.set_reveal_child(not expanded)
self.taskbar_reveal.set_visible(not expanded)
self.grid.show_takeover() if expanded else self.grid.hide_takeover()
# -- appdrawer expansion ----------------------------------------------
def _on_appdrawer_expand(self, expanded: bool) -> None:
self._drawer_expanded = expanded
self._apply_anchor()
# Hide (not just un-reveal) the stats/taskbar/quads so they reserve zero space
# and the drawer fills the whole panel.
for rev in (self.statsbar_reveal, self.quad_reveal, self.taskbar_reveal):
rev.set_reveal_child(not expanded)
rev.set_visible(not expanded)
self.appdrawer_wrap.set_vexpand(expanded)
# -- visibility --------------------------------------------------------
def show_menu(self, focus_appdrawer: bool = False) -> None:
self.appdrawer.set_expanded(False)
self.taskbar.refresh()
self.grid.on_show()
self.appdrawer.on_show()
self.set_visible(True)
self.present()
GLib.timeout_add(30, lambda: (self._apply_size(), False)[1])
if focus_appdrawer:
self.appdrawer.set_expanded(True)
if self._hologram.enabled and self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)
self._hologram.start_intro()
def hide_menu(self) -> None:
self.grid.on_hide()
self.taskbar.collapse_panel()
self.grid.hide_takeover()
self.appdrawer.set_expanded(False)
# Play the closing dissolve first (content fades back into static), then
# actually hide via _finish_hide. If the hologram is off, hide at once.
if self._hologram.enabled and self._tick_id is not None:
self._hologram.start_outro(self._finish_hide)
else:
self._finish_hide()
def _finish_hide(self) -> None:
self.set_visible(False)
if self._tick_id is not None:
self.remove_tick_callback(self._tick_id)
self._tick_id = None
self._last_tick = None
def toggle(self, focus_appdrawer: bool = False) -> None:
if self.get_visible():
self.hide_menu()
else:
self.show_menu(focus_appdrawer)
# -- hologram animation loop --------------------------------------------
def _on_tick(self, _widget, frame_clock) -> bool:
now = frame_clock.get_frame_time() / 1_000_000
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
self._last_tick = now
self._hologram.tick(dt)
return True # keep ticking every frame while the menu is visible
def _on_key(self, _c, keyval, _kc, _state) -> bool:
if keyval == Gdk.KEY_Escape:
self.hide_menu()
return True
return False