windows: centralise input focus on a single FocusTarget, raise+focus on every window interaction
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Focus was scattered across three stores — `Layouts::focus`, `Layers::focus` and `Windows::activated` — plus a hack that smuggled X11 surfaces through `Layers::focus`, with the keyboard target derived each frame from a `layers.focus.or(layout)` precedence. As a result "focused", "activated" and "has the keyboard" could drift apart: a window could be raised without taking the keyboard, the recurring "click a console window, then click it again before you can actually type" symptom.
New `windows/focus.rs` with `FocusTarget { Layout(Weak<Window>), Layer(WlSurface, app_id), X11(WlSurface) }`. `Windows` now holds one `focus_target: Option<FocusTarget>` as the single source of truth; `activated` survives only as a reconciliation cache for the xdg `Activated` state and is never set by hand. `set_focus` takes one `Option<FocusTarget>` and is the only entry point for changing focus: it brings the target to the front (`z_promote` + desktop stacking), refreshes the MRU and stores the target, and keyboard focus plus activation are applied from it on the next `focus()`. `focus()` resolves a baseline (the active layout's window, which carries `Activated`) and an overlay (a focused layer/X11 surface that takes the keyboard over the baseline without de-activating it), mirroring the old precedence but from one field, so keyboard focus follows focus by construction. `Layers::focus` is removed and X11 no longer rides inside it; the exclusive-keyboard layer path and `reap_layer` drive `focus_target` directly, and every click/touch/activation site collapses to `set_focus(Some(FocusTarget::…))`.
surface_at popup tie-break. When a layout hit is a popup or subsurface, its `wl_surface` is not tracked in `toplevel_z_order`, so the layout-vs-X11 z compare resolved to `None` and the X window underneath wrongly won — clicking a GTK menu (e.g. Firefox's) over an X11 window pressed the X window instead of the menu item. The tie-break now compares the owning toplevel's root z, taken from the hit's `FocusTarget::Layout` weak, so the popup's parent stacking decides.
Raise and focus on every window interaction, matching standard desktop behaviour. Client-driven (un)maximize, the SSD maximize/minimize/close buttons and the maximize keybind now bring the window to the front in both the render z-order and the hit-test stacking through a new `raise_toplevel` helper; previously only the stacking was updated, so an unmaximised window could stay visually behind another. Starting a move or resize — including a press on the resize edge — now routes through `set_focus`, so the window comes to the front and takes the keyboard before the drag instead of being resized or moved while it stays behind.
Input robustness around grabs and drags. A press under an active pointer grab (Wayland popup, drag-and-drop or explicit client grab) is routed to the grab instead of forge's own surface_at handling, so a click cannot leak to a window underneath a grabbing popup and a click outside the popup dismisses it cleanly. Only the press is blocked, never the release, so an in-flight move/resize drag or topbar swipe always reaches its end handler and can never stay glued to the cursor if a grab appears mid-gesture. `on_touch_up` now ends any active move/resize drag before its other early-return paths (armed SSD button, topbar swipe, app switcher) and drops a stale armed button, fixing a race where pressing a second, overlapping window left the drag running forever after release.
fix(input): keep the primary touch slot when migrating a drag across surfaces
A touch drag started on an overlay that hides mid-gesture (e.g. the app launcher when dropping an icon onto the dock or the homescreen) froze: it neither moved nor dropped. Destroying the origin surface migrated the drag state (long_press_fired / long_press_origin) and touch_focus to the main surface, but not its primary_touch_id; subsequent touch motion/up events then fell through the auxiliary path and never reached the gesture machine, leaving on_drag_move and on_drop uncalled.
reconcile_overlays and discard_overlay now adopt the destroyed overlay's primary_touch_id when a drag is in flight, so the rest of the touch sequence keeps driving the main surface. Touch-only; the mouse already migrated correctly via pointer_focus.
ltk: add a chassis module for full-screen ambient surfaces
New `src/chassis.rs`, re-exported from the crate root, gathers the scaffolding that every full-screen ambient surface — greeter, lock screen, kiosk — otherwise repeats by hand over the existing theme and `WallpaperBundle` primitives. `set_default_theme(mode)` finds, installs and activates the `default` theme document, returning the failure message instead of exiting so the caller decides how to abort. `theme_logo_rgba(size)` decodes the active theme's horizontal logo to RGBA, and `theme_icon_tinted(name, size, tint)` loads a symbolic theme icon and tints it. `branding_bundle_or_solid(name)` resolves a theme branding image (`"wallpaper"`, `"lockscreen"`, …) to a `WallpaperBundle`, falling back to a solid fill of the palette background when the theme ships none; `wallpaper_bundle_or_solid()` is the `"wallpaper"` convenience. `backdrop(content, &wallpaper, w, h)` stacks content over the wallpaper resolved for the surface size. No new capability — thin convenience over what the theme module and `WallpaperBundle` already expose — but it removes the per-application `load_theme_logo` / `build_wallpaper_bundle` / theme-bootstrap duplication.
ltk: animatable, input-transparent child surfaces via App::subsurfaces()
New `App::subsurfaces() -> Vec<SubsurfaceSpec<Msg>>` (default empty) describes input-transparent child surfaces composited over the main surface, with `SubsurfaceSpec { id, view, x, y, content_version }` and the stable `SubsurfaceId`. The motivating use is a slide/reveal that tracks a finger without the per-frame full-screen CPU re-raster a single-surface opacity or translate would cost: the content buffer is rasterised once and the compositor moves it.
`SubcompositorState` is bound in `event_loop/run.rs` from the compositor's `wl_compositor` (absent → `App::subsurfaces` silently degrades to none); `delegate_subcompositor!` is added on `AppData`, which gains a `subcompositor` binding and a `subsurfaces` map. New `event_loop/subsurface.rs` reconciles the live subsurfaces against the specs: each spec becomes a `wl_subsurface` sized to the main surface with an empty input region, so all pointer/touch falls through to the parent and the host keeps a single gesture/input model. The content — its own SHM pool and `Canvas`, drawn through the existing `DrawCtx` / `layout_and_draw` path — is rasterised only when the surface size or the spec's `content_version` changes; a position-only change emits `wl_subsurface.set_position` and commits the child surface, then a bare parent commit for placement. Committing the child is deliberate: desync subsurface state (the position) is applied on the child surface's own commit, not the parent's, so without it the move is queued but never lands. Positions are given in layout (physical) pixels and divided by the surface scale for the logical `set_position`.
The reconcile pass runs on every run-loop iteration rather than being gated behind a main-surface redraw, so a finger-driven move repositions at input-event rate, decoupled from the frame-callback cadence that paces full redraws — without this the subsurface only moved when the main surface happened to redraw, so a drag over a static background froze the panel in place.
This commit is contained in:
2026-05-29 23:28:48 +02:00
parent 9ca3b60f3a
commit 042652ec73
9 changed files with 407 additions and 2 deletions

66
src/chassis.rs Normal file
View File

@@ -0,0 +1,66 @@
//! Scaffolding shared by full-screen ambient surfaces (greeter, lock screen,
//! kiosk): theme bring-up, branding/wallpaper loading and the wallpaper-backed
//! view stack. Thin convenience over [`theme`](crate::theme) and
//! [`WallpaperBundle`](crate::WallpaperBundle) — every app that paints a
//! wallpaper behind centred content repeats this otherwise.
use std::sync::Arc;
use crate::{ Color, Element, ImageData, ThemeMode, WallpaperBundle };
/// Find, install and activate the `default` theme document in `mode`. Returns
/// the failure message instead of exiting; the caller decides how to abort.
pub fn set_default_theme( mode: ThemeMode ) -> Result<(), String>
{
let doc = crate::ThemeDocument::find( "default" ).map_err( |e| format!( "{e}" ) )?;
crate::set_active_document( doc );
crate::set_active_mode( mode );
Ok( () )
}
/// Decode the active theme's horizontal logo to RGBA, `size` px on the longer
/// edge. `None` if the theme ships no logo or the SVG fails to rasterise.
pub fn theme_logo_rgba( size: u32 ) -> Option<ImageData>
{
let path = crate::theme_logo_horizontal()?;
let bytes = std::fs::read( &path ).ok()?;
crate::decode_svg_bytes( &bytes, size )
}
/// Load a symbolic theme icon to RGBA, tinted with `tint`. `None` if missing.
pub fn theme_icon_tinted( name: &str, size: u32, tint: Color ) -> Option<ImageData>
{
let ( rgba, w, h ) = crate::theme_icon_rgba( name, size )?;
Some( ( Arc::new( crate::tint_symbolic( &rgba, tint ) ), w, h ) )
}
/// The active theme's branding image `name` (e.g. `"wallpaper"`,
/// `"lockscreen"`) as a [`WallpaperBundle`], falling back to a solid fill of
/// the palette background when the theme ships none.
pub fn branding_bundle_or_solid( name: &str ) -> WallpaperBundle
{
let path = crate::theme_branding_image( name, 0, 0 );
let bg = crate::theme_palette().bg;
WallpaperBundle::from_path_or_solid(
path.as_deref(),
( bg.r * 255.0 ) as u8,
( bg.g * 255.0 ) as u8,
( bg.b * 255.0 ) as u8,
)
}
/// Convenience for [`branding_bundle_or_solid`] with `"wallpaper"`.
pub fn wallpaper_bundle_or_solid() -> WallpaperBundle
{
branding_bundle_or_solid( "wallpaper" )
}
/// Stack `content` over `wallpaper` resolved for the given surface size.
pub fn backdrop<M: Clone>( content: Element<M>, wallpaper: &WallpaperBundle, w: u32, h: u32 ) -> Element<M>
{
let ( rgba, iw, ih ) = wallpaper.for_size( w, h );
crate::stack::<M>()
.push( crate::img_widget( rgba, iw, ih ) )
.push( content )
.into()
}