← Otto Developer GuideExposé

Exposé shows a scaled preview of every visible window on the current workspace, laid out on a grid, so windows can be picked or dragged onto another workspace. It is triggered by a keyboard shortcut or a three-finger vertical swipe.

The core trick: mirrors, not moved windows

Exposé does not move the real windows. Each window gets a mirror layer — a second node in the scene graph that follows the real window’s layer via add_follower_node — and it is the mirrors that are laid out on the grid.

The value of this is that a window keeps rendering into its normal place in the scene while a scaled copy of it appears in the grid. Live video keeps playing in the preview; the window’s own state is untouched, so leaving exposé needs no restore step.

Mirrors are created in WorkspaceView::map_window, which hands them to window_selector_view.map_window. A window being dragged is excluded (expose_dragging_window) so it isn’t drawn twice, and a minimized window’s mirror is hidden by minimize_window and restored on unminimize.

The mirror-freeze trap. lay-rs propagates NEEDS_PAINT from a leader node itself to its followers — never from the leader’s descendants. A client commit repaints the surface layer deep inside the window’s subtree, so the mirror is never flagged and keeps drawing its last recorded picture. With workspaces_layer hidden during exposé, nothing else damages it either, and the previews freeze — a playing video looks stuck. update_window_view therefore calls add_damage on the window’s base layer while exposé is up, which does mark the followers.

This is covered by the expose_preview_repaints_on_client_commit headless test (tests/headless_basic.rs), which asserts subtree_damage on the mirror node. Asserting whole-scene damage is not specific enough to catch the regression.

The wallpaper is painted twice, on purpose

WindowSelectorView mirrors the workspace background (window_selector_background) and the wlr-layer-shell background (layer_shell_bg_expose_mirror) into its own subtree, below the previews — even though the background plane underneath is already showing the same wallpaper. Both are needed:

Painting the wallpaper into the exposé subtree fixes both, and fixes them with the right pixels: the blur reads the canvas under the mirror’s own transform, so a preview samples the wallpaper where the preview sits, not where the real window sits (which is what seeding the external backdrop by the leader’s global bounds would have given).

The cost is one extra full-screen wallpaper draw per exposé frame. Only the on-screen workspace pays it — the other workspaces’ selector roots are laid out side by side beyond the output edge and are clipped away.

The same limitation still applies to a preview dragged onto the workspace strip: it is reparented into the drag overlay, in the overlay plane, whose buffer has no wallpaper either.

Outside exposé the same class of bug hit the ordinary server-side titlebar — its blur is a real BackgroundBlur layer, but the windows plane was never given a backdrop, so it blurred an empty buffer too. That one is fixed the other way, with the external backdrop: udev::backdrop hands the middle plane the background-only stage of the composite and the titlebar opts into blur_include_content, so it blurs the wallpaper and the windows painted below it in the same pass. See specs/plane-scanout.md.

Lifecycle

Layout: natural flow

expose_show_all_layout builds a list of windows with their real geometry and title, skipping minimized and currently-dragged ones. WindowSelectorView::update_windows then calls natural_layout (src/utils/natural_layout.rs) to pack them into the target rectangle:

Hover selection

WindowSelectorState.current_selection is the index of the hovered preview. It drives the accent highlight and the title label drawn by view_window_selector.

Keeping it across a re-layout is fiddlier than it sounds, because re-layouts are not rare: a window’s geometry comes from its surface-tree bbox, so an ordinary client commit invalidates the layout hash and rebuilds the grid under a stationary pointer. update_windows rebuilds rects from scratch, so it carries the hovered window over by id, keeping it selected as long as the last recorded cursor position still falls inside that window’s (possibly moved) preview.

For the same reason expose_update_if_needed re-shows the selection overlay (show_selection_overlays) after scheduling the re-layout animation: expose_show_all_apply blanks that overlay to zero opacity for the length of the open animation and only restores it in the animation’s on_finish, which would otherwise blink the highlight and label out on every re-layout while exposé is already open.

Covered by the expose_selection_survives_client_commit headless test.

Animation and positioning

expose_show_all_apply interpolates window layers from their on-screen bbox to the target rects in expose_bin, applying translation plus scale. Easing is Spring-based when end_gesture is true. The workspace selector, the dock and overlay opacity animate in tandem so the whole UI slides into place; the dock hides while exposé is open unless fullscreen requires otherwise. Popups are hidden for the whole exposé lifetime and restored in the close animation’s on_finish.

Gesture direction detection

A three-finger swipe can mean either “switch workspace” or “exposé”, and the compositor cannot know which until the finger has moved.

So it commits late. Both horizontal and vertical deltas accumulate without activating either mode. Once accumulated movement passes 20 px in either direction, the axis with the greater magnitude wins: horizontal goes to workspace_swipe_update, vertical to expose_update. After that, every subsequent event feeds the chosen mode directly, with no re-evaluation — so a diagonal drift mid-gesture cannot flip modes. Velocity samples are collected along the way for workspace switching, which uses them for momentum-based snapping on release.

Drag and drop

Multi-output

Exposé is global, not per-output: opening or closing it drives every output’s grid at once.

Two multi-output footguns

Deadlock. Workspaces::focused_output() takes the model’s read lock, so it must be resolved before entering a with_model closure. Nesting the two deadlocks the main thread as soon as a writer contends for the lock. Layout and animation hoist focused_output() / focused_output_workspaces() to the top of the function for exactly this reason.

Stale focused output. focused_output() resolves to the output most recently confirmed under the pointer, falling back to primary. It is kept current by every pointer-motion path — udev relative motion, udev absolute motion, winit, and the virtual-pointer harness path. A path that forgets to update it leaves exposé and the selector opening on the wrong screen. Virtual-pointer motion is clamped to the combined output bounds (Otto::clamp_coords) like real input, so a synthesized move cannot drift the focused output out of resolvable range.

Hit-testing. layers_engine.pointer_move — which drives hover state and all dock/overlay-UI interaction, not only exposé — is fed the pointer rebased to the focused output’s own origin, because every output’s scene subtree overlaps at (0, 0). See specs/multi-output.md. Without the rebasing, hover and clicks land on whichever output subtree happens to be topmost in the layer tree, rather than the one the pointer is actually over.

Entry points

ActionCall
Toggle exposéexpose_show_all(delta, end_gesture)
Force a relayout while openexpose_update_if_needed / expose_update_if_needed_workspace
Show desktop (push windows away)expose_show_desktop(delta, end_gesture)

Show desktop reuses the exposé machinery: it hides workspaces_layer, shows expose_layer, and animates the same mirror layers off the screen edges. The render paths must therefore drop the real windows plane for it too — they gate on Workspaces::mirrors_active() (exposé, its transition, show desktop, or its transition), not on get_show_all() alone. Testing only the exposé flags left the untouched windows compositing on top of the mirrors sliding away, so the gesture rendered as a no-op.

Its completion hook — the one that hides the mirrors and hands the screen back to the real windows — rides the first mirror’s own position animation. Hanging it off a no-op property change instead (setting a layer to the opacity it already has) finished on the spot, so dismissing show desktop restored the windows in a single frame while the mirrors were still flying back. Anything that dismisses it (clicking a window, ExposeShowDesktop, a three-finger swipe) goes through expose_show_desktop(-2.0, true) and gets that animation.

Hiding the scene layer is not enough on its own: plane subtrees ignore ancestor visibility, so the render paths keep their own test. show_desktop_animating — the show-desktop twin of expose_animating, and for the same reason — holds is_show_desktop_transitioning (and through it mirrors_active) true for the whole flight, because the gesture accumulator commits to its final 0/1000 the moment the spring is scheduled. Without it the windows plane returns on the click frame and the windows snap home under the mirrors still flying back.

Keyboard focus

Opening exposé clears keyboard focus (Otto::enter_expose_focus, called alongside dismiss_all_popups / demote_all_scanout_windows at every open site: the action handler and both gesture handlers). Closing restores it — close_expose_show_all_and_focus_top for the click/keyboard path, expose_end_with_velocity_and_focus_top for the gesture, both focusing the hovered preview or the workspace’s top window.

Two things ride on this. Keys pressed while the previews are up no longer land in whatever window happened to be in front. And it is the only signal a client gets that exposé opened: dismiss_all_popups reaches popups, but an app that draws transient chrome into a subsurface — the file browser’s quick view panel — is out of its reach, and takes the panel down on wl_keyboard.leave instead.

Testing notes