From fc045a9c22a12908effc58d87770fceb2b266d0f Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Tue, 26 May 2026 00:11:33 +0200 Subject: [PATCH] =?UTF-8?q?ltk:=20ext-session-lock-v1=20client=20surface?= =?UTF-8?q?=20mode,=20plus=20a=20read-only=20mode=20for=20text=20fields=20?= =?UTF-8?q?Add=20a=20third=20Wayland=20surface=20type=20to=20the=20runtime?= =?UTF-8?q?=20so=20an=20ltk=20`App`=20can=20be=20a=20screen=20locker,=20al?= =?UTF-8?q?ongside=20the=20existing=20xdg-shell=20window=20and=20wlr-layer?= =?UTF-8?q?-shell=20surfaces.=20A=20new=20`ShellMode::SessionLock`=20makes?= =?UTF-8?q?=20`run()`=20bind=20`ext=5Fsession=5Flock=5Fmanager=5Fv1`=20and?= =?UTF-8?q?=20request=20the=20lock=20at=20startup;=20the=20lock=20surface?= =?UTF-8?q?=20itself=20is=20created=20in=20the=20new=20`SessionLockHandler?= =?UTF-8?q?::locked`=20callback=20(one=20surface=20on=20the=20first=20adve?= =?UTF-8?q?rtised=20output)=20and=20replaces=20the=20`SurfaceKind::Pending?= =?UTF-8?q?Lock`=20placeholder=20the=20main=20surface=20holds=20until=20th?= =?UTF-8?q?e=20compositor=20grants=20the=20lock.=20The=20`configure`=20eve?= =?UTF-8?q?nt=20routes=20through=20the=20same=20`on=5Fconfigure`=20path=20?= =?UTF-8?q?as=20layer=20and=20xdg=20surfaces,=20so=20sizing=20and=20render?= =?UTF-8?q?ing=20are=20unchanged,=20and=20`finished`=20(the=20compositor?= =?UTF-8?q?=20denied=20or=20ended=20the=20lock)=20tears=20the=20loop=20dow?= =?UTF-8?q?n.=20The=20whole=20thing=20is=20additive=20and=20opt-in:=20the?= =?UTF-8?q?=20`Window`=20and=20`Layer`=20paths=20are=20untouched=20and=20n?= =?UTF-8?q?othing=20enters=20lock=20mode=20unless=20an=20`App`=20returns?= =?UTF-8?q?=20`ShellMode::SessionLock`,=20so=20existing=20apps=20are=20una?= =?UTF-8?q?ffected=20=E2=80=94=20the=20only=20non-additive=20edits=20are?= =?UTF-8?q?=20the=20two=20exhaustive=20`match`es=20on=20`SurfaceKind`=20(`?= =?UTF-8?q?wl=5Fsurface`=20/=20`try=5Fwl=5Fsurface`),=20which=20gain=20arm?= =?UTF-8?q?s=20for=20the=20two=20new=20variants.=20Doing=20the=20locker=20?= =?UTF-8?q?as=20a=20first-class=20surface=20rather=20than=20compositing=20?= =?UTF-8?q?a=20static=20texture=20into=20an=20offscreen=20`UiSurface`=20is?= =?UTF-8?q?=20the=20whole=20point:=20the=20compositor=20gives=20the=20lock?= =?UTF-8?q?=20surface=20keyboard=20focus,=20so=20ltk's=20existing=20text-i?= =?UTF-8?q?nput,=20editing,=20focus=20and=20IME=20machinery=20works=20insi?= =?UTF-8?q?de=20the=20lock=20exactly=20as=20on=20any=20other=20surface=20?= =?UTF-8?q?=E2=80=94=20cursor,=20click-to-focus,=20Tab,=20character=20inpu?= =?UTF-8?q?t.=20A=20locker=20built=20on=20top=20of=20this=20is=20just=20a?= =?UTF-8?q?=20normal=20interactive=20ltk=20app=20that=20happens=20to=20be?= =?UTF-8?q?=20presented=20on=20the=20lock=20layer,=20with=20no=20special?= =?UTF-8?q?=20input=20plumbing=20on=20the=20compositor=20or=20the=20app=20?= =?UTF-8?q?side.=20`App::requested=5Fexit()`=20is=20the=20new=20way=20an?= =?UTF-8?q?=20app=20asks=20the=20runtime=20to=20tear=20the=20surface=20dow?= =?UTF-8?q?n=20and=20leave=20the=20loop;=20it=20is=20polled=20after=20ever?= =?UTF-8?q?y=20batch=20of=20`update`s.=20It=20exists=20because=20of=20the?= =?UTF-8?q?=20one=20hard=20invariant=20of=20`ext-session-lock-v1`:=20a=20l?= =?UTF-8?q?ocker=20that=20disconnects=20without=20sending=20`unlock`=20lea?= =?UTF-8?q?ves=20the=20compositor's=20outputs=20blanked=20forever=20?= =?UTF-8?q?=E2=80=94=20that=20is=20the=20protocol's=20deliberate=20anti-by?= =?UTF-8?q?pass=20guarantee.=20So=20when=20`requested=5Fexit()`=20returns?= =?UTF-8?q?=20true=20and=20the=20surface=20is=20a=20session=20lock,=20the?= =?UTF-8?q?=20loop=20calls=20`session=5Flock.unlock()`=20and=20round-trips?= =?UTF-8?q?=20the=20connection=20before=20setting=20`exit=5Frequested`,=20?= =?UTF-8?q?lifting=20the=20lock=20cleanly;=20for=20a=20`Window`=20or=20`La?= =?UTF-8?q?yer`=20surface=20there=20is=20no=20lock=20and=20it=20simply=20e?= =?UTF-8?q?xits.=20The=20consequence=20for=20lock=20apps=20is=20that=20the?= =?UTF-8?q?y=20must=20stop=20calling=20`process::exit`=20from=20the=20lock?= =?UTF-8?q?=20path=20and=20instead=20flip=20a=20flag=20they=20return=20fro?= =?UTF-8?q?m=20`requested=5Fexit()`.=20`text=5Fedit`=20gains=20a=20`read?= =?UTF-8?q?=5Fonly(=20bool=20)`=20builder.=20A=20read-only=20field=20still?= =?UTF-8?q?=20renders=20its=20box=20and=20value=20in=20the=20normal=20fiel?= =?UTF-8?q?d=20style=20but=20takes=20no=20keyboard=20focus=20and=20accepts?= =?UTF-8?q?=20no=20input:=20`Element::is=5Ffocusable`=20and=20`Element::is?= =?UTF-8?q?=5Ftext=5Finput`=20now=20return=20false=20for=20a=20read-only?= =?UTF-8?q?=20`TextEdit`,=20which=20keeps=20it=20out=20of=20the=20Tab=20cy?= =?UTF-8?q?cle,=20off=20the=20keyboard-edit=20path,=20and=20stops=20the=20?= =?UTF-8?q?cursor=20from=20ever=20being=20drawn=20on=20it.=20The=20flag=20?= =?UTF-8?q?is=20carried=20through=20`map=5Fmsg`=20so=20it=20survives=20`El?= =?UTF-8?q?ement::map`.=20This=20is=20for=20presenting=20a=20known,=20non-?= =?UTF-8?q?editable=20value=20in=20the=20same=20visual=20idiom=20as=20the?= =?UTF-8?q?=20editable=20fields=20beside=20it=20=E2=80=94=20for=20example?= =?UTF-8?q?=20the=20already-known=20user=20shown=20on=20a=20session=20lock?= =?UTF-8?q?,=20where=20letting=20that=20field=20take=20focus=20or=20blink?= =?UTF-8?q?=20a=20cursor=20would=20be=20wrong.=20The=20`shell=5Fmode()`=20?= =?UTF-8?q?doc=20comment=20and=20the=20README=20now=20list=20the=20`Sessio?= =?UTF-8?q?nLock`=20surface=20type=20and=20point=20at=20`requested=5Fexit(?= =?UTF-8?q?)`=20for=20the=20unlock.=20Two=20warnings=20are=20cleared=20alo?= =?UTF-8?q?ng=20the=20way:=20the=20runtime=20no=20longer=20stores=20the=20?= =?UTF-8?q?`SessionLockState`=20after=20requesting=20the=20lock=20?= =?UTF-8?q?=E2=80=94=20it=20has=20no=20`Drop`,=20so=20the=20manager=20obje?= =?UTF-8?q?ct=20outlives=20the=20dropped=20handle=20inside=20the=20connect?= =?UTF-8?q?ion=20and=20the=20lock=20lifecycle=20runs=20entirely=20off=20th?= =?UTF-8?q?e=20returned=20`SessionLock`,=20which=20removes=20a=20never-rea?= =?UTF-8?q?d=20field=20=E2=80=94=20and=20a=20pre-existing=20rustdoc=20`pri?= =?UTF-8?q?vate=5Fintra=5Fdoc=5Flinks`=20warning=20in=20`list=5Fitem`=20(a?= =?UTF-8?q?=20public=20doc=20comment=20linking=20to=20the=20private=20`the?= =?UTF-8?q?me::ICON=5FSIZE`)=20is=20downgraded=20to=20plain=20code=20forma?= =?UTF-8?q?tting.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++++- src/app.rs | 14 ++++++++++++++ src/event_loop/app_data.rs | 2 ++ src/event_loop/handlers.rs | 37 ++++++++++++++++++++++++++++++++++++- src/event_loop/run.rs | 34 ++++++++++++++++++++++++++++++++++ src/event_loop/surface.rs | 12 ++++++++++-- src/widget/element.rs | 5 +++-- src/widget/list_item/mod.rs | 2 +- src/widget/text_edit/mod.rs | 13 +++++++++++++ 9 files changed, 119 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index aadea19..f91138f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ model is "public Wayland toolkit with production consumers" rather than - low idle wakeups and event-driven redraws - partial redraws and damage tracking - a simple declarative tree instead of retained widgets -- direct support for normal windows and layer-shell surfaces +- direct support for normal windows, layer-shell, and ext-session-lock surfaces - a runtime-free core (`ltk::core::UiSurface`) for embedding layout and drawing without `ltk::run()` @@ -230,6 +230,11 @@ Switch to layer-shell only when you are building shell surfaces such as: - greeters - lock screens +For a screen locker, use `ShellMode::SessionLock` instead of layer-shell: it +presents an `ext-session-lock-v1` surface that the compositor keeps on top of +everything until the app returns `true` from `requested_exit()`, which makes +the runtime call `unlock` and lift the lock. + ## Performance Notes `ltk` is designed to sleep when idle and redraw only on real work. diff --git a/src/app.rs b/src/app.rs index af58467..8679d1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,11 @@ pub enum ShellMode /// Layer-shell surface at the specified layer. /// Used for shell components like panels, backgrounds, overlays. Layer( Layer ), + + /// `ext-session-lock-v1` lock surface. The compositor blanks the outputs + /// and shows only this surface until the app requests exit (see + /// [`App::requested_exit`]). Used by the screen locker. + SessionLock, } /// Layer-shell layer position. @@ -641,11 +646,20 @@ pub trait App: 'static /// /// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.** /// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell). + /// - [`ShellMode::SessionLock`]: `ext-session-lock-v1` lock surface (screen locker). /// /// For regular applications, use the default `Window` mode. /// For shell components (panels, backgrounds, overlays), use `Layer`. + /// For a screen locker, use `SessionLock` and request the unlock by + /// returning `true` from [`requested_exit`](Self::requested_exit). fn shell_mode( &self ) -> ShellMode { ShellMode::Window } + /// Return `true` to tear the surface down and exit the event loop. For a + /// [`ShellMode::SessionLock`] surface the runtime calls `unlock` first, so + /// the compositor lifts the lock instead of leaving the outputs blanked. + /// Polled after every batch of `update`s. + fn requested_exit( &self ) -> bool { false } + /// Suggest an initial size for an xdg-shell window in logical pixels. /// /// Returning `Some(( w, h ))` makes ltk call both diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 61a7cf7..cfc327b 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -9,6 +9,7 @@ use smithay_client_toolkit:: seat::SeatState, shell::{ wlr_layer::LayerShell, xdg::XdgShell }, shm::Shm, + session_lock::SessionLock, }; use smithay_client_toolkit::reexports::client:: { @@ -45,6 +46,7 @@ pub struct AppData pub output_state: OutputState, pub compositor_state: CompositorState, pub shm: Shm, + pub session_lock: Option, /// 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. diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index f78203c..221228b 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -7,7 +7,7 @@ use smithay_client_toolkit:: delegate_compositor, 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_xdg_shell, delegate_xdg_window, delegate_session_lock, foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler }, output::{ OutputHandler, OutputState }, registry::{ ProvidesRegistryState, RegistryState }, @@ -22,6 +22,7 @@ use smithay_client_toolkit:: xdg::window::{ Window, WindowConfigure, WindowHandler }, }, shm::{ Shm, ShmHandler }, + session_lock::{ SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure }, }; use smithay_client_toolkit::reexports::protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1; use smithay_client_toolkit::reexports::client:: @@ -396,6 +397,39 @@ impl PopupHandler for AppData // --- Delegate macros --- +impl SessionLockHandler for AppData +{ + fn locked( &mut self, _conn: &Connection, qh: &QueueHandle, session_lock: SessionLock ) + { + if let Some( output ) = self.output_state.outputs().next() + { + let surface = self.compositor_state.create_surface( qh ); + let lock_surface = session_lock.create_lock_surface( surface, &output, qh ); + self.main.surface = super::SurfaceKind::Lock( lock_surface ); + } + self.session_lock = Some( session_lock ); + } + + fn finished( &mut self, _conn: &Connection, _qh: &QueueHandle, _session_lock: SessionLock ) + { + // Compositor refused the lock or ended it; nothing left to show. + self.exit_requested = true; + } + + fn configure( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: SessionLockSurface, + configure: SessionLockSurfaceConfigure, + _serial: u32, + ) + { + let ( w, h ) = configure.new_size; + self.on_configure( w.max( 1 ), h.max( 1 ) ); + } +} + delegate_compositor!( @ AppData ); delegate_output!( @ AppData ); delegate_shm!( @ AppData ); @@ -404,6 +438,7 @@ delegate_keyboard!( @ AppData ); delegate_pointer!( @ AppData ); delegate_touch!( @ AppData ); delegate_layer!( @ AppData ); +delegate_session_lock!( @ AppData ); delegate_xdg_shell!( @ AppData ); delegate_xdg_window!( @ AppData ); delegate_xdg_popup!( @ AppData ); diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index d95c4ca..107402f 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -143,6 +143,11 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> use crate::app::ShellMode; match app.shell_mode() { + ShellMode::SessionLock => { + // Lock surface is created in SessionLockHandler::locked once the + // compositor grants the lock; until then a placeholder. + ( SurfaceKind::PendingLock, None ) + } ShellMode::Window => { let xdg = bind_xdg( &globals, &qh )?; let surface = compositor.create_surface( &qh ); @@ -183,6 +188,20 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> } }; + // Bind the session-lock manager and request the lock. We don't keep the + // `SessionLockState` (the manager) around: it has no `Drop`, so the + // manager object persists in the connection, and the lock lifecycle runs + // entirely off the returned `SessionLock` + its surfaces. + let session_lock = + if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock ) + { + smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh ) + .lock( &qh ) + .ok() + } else { + None + }; + let text_input_manager: Option = globals.bind( &qh, 1..=1, () ).ok(); // wl_data_device_manager: optional. Required for cross-application // copy/paste; absence means the clipboard stays process-local. @@ -243,6 +262,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> output_state: OutputState::new( &globals, &qh ), compositor_state: compositor, shm, + session_lock, egl_context, xdg_shell, layer_shell: layer_shell_opt, @@ -459,6 +479,20 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> let pf = data.pointer_focus; data.dispatch_cursor_shape( pf ); } + + // App asked to exit (e.g. the lockscreen authenticated). For a + // session-lock surface, unlock first — exiting without unlocking + // leaves the compositor's lock in place by design. + if data.app.requested_exit() + { + if let Some( lock ) = data.session_lock.take() + { + lock.unlock(); + let _ = conn.roundtrip(); + } + data.exit_requested = true; + } + // Seed `on_drag_move` with the long-press origin for any drag that // just started. Must run *after* `update()` so the app's drag state // has already been set up by the paired long-press message — the diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index f0f70f2..b7f64c4 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -17,6 +17,7 @@ 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; @@ -70,6 +71,11 @@ pub( crate ) enum SurfaceKind /// 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 @@ -81,9 +87,10 @@ impl SurfaceKind 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( .. ) => unreachable!( "surface not yet created" ), + SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => unreachable!( "surface not yet created" ), } } @@ -98,7 +105,8 @@ impl SurfaceKind SurfaceKind::Layer( l ) => Some( l.wl_surface() ), SurfaceKind::Window( w ) => Some( w.wl_surface() ), SurfaceKind::Popup( p ) => Some( p.wl_surface() ), - SurfaceKind::Pending( .. ) => None, + SurfaceKind::Lock( s ) => Some( s.wl_surface() ), + SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => None, } } diff --git a/src/widget/element.rs b/src/widget/element.rs index 7c834f1..74f8f70 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -189,7 +189,8 @@ impl Element match self { Element::Button( b ) => b.focusable, - Element::TextEdit( _ ) | Element::Slider( _ ) | Element::VSlider( _ ) => true, + Element::TextEdit( t ) => !t.read_only, + Element::Slider( _ ) | Element::VSlider( _ ) => true, Element::Toggle( _ ) | Element::Checkbox( _ ) | Element::Radio( _ ) | Element::ListItem( _ ) => true, Element::WindowButton( b ) => b.focusable, _ => false, @@ -198,7 +199,7 @@ impl Element pub fn is_text_input( &self ) -> bool { - matches!( self, Element::TextEdit( _ ) ) + matches!( self, Element::TextEdit( t ) if !t.read_only ) } /// Cursor shape to display while the pointer is over this widget. diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index a81bb94..14adb6e 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -81,7 +81,7 @@ impl ListItem /// Attach a leading icon. Pass the decoded RGBA buffer alongside /// the image's native width and height; the draw path scales it - /// down to [`theme::ICON_SIZE`] on the same row baseline as the + /// down to `theme::ICON_SIZE` on the same row baseline as the /// label. Symbolic icons should be pre-tinted by the caller (see /// [`crate::tint_symbolic`]). pub fn icon( mut self, rgba: Arc>, w: u32, h: u32 ) -> Self diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index cafe1a8..2e1366a 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -153,6 +153,9 @@ pub struct TextEdit /// has `secure( true )` and the explicit flag becomes redundant /// — the toggle controls the visibility from then on. pub password_toggle: Option<( bool, Msg )>, + /// When `true`, the field renders its box and value but takes no keyboard + /// focus and accepts no input — a read-only display styled as a text field. + pub read_only: bool, } impl TextEdit @@ -181,6 +184,7 @@ impl TextEdit font_size: theme::FONT_SIZE, select_on_focus: false, password_toggle: None, + read_only: false, } } @@ -356,6 +360,14 @@ impl TextEdit self } + /// Render the field read-only: shows the value styled as a field but + /// takes no focus and accepts no input. + pub fn read_only( mut self, on: bool ) -> Self + { + self.read_only = on; + self + } + /// Assign a stable identifier for focus management. pub fn id( mut self, id: WidgetId ) -> Self { @@ -487,6 +499,7 @@ impl TextEdit font_size: self.font_size, select_on_focus: self.select_on_focus, password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), + read_only: self.read_only, } } }