Cover every output from a background shell: the session-lock secondary machinery generalizes to layer surfaces
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

The runtime already covered outputs beyond the first, but only for `ShellMode::SessionLock`: `locked` created one `ext-session-lock` surface per output, tracked in `lock_extras`, rendered from `App::lock_secondary_view`, excluded from pointer/touch routing and repainted through the shared invalidation path. A background shell had nothing equivalent, so on a multi-monitor session its wallpaper simply did not exist on the extended screens — the compositor had to fake one by cover-scaling the primary's background, wrong scale and wrong crop included.
The machinery is shared rather than duplicated. The state and the hook lose their lock-specific names — `lock_extras` becomes `secondaries`, `main_lock_output` becomes `main_output`, `App::lock_secondary_view` becomes `App::secondary_view` — and everything downstream of creation (the draw loop in frame.rs, invalidation, per-surface scale handling, the input exclusion in pointer/touch, redraw scheduling in run.rs) is the same single copy serving both kinds.
What is genuinely new is the second creation site: in `new_output`, when the app's shell mode is `Layer( Background )`, every output beyond the main surface's gets its own background layer surface — anchored to all four edges with size delegated to the compositor, exclusive zone 0, no keyboard interactivity — bound to that output at creation and torn down in `output_destroyed`. The main surface's output is now recorded when it materializes so the runtime knows which output not to cover.
Layer-shell events learn about secondaries too, and the `configure` branch is not optional: secondaries live outside the focus map, so their configure used to fall into the `None` arm and resize the main surface. `closed` on a secondary now just drops it instead of asking the app to exit.
`secondary_view` keeps its default of `None` (surface filled with `background_color`), so nothing changes for apps that do not opt in; the session-lock behaviour is unchanged aside from the renames.
This commit is contained in:
2026-08-14 23:20:58 +02:00
parent 99a4767815
commit 78053b8b01
8 changed files with 94 additions and 42 deletions

View File

@@ -794,15 +794,16 @@ pub trait App: 'static
/// returning `true` from [`requested_exit`](Self::requested_exit).
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).
/// Content for the surfaces the runtime creates on every output beyond
/// the first. In [`ShellMode::SessionLock`] these are lock surfaces —
/// the compositor blanks any output without one, so each is covered. In
/// [`ShellMode::Layer`] on [`Layer::Background`] they are background
/// layer surfaces, so a shell's wallpaper reaches extended outputs
/// (typically without the grid or any chrome). They take no input.
/// `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>>
fn secondary_view( &self, _width: u32, _height: u32 ) -> Option<Element<Self::Message>>
{
None
}

View File

@@ -219,15 +219,17 @@ pub struct AppData<A: App>
/// 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>>,
/// 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>,
/// Surfaces covering every output beyond the first, paired with the
/// output each one belongs to: session-lock surfaces while locked (the
/// compositor blanks any output without one), background layer surfaces
/// for a [`crate::Layer::Background`] shell (the wallpaper reaches
/// extended outputs). These render [`App::secondary_view`] and take no
/// input (the input handlers find no focus for them, and keyboard falls
/// through to `Main`).
pub secondaries: Vec<( WlOutput, SurfaceState<A::Message> )>,
/// Output the main surface was created on, so a later `new_output` for
/// the same output does not add a duplicate secondary surface.
pub main_output: Option<WlOutput>,
/// Input-transparent child surfaces keyed by their stable
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
@@ -295,14 +297,14 @@ impl<A: App> AppData<A>
None
}
/// Whether `wl` is one of the secondary session-lock surfaces. Pointer and
/// Whether `wl` is one of the secondary per-output 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
/// the compositor puts the focus.
pub( crate ) fn is_secondary_surface( &self, wl: &WlSurface ) -> bool
{
self.lock_extras.iter().any( |( _, ss )| ss.surface.try_wl_surface() == Some( wl ) )
self.secondaries.iter().any( |( _, ss )| ss.surface.try_wl_surface() == Some( wl ) )
}
/// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus`

View File

@@ -71,11 +71,11 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
}
}
for ( _, ss ) in data.lock_extras.iter_mut()
for ( _, ss ) in data.secondaries.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 )
let view = data.app.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

View File

@@ -92,9 +92,9 @@ impl<A: App> CompositorHandler for AppData<A>
if new_factor <= 0 { return; }
let Some( focus ) = self.focus_for_surface( surface ) else
{
// Lock extras live outside the focus map.
// Secondary per-output surfaces live outside the focus map.
let shm = &self.shm;
if let Some( ( _, ss ) ) = self.lock_extras.iter_mut()
if let Some( ( _, ss ) ) = self.secondaries.iter_mut()
.find( |( _, ss )| ss.surface.try_wl_surface() == Some( surface ) )
{
apply_surface_scale( ss, shm, surface, new_factor );
@@ -165,6 +165,11 @@ impl<A: App> LayerShellHandler for AppData<A>
layer: &LayerSurface,
)
{
if self.is_secondary_surface( layer.wl_surface() )
{
self.secondaries.retain( |( _, ss )| ss.surface.try_wl_surface() != Some( layer.wl_surface() ) );
return;
}
match self.focus_for_surface( layer.wl_surface() )
{
Some( super::SurfaceFocus::Main ) | None =>
@@ -200,6 +205,17 @@ impl<A: App> LayerShellHandler for AppData<A>
{
let ( w, h ) = configure.new_size;
let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
// Secondaries live outside the focus map; without this branch their
// configure would fall into the `None` arm and resize the main
// surface instead.
for ( _, ss ) in self.secondaries.iter_mut()
{
if ss.surface.try_wl_surface() == Some( layer.wl_surface() )
{
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
return;
}
}
match self.focus_for_surface( layer.wl_surface() )
{
Some( super::SurfaceFocus::Main ) | None =>
@@ -290,23 +306,56 @@ impl<A: App> OutputHandler for AppData<A>
// Same for any overlays that were created before an output existed.
if let Some( ref layer_shell ) = self.layer_shell
{
self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
if self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output )
{
self.main_output = Some( output.clone() );
}
for ss in self.overlays.values_mut()
{
ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
}
// A background shell covers every output: each one beyond the
// main's gets its own background layer surface, painted with
// `App::secondary_view` — the wallpaper without any chrome.
if self.app.shell_mode() == crate::app::ShellMode::Layer( crate::app::Layer::Background )
&& self.main_output.is_some()
&& self.main_output.as_ref() != Some( &output )
&& !self.secondaries.iter().any( |( o, _ )| o == &output )
{
use smithay_client_toolkit::shell::wlr_layer;
let surface = self.compositor_state.create_surface( qh );
let layer_surface = layer_shell.create_layer_surface(
qh,
surface,
wlr_layer::Layer::Background,
Some( "ltk-secondary" ),
Some( &output ),
);
layer_surface.set_anchor(
wlr_layer::Anchor::TOP | wlr_layer::Anchor::BOTTOM
| wlr_layer::Anchor::LEFT | wlr_layer::Anchor::RIGHT,
);
layer_surface.set_exclusive_zone( 0 );
layer_surface.set_keyboard_interactivity( wlr_layer::KeyboardInteractivity::None );
layer_surface.set_size( 0, 0 );
layer_surface.commit();
self.secondaries.push( (
output.clone(),
super::SurfaceState::new( super::SurfaceKind::Layer( layer_surface ), 0.0, String::new() ),
) );
}
}
// 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 )
&& self.main_output.as_ref() != Some( &output )
&& !self.secondaries.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( (
self.secondaries.push( (
output,
super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ),
) );
@@ -318,10 +367,10 @@ impl<A: App> OutputHandler for AppData<A>
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.secondaries.retain( |( o, _ )| o != &output );
if self.main_output.as_ref() == Some( &output )
{
self.main_lock_output = None;
self.main_output = None;
}
}
}
@@ -484,7 +533,7 @@ impl<A: App> SessionLockHandler for AppData<A>
{
// 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`.
// the rest render `App::secondary_view`.
let outputs: Vec<WlOutput> = self.output_state.outputs().collect();
let mut outputs = outputs.into_iter();
if let Some( output ) = outputs.next()
@@ -492,13 +541,13 @@ impl<A: App> SessionLockHandler for AppData<A>
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.main_lock_output = Some( output );
self.main_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( (
self.secondaries.push( (
output,
super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ),
) );
@@ -529,7 +578,7 @@ impl<A: App> SessionLockHandler for AppData<A>
self.on_configure( w, h );
return;
}
for ( _, ss ) in self.lock_extras.iter_mut()
for ( _, ss ) in self.secondaries.iter_mut()
{
if ss.surface.try_wl_surface() == Some( wl )
{

View File

@@ -27,7 +27,7 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
{
ss.request_redraw();
}
for ( _, ss ) in data.lock_extras.iter_mut()
for ( _, ss ) in data.secondaries.iter_mut()
{
ss.request_redraw();
}
@@ -44,7 +44,7 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
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()
for ( _, ss ) in data.secondaries.iter_mut()
{
ss.request_redraw();
}

View File

@@ -320,8 +320,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
pending_size_hint_unpin,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
lock_extras: Vec::new(),
main_lock_output: None,
secondaries: Vec::new(),
main_output: None,
subsurfaces: std::collections::HashMap::new(),
subsurface_gles_canvas: None,
pointer_focus: SurfaceFocus::Main,
@@ -600,7 +600,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// 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 )
|| 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 );
|| data.secondaries.iter().any( |( _, ss )| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable
{
// Rebuild while motion is in progress and on the first frame

View File

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

View File

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