Skip to content

Windows and popups

Every surface the shell draws — panel, background, overlay, OSD, badge, selection surface, lock screen, popup — is a WindowEntry in the one root WindowRegistry, reachable through WindowRegistry.of(context). The wlr-layer-shell bridge is the layer_shell git dependency, which also re-exports the @internal SDK windowing pieces.

The root’s set of entries is diffed against the live ones, keyed on controller. A declarative list returned from build would take every module’s popup down with it on any rebuild.

Survivors keep the builder they were created with, so builders read the root’s fields rather than capturing them. Entries render unkeyed, which means dropping one shifts every later one down a slot — hence the registration order: per-monitor surfaces, then overlays, then popups last.

WindowTeardown is the only correct path, and every teardown goes through it. It unmaps, polls viewIsAttached once per frame, then waits one more. It schedules its own frames — an idle shell produces none — destroys anyway at maxFrames, and re-checks isDestroyed. Lock passes hide: false.

A full-screen layer-shell surface must call spanFullOutput. The default exclusive zone of 0 means “move me so I don’t occlude surfaces that reserved space”, so the compositor otherwise shrinks a full-screen surface into the gap between the panels.

Panels take no keyboard focus; a popup that types borrows it. Panels are LayerShellKeyboardMode.none, which is what stops the bar pulling focus off whatever the user is typing in. Interactivity is inherited by popups, so openPopup(needsKeyboard:) flips the panel to onDemand and back on close and from dispose. Only a borrow that actually flipped is given back, and the flip is force-committed or it sits queued on a mapped surface. The desktop surface does the same for an icon rename.

A margin is native, never a Flutter Padding. With no input-region support, an inset inside a full-size surface swallows every click in the gap. The exclusive zone stays the panel height: per wlr-layer-shell the zone includes the margin, so adding it would reserve it twice.

Nothing in the stack says a popup should go away. The Linux popup controller takes no gdk_seat_grab, so no popup_done ever arrives, and no layer-shell surface reports focus loss. PopupCoordinator.instance is the only thing that closes them, and PopupDismissArea the only thing that notices an outside click.

  • A handle’s chain is itself plus its transitive parents, and nothing in a chain dismisses anything else in it. Parentage is read from TransientScope.maybeOf(context), never passed by a call site — which is what makes the TransientScope wrapper in the entry builder load-bearing.
  • The coordinator requests a graceful close (the owner’s onDismiss), never closeLayerWindow itself, so exit animations still play.
  • TransientPolicy is two booleans. dismissesOthers: false is a hover tooltip; dismissable: false is a consent prompt, because dismissing the screencast picker is a denial. Refusal is inherited down a chain.
  • The reopen guard exists because PopupDismissArea’s ancestor Listener fires before any descendant recognizer — without it, no bar popup could be dismissed by its own button. It is primary-button only, keyed on the host State.
  • A closing popup outlives the host that closed it. closePopup drops every reference synchronously, so close-then-open in one gesture works, and moves the handle, entry, window and onClosed into a record that finishes on the animation or a timer, and outright on dispose.

Two clicks the coordinator cannot catch by construction: one on an ordinary application window (there is no grab), and one on bare desktop with no background surface. A full-screen invisible barrier is not the answer — with no input-region support it would swallow every click on the monitor.

PopupTransition plays one controller forward to open and backward to close, so an effect cannot describe an opening it has no closing for. The effect comes from the theme (popup_animation), snapshotted at open; PopupEffect.none wraps nothing and creates no controller.

Popup content lays out under its own FlutterView, so it must carry its own ShellTextRoot — Directionality.of is a null-assert in release too.