Centralised popout coordinator for Phosphor shells. Owns the single arbiter for popout lifetime, focus, and exclusivity policy across every transient surface in the shell.
A desktop shell has many transient surfaces. Examples include the control center, launcher, calendar, notification history, OSDs, and tray menus. Each one needs a Wayland layer-shell surface, keyboard- focus arbitration, screen affinity, exclusive-zone handling, a dismiss-on-focus-loss behaviour, and open/close animations. Letting each surface reinvent that machinery produces the "two popups
fighting over a Wayland grab" bug class. phosphor-popout is the one place every popout request flows through so policy is resolved consistently.
PopoutController. The actual layer-shell surface creation is behind IPopoutTransport. A future LayerPopoutTransport will wire to phosphor-layer::SurfaceFactory once the shell binary needs popouts. The in-app demo transport ships today as the acceptance harness. Tests inject a fake transport and drive the controller as pure logic with no Wayland in sight.open entry point.close consumes. Logical identity is separate via popoutId. toggle("control-center") does the right thing regardless of which handle the existing instance has. The controller never leaks transport-specific identifiers to QML or to consumers further up the stack.| Type | Purpose |
|---|---|
PhosphorPopout::PopoutRequest | Value type. Q_GADGET, registered as QML value type popoutRequest. Describes a popout to open: id, content, screen, anchor, exclusivity, scope, focus flags, props |
PhosphorPopout::ExclusiveMode | Cooperative / Modal / Detached arbitration modes. Q_ENUM_NS, reachable from QML as ExclusiveMode.Cooperative etc. |
PhosphorPopout::Anchor | Where on the screen the popout anchors. Values are BarLeft, BarCenter, BarRight, ScreenCenter, AtPointer, Custom. Q_ENUM_NS |
PhosphorPopout::IPopoutService | Abstract service. Methods are open, close, toggle, isOpen, closeAll |
PhosphorPopout::IPopoutTransport | Transport seam. Implementations create, tear down, and track layer-shell surfaces. Notifies the controller on self-dismiss |
PhosphorPopout::PopoutController | Concrete IPopoutService. Owns the arbitration state machine and delegates surface creation to an injected transport. Registered as a QML element but marked uncreatable. Instantiate from C++ and publish via qmlRegisterSingletonInstance |
Phosphor.Popout.PopoutHost (QML) | Transport-agnostic wrapper for popout content. Owns the open/close opacity-plus-scale animation, the backdrop dim, and the click-outside dismiss. Both transports instantiate this host wrapping the content delegate |
| Member | Direction | Purpose |
|---|---|---|
contentComponent: Component | property | A QML Component the host instantiates inline via a Loader. Mutually exclusive with contentItem |
contentItem: Item | property | A pre-built Item the host reparents under its content frame. Wins over contentComponent if both are set |
open: bool | property | Drives the show/hide animation. The transport sets this true after the surface is parented and false to begin teardown |
dismissOnClickOutside: bool | property | When true, a click on the backdrop sets open = false. The transport wires this from PopoutRequest.dismissOnFocusLoss |
backdropColor: color | property | Background dim color. The transport binds a scrim for Modal, a lighter dim for Cooperative, transparent for Detached |
dismiss() | function | Content delegates several levels deep can call this to close the popout without walking the parent chain. The transport injects a _popoutHost reference on the content, and the content calls _popoutHost.dismiss() |
dismissed() | signal | Emitted after the close animation finishes. Fires once per open-then-close cycle, so transports that reuse a single host across many popouts get a fresh dismissed each time. Also fires once on Component.onDestruction if the host had ever been opened and is torn down before the close-animation timer emits dismissed itself, so transport bookkeeping never leaks a handle when a popout is destroyed mid-cycle. A host that is constructed and destroyed without ever having open driven true does NOT fire dismissed, so transports that pre-build and discard surfaces don't see a phantom dismissed in that case. The transport's dismissed callback fires through here so the bookkeeping lines up with the visual |
C++. Wire a controller to a transport and route a request.
#include <PhosphorPopout/PhosphorPopout.h>
#include <QObject>
using namespace PhosphorPopout;
MyPopoutTransport transport(...); // implements IPopoutTransport
PopoutController controller(&transport); // transport must outlive the controller
QObject::connect(&controller, &PopoutController::popoutOpened,
[](const QString& id, const QString& handle) {
qDebug() << "opened" << id << "->" << handle;
});
QQmlComponent* controlCenterComponent = ...; // caller owns; keep alive until popoutClosed
PopoutRequest req;
req.popoutId = QStringLiteral("control-center");
req.content = controlCenterComponent;
req.anchor = Anchor::BarRight; // BarLeft / BarCenter / BarRight / ScreenCenter
// / AtPointer / Custom. Custom uses req.customAnchor.
req.exclusive = ExclusiveMode::Cooperative;
const QString handle = controller.open(req); // empty if rejectedQML. Toggle from a button. Register the controller via qmlRegisterSingletonInstance in your engine setup so the whole UI sees the same arbiter. The example below assumes the singleton is registered as Popouts.
import QtQuick
import QtQuick.Controls
import Phosphor.Popout
ToolButton {
text: i18nc("@action:button", "Toggle calendar")
Component {
id: calendarComponent
Rectangle { implicitWidth: 320; implicitHeight: 220; color: "white" }
}
onClicked: {
let req = popoutRequest();
req.popoutId = "calendar";
req.content = calendarComponent;
req.anchor = Anchor.BarCenter;
req.exclusive = ExclusiveMode.Cooperative;
Popouts.toggle(req);
}
}The popoutRequest value type, Anchor, and ExclusiveMode enums are all visible to QML through the registrations described in the key-types table above. popoutRequest() is the value type's default constructor. Field assignment uses regular property syntax.
Cooperative request in the same scope closes the prior one before opening the new one. Different scopes are independent. The default scope is "default". Shells that want one cooperative popout per output set scope = "screen-DP-1".Modal request closes every Cooperative across every scope. While any Modal is open, new Cooperative requests are rejected. open returns empty string. Modals stack. A second Modal does not close the first. Cooperative popouts are NOT restored when the Modal closes. The user demanded the Modal. Reopening their prior popout would clobber whatever they focused after dismissing it.open with a popoutId that is already open is rejected regardless of ExclusiveMode. Callers that want the new instance must close the old handle first.QtCore, QtGui, QtQml, QtQuick. The C++ core links the first three. The QML module also links Quick.Phosphor.Theme. PopoutHost.qml binds Motion durations and easings for its animation timing. The C++ core has no Phosphor link-time dependency.PhosphorPopout::PhosphorPopoutQml) is the in-tree linkable artefact. Out-of-tree consumption via find_package(PhosphorPopout) exposes the C++ core (PhosphorPopout::PhosphorPopout) only. Installed QML module deployment lands together with the layer-shell transport in a follow-up. In-tree consumers that link PhosphorPopout::PhosphorPopoutQml pick up the PhosphorTheme QML plugin transitively, so the runtime import Phosphor.Theme inside PopoutHost.qml resolves without extra wiring at the consumer side.libs/phosphor-layer/. The policy layer over wlr-layer-shell that the default transport will consume.