android/window.rs
Related: ADR-002, ADR-009
Decision
Cordial targets Wayland natively. The window is a GTK4 + libadwaita window, and the engine renders into a Wayland surface belonging to it. X11 is not developed further.Why this reverses the earlier choice
window.rs opens by explaining that X11 came first because
eglCreateWindowSurface takes an X Window directly, whereas Wayland needs a
wl_egl_window and a surface role — “more moving parts for the same first
frame”. That was a correct judgement about reaching a first frame fastest. It is
the wrong judgement about the client people actually use, and two symptoms made
that concrete.
Resize. Dragging Cordial’s window flashes flat colour for about a tenth of a
second before the engine catches up. Sober, which is native Wayland, resizes
cleanly. On Xwayland the compositor cannot know when the client’s new-size
content is ready, so it shows something in the meantime; on Wayland the
xdg_toplevel configure/ack/commit sequence exists precisely so that a resize is
atomic and the surface never appears in a half-updated state. The X11 path can be
patched to flash less. It cannot be made correct, because the protocol has no way
to express what is needed.
Text entry. The remaining sign-in blocker is that Cordial has to act as the
IME. On Wayland there is a protocol for exactly this — zwp_text_input_v3 —
which is how squeekboard, Maliit, ibus and fcitx all deliver text. Bridging to it
gets Linux phones and CJK input for the same work, rather than hand-rolling an
input method against raw X keysyms. That was already the plan; Wayland is where
the plan is expressible.
Still open, and not settled by the subsurface work below. Cordial’s own
zwp_text_input_v3 client has a live bug — interface 'zwp_text_input_v3' has no event 8, which freezes the landing page — and it was neither fixed nor
removed when the window changed hands to GTK. Removing it was considered and
rejected: it would only have been right if GTK were taking the IME over, and
the evidence for the widget-overlay theory that implied has weakened (Sober
shows typed text live with no toolkit in its engine process at all). One thing
was established: bringing GTK into the process does not create a second
text-input object, because GDK creates one only when a GTK text widget takes
focus and this window has none.
The background behind the canvas. While the engine catches up, whatever is
behind its canvas is visible. It should be the desktop’s own background colour,
following light and dark mode, rather than a flash of white. That is a themed
window’s job, and libadwaita answers it directly through AdwStyleManager.
Suppressing the repaint instead — the first attempt here — is a workaround for
not having a themed window, and was reverted.
Why libadwaita specifically
ADR-002 already specifies a native GTK/libadwaita core shell that paints the chooser before the plugin host is up, and a UI plugin that takes over afterwards. That shell and this window are the same window. Building the engine’s host window as a bare Wayland surface would mean building the shell twice, and the second one would have to inherit the theme anyway. It also settles the light/dark question without a portal round-trip of Cordial’s own:AdwStyleManager already tracks org.freedesktop.appearance color-scheme
and updates live when the user switches.
How the engine’s surface attaches to that window
Decided and implemented 2026-08-02. “The engine renders into a Wayland surface belonging to it” was true of the intent and false of the code: the engine owned a barexdg_toplevel of its own, so the engine’s canvas was the whole window
and there was no titlebar, because there was no libadwaita window for one to
belong to.
The engine’s wl_surface is a wl_subsurface of the GTK toplevel. Three
things follow from that and none of them is optional.
One connection, therefore one process. Wayland object ids are scoped to the
connection that created them, so a subsurface cannot parent to a surface on
another connection — and a connection cannot be shared between processes. GTK
opens the connection; wayland.rs takes gdk_wayland_display_get_wl_display
rather than calling wl_display_connect, and Mesa is handed the same pointer it
already was. So cordial-load links GTK4 and libadwaita. ADR-002 accepted core
taking a GTK dependency; this is the runtime taking it too, and it is not
avoidable by any amount of process separation. Sober’s shape — a GTK process
beside a toolkit-free engine process — is a different arrangement that buys
different things; it cannot produce one window with the engine inside it.
The window definition is shared, not duplicated. cordial-shell becomes a
library as well as a binary, and cordial_shell::host_window is the one place
the AdwWindow/AdwToolbarView/AdwHeaderBar are built. The shell binary puts
the chooser in the content slot; the runtime puts the engine’s subsurface over
it. This ADR’s “that shell and this window are the same window” was an intention
until now.
set_desync is load-bearing. A subsurface is created synchronised: its
commits do not take effect until the parent commits. GTK commits when it draws,
which for an idle window is never, so without wl_subsurface.set_desync the
engine presents into a surface nobody ever latches. Conversely
wl_subsurface.set_position is latched on the parent’s commit, so moving the
canvas requires asking GTK to repaint.
Measured: the client reaches APP_READY (Landing) on three consecutive
25-second runs with 547-550 vkQueuePresentKHR calls each, and a screenshot
shows the libadwaita header bar above Roblox’s landing page. WAYLAND_DEBUG=1
confirms get_subsurface, set_desync, set_position(25, 71) and
xdg_toplevel.set_app_id("Cordial") on the wire.
Not verified: anything needing a keystroke or a drag — window resize with
the engine live, and text entry. See docs/NEXT.md §1a.
A trap that cost an hour, recorded so it costs nobody else one. GTK will
not open a Wayland display if GDK_BACKEND names something else, even when
gdk_set_allowed_backends("wayland") has been called: the two are separate
filters and their intersection was empty. The symptom is gtk_init_check
returning false with nothing printed, which surfaces as “no window” and no
further explanation. This developer’s ordinary GNOME session exports
GDK_BACKEND=x11. host_window::init_wayland sets both.
Consequences
Accepted: a native Wayland backend is substantially more code than the X11 one — registry binding,wl_seat input with xkbcommon keymaps,
wl_egl_window for EGL, and VK_KHR_wayland_surface in place of
VK_KHR_xlib_surface in the Vulkan interposition. The X11 backend reached a
first frame sooner and that was worth having; it is not worth keeping two.
Corrected: this list used to include xdg_shell, hand-marshalled because no
protocol XML was available to generate from. GTK owns the toplevel now, so that
code is gone and with it two of the three crashes this file’s history records.
text-input-unstable-v3 is still hand-written and still the one interface whose
event table is Cordial’s assertion rather than libwayland’s.
Accepted, with a hard trigger: the existing X11 code stays only until the
Wayland path runs the sign-in flow end to end, and is deleted in that same
change — not in a follow-up, not when someone gets to it.
That is deliberately a rule and not an intention, because this ADR already
rejected maintaining two backends on the grounds that a fallback nobody runs
rots. Keeping X11 “for now” is that same arrangement wearing a deadline, and it
decays the moment the deadline is soft. Two specifics make it decay faster than
usual here: once Wayland works nobody will run X11, including CI, which runs
neither; and the shared android/input.rs means a rotting X11 path drags shared
editing logic with it rather than rotting in isolation.
So the condition is mechanical. When the Wayland backend can reach sign-in,
window.rs and its Backend::X11 arm go in the same commit. A change that makes
Wayland work and leaves X11 in place has not finished. Until that point X11 is
not a fallback, it is the only working backend and is load-bearing — the risk
described here starts the day that stops being true.
Removal is cheap by construction: what remains in window.rs is X-specific
surface and event plumbing, because the display-independent parts already moved
to input.rs. Nothing else has to be untangled first.
Accepted: input has to be rebuilt, not ported. X11 delivers keysyms; Wayland
delivers keycodes plus an xkb keymap the client is expected to interpret. The
TextField editing logic is unaffected — it takes committed text and a caret,
which is what zwp_text_input_v3 hands over — but everything below it changes.
Accepted: WM_CLASS becomes the xdg_toplevel app_id, which must keep
matching the desktop entry for the reasons in
ADR-009. The test that pins them
together needs to follow.
That string is not the id WebKit uses. Cordial is not a valid application id,
so cordial-run had WebKit name itself org.webkit.app-<hash of the executable path>, and inside the Flatpak the portal refuses a web process whose a11y bus
name is not prefixed by the real app id: opening any in-app web window aborted
the client. host_window::claim_webkit_application_id gives WebKit the
Flatpak’s id through an unregistered default GApplication, and leaves the
xdg_toplevel app_id as Cordial — GTK4 reads that from the program name
unless a GtkApplication is associated with the window.
Rejected: Wayland with an X11 fallback maintained in parallel. Two backends
means every input and surface bug is asked “on which one?”, and the fallback
rots because nobody runs it. Xwayland exists for hosts without a Wayland
compositor and Cordial does not need to reimplement it.