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 };