From 042652ec73e578ffe2a3868f4842bdd415d0e483 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Fri, 29 May 2026 23:28:48 +0200 Subject: [PATCH] =?UTF-8?q?windows:=20centralise=20input=20focus=20on=20a?= =?UTF-8?q?=20single=20FocusTarget,=20raise+focus=20on=20every=20window=20?= =?UTF-8?q?interaction=20Focus=20was=20scattered=20across=20three=20stores?= =?UTF-8?q?=20=E2=80=94=20`Layouts::focus`,=20`Layers::focus`=20and=20`Win?= =?UTF-8?q?dows::activated`=20=E2=80=94=20plus=20a=20hack=20that=20smuggle?= =?UTF-8?q?d=20X11=20surfaces=20through=20`Layers::focus`,=20with=20the=20?= =?UTF-8?q?keyboard=20target=20derived=20each=20frame=20from=20a=20`layers?= =?UTF-8?q?.focus.or(layout)`=20precedence.=20As=20a=20result=20"focused",?= =?UTF-8?q?=20"activated"=20and=20"has=20the=20keyboard"=20could=20drift?= =?UTF-8?q?=20apart:=20a=20window=20could=20be=20raised=20without=20taking?= =?UTF-8?q?=20the=20keyboard,=20the=20recurring=20"click=20a=20console=20w?= =?UTF-8?q?indow,=20then=20click=20it=20again=20before=20you=20can=20actua?= =?UTF-8?q?lly=20type"=20symptom.=20New=20`windows/focus.rs`=20with=20`Foc?= =?UTF-8?q?usTarget=20{=20Layout(Weak),=20Layer(WlSurface,=20app?= =?UTF-8?q?=5Fid),=20X11(WlSurface)=20}`.=20`Windows`=20now=20holds=20one?= =?UTF-8?q?=20`focus=5Ftarget:=20Option`=20as=20the=20single?= =?UTF-8?q?=20source=20of=20truth;=20`activated`=20survives=20only=20as=20?= =?UTF-8?q?a=20reconciliation=20cache=20for=20the=20xdg=20`Activated`=20st?= =?UTF-8?q?ate=20and=20is=20never=20set=20by=20hand.=20`set=5Ffocus`=20tak?= =?UTF-8?q?es=20one=20`Option`=20and=20is=20the=20only=20entr?= =?UTF-8?q?y=20point=20for=20changing=20focus:=20it=20brings=20the=20targe?= =?UTF-8?q?t=20to=20the=20front=20(`z=5Fpromote`=20+=20desktop=20stacking)?= =?UTF-8?q?,=20refreshes=20the=20MRU=20and=20stores=20the=20target,=20and?= =?UTF-8?q?=20keyboard=20focus=20plus=20activation=20are=20applied=20from?= =?UTF-8?q?=20it=20on=20the=20next=20`focus()`.=20`focus()`=20resolves=20a?= =?UTF-8?q?=20baseline=20(the=20active=20layout's=20window,=20which=20carr?= =?UTF-8?q?ies=20`Activated`)=20and=20an=20overlay=20(a=20focused=20layer/?= =?UTF-8?q?X11=20surface=20that=20takes=20the=20keyboard=20over=20the=20ba?= =?UTF-8?q?seline=20without=20de-activating=20it),=20mirroring=20the=20old?= =?UTF-8?q?=20precedence=20but=20from=20one=20field,=20so=20keyboard=20foc?= =?UTF-8?q?us=20follows=20focus=20by=20construction.=20`Layers::focus`=20i?= =?UTF-8?q?s=20removed=20and=20X11=20no=20longer=20rides=20inside=20it;=20?= =?UTF-8?q?the=20exclusive-keyboard=20layer=20path=20and=20`reap=5Flayer`?= =?UTF-8?q?=20drive=20`focus=5Ftarget`=20directly,=20and=20every=20click/t?= =?UTF-8?q?ouch/activation=20site=20collapses=20to=20`set=5Ffocus(Some(Foc?= =?UTF-8?q?usTarget::=E2=80=A6))`.=20surface=5Fat=20popup=20tie-break.=20W?= =?UTF-8?q?hen=20a=20layout=20hit=20is=20a=20popup=20or=20subsurface,=20it?= =?UTF-8?q?s=20`wl=5Fsurface`=20is=20not=20tracked=20in=20`toplevel=5Fz=5F?= =?UTF-8?q?order`,=20so=20the=20layout-vs-X11=20z=20compare=20resolved=20t?= =?UTF-8?q?o=20`None`=20and=20the=20X=20window=20underneath=20wrongly=20wo?= =?UTF-8?q?n=20=E2=80=94=20clicking=20a=20GTK=20menu=20(e.g.=20Firefox's)?= =?UTF-8?q?=20over=20an=20X11=20window=20pressed=20the=20X=20window=20inst?= =?UTF-8?q?ead=20of=20the=20menu=20item.=20The=20tie-break=20now=20compare?= =?UTF-8?q?s=20the=20owning=20toplevel's=20root=20z,=20taken=20from=20the?= =?UTF-8?q?=20hit's=20`FocusTarget::Layout`=20weak,=20so=20the=20popup's?= =?UTF-8?q?=20parent=20stacking=20decides.=20Raise=20and=20focus=20on=20ev?= =?UTF-8?q?ery=20window=20interaction,=20matching=20standard=20desktop=20b?= =?UTF-8?q?ehaviour.=20Client-driven=20(un)maximize,=20the=20SSD=20maximiz?= =?UTF-8?q?e/minimize/close=20buttons=20and=20the=20maximize=20keybind=20n?= =?UTF-8?q?ow=20bring=20the=20window=20to=20the=20front=20in=20both=20the?= =?UTF-8?q?=20render=20z-order=20and=20the=20hit-test=20stacking=20through?= =?UTF-8?q?=20a=20new=20`raise=5Ftoplevel`=20helper;=20previously=20only?= =?UTF-8?q?=20the=20stacking=20was=20updated,=20so=20an=20unmaximised=20wi?= =?UTF-8?q?ndow=20could=20stay=20visually=20behind=20another.=20Starting?= =?UTF-8?q?=20a=20move=20or=20resize=20=E2=80=94=20including=20a=20press?= =?UTF-8?q?=20on=20the=20resize=20edge=20=E2=80=94=20now=20routes=20throug?= =?UTF-8?q?h=20`set=5Ffocus`,=20so=20the=20window=20comes=20to=20the=20fro?= =?UTF-8?q?nt=20and=20takes=20the=20keyboard=20before=20the=20drag=20inste?= =?UTF-8?q?ad=20of=20being=20resized=20or=20moved=20while=20it=20stays=20b?= =?UTF-8?q?ehind.=20Input=20robustness=20around=20grabs=20and=20drags.=20A?= =?UTF-8?q?=20press=20under=20an=20active=20pointer=20grab=20(Wayland=20po?= =?UTF-8?q?pup,=20drag-and-drop=20or=20explicit=20client=20grab)=20is=20ro?= =?UTF-8?q?uted=20to=20the=20grab=20instead=20of=20forge's=20own=20surface?= =?UTF-8?q?=5Fat=20handling,=20so=20a=20click=20cannot=20leak=20to=20a=20w?= =?UTF-8?q?indow=20underneath=20a=20grabbing=20popup=20and=20a=20click=20o?= =?UTF-8?q?utside=20the=20popup=20dismisses=20it=20cleanly.=20Only=20the?= =?UTF-8?q?=20press=20is=20blocked,=20never=20the=20release,=20so=20an=20i?= =?UTF-8?q?n-flight=20move/resize=20drag=20or=20topbar=20swipe=20always=20?= =?UTF-8?q?reaches=20its=20end=20handler=20and=20can=20never=20stay=20glue?= =?UTF-8?q?d=20to=20the=20cursor=20if=20a=20grab=20appears=20mid-gesture.?= =?UTF-8?q?=20`on=5Ftouch=5Fup`=20now=20ends=20any=20active=20move/resize?= =?UTF-8?q?=20drag=20before=20its=20other=20early-return=20paths=20(armed?= =?UTF-8?q?=20SSD=20button,=20topbar=20swipe,=20app=20switcher)=20and=20dr?= =?UTF-8?q?ops=20a=20stale=20armed=20button,=20fixing=20a=20race=20where?= =?UTF-8?q?=20pressing=20a=20second,=20overlapping=20window=20left=20the?= =?UTF-8?q?=20drag=20running=20forever=20after=20release.=20fix(input):=20?= =?UTF-8?q?keep=20the=20primary=20touch=20slot=20when=20migrating=20a=20dr?= =?UTF-8?q?ag=20across=20surfaces=20A=20touch=20drag=20started=20on=20an?= =?UTF-8?q?=20overlay=20that=20hides=20mid-gesture=20(e.g.=20the=20app=20l?= =?UTF-8?q?auncher=20when=20dropping=20an=20icon=20onto=20the=20dock=20or?= =?UTF-8?q?=20the=20homescreen)=20froze:=20it=20neither=20moved=20nor=20dr?= =?UTF-8?q?opped.=20Destroying=20the=20origin=20surface=20migrated=20the?= =?UTF-8?q?=20drag=20state=20(long=5Fpress=5Ffired=20/=20long=5Fpress=5For?= =?UTF-8?q?igin)=20and=20touch=5Ffocus=20to=20the=20main=20surface,=20but?= =?UTF-8?q?=20not=20its=20primary=5Ftouch=5Fid;=20subsequent=20touch=20mot?= =?UTF-8?q?ion/up=20events=20then=20fell=20through=20the=20auxiliary=20pat?= =?UTF-8?q?h=20and=20never=20reached=20the=20gesture=20machine,=20leaving?= =?UTF-8?q?=20on=5Fdrag=5Fmove=20and=20on=5Fdrop=20uncalled.=20reconcile?= =?UTF-8?q?=5Foverlays=20and=20discard=5Foverlay=20now=20adopt=20the=20des?= =?UTF-8?q?troyed=20overlay's=20primary=5Ftouch=5Fid=20when=20a=20drag=20i?= =?UTF-8?q?s=20in=20flight,=20so=20the=20rest=20of=20the=20touch=20sequenc?= =?UTF-8?q?e=20keeps=20driving=20the=20main=20surface.=20Touch-only;=20the?= =?UTF-8?q?=20mouse=20already=20migrated=20correctly=20via=20pointer=5Ffoc?= =?UTF-8?q?us.=20ltk:=20add=20a=20chassis=20module=20for=20full-screen=20a?= =?UTF-8?q?mbient=20surfaces=20New=20`src/chassis.rs`,=20re-exported=20fro?= =?UTF-8?q?m=20the=20crate=20root,=20gathers=20the=20scaffolding=20that=20?= =?UTF-8?q?every=20full-screen=20ambient=20surface=20=E2=80=94=20greeter,?= =?UTF-8?q?=20lock=20screen,=20kiosk=20=E2=80=94=20otherwise=20repeats=20b?= =?UTF-8?q?y=20hand=20over=20the=20existing=20theme=20and=20`WallpaperBund?= =?UTF-8?q?le`=20primitives.=20`set=5Fdefault=5Ftheme(mode)`=20finds,=20in?= =?UTF-8?q?stalls=20and=20activates=20the=20`default`=20theme=20document,?= =?UTF-8?q?=20returning=20the=20failure=20message=20instead=20of=20exiting?= =?UTF-8?q?=20so=20the=20caller=20decides=20how=20to=20abort.=20`theme=5Fl?= =?UTF-8?q?ogo=5Frgba(size)`=20decodes=20the=20active=20theme's=20horizont?= =?UTF-8?q?al=20logo=20to=20RGBA,=20and=20`theme=5Ficon=5Ftinted(name,=20s?= =?UTF-8?q?ize,=20tint)`=20loads=20a=20symbolic=20theme=20icon=20and=20tin?= =?UTF-8?q?ts=20it.=20`branding=5Fbundle=5For=5Fsolid(name)`=20resolves=20?= =?UTF-8?q?a=20theme=20branding=20image=20(`"wallpaper"`,=20`"lockscreen"`?= =?UTF-8?q?,=20=E2=80=A6)=20to=20a=20`WallpaperBundle`,=20falling=20back?= =?UTF-8?q?=20to=20a=20solid=20fill=20of=20the=20palette=20background=20wh?= =?UTF-8?q?en=20the=20theme=20ships=20none;=20`wallpaper=5Fbundle=5For=5Fs?= =?UTF-8?q?olid()`=20is=20the=20`"wallpaper"`=20convenience.=20`backdrop(c?= =?UTF-8?q?ontent,=20&wallpaper,=20w,=20h)`=20stacks=20content=20over=20th?= =?UTF-8?q?e=20wallpaper=20resolved=20for=20the=20surface=20size.=20No=20n?= =?UTF-8?q?ew=20capability=20=E2=80=94=20thin=20convenience=20over=20what?= =?UTF-8?q?=20the=20theme=20module=20and=20`WallpaperBundle`=20already=20e?= =?UTF-8?q?xpose=20=E2=80=94=20but=20it=20removes=20the=20per-application?= =?UTF-8?q?=20`load=5Ftheme=5Flogo`=20/=20`build=5Fwallpaper=5Fbundle`=20/?= =?UTF-8?q?=20theme-bootstrap=20duplication.=20ltk:=20animatable,=20input-?= =?UTF-8?q?transparent=20child=20surfaces=20via=20App::subsurfaces()=20New?= =?UTF-8?q?=20`App::subsurfaces()=20->=20Vec>`=20(defa?= =?UTF-8?q?ult=20empty)=20describes=20input-transparent=20child=20surfaces?= =?UTF-8?q?=20composited=20over=20the=20main=20surface,=20with=20`Subsurfa?= =?UTF-8?q?ceSpec=20{=20id,=20view,=20x,=20y,=20content=5Fversion=20}`=20a?= =?UTF-8?q?nd=20the=20stable=20`SubsurfaceId`.=20The=20motivating=20use=20?= =?UTF-8?q?is=20a=20slide/reveal=20that=20tracks=20a=20finger=20without=20?= =?UTF-8?q?the=20per-frame=20full-screen=20CPU=20re-raster=20a=20single-su?= =?UTF-8?q?rface=20opacity=20or=20translate=20would=20cost:=20the=20conten?= =?UTF-8?q?t=20buffer=20is=20rasterised=20once=20and=20the=20compositor=20?= =?UTF-8?q?moves=20it.=20`SubcompositorState`=20is=20bound=20in=20`event?= =?UTF-8?q?=5Floop/run.rs`=20from=20the=20compositor's=20`wl=5Fcompositor`?= =?UTF-8?q?=20(absent=20=E2=86=92=20`App::subsurfaces`=20silently=20degrad?= =?UTF-8?q?es=20to=20none);=20`delegate=5Fsubcompositor!`=20is=20added=20o?= =?UTF-8?q?n=20`AppData`,=20which=20gains=20a=20`subcompositor`=20binding?= =?UTF-8?q?=20and=20a=20`subsurfaces`=20map.=20New=20`event=5Floop/subsurf?= =?UTF-8?q?ace.rs`=20reconciles=20the=20live=20subsurfaces=20against=20the?= =?UTF-8?q?=20specs:=20each=20spec=20becomes=20a=20`wl=5Fsubsurface`=20siz?= =?UTF-8?q?ed=20to=20the=20main=20surface=20with=20an=20empty=20input=20re?= =?UTF-8?q?gion,=20so=20all=20pointer/touch=20falls=20through=20to=20the?= =?UTF-8?q?=20parent=20and=20the=20host=20keeps=20a=20single=20gesture/inp?= =?UTF-8?q?ut=20model.=20The=20content=20=E2=80=94=20its=20own=20SHM=20poo?= =?UTF-8?q?l=20and=20`Canvas`,=20drawn=20through=20the=20existing=20`DrawC?= =?UTF-8?q?tx`=20/=20`layout=5Fand=5Fdraw`=20path=20=E2=80=94=20is=20raste?= =?UTF-8?q?rised=20only=20when=20the=20surface=20size=20or=20the=20spec's?= =?UTF-8?q?=20`content=5Fversion`=20changes;=20a=20position-only=20change?= =?UTF-8?q?=20emits=20`wl=5Fsubsurface.set=5Fposition`=20and=20commits=20t?= =?UTF-8?q?he=20child=20surface,=20then=20a=20bare=20parent=20commit=20for?= =?UTF-8?q?=20placement.=20Committing=20the=20child=20is=20deliberate:=20d?= =?UTF-8?q?esync=20subsurface=20state=20(the=20position)=20is=20applied=20?= =?UTF-8?q?on=20the=20child=20surface's=20own=20commit,=20not=20the=20pare?= =?UTF-8?q?nt's,=20so=20without=20it=20the=20move=20is=20queued=20but=20ne?= =?UTF-8?q?ver=20lands.=20Positions=20are=20given=20in=20layout=20(physica?= =?UTF-8?q?l)=20pixels=20and=20divided=20by=20the=20surface=20scale=20for?= =?UTF-8?q?=20the=20logical=20`set=5Fposition`.=20The=20reconcile=20pass?= =?UTF-8?q?=20runs=20on=20every=20run-loop=20iteration=20rather=20than=20b?= =?UTF-8?q?eing=20gated=20behind=20a=20main-surface=20redraw,=20so=20a=20f?= =?UTF-8?q?inger-driven=20move=20repositions=20at=20input-event=20rate,=20?= =?UTF-8?q?decoupled=20from=20the=20frame-callback=20cadence=20that=20pace?= =?UTF-8?q?s=20full=20redraws=20=E2=80=94=20without=20this=20the=20subsurf?= =?UTF-8?q?ace=20only=20moved=20when=20the=20main=20surface=20happened=20t?= =?UTF-8?q?o=20redraw,=20so=20a=20drag=20over=20a=20static=20background=20?= =?UTF-8?q?froze=20the=20panel=20in=20place.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 48 +++++ src/chassis.rs | 66 +++++++ src/event_loop/app_data.rs | 15 +- src/event_loop/handlers.rs | 3 +- src/event_loop/mod.rs | 1 + src/event_loop/overlays_reconcile.rs | 5 + src/event_loop/run.rs | 15 ++ src/event_loop/subsurface.rs | 253 +++++++++++++++++++++++++++ src/lib.rs | 3 + 9 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 src/chassis.rs create mode 100644 src/event_loop/subsurface.rs diff --git a/src/app.rs b/src/app.rs index 8679d1b..3ed2ee4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -118,6 +118,12 @@ impl Anchor #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )] pub struct OverlayId( pub u32 ); +/// Stable identifier for a subsurface, used to diff the list returned by +/// [`App::subsurfaces`] between frames the same way [`OverlayId`] diffs +/// overlays. +#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )] +pub struct SubsurfaceId( pub u32 ); + /// One of the surfaces an [`App`] can target with an invalidation. Used inside /// [`InvalidationScope::Only`] to name the affected surfaces. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )] @@ -255,6 +261,42 @@ pub struct OverlaySpec pub anchor_widget_id: Option, } +/// An input-transparent child surface composited over the main surface and +/// moved by the compositor. +/// +/// Returned from [`App::subsurfaces`]. The runtime creates one `wl_subsurface` +/// per active spec, full-size over the main surface, with an **empty input +/// region** — all pointer/touch falls through to the main surface, so the host +/// keeps a single input/gesture model. The win is in the move: the content +/// buffer is rasterised only when [`content_version`](Self::content_version) +/// (or the surface size) changes, while [`x`](Self::x) / [`y`](Self::y) +/// changes only emit `wl_subsurface.set_position` + a parent commit — no +/// re-raster, no buffer re-upload. This makes an animated reveal/slide track +/// the finger at the compositor's compositing cost rather than the client's +/// raster cost. +pub struct SubsurfaceSpec +{ + /// Stable identifier, used to diff subsurfaces between frames. + pub id: SubsurfaceId, + + /// Widget tree for this subsurface. Should paint its own opaque + /// background if it must cover the main surface beneath it. + pub view: Element, + + /// Position of the subsurface relative to the parent's top-left, in + /// layout (physical) pixels — the same space as [`App::on_resize`] and + /// widget rects. The runtime divides by the surface scale to obtain the + /// logical position it hands to the compositor. Animate this for a cheap + /// compositor-side move. + pub x: i32, + pub y: i32, + + /// Content revision. Bump it whenever [`view`](Self::view) would paint + /// differently; leave it unchanged for position-only updates so the + /// runtime skips the re-raster and only repositions. + pub content_version: u64, +} + /// Trait that application types must implement to integrate with ltk. pub trait App: 'static { @@ -295,6 +337,12 @@ pub trait App: 'static /// IDs that disappear cause the surface to be destroyed. fn overlays( &self ) -> Vec> { Vec::new() } + /// Describe the input-transparent child surfaces composited over the main + /// surface this frame. Each becomes a `wl_subsurface` the compositor moves + /// by position; see [`SubsurfaceSpec`]. Diffed across frames by + /// [`SubsurfaceSpec::id`]. Default empty. + fn subsurfaces( &self ) -> Vec> { Vec::new() } + /// Return any pending messages from external sources (timers, async, etc.). fn poll_external( &mut self ) -> Vec { vec![] } diff --git a/src/chassis.rs b/src/chassis.rs new file mode 100644 index 0000000..12c8256 --- /dev/null +++ b/src/chassis.rs @@ -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 +{ + 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 +{ + 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( content: Element, wallpaper: &WallpaperBundle, w: u32, h: u32 ) -> Element +{ + let ( rgba, iw, ih ) = wallpaper.for_size( w, h ); + crate::stack::() + .push( crate::img_widget( rgba, iw, ih ) ) + .push( content ) + .into() +} diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index c8bd564..7190450 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -10,6 +10,7 @@ use smithay_client_toolkit:: shell::{ wlr_layer::LayerShell, xdg::XdgShell }, shm::Shm, session_lock::SessionLock, + subcompositor::SubcompositorState, }; use smithay_client_toolkit::reexports::client:: { @@ -30,11 +31,12 @@ use wayland_protocols::wp::text_input::zv3::client:: use std::collections::HashMap; use std::sync::Arc; -use crate::app::{ App, OverlayId }; +use crate::app::{ App, OverlayId, SubsurfaceId }; use crate::egl_context::EglContext; use crate::types::Point; use super::repeat::{ ButtonRepeatState, KeyRepeatState }; +use super::subsurface::SubsurfaceSlot; use super::surface::{ SurfaceFocus, SurfaceState }; use super::tooltip::{ TooltipPending, TooltipVisible }; @@ -45,6 +47,9 @@ pub struct AppData pub seat_state: SeatState, pub output_state: OutputState, pub compositor_state: CompositorState, + /// `wl_subcompositor` binding for [`crate::app::App::subsurfaces`]. + /// `None` when the compositor does not advertise the protocol. + pub subcompositor: Option, pub shm: Shm, pub session_lock: Option, /// Process-wide EGL context (display + GLES context). `None` when EGL @@ -213,6 +218,9 @@ pub struct AppData /// Populated and reconciled by the run loop from [`App::overlays`]. Always /// empty until overlay diffing is wired up. pub overlays: HashMap>, + /// Input-transparent child surfaces keyed by their stable + /// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`]. + pub subsurfaces: HashMap, /// Surface currently receiving pointer events (updated on each pointer /// event via its `surface` field). pub pointer_focus: SurfaceFocus, @@ -332,6 +340,11 @@ impl AppData { self.main.gesture.long_press_fired = true; self.main.gesture.long_press_origin = ss.gesture.long_press_origin; + // Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine. + if self.main.primary_touch_id.is_none() + { + self.main.primary_touch_id = ss.primary_touch_id; + } } } if let SurfaceFocus::Overlay( fid ) = self.pointer_focus diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index b5a6ec7..1f35282 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -4,7 +4,7 @@ use smithay_client_toolkit:: { compositor::CompositorHandler, - delegate_compositor, delegate_foreign_toplevel_list, delegate_layer, + delegate_compositor, delegate_subcompositor, delegate_foreign_toplevel_list, delegate_layer, delegate_output, delegate_registry, delegate_seat, delegate_keyboard, delegate_pointer, delegate_touch, delegate_shm, delegate_xdg_popup, delegate_xdg_shell, delegate_xdg_window, delegate_session_lock, @@ -431,6 +431,7 @@ impl SessionLockHandler for AppData } delegate_compositor!( @ AppData ); +delegate_subcompositor!( @ AppData ); delegate_output!( @ AppData ); delegate_shm!( @ AppData ); delegate_seat!( @ AppData ); diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 81a2054..a2749d1 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -10,6 +10,7 @@ pub( crate ) mod drag; pub( crate ) mod focus; mod handlers; pub( crate ) mod repeat; +pub( crate ) mod subsurface; pub( crate ) mod surface; pub( crate ) mod text_editing; pub( crate ) mod tooltip; diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index c5628da..e3e4afd 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -56,6 +56,11 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) { data.main.gesture.long_press_fired = true; data.main.gesture.long_press_origin = ss.gesture.long_press_origin; + // Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine. + if data.main.primary_touch_id.is_none() + { + data.main.primary_touch_id = ss.primary_touch_id; + } } } } diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index bcbd521..bd5e003 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -18,6 +18,7 @@ use smithay_client_toolkit:: }, }, shm::Shm, + subcompositor::SubcompositorState, }; use smithay_client_toolkit::reexports::client::Connection; use smithay_client_toolkit::reexports::calloop::EventLoop; @@ -70,6 +71,10 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> let shm = Shm::bind( &globals, &qh ) .map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?; + // Optional: compositors lacking wl_subcompositor leave this None and + // App::subsurfaces() silently degrades to no subsurfaces. + let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok(); + // Try to bring EGL up. On failure (no libEGL, no compatible config, // LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the // reason and every surface falls back to the SHM path. @@ -261,6 +266,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> seat_state: SeatState::new( &globals, &qh ), output_state: OutputState::new( &globals, &qh ), compositor_state: compositor, + subcompositor, shm, session_lock, egl_context, @@ -314,6 +320,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> pending_size_hint_unpin, main: SurfaceState::::new( surface_kind, titlebar_height, titlebar_title ), overlays: std::collections::HashMap::new(), + subsurfaces: std::collections::HashMap::new(), pointer_focus: SurfaceFocus::Main, keyboard_focus: SurfaceFocus::Main, touch_focus: std::collections::HashMap::new(), @@ -638,6 +645,14 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> data.a11y = a11y_taken; } + // Reconcile + reposition input-transparent subsurfaces every + // iteration, decoupled from the main redraw cadence: a finger drag + // emits motion events faster than frame callbacks clear + // `frame_pending`, so gating this on a main draw would pin the + // subsurface between frames. A position-only change is just + // set_position + a bare parent commit; no-ops when nothing moved. + super::subsurface::reconcile_subsurfaces( &mut data ); + // Drain inbound AT-SPI2 actions (Orca pressing a button, // switch-control focusing a node, etc.) and translate each // into a synthetic press / focus on the matching widget. diff --git a/src/event_loop/subsurface.rs b/src/event_loop/subsurface.rs new file mode 100644 index 0000000..e6d88b4 --- /dev/null +++ b/src/event_loop/subsurface.rs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Input-transparent child surfaces ([`crate::app::SubsurfaceSpec`]). +//! +//! Each spec maps to one `wl_subsurface` sized to the main surface, with an +//! empty input region so all input falls through to the parent — the host +//! keeps a single gesture/input model. The content buffer is rasterised only +//! when the size or the spec's `content_version` changes; a position change +//! emits `wl_subsurface.set_position` plus a bare parent commit, so an animated +//! slide costs a compositor recomposite, not a client re-raster. + +use std::collections::HashMap; +use std::sync::Arc; + +use smithay_client_toolkit::compositor::CompositorState; +use smithay_client_toolkit::shm::Shm; +use smithay_client_toolkit::shm::slot::SlotPool; +use smithay_client_toolkit::reexports::client::protocol::wl_shm; +use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface; +use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; + +use crate::app::App; +use crate::draw::DrawCtx; +use crate::draw::chrome::apply_input_region; +use crate::draw::layout_and_draw; +use crate::render::Canvas; +use crate::types::Rect; +use crate::widget::Element; + +use super::frame::pick_shm_format; +use super::AppData; + +/// Convert a layout (physical) position to the logical position the +/// compositor expects for `wl_subsurface.set_position`. +fn to_logical( x: i32, y: i32, scale: u32 ) -> ( i32, i32 ) +{ + let s = scale.max( 1 ) as i32; + ( x / s, y / s ) +} + +/// Live state for one subsurface: its Wayland objects, its own SHM pool and +/// canvas, and the last (size, version, position) we committed so the per-frame +/// pass can tell a re-raster from a cheap reposition. +pub( crate ) struct SubsurfaceSlot +{ + subsurface: WlSubsurface, + surface: WlSurface, + pool: Option, + canvas: Option, + last_pos: ( i32, i32 ), + last_version: u64, + rastered: bool, +} + +impl SubsurfaceSlot +{ + fn destroy( self ) + { + self.subsurface.destroy(); + self.surface.destroy(); + } +} + +/// Reconcile the live subsurfaces against [`App::subsurfaces`], render content +/// where it changed and reposition where it moved. Cheap when nothing but a +/// position changed. Must run after the parent surface is configured. +pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) +{ + if data.subcompositor.is_none() { return; } + if !data.main.configured { return; } + + let parent = match data.main.surface.try_wl_surface() + { + Some( s ) => s.clone(), + None => return, + }; + + let scale = data.main.scale_factor.max( 1 ) as u32; + let ( w, h ) = ( data.main.width, data.main.height ); + if w == 0 || h == 0 { return; } + let pw = w * scale; + let ph = h * scale; + let ( format, swap_rb ) = pick_shm_format( &data.shm ); + + let specs: Vec> = data.app.subsurfaces(); + + let mut parent_dirty = false; + + // Drop subsurfaces whose id disappeared from the spec list. + let present: std::collections::HashSet = + specs.iter().map( |s| s.id ).collect(); + let stale: Vec = data.subsurfaces.keys() + .copied() + .filter( |id| !present.contains( id ) ) + .collect(); + for id in stale + { + if let Some( slot ) = data.subsurfaces.remove( &id ) + { + slot.destroy(); + parent_dirty = true; + } + } + + for spec in &specs + { + // Create on first sight. + if !data.subsurfaces.contains_key( &spec.id ) + { + let ( subsurface, surface ) = { + let sc = data.subcompositor.as_ref().unwrap(); + sc.create_subsurface( parent.clone(), &data.qh ) + }; + // Independent buffer commits; placement still applies on the + // parent commit we issue below. + subsurface.set_desync(); + let ( lx, ly ) = to_logical( spec.x, spec.y, scale ); + subsurface.set_position( lx, ly ); + data.subsurfaces.insert( spec.id, SubsurfaceSlot { + subsurface, + surface, + pool: None, + canvas: None, + last_pos: ( spec.x, spec.y ), + last_version: u64::MAX, + rastered: false, + } ); + parent_dirty = true; + } + + let size_changed = data.subsurfaces.get( &spec.id ) + .and_then( |s| s.canvas.as_ref() ) + .map( |c| c.size() != ( pw, ph ) ) + .unwrap_or( true ); + let needs_raster = { + let slot = data.subsurfaces.get( &spec.id ).unwrap(); + !slot.rastered || size_changed || slot.last_version != spec.content_version + }; + + if needs_raster + { + let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); + render_slot::( + slot, &data.shm, &data.compositor_state, &spec.view, + pw, ph, scale, format, swap_rb, size_changed, + ); + slot.rastered = true; + slot.last_version = spec.content_version; + parent_dirty = true; + } + + let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); + if slot.last_pos != ( spec.x, spec.y ) + { + let ( lx, ly ) = to_logical( spec.x, spec.y, scale ); + slot.subsurface.set_position( lx, ly ); + // Desync subsurface state (the position) is applied on the child + // surface's own commit, not the parent's; commit it here so the + // move actually lands. The parent commit below applies placement. + slot.surface.commit(); + slot.last_pos = ( spec.x, spec.y ); + parent_dirty = true; + } + } + + // Applies pending subsurface placement / mapping without re-attaching the + // parent buffer — the cheap per-frame move. + if parent_dirty + { + parent.commit(); + } +} + +#[ allow( clippy::too_many_arguments ) ] +fn render_slot( + slot: &mut SubsurfaceSlot, + shm: &Shm, + compositor: &CompositorState, + view: &Element, + pw: u32, + ph: u32, + scale: u32, + shm_format: wl_shm::Format, + swap_rb: bool, + size_changed: bool, +) +{ + if slot.pool.is_none() || size_changed + { + match SlotPool::new( ( pw * ph * 4 ) as usize, shm ) + { + Ok( p ) => slot.pool = Some( p ), + Err( _ ) => return, + } + } + let pool = slot.pool.as_mut().unwrap(); + let stride = pw * 4; + let ( buffer, canvas_buf ) = match pool.create_buffer( + pw as i32, ph as i32, stride as i32, shm_format, + ) + { + Ok( r ) => r, + Err( _ ) => return, + }; + + let canvas = slot.canvas.get_or_insert_with( || { + let mut c = Canvas::new( pw, ph ); + c.set_dpi_scale( scale as f32 ); + if let Some( reg ) = crate::theme::build_font_registry() + { + c.set_font_registry( Arc::new( reg ) ); + } + c + } ); + if canvas.size() != ( pw, ph ) + { + canvas.resize( pw, ph ); + canvas.set_dpi_scale( scale as f32 ); + } + canvas.clear_clip(); + canvas.clear(); + + let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 }; + let mut ctx: DrawCtx = DrawCtx + { + focused_idx: None, + hovered_idx: None, + pressed_idx: None, + cursor_state: HashMap::new(), + selection_anchor: HashMap::new(), + widget_rects: Vec::new(), + debug_layout: false, + scroll_offsets: HashMap::new(), + scroll_rects: Vec::new(), + scroll_canvases: HashMap::new(), + scroll_navigable_items: HashMap::new(), + previous_widget_rects: Vec::new(), + accessible_extras: Vec::new(), + live_depth: 0, + }; + layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + + canvas.write_to_wayland_buf( canvas_buf, swap_rb ); + + let wl = &slot.surface; + if buffer.attach_to( wl ).is_err() { return; } + wl.set_buffer_scale( scale as i32 ); + wl.damage_buffer( 0, 0, pw as i32, ph as i32 ); + // Empty input region: pointer/touch fall through to the parent. + apply_input_region( wl, compositor, Some( &[] ), scale ); + wl.commit(); +} diff --git a/src/lib.rs b/src/lib.rs index 9cb7c0f..edf1760 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -193,6 +193,7 @@ pub( crate ) mod layout; pub( crate ) mod app; pub mod theme; pub mod wallpaper; +pub mod chassis; pub( crate ) mod tree; pub( crate ) mod draw; @@ -206,6 +207,7 @@ pub mod core; pub use app:: { Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, + SubsurfaceId, SubsurfaceSpec, InvalidationScope, SurfaceTarget, ToplevelEvent, }; pub use theme:: @@ -239,6 +241,7 @@ pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as th pub use gles_render::{ BorrowedGlesTexture, GlesVersion }; pub use render::is_software_render; pub use wallpaper::{ WallpaperBundle, ImageData }; +pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop }; pub use types::{ Color, Corners, CursorShape, Length, LengthBase, Point, Rect, Size, WidgetId }; pub use types::{ design_reference, set_design_reference }; pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };