event_loop: cover every output with a session-lock surface, with the app's backdrop on the secondary ones
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

ext-session-lock-v1 obliges the compositor to blank any output that has no lock surface, and the runtime only ever created one, on the first output — so on a multi-monitor session the extended screens went black the moment the lock engaged. The lock grant now creates one lock surface per advertised output: the first keeps hosting the app's main surface exactly as before, and each remaining output gets its own surface tracked in `lock_extras`, paired with its `WlOutput` so configure, scale and teardown can be routed per surface.
What those extra surfaces show is up to the app: a new `App::lock_secondary_view( width, height )` returns the content for a secondary output of that physical size, and the default `None` paints the surface with `background_color()`. The draw pass renders them without requesting frame callbacks — their content is static between invalidations, and a callback that is never re-requested would leave `frame_pending` stuck — and invalidations that touch the main surface also mark the extras, so a backdrop that follows main-surface state (wallpaper swaps, theme changes) stays in sync.
The per-surface plumbing that only ever knew about the main surface learns to route: `SessionLockHandler::configure` matches the surface against main and the extras instead of assuming main (an extra's configure used to resize the main surface's target), and the scale-change resize moves into an `apply_surface_scale` helper shared by main, overlays and the lock extras, so a HiDPI secondary gets a correctly scaled buffer instead of staying at factor 1. `new_output` creates a lock surface for an output hotplugged while locked — the compositor would blank it otherwise — and `output_destroyed` drops the matching extra.
Input on the extras is deliberately inert, with one asymmetry: pointer and touch events landing on them are discarded rather than falling back to `Main`, because their coordinates would hit-test the main surface's widgets and a click on a secondary screen could press whatever sits at those coordinates on the form — the power button included. Keyboard keeps its existing fall-through to `Main`, so typing works wherever the compositor decides to put the lock focus.
This commit is contained in:
2026-08-14 13:01:15 +02:00
parent adbbc0248b
commit 99a4767815
8 changed files with 188 additions and 34 deletions

View File

@@ -794,6 +794,19 @@ pub trait App: 'static
/// returning `true` from [`requested_exit`](Self::requested_exit). /// returning `true` from [`requested_exit`](Self::requested_exit).
fn shell_mode( &self ) -> ShellMode { ShellMode::Window } fn shell_mode( &self ) -> ShellMode { ShellMode::Window }
/// Content for the lock surfaces the runtime creates on every output
/// beyond the first while in [`ShellMode::SessionLock`]. The compositor
/// blanks any output without a lock surface, so the runtime covers each
/// one; this view is what gets painted there (typically the same
/// wallpaper as the main surface, without the interactive form).
/// `width` / `height` are that output's surface size in physical pixels.
/// Default `None`: the surface is filled with
/// [`background_color`](Self::background_color).
fn lock_secondary_view( &self, _width: u32, _height: u32 ) -> Option<Element<Self::Message>>
{
None
}
/// Return `true` to tear the surface down and exit the event loop. For a /// Return `true` to tear the surface down and exit the event loop. For a
/// [`ShellMode::SessionLock`] surface the runtime calls `unlock` first, so /// [`ShellMode::SessionLock`] surface the runtime calls `unlock` first, so
/// the compositor lifts the lock instead of leaving the outputs blanked. /// the compositor lifts the lock instead of leaving the outputs blanked.

View File

@@ -17,6 +17,7 @@ use smithay_client_toolkit::reexports::client::
protocol:: protocol::
{ {
wl_keyboard::WlKeyboard, wl_keyboard::WlKeyboard,
wl_output::WlOutput,
wl_pointer::WlPointer, wl_pointer::WlPointer,
wl_surface::WlSurface, wl_surface::WlSurface,
wl_touch::WlTouch, wl_touch::WlTouch,
@@ -218,6 +219,15 @@ pub struct AppData<A: App>
/// Populated and reconciled by the run loop from [`App::overlays`]. Always /// Populated and reconciled by the run loop from [`App::overlays`]. Always
/// empty until overlay diffing is wired up. /// empty until overlay diffing is wired up.
pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>, pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>,
/// Session-lock surfaces covering every output beyond the first, paired
/// with the output each one belongs to. The compositor blanks any output
/// without a lock surface, so one is created per output; these render
/// [`App::lock_secondary_view`] and take no input (the input handlers
/// find no focus for them, and keyboard falls through to `Main`).
pub lock_extras: Vec<( WlOutput, SurfaceState<A::Message> )>,
/// Output the main lock surface was created on, so a later `new_output`
/// for the same output does not add a duplicate lock surface.
pub main_lock_output: Option<WlOutput>,
/// Input-transparent child surfaces keyed by their stable /// Input-transparent child surfaces keyed by their stable
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`]. /// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>, pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
@@ -285,6 +295,16 @@ impl<A: App> AppData<A>
None None
} }
/// Whether `wl` is one of the secondary session-lock surfaces. Pointer and
/// touch events landing on them must be dropped rather than falling back
/// to `Main` — their coordinates would hit-test the main surface's
/// widgets. Keyboard keeps the `Main` fallback so typing works wherever
/// the compositor puts the lock focus.
pub( crate ) fn is_lock_extra_surface( &self, wl: &WlSurface ) -> bool
{
self.lock_extras.iter().any( |( _, ss )| ss.surface.try_wl_surface() == Some( wl ) )
}
/// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus` /// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus`
/// refers to an overlay that is not currently registered — callers must /// refers to an overlay that is not currently registered — callers must
/// only pass focus values obtained from [`focus_for_surface`] or from the /// only pass focus values obtained from [`focus_for_surface`] or from the

View File

@@ -71,6 +71,33 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
} }
} }
for ( _, ss ) in data.lock_extras.iter_mut()
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let scale = ss.scale_factor.max( 1 ) as u32;
let view = data.app.lock_secondary_view( ss.width * scale, ss.height * scale )
.unwrap_or_else( || crate::spacer().into() );
// No frame callback: these surfaces are static between invalidations,
// so there is no per-frame pacing to keep — and a callback that never
// gets requested again would leave `frame_pending` stuck.
let req_frame = | _: &WlSurface | {};
draw_surface::<A::Message>(
ss,
&data.compositor_state,
egl_ctx,
&view,
main_bg,
None,
debug_layout,
format,
swap_rb,
&req_frame,
);
ss.needs_redraw = false;
ss.frame_pending = false;
ss.last_draw = std::time::Instant::now();
}
for spec in overlays for spec in overlays
{ {
if let Some( ss ) = data.overlays.get_mut( &spec.id ) if let Some( ss ) = data.overlays.get_mut( &spec.id )

View File

@@ -45,12 +45,62 @@ use wayland_protocols::wp::text_input::zv3::client::
use crate::app::{ App, ToplevelEvent }; use crate::app::{ App, ToplevelEvent };
use super::app_data::AppData; use super::app_data::AppData;
/// Adopt a new buffer scale on `ss`: resize whichever rendering target is
/// active and request a redraw. Returns the new physical size, or `None`
/// when the factor is unchanged.
fn apply_surface_scale<Msg: Clone>(
ss: &mut super::SurfaceState<Msg>,
shm: &Shm,
surface: &WlSurface,
new_factor: i32,
) -> Option<( u32, u32 )>
{
if new_factor == ss.scale_factor { return None; }
ss.scale_factor = new_factor;
surface.set_buffer_scale( new_factor );
if let Some( ref mut canvas ) = ss.canvas
{
canvas.set_dpi_scale( new_factor as f32 );
}
let pw = ss.width * new_factor as u32;
let ph = ss.height * new_factor as u32;
// The two targets are mutually exclusive so only one branch runs.
if let Some( ref es ) = ss.egl_surface
{
es.resize( pw as i32, ph as i32 );
// Canvas FBO reallocation is deferred to the draw path: it needs
// `eglMakeCurrent` first.
} else {
ss.pool = Some(
smithay_client_toolkit::shm::slot::SlotPool::new(
( pw * ph * 4 ) as usize, shm,
).expect( "pool" ),
);
if let Some( ref mut canvas ) = ss.canvas
{
canvas.resize( pw, ph );
}
}
ss.request_redraw();
Some( ( pw, ph ) )
}
impl<A: App> CompositorHandler for AppData<A> impl<A: App> CompositorHandler for AppData<A>
{ {
fn scale_factor_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, surface: &WlSurface, new_factor: i32 ) fn scale_factor_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, surface: &WlSurface, new_factor: i32 )
{ {
if new_factor <= 0 { return; } if new_factor <= 0 { return; }
let Some( focus ) = self.focus_for_surface( surface ) else { return }; let Some( focus ) = self.focus_for_surface( surface ) else
{
// Lock extras live outside the focus map.
let shm = &self.shm;
if let Some( ( _, ss ) ) = self.lock_extras.iter_mut()
.find( |( _, ss )| ss.surface.try_wl_surface() == Some( surface ) )
{
apply_surface_scale( ss, shm, surface, new_factor );
}
return;
};
let ( pw, ph ) = { let ( pw, ph ) = {
let shm = &self.shm; let shm = &self.shm;
let ss = match focus let ss = match focus
@@ -62,35 +112,11 @@ impl<A: App> CompositorHandler for AppData<A>
None => return, None => return,
}, },
}; };
if new_factor == ss.scale_factor { return; } match apply_surface_scale( ss, shm, surface, new_factor )
ss.scale_factor = new_factor;
surface.set_buffer_scale( new_factor );
if let Some( ref mut canvas ) = ss.canvas
{ {
canvas.set_dpi_scale( new_factor as f32 ); Some( dims ) => dims,
None => return,
} }
let pw = ss.width * new_factor as u32;
let ph = ss.height * new_factor as u32;
// Resize whichever rendering target is active. The two are mutually
// exclusive so only one branch runs.
if let Some( ref es ) = ss.egl_surface
{
es.resize( pw as i32, ph as i32 );
// Canvas FBO reallocation is deferred to the draw path: it needs
// `eglMakeCurrent` first.
} else {
ss.pool = Some(
smithay_client_toolkit::shm::slot::SlotPool::new(
( pw * ph * 4 ) as usize, shm,
).expect( "pool" ),
);
if let Some( ref mut canvas ) = ss.canvas
{
canvas.resize( pw, ph );
}
}
ss.request_redraw();
( pw, ph )
}; };
// Anchors and baked overlay geometry are in the old scale's // Anchors and baked overlay geometry are in the old scale's
// physical pixels — drop the tooltip and rebuild the specs. // physical pixels — drop the tooltip and rebuild the specs.
@@ -270,10 +296,34 @@ impl<A: App> OutputHandler for AppData<A>
ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output ); ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
} }
} }
// An output hotplugged while the session is locked would be blanked by
// the compositor until it gets a lock surface of its own.
if let Some( ref lock ) = self.session_lock
{
if matches!( self.main.surface, super::SurfaceKind::Lock( .. ) )
&& self.main_lock_output.as_ref() != Some( &output )
&& !self.lock_extras.iter().any( |( o, _ )| o == &output )
{
let surface = self.compositor_state.create_surface( qh );
let lock_surface = lock.create_lock_surface( surface, &output, qh );
self.lock_extras.push( (
output,
super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ),
) );
}
}
} }
fn update_output( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {} fn update_output( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, output: WlOutput )
{
self.lock_extras.retain( |( o, _ )| o != &output );
if self.main_lock_output.as_ref() == Some( &output )
{
self.main_lock_output = None;
}
}
} }
impl<A: App> SeatHandler for AppData<A> impl<A: App> SeatHandler for AppData<A>
@@ -432,11 +482,26 @@ impl<A: App> SessionLockHandler for AppData<A>
{ {
fn locked( &mut self, _conn: &Connection, qh: &QueueHandle<Self>, session_lock: SessionLock ) fn locked( &mut self, _conn: &Connection, qh: &QueueHandle<Self>, session_lock: SessionLock )
{ {
if let Some( output ) = self.output_state.outputs().next() // Every output needs its own lock surface — the compositor blanks any
// output left without one. The first hosts the app's main surface;
// the rest render `App::lock_secondary_view`.
let outputs: Vec<WlOutput> = self.output_state.outputs().collect();
let mut outputs = outputs.into_iter();
if let Some( output ) = outputs.next()
{ {
let surface = self.compositor_state.create_surface( qh ); let surface = self.compositor_state.create_surface( qh );
let lock_surface = session_lock.create_lock_surface( surface, &output, qh ); let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
self.main.surface = super::SurfaceKind::Lock( lock_surface ); self.main.surface = super::SurfaceKind::Lock( lock_surface );
self.main_lock_output = Some( output );
}
for output in outputs
{
let surface = self.compositor_state.create_surface( qh );
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
self.lock_extras.push( (
output,
super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ),
) );
} }
self.session_lock = Some( session_lock ); self.session_lock = Some( session_lock );
} }
@@ -451,13 +516,27 @@ impl<A: App> SessionLockHandler for AppData<A>
&mut self, &mut self,
_conn: &Connection, _conn: &Connection,
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
_surface: SessionLockSurface, surface: SessionLockSurface,
configure: SessionLockSurfaceConfigure, configure: SessionLockSurfaceConfigure,
_serial: u32, _serial: u32,
) )
{ {
let ( w, h ) = configure.new_size; let ( w, h ) = configure.new_size;
self.on_configure( w.max( 1 ), h.max( 1 ) ); let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
let wl = surface.wl_surface();
if self.main.surface.try_wl_surface() == Some( wl )
{
self.on_configure( w, h );
return;
}
for ( _, ss ) in self.lock_extras.iter_mut()
{
if ss.surface.try_wl_surface() == Some( wl )
{
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
return;
}
}
} }
} }

View File

@@ -27,6 +27,10 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
{ {
ss.request_redraw(); ss.request_redraw();
} }
for ( _, ss ) in data.lock_extras.iter_mut()
{
ss.request_redraw();
}
} }
InvalidationScope::Only( targets ) => InvalidationScope::Only( targets ) =>
{ {
@@ -38,6 +42,12 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
{ {
data.view_dirty = true; data.view_dirty = true;
data.main.request_redraw(); data.main.request_redraw();
// Lock extras mirror the main surface's backdrop, so
// they follow its invalidations.
for ( _, ss ) in data.lock_extras.iter_mut()
{
ss.request_redraw();
}
} }
SurfaceTarget::Overlay( id ) => SurfaceTarget::Overlay( id ) =>
{ {

View File

@@ -320,6 +320,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
pending_size_hint_unpin, pending_size_hint_unpin,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ), main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(), overlays: std::collections::HashMap::new(),
lock_extras: Vec::new(),
main_lock_output: None,
subsurfaces: std::collections::HashMap::new(), subsurfaces: std::collections::HashMap::new(),
subsurface_gles_canvas: None, subsurface_gles_canvas: None,
pointer_focus: SurfaceFocus::Main, pointer_focus: SurfaceFocus::Main,
@@ -597,7 +599,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// on a frame callback. The compositor decides our cadence — when no // on a frame callback. The compositor decides our cadence — when no
// surface qualifies we just loop back to `dispatch(None)` and sleep. // surface qualifies we just loop back to `dispatch(None)` and sleep.
let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending ) let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending ); || data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending )
|| data.lock_extras.iter().any( |( _, ss )| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable if any_drawable
{ {
// Rebuild while motion is in progress and on the first frame // Rebuild while motion is in progress and on the first frame

View File

@@ -51,6 +51,7 @@ impl<A: App> PointerHandler for AppData<A>
{ {
for event in events for event in events
{ {
if self.is_lock_extra_surface( &event.surface ) { continue; }
let focus = self.focus_for_surface( &event.surface ) let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main ); .unwrap_or( SurfaceFocus::Main );
self.pointer_focus = focus; self.pointer_focus = focus;

View File

@@ -60,6 +60,7 @@ impl<A: App> TouchHandler for AppData<A>
) )
{ {
self.last_input_serial = serial; self.last_input_serial = serial;
if self.is_lock_extra_surface( &surface ) { return; }
let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main ); let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main );
self.touch_focus.insert( id, focus ); self.touch_focus.insert( id, focus );
let pos = self.surface( focus ).to_physical( position.0, position.1 ); let pos = self.surface( focus ).to_physical( position.0, position.1 );