Files
ltk/src/event_loop/app_data.rs
Pedro M. de Echanove Pasquin fc045a9c22
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
ltk: ext-session-lock-v1 client surface mode, plus a read-only mode for text fields
Add a third Wayland surface type to the runtime so an ltk `App` can be a screen locker, alongside the existing xdg-shell window and wlr-layer-shell surfaces. A new `ShellMode::SessionLock` makes `run()` bind `ext_session_lock_manager_v1` and request the lock at startup; the lock surface itself is created in the new `SessionLockHandler::locked` callback (one surface on the first advertised output) and replaces the `SurfaceKind::PendingLock` placeholder the main surface holds until the compositor grants the lock. The `configure` event routes through the same `on_configure` path as layer and xdg surfaces, so sizing and rendering are unchanged, and `finished` (the compositor denied or ended the lock) tears the loop down. The whole thing is additive and opt-in: the `Window` and `Layer` paths are untouched and nothing enters lock mode unless an `App` returns `ShellMode::SessionLock`, so existing apps are unaffected — the only non-additive edits are the two exhaustive `match`es on `SurfaceKind` (`wl_surface` / `try_wl_surface`), which gain arms for the two new variants.
Doing the locker as a first-class surface rather than compositing a static texture into an offscreen `UiSurface` is the whole point: the compositor gives the lock surface keyboard focus, so ltk's existing text-input, editing, focus and IME machinery works inside the lock exactly as on any other surface — cursor, click-to-focus, Tab, character input. A locker built on top of this is just a normal interactive ltk app that happens to be presented on the lock layer, with no special input plumbing on the compositor or the app side.
`App::requested_exit()` is the new way an app asks the runtime to tear the surface down and leave the loop; it is polled after every batch of `update`s. It exists because of the one hard invariant of `ext-session-lock-v1`: a locker that disconnects without sending `unlock` leaves the compositor's outputs blanked forever — that is the protocol's deliberate anti-bypass guarantee. So when `requested_exit()` returns true and the surface is a session lock, the loop calls `session_lock.unlock()` and round-trips the connection before setting `exit_requested`, lifting the lock cleanly; for a `Window` or `Layer` surface there is no lock and it simply exits. The consequence for lock apps is that they must stop calling `process::exit` from the lock path and instead flip a flag they return from `requested_exit()`.
`text_edit` gains a `read_only( bool )` builder. A read-only field still renders its box and value in the normal field style but takes no keyboard focus and accepts no input: `Element::is_focusable` and `Element::is_text_input` now return false for a read-only `TextEdit`, which keeps it out of the Tab cycle, off the keyboard-edit path, and stops the cursor from ever being drawn on it. The flag is carried through `map_msg` so it survives `Element::map`. This is for presenting a known, non-editable value in the same visual idiom as the editable fields beside it — for example the already-known user shown on a session lock, where letting that field take focus or blink a cursor would be wrong.
The `shell_mode()` doc comment and the README now list the `SessionLock` surface type and point at `requested_exit()` for the unlock. Two warnings are cleared along the way: the runtime no longer stores the `SessionLockState` after requesting the lock — it has no `Drop`, so the manager object outlives the dropped handle inside the connection and the lock lifecycle runs entirely off the returned `SessionLock`, which removes a never-read field — and a pre-existing rustdoc `private_intra_doc_links` warning in `list_item` (a public doc comment linking to the private `theme::ICON_SIZE`) is downgraded to plain code formatting.
2026-05-26 00:11:33 +02:00

445 lines
18 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorState,
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::{ wlr_layer::LayerShell, xdg::XdgShell },
shm::Shm,
session_lock::SessionLock,
};
use smithay_client_toolkit::reexports::client::
{
protocol::
{
wl_keyboard::WlKeyboard,
wl_pointer::WlPointer,
wl_surface::WlSurface,
wl_touch::WlTouch,
},
QueueHandle,
};
use wayland_protocols::wp::text_input::zv3::client::
{
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
zwp_text_input_v3::ZwpTextInputV3,
};
use std::collections::HashMap;
use std::sync::Arc;
use crate::app::{ App, OverlayId };
use crate::egl_context::EglContext;
use crate::types::Point;
use super::repeat::{ ButtonRepeatState, KeyRepeatState };
use super::surface::{ SurfaceFocus, SurfaceState };
use super::tooltip::{ TooltipPending, TooltipVisible };
pub struct AppData<A: App>
{
pub app: A,
pub registry_state: RegistryState,
pub seat_state: SeatState,
pub output_state: OutputState,
pub compositor_state: CompositorState,
pub shm: Shm,
pub session_lock: Option<SessionLock>,
/// Process-wide EGL context (display + GLES context). `None` when EGL
/// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then
/// falls back to the SHM path.
pub egl_context: Option<Arc<EglContext>>,
#[allow(dead_code)]
pub xdg_shell: Option<XdgShell>,
/// Shared layer-shell binding used for the main surface and every
/// overlay. `None` when the compositor does not advertise the protocol.
pub layer_shell: Option<LayerShell>,
pub keyboard: Option<WlKeyboard>,
pub pointer: Option<WlPointer>,
pub touch: Option<WlTouch>,
pub pointer_pos: Point,
/// Process-wide cursor-shape manager (`wp_cursor_shape_v1`).
/// `None` when the compositor does not advertise the protocol —
/// the runtime then leaves cursor shape to the compositor's
/// defaults.
pub cursor_shape_manager: Option<smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager>,
/// Per-pointer cursor-shape device, populated when the compositor
/// supports cursor-shape AND the seat has a pointer capability.
pub cursor_shape_device: Option<smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
/// Last `Enter` serial seen on a pointer event. Required by
/// `wp_cursor_shape_device_v1::set_shape` per spec — the
/// compositor tags every cursor change with the entry serial.
pub last_pointer_enter_serial: u32,
/// The cursor shape currently active on the pointer. `None`
/// means "we have not pushed any shape since the last
/// `wl_pointer.enter`" — the compositor is showing whatever the
/// previous client requested, so the next `dispatch_cursor_shape`
/// must send unconditionally to claim the cursor for our
/// surface. `Some(s)` lets the dispatch short-circuit when the
/// target equals what we already sent.
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// `xdg-activation-v1`. Present when the compositor advertises the
/// global. Used on first configure to honour an incoming
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
/// main surface raised to focus) and exposed via
/// [`App::request_activation_token`] for outbound requests.
pub activation_state:
Option<smithay_client_toolkit::activation::ActivationState>,
/// Honour the activation token exactly once — after the first
/// successful configure of the main surface. Subsequent configures
/// (resizes, scale changes) must not re-activate.
pub activation_token_pending: Option<String>,
/// `wl_data_device_manager` binding. `None` when the compositor
/// does not advertise the global, in which case copy / paste stays
/// process-local and inbound selections from other clients are
/// invisible.
pub data_device_manager:
Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
/// Per-seat `wl_data_device` handle, created the first time a seat
/// with a keyboard or pointer capability appears. Required for
/// `set_selection` and to receive inbound `selection` events.
pub data_device:
Option<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
/// Currently-published outbound selection source. Held alive
/// across [`DataSourceHandler::send_request`] so the cached
/// clipboard text can be re-served on each paste from the peer.
pub clipboard_source:
Option<smithay_client_toolkit::data_device_manager::data_source::CopyPasteSource>,
/// Sender half of the cross-thread channel used to ferry inbound
/// selection bytes (a worker thread drains the read pipe).
pub clipboard_inbox_tx: std::sync::mpsc::Sender<String>,
/// Receiver half — drained once per run loop iteration.
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
/// Cross-application drag-and-drop: best mime negotiated on
/// `enter` and the last pointer position (in logical surface
/// coords). Cleared on `leave` and after `drop_performed`.
pub drop_position: Option<( f64, f64 )>,
pub drop_mime: Option<String>,
pub drop_inbox_tx: std::sync::mpsc::Sender<super::data_device::DropPayload>,
pub drop_inbox_rx: std::sync::mpsc::Receiver<super::data_device::DropPayload>,
/// AccessKit / AT-SPI2 adapter. `None` when the platform adapter
/// could not be created (no AT-SPI2 daemon on the session bus,
/// missing system libraries at runtime, headless CI). The
/// runtime carries on without an accessibility tree in that
/// case; nothing else in the pipeline reads this field except
/// the per-frame tree update and the per-iteration action
/// inbox drain.
pub a11y: Option<crate::a11y::A11yState>,
/// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the
/// global proxy plus the live list of open toplevels; SCTK fans
/// events through this object into our `ForeignToplevelListHandler`
/// impl in `handlers.rs`, which then routes them to
/// `App::on_toplevel_event`. Present on every session, even when
/// the compositor does not advertise the global — internally the
/// list holds a `GlobalProxy` that simply yields no toplevels in
/// that case.
pub foreign_toplevel_list:
smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList,
pub shift_pressed: bool,
pub ctrl_pressed: bool,
/// Calloop handle for inserting timers / channels. Used by the
/// key-repeat machinery and any future feature that needs to
/// schedule work on the run loop without going through the message
/// queue.
pub loop_handle: calloop::LoopHandle<'static, Self>,
/// Repeat rate (events per second) advertised by the compositor in
/// `wl_keyboard.repeat_info`. `0` means "the compositor disabled
/// repeat" — we honour that and never start a repeat timer.
pub compositor_repeat_rate: u32,
/// Initial delay (ms) before the first repeat fires, advertised by
/// the compositor. `0` means the compositor disabled repeat.
pub compositor_repeat_delay: u32,
/// Currently-active key repeat. `Some` between the press of a
/// repeating key and either its release, the keyboard losing focus
/// or another key being pressed.
pub key_repeat: Option<KeyRepeatState>,
/// Currently-active button-press repeat. `Some` between the
/// press of a repeating-button and either its release, the
/// pointer leaving the surface, a gesture cancel, focus loss or
/// long-press promotion.
pub button_repeat: Option<ButtonRepeatState>,
/// Clipboard buffer. Copy / Cut store the selected text here;
/// Paste retrieves from here. Synchronised with the Wayland
/// selection through `wl_data_device_manager` when the compositor
/// advertises the global: outbound copies publish a
/// `CopyPasteSource`, and inbound selections from other clients
/// land here through a worker thread (see
/// [`super::data_device`]).
pub clipboard: String,
/// Timestamp of the previous press (pointer or touch). Combined
/// with [`Self::last_press_pos`] it lets the press handler
/// detect a double-click on a TextEdit and turn it into a
/// word-select.
pub last_press_time: Option<std::time::Instant>,
/// Position of the previous press. See [`Self::last_press_time`].
pub last_press_pos: Option<Point>,
pub debug_layout: bool,
pub pending_msgs: Vec<A::Message>,
/// Initial cursor positions queued when a long-press fires, to be flushed
/// to [`App::on_drag_move`] right after the paired long-press message is
/// processed. This gives the drag ghost a valid starting position even
/// before the user's finger / cursor moves — useful on mouse where the
/// pointer might sit perfectly still between press and drag.
pub pending_drag_inits: Vec<Point>,
pub tooltip_pending: Option<TooltipPending>,
pub tooltip_visible: Option<TooltipVisible>,
pub qh: QueueHandle<Self>,
/// Last pointer serial (needed for interactive move).
pub last_pointer_serial: u32,
/// Last serial from any input event. Required by `xdg_popup.grab`,
/// which only honours serials from recent input events on the same
/// seat.
pub last_input_serial: u32,
/// Set to true when the app has accepted a close request.
pub exit_requested: bool,
/// First-configure latch for `App::start_fullscreen`.
pub pending_fullscreen: bool,
/// First-configure latch: clears `max_size` after the compositor
/// has honoured the pinned initial size.
pub pending_size_hint_unpin: bool,
/// Main application surface.
pub main: SurfaceState<A::Message>,
/// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`].
/// Populated and reconciled by the run loop from [`App::overlays`]. Always
/// empty until overlay diffing is wired up.
pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>,
/// Surface currently receiving pointer events (updated on each pointer
/// event via its `surface` field).
pub pointer_focus: SurfaceFocus,
/// Surface currently holding keyboard focus (updated on keyboard
/// enter/leave).
pub keyboard_focus: SurfaceFocus,
/// Per-touch-id tracking of which surface each active touch belongs to
/// (populated on `down`, cleared on `up`/`cancel`).
pub touch_focus: HashMap<i32, SurfaceFocus>,
/// Cached widget tree returned by [`App::view`]. Populated lazily by the
/// run loop just before drawing when `view_dirty` is set, then re-used by
/// every subsequent partial / interaction-only redraw until invalidated.
/// Dropping the rebuild on idle frames is a big win for shells that sit
/// idle most of the time.
pub cached_view: Option<crate::widget::Element<A::Message>>,
/// Cached overlay-spec list returned by [`App::overlays`]. Same lifecycle
/// as `cached_view` but driven by `overlays_dirty`.
pub cached_overlays: Option<Vec<crate::app::OverlaySpec<A::Message>>>,
/// `true` when `cached_view` is stale and must be rebuilt before the next
/// draw. Set on startup, by every `InvalidationScope` touching `Main`, and
/// every frame `App::is_animating` returns `true`.
pub view_dirty: bool,
/// Counterpart of `view_dirty` for `cached_overlays`.
pub overlays_dirty: bool,
/// Set after the first commit of a rendered buffer on the main surface.
/// Used to drive `App::on_first_frame_committed` exactly once.
pub first_frame_committed: bool,
}
impl<A: App> AppData<A>
{
/// Find which [`SurfaceFocus`] owns the given `WlSurface`, or `None` if it
/// does not correspond to any tracked surface. Safe even when the main
/// surface is still `Pending` (returns `None`).
pub( crate ) fn focus_for_surface(
&self,
wl: &WlSurface,
) -> Option<SurfaceFocus>
{
if self.main.surface.try_wl_surface() == Some( wl )
{
return Some( SurfaceFocus::Main );
}
for ( id, ss ) in self.overlays.iter()
{
if ss.surface.try_wl_surface() == Some( wl )
{
return Some( SurfaceFocus::Overlay( *id ) );
}
}
None
}
/// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus`
/// refers to an overlay that is not currently registered — callers must
/// only pass focus values obtained from [`focus_for_surface`] or from the
/// per-device focus fields, which the run loop keeps in sync.
#[allow( dead_code )]
pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState<A::Message>
{
match focus
{
SurfaceFocus::Main => &self.main,
SurfaceFocus::Overlay( id ) => self.overlays.get( &id )
.expect( "surface(): overlay not registered" ),
}
}
/// Non-panicking variant of [`surface`]. Returns `None` when `focus`
/// refers to an overlay that has already been removed — callers on
/// async dispatch paths (IME Done, tooltip arm) must use this.
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message>>
{
match focus
{
SurfaceFocus::Main => Some( &self.main ),
SurfaceFocus::Overlay( id ) => self.overlays.get( &id ),
}
}
/// Mutable counterpart of [`surface`].
#[allow( dead_code )]
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message>
{
match focus
{
SurfaceFocus::Main => &mut self.main,
SurfaceFocus::Overlay( id ) => self.overlays.get_mut( &id )
.expect( "surface_mut(): overlay not registered" ),
}
}
/// Non-panicking variant of [`surface_mut`].
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message>>
{
match focus
{
SurfaceFocus::Main => Some( &mut self.main ),
SurfaceFocus::Overlay( id ) => self.overlays.get_mut( &id ),
}
}
/// Synchronous overlay teardown: removes the overlay from the map
/// and rewrites every per-device focus that pointed at it so the
/// next event in the same dispatch can no longer land on a freed
/// surface. Used by the compositor-driven destruction paths
/// (`PopupHandler::done`, `LayerShellHandler::closed`) where
/// waiting for the next `reconcile_overlays` would leave a window
/// in which `surface()` / `surface_mut()` panic. Migrates an
/// in-flight long-press drag to the main surface for the same
/// reason `reconcile_overlays` does.
pub( crate ) fn discard_overlay( &mut self, id: crate::app::OverlayId )
{
if let Some( ss ) = self.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
self.main.gesture.long_press_fired = true;
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
{
if fid == id { self.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( fid ) = self.keyboard_focus
{
if fid == id { self.keyboard_focus = SurfaceFocus::Main; }
}
for f in self.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( fid ) = *f
{
if fid == id { *f = SurfaceFocus::Main; }
}
}
if let Some( pending ) = self.tooltip_pending.as_ref()
{
if pending.focus == SurfaceFocus::Overlay( id ) { self.tooltip_pending = None; }
}
if let Some( visible ) = self.tooltip_visible.as_ref()
{
if visible.focus == SurfaceFocus::Overlay( id )
{
self.tooltip_visible = None;
self.overlays_dirty = true;
}
}
}
// Configure the main surface size, (re)allocate its rendering target, and
// request a redraw. Routes to the GPU or SHM path inside `SurfaceState`
// according to whether `self.egl_context` is available.
pub( crate ) fn on_configure( &mut self, w: u32, h: u32 )
{
self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
if let Some( ref mut a ) = self.a11y
{
let sf = self.main.scale_factor.max( 1 ) as f64;
a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf );
a.set_window_focus( true );
}
// First-configure latch: honour an incoming
// `XDG_ACTIVATION_TOKEN` exactly once, after the surface has
// been mapped (`xdg_activation_v1.activate` is only meaningful
// against a configured surface). Compositors that don't
// advertise the global leave `activation_state` as `None` and
// the call drops the token silently.
if let Some( token ) = self.activation_token_pending.take()
{
if let ( Some( ref activation ), Some( wl ) ) =
( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
}
}
// `on_resize` is documented to deliver **physical** pixels, matching the
// coordinate space that the layout passes and the pointer/touch
// callbacks (`on_drag_move`, `on_drop`) work in. Wayland's
// configure.new_size is surface-local (logical), so multiply by the
// current buffer scale before handing the dimensions to the app.
let sf = self.main.scale_factor.max( 1 ) as u32;
self.app.on_resize( w * sf, h * sf );
// `on_resize` may flip app-state that the view depends on (apps that
// branch on the new dimensions, layout caches keyed by size, …), so
// drop the cached tree to force a rebuild on the next draw.
self.dirty_caches();
}
/// Mark both view caches stale after a direct app-state mutation that
/// doesn't go through [`App::update`] — swipe progress callbacks, text-
/// input focus changes, configure-driven resize. Per-message updates use
/// the run loop's `apply_invalidation` path instead so that
/// [`App::invalidate_after`] can scope the rebuild.
pub( crate ) fn dirty_caches( &mut self )
{
self.view_dirty = true;
self.overlays_dirty = true;
}
/// Smallest remaining time until a pending long-press deadline fires.
///
/// Used by the run loop to bound `event_loop.dispatch()` so that a
/// stationary press wakes us up at the right moment even when no
/// Wayland events arrive. `Some(Duration::ZERO)` means the deadline
/// has already elapsed; `None` means nothing is pending.
pub( crate ) fn next_long_press_wakeup( &self ) -> Option<std::time::Duration>
{
let dur = self.app.long_press_duration();
let now = std::time::Instant::now();
let mut soonest: Option<std::time::Duration> = None;
let mut consider = |start: Option<std::time::Instant>|
{
if let Some( s ) = start
{
let deadline = s + dur;
let remaining = deadline.saturating_duration_since( now );
soonest = Some( match soonest
{
Some( cur ) => cur.min( remaining ),
None => remaining,
} );
}
};
consider( self.main.gesture.long_press_start );
for ss in self.overlays.values()
{
consider( ss.gesture.long_press_start );
}
soonest
}
}