// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use smithay_client_toolkit:: { compositor::CompositorState, shell:: { WaylandSurface, wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface }, xdg::{ popup::Popup, window::Window }, }, shm::{ Shm, slot::SlotPool }, }; use smithay_client_toolkit::reexports::client:: { protocol::wl_surface::WlSurface, QueueHandle, }; use smithay_client_toolkit::session_lock::SessionLockSurface; use std::collections::HashMap; use std::sync::Arc; use super::app_data::AppData; use crate::app::OverlayId; use crate::egl_context::{ EglContext, EglSurface }; use crate::input::GestureState; use crate::render::Canvas; use crate::types::{ Point, Rect, WidgetId }; use crate::widget::LaidOutWidget; /// Identifies which surface an event, focus, or draw call belongs to. /// /// `Main` refers to the application's main surface (xdg window or layer /// shell). `Overlay( id )` refers to an auxiliary layer-shell surface created /// from an entry in [`crate::app::App::overlays`]. #[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ] pub( crate ) enum SurfaceFocus { Main, #[ allow( dead_code ) ] Overlay( OverlayId ), } /// Configuration for a layer-shell surface, used both for the main surface /// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay /// returned by [`crate::app::App::overlays`]. #[ derive( Clone ) ] pub( crate ) struct LayerConfig { pub layer: Layer, pub exclusive_zone: i32, pub anchor: crate::app::Anchor, pub size: ( u32, u32 ), pub keyboard_exclusive: bool, /// Wayland surface role namespace, sent to the compositor for debugging. pub namespace: &'static str, } pub( crate ) enum SurfaceKind { /// Layer shell surface, fully created and committed. Layer( LayerSurface ), /// XDG window fallback (no layer shell compositor support). Window( Window ), /// Layer shell is available but we are waiting for an output to be /// advertised before creating the Wayland surface. Pending( LayerConfig ), /// XDG popup, child of the main window. Used for combo dropdowns, /// context menus, tooltips. The compositor positions it relative /// to an anchor rect specified at creation time and may flip it /// (drop-up vs drop-down) when constrained. Popup( Popup ), /// `ext-session-lock-v1` lock surface, created in `SessionLockHandler::locked`. Lock( SessionLockSurface ), /// Session lock requested; waiting for the compositor's `locked` event /// before the lock surface exists. PendingLock, } impl SurfaceKind { pub( crate ) fn wl_surface( &self ) -> &WlSurface { match self { SurfaceKind::Layer( l ) => l.wl_surface(), SurfaceKind::Window( w ) => w.wl_surface(), SurfaceKind::Popup( p ) => p.wl_surface(), SurfaceKind::Lock( s ) => s.wl_surface(), // Unreachable: draw_frame is only called when configured == true, // which only becomes true after on_configure, which requires a real surface. SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => unreachable!( "surface not yet created" ), } } /// Same as [`wl_surface`] but returns `None` for a [`SurfaceKind::Pending`] /// surface instead of panicking. Used by event dispatch to look up which /// [`SurfaceState`] an incoming Wayland event belongs to without risk of /// crashing during startup. pub( crate ) fn try_wl_surface( &self ) -> Option<&WlSurface> { match self { SurfaceKind::Layer( l ) => Some( l.wl_surface() ), SurfaceKind::Window( w ) => Some( w.wl_surface() ), SurfaceKind::Popup( p ) => Some( p.wl_surface() ), SurfaceKind::Lock( s ) => Some( s.wl_surface() ), SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => None, } } /// Create the actual layer surface on `output` using `layer_shell`. Returns /// true if the surface was created (i.e., was `Pending` before). pub( crate ) fn materialize( &mut self, compositor: &CompositorState, layer_shell: &LayerShell, qh: &QueueHandle>, output: &smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput, ) -> bool { if let SurfaceKind::Pending( ref cfg ) = *self { let surface = compositor.create_surface( qh ); let layer_surface = layer_shell.create_layer_surface( qh, surface, cfg.layer, Some( cfg.namespace ), Some( output ), ); layer_surface.set_exclusive_zone( cfg.exclusive_zone ); layer_surface.set_anchor( cfg.anchor.to_wlr_anchor() ); let interactivity = if cfg.keyboard_exclusive { KeyboardInteractivity::Exclusive } else { KeyboardInteractivity::OnDemand }; layer_surface.set_keyboard_interactivity( interactivity ); layer_surface.set_size( cfg.size.0, cfg.size.1 ); layer_surface.commit(); *self = SurfaceKind::Layer( layer_surface ); true } else { false } } } /// The subset of per-surface state the draw pass owns and threads through /// [`crate::draw::DrawCtx`]: the laid-out widget rects it produces, the scroll / /// text-edit / a11y state carried across frames, and the previous frame's /// interaction indices for damage tracking. Grouped into its own struct so the /// draw helpers can borrow it as one unit, disjoint from `canvas` / `pool`. pub( crate ) struct FrameState { pub widget_rects: Vec>, pub cursor_state: HashMap, /// Selection anchor (byte offset). When equal to the cursor, there is no /// active selection. Mutated jointly with `cursor_state`: plain arrow keys /// collapse the selection by setting anchor = cursor; Shift+arrow extends by /// leaving anchor put while moving cursor; pointer drags set both ends. pub selection_anchor: HashMap, pub accessible_extras: Vec, /// Per-scroll-viewport offset, indexed by `flat_idx` of the `Scroll` widget. /// Tuple is `(x, y)` in physical pixels: x is applied only when the widget's /// axis allows horizontal scroll (and stays at `0` otherwise), y mirrors the /// historic single-f32 behaviour for vertical scrolls. pub scroll_offsets: HashMap, pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, pub scroll_canvases: HashMap, pub viewport_canvases: HashMap, /// Per-scroll list of `(flat_idx, content_y, height)` entries for every /// interactive item the scroll's child laid out, in document order. Includes /// items currently scrolled off-screen so keyboard arrow handlers can step /// `hovered_idx` item-by-item without regenerating the layout pass. Y is in /// pre-translation, pre-offset content coordinates. pub scroll_navigable_items: HashMap>, /// Widgets with a [`WidgetId`] clipped out of `widget_rects` by a scroll /// viewport this frame: `(id, scroll flat_idx, rect)` in content coordinates. pub scroll_clipped: Vec<( WidgetId, usize, Rect )>, /// Previous frame interaction state for damage tracking. pub prev_focused: Option, pub prev_hovered: Option, pub prev_pressed: Option, } impl FrameState { pub( crate ) fn new() -> Self { Self { widget_rects: Vec::new(), cursor_state: HashMap::new(), selection_anchor: HashMap::new(), accessible_extras: Vec::new(), scroll_offsets: HashMap::new(), scroll_rects: Vec::new(), scroll_canvases: HashMap::new(), viewport_canvases: HashMap::new(), scroll_navigable_items: HashMap::new(), scroll_clipped: Vec::new(), prev_focused: None, prev_hovered: None, prev_pressed: None, } } } /// Per-surface render + interaction state. /// /// Each Wayland surface managed by the event loop (main surface and any /// active overlays) owns an instance of this struct, so multiple surfaces /// can coexist without interfering. pub( crate ) struct SurfaceState { pub pool: Option, /// EGL window surface for the GPU path. Populated on the first configure /// when an [`EglContext`] is available; mutually exclusive with `pool` /// (the path is locked after the first configure). pub egl_surface: Option, pub surface: SurfaceKind, pub canvas: Option, pub width: u32, pub height: u32, pub configured: bool, pub needs_redraw: bool, /// Set to `true` when the app's view may have changed in a way that rects /// don't capture (text content, progress value, slider thumb position, etc). /// Interaction-only transitions (hover in/out, pressed in/out) leave this /// `false` so the draw path can take the partial-redraw fast path. /// Always reset to `false` at the end of a successful draw. pub content_dirty: bool, pub last_draw: std::time::Instant, pub focused_idx: Option, pub focused_id: Option, pub hovered_idx: Option, pub pending_text_values: HashMap, /// Per-frame draw state: laid-out rects, carried scroll / text-edit / a11y /// state, and the previous frame's interaction indices. Grouped so the draw /// helpers can borrow it as one unit, disjoint from `canvas` / `pool`. pub frame: FrameState, /// Built-in Copy / Cut / Paste context menu when shown on this /// surface. `None` means no menu is up. pub context_menu: Option, /// Per-surface gesture state machine. Owns the press → /// (long-press? / drag? / scroll? / swipe?) → release lifecycle, /// driven by both pointer and touch handlers. The input layer /// (`src/input/`) calls the lifecycle methods; the long-press /// deadline poller reaches into `gesture.long_press_*` directly. pub gesture: GestureState, /// `wl_touch.id` of the finger currently driving the single-slot /// gesture machine. `None` when no finger is down on this surface. /// Subsequent fingers landing while this is `Some` route through /// the auxiliary multi-touch path ([`App::on_touch_down`] etc.) /// instead of being absorbed into the primary slot. pub primary_touch_id: Option, /// Auxiliary touch slots: every non-primary finger's last known /// position, keyed by `wl_touch.id`. Used by the touch handler to /// generate `on_touch_up` with the correct release point (Wayland /// `wl_touch.up` does not carry a position) and to keep state /// across `motion` events between dispatchers. pub touch_slots: HashMap, /// Height of the client-side title bar (0 for layer-shell surfaces). pub titlebar_height: f32, /// Title bar height before decoration negotiation; restored when the /// compositor hands decorations back to the client. pub titlebar_base: f32, /// Title text shown in the client-side title bar. pub titlebar_title: String, /// Rect of the close button in the title bar (for hit testing). pub titlebar_close_rect: Rect, /// Compositor scale factor (integer, e.g. 1 or 2). Used to create /// high-DPI buffers and set `dpi_scale` on the canvas. pub scale_factor: i32, /// Last size requested from the compositor via `layer_surface.set_size`. /// Compared against the app's current [`crate::app::OverlaySpec::size`] /// each reconcile pass so that overlays can resize after creation (used /// for slide-down / grow animations). `(0, 0)` for non-layer surfaces /// and for layer surfaces before their first configure. pub last_requested_size: ( u32, u32 ), /// Logical exclusive zone last sent with `set_exclusive_zone`. Tracked /// alongside the size because a zone that stays at its creation-time /// value while the surface keeps resizing leaves the compositor /// reserving a band that no longer matches what the overlay paints. pub last_requested_zone: i32, pub layer_anchor: Option, /// Anchor rect the xdg-popup positioner was last configured with. /// `None` for non-popup surfaces. pub last_popup_anchor: Option, /// Monotonic counter for `xdg_popup.reposition` tokens. pub popup_reposition_token: u32, /// `true` after the surface has been committed and we are waiting for the /// matching `wl_surface.frame` callback to fire. Drawing is gated on this /// being `false`: even if `needs_redraw` flips back to `true` we sit on /// the next frame until the compositor signals it's time to commit again, /// so the per-surface pacing follows the compositor (and the display /// refresh / VRR / "screen is off") instead of a fixed-period timer. pub frame_pending: bool, /// Set by the frame callback while the app animates by moving /// subsurfaces only; the subsurface reconcile keeps the cadence /// with a bare frame request + commit when nothing moved. pub frame_tick_due: bool, /// Opaque rects (physical) last applied to the surface. pub opaque_full: Option>, } impl SurfaceState { pub( crate ) fn new( surface: SurfaceKind, titlebar_height: f32, titlebar_title: String ) -> Self { Self { pool: None, egl_surface: None, surface, canvas: None, width: 0, height: 0, configured: false, needs_redraw: false, content_dirty: true, last_draw: std::time::Instant::now() - std::time::Duration::from_millis( 100 ), focused_idx: None, focused_id: None, hovered_idx: None, pending_text_values: HashMap::new(), frame: FrameState::new(), primary_touch_id: None, touch_slots: HashMap::new(), context_menu: None, gesture: GestureState::new(), titlebar_height, titlebar_base: titlebar_height, titlebar_title, titlebar_close_rect: Rect::default(), scale_factor: 1, last_requested_size: ( 0, 0 ), last_requested_zone: 0, layer_anchor: None, last_popup_anchor: None, popup_reposition_token: 0, frame_pending: false, frame_tick_due: false, opaque_full: None, } } /// Convert a logical (surface-local) position to physical pixel coordinates. pub( crate ) fn to_physical( &self, x: f64, y: f64 ) -> Point { let sf = self.scale_factor.max( 1 ) as f32; Point { x: x as f32 * sf, y: y as f32 * sf } } /// Surface width in physical pixels (logical × buffer scale). Matches the /// coordinate space used by pointer/touch callbacks and layout, so swipe /// thresholds can be compared against physical `dx` / `start.x` without /// unit mixing. pub( crate ) fn physical_width( &self ) -> u32 { self.width * self.scale_factor.max( 1 ) as u32 } /// Surface height in physical pixels. See [`Self::physical_width`]. pub( crate ) fn physical_height( &self ) -> u32 { self.height * self.scale_factor.max( 1 ) as u32 } /// Configure the surface with a new logical size. Picks the rendering path /// on the very first configure (GPU when `egl_context` is `Some` and the /// EGL window can be created, SHM otherwise) and just resizes the existing /// path on subsequent configures. The two paths are mutually exclusive /// (`pool.is_some()` ⇔ SHM, `egl_surface.is_some()` ⇔ GPU) and locked /// after the first configure — switching backends mid-life would require /// tearing down the canvas as well. pub( crate ) fn on_configure( &mut self, shm: &Shm, egl_context: Option<&Arc>, w: u32, h: u32, ) { let scale = self.scale_factor.max( 1 ) as u32; let pw = w.saturating_mul( scale ); let ph = h.saturating_mul( scale ); let pool_bytes = (pw as u64) .saturating_mul( ph as u64 ) .saturating_mul( 4 ); const MAX_POOL: u64 = 256 * 1024 * 1024; if pool_bytes > MAX_POOL || pw == 0 || ph == 0 { eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" ); return; } let pool_bytes = pool_bytes as usize; if self.egl_surface.is_some() { // GPU path: resize the wl_egl_window so the next eglSwapBuffers // publishes a buffer of the new size. The canvas FBO is reallocated // by `Canvas::resize`, but only after we eglMakeCurrent — the draw // path will do that, so we just record the pending size here. if let Some( ref es ) = self.egl_surface { es.resize( pw as i32, ph as i32 ); } } else if self.pool.is_some() { // SHM path: reallocate the pool to fit the new buffer size. match SlotPool::new( pool_bytes, shm ) { Ok( pool ) => self.pool = Some( pool ), Err( e ) => { eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" ); return; } } } else { // First configure — pick the path. Try GPU when the bootstrap // succeeded; fall back to SHM if surface creation fails. let mut chose_gpu = false; if let Some( ctx ) = egl_context { match ctx.create_surface( self.surface.wl_surface(), pw as i32, ph as i32 ) { Ok( es ) => { self.egl_surface = Some( es ); chose_gpu = true; } Err( e ) => { eprintln!( "[ltk] eglCreateWindowSurface failed: {e} — using SHM" ); } } } if !chose_gpu { match SlotPool::new( pool_bytes, shm ) { Ok( pool ) => self.pool = Some( pool ), Err( e ) => { eprintln!( "[ltk] SlotPool init failed: {e}" ); return; } } } } self.width = w; self.height = h; self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) ); self.configured = true; self.needs_redraw = true; self.content_dirty = true; } /// Request a redraw and mark the surface's content as dirty. /// /// Use this from every site that potentially changes what the app's view /// returns (text edits, scroll offset, slider value, app messages, animation /// tick, configure). Pure hover/pressed transitions should leave /// `content_dirty` alone and only set `needs_redraw` directly so the draw /// pass can take the cheap partial-redraw path. pub( crate ) fn request_redraw( &mut self ) { self.needs_redraw = true; self.content_dirty = true; } }