Layer::Window overlays: an OverlaySpec can now be an ordinary xdg toplevel, named through App::overlay_title; showcase persists the last pressed button too
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

A shell built on ltk had no way to show something as a regular window: every overlay is a layer-shell surface, and layer-shell surfaces are either below all application windows or above all of them. crustace's "closing applications…" card ran into exactly that — on the overlay layer it covered the applications' own "save changes?" prompts, on the bottom layer it vanished behind their windows — and what that card wants is to be one window among the others: decorated by the compositor, stackable, listed in the switcher and the dock.
`Layer` gains a `Window` variant. An `OverlaySpec` carrying it is materialised by the reconciler as an `xdg_toplevel` (sctk `Window`, server-side decorations) instead of a layer surface: `size` is its fixed size, applied through min/max size (a zero component falls back to a default, since a toplevel cannot "fill"); anchor, exclusive zone and keyboard exclusivity are ignored. The window's app_id is `App::app_id()` and its title comes from the new, defaulted `App::overlay_title( id )`, so the compositor's window lists show something meaningful. `WindowHandler::configure` and `request_close` now resolve which surface a window belongs to: the main window keeps its behaviour, an overlay window is configured through its own `SurfaceState` (with the window geometry set to the configured size) and the compositor's close button delivers the spec's `on_dismiss` instead of exiting the app. Layer-shell applications now bind `xdg_wm_base` when the compositor offers it, so they can open such a window later. Adding a variant keeps every existing `OverlaySpec` literal compiling; the new trait method has a default.
The showcase example now also saves and restores `last_pressed`, bumping its private state format to version 2 with the note kept last so its line breaks survive.
This commit is contained in:
2026-08-15 17:56:06 +02:00
parent ccf07de593
commit 3046d86337
6 changed files with 71 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
- **`xdg-session-management-v1` (client)** — before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, issues `get_session( reason, stored_id )` and `restore_toplevel( toplevel, "main" )`, so a supporting compositor restores window geometry on every launch. Bindings are generated in-tree from the vendored XML under `protocols/` with `wayland-scanner` (`src/protocol/`). - **`xdg-session-management-v1` (client)** — before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, issues `get_session( reason, stored_id )` and `restore_toplevel( toplevel, "main" )`, so a supporting compositor restores window geometry on every launch. Bindings are generated in-tree from the vendored XML under `protocols/` with `wayland-scanner` (`src/protocol/`).
- **Runtime session persistence** — `$XDG_STATE_HOME/<app_id>/session.json` (compositor session id, clean-exit marker, pid) plus `state.bin` (the bytes from `App::save_state`), written atomically with mode `0600`; saved every 30 s when the bytes changed, on close and on signal; handed back through `App::restore_state` before the first frame only on a session restore (`LTK_SESSION_RESTORE=1`) or after an unclean exit. Module `src/session_state.rs`. - **Runtime session persistence** — `$XDG_STATE_HOME/<app_id>/session.json` (compositor session id, clean-exit marker, pid) plus `state.bin` (the bytes from `App::save_state`), written atomically with mode `0600`; saved every 30 s when the bytes changed, on close and on signal; handed back through `App::restore_state` before the first frame only on a session restore (`LTK_SESSION_RESTORE=1`) or after an unclean exit. Module `src/session_state.rs`.
- **`Layer::Window` overlays** — an `OverlaySpec` with `layer: Layer::Window` is materialised as an ordinary `xdg_toplevel` of the given fixed size instead of a layer-shell surface: decorated, stacked and listed by the compositor like an application window. Anchor, exclusive zone and keyboard exclusivity are ignored; the compositor's close button delivers `on_dismiss`. `App::overlay_title( id )` (defaulted to the app id) names it. Layer-shell apps now bind `xdg_wm_base` when available so they can open one.
- **Clean exit on `SIGTERM` / `SIGINT`** — the runtime installs a calloop signal source and leaves the event loop instead of dying, so `save_state` runs and `ltk::run` returns. - **Clean exit on `SIGTERM` / `SIGINT`** — the runtime installs a calloop signal source and leaves the event loop instead of dying, so `save_state` runs and `ltk::run` returns.
- **`Slider::on_release` / `VSlider::on_release`** — fired once with the final value when the drag ends, so an app can keep an expensive commit (a subprocess, a D-Bus round trip, a compositor reconfigure) off the per-motion `on_change` path and still move the thumb live. The gesture machine emits it from the slider branch of `on_release`; `on_change` alone behaves exactly as before. - **`Slider::on_release` / `VSlider::on_release`** — fired once with the final value when the drag ends, so an app can keep an expensive commit (a subprocess, a D-Bus round trip, a compositor reconfigure) off the per-motion `on_change` path and still move the thumb live. The gesture machine emits it from the slider branch of `on_release`; `on_change` alone behaves exactly as before.

View File

@@ -94,17 +94,19 @@ impl App for ShowcaseApp
fn app_id( &self ) -> &str { "net.liberux.ltk.example.showcase" } fn app_id( &self ) -> &str { "net.liberux.ltk.example.showcase" }
// One field per line, the note last so its own line breaks survive.
fn save_state( &self ) -> Option<Vec<u8>> fn save_state( &self ) -> Option<Vec<u8>>
{ {
Some( format!( "1\n{}\n{}\n{}", self.tab, self.slider_value, self.note ).into_bytes() ) Some( format!( "2\n{}\n{}\n{}\n{}", self.tab, self.last_pressed, self.slider_value, self.note ).into_bytes() )
} }
fn restore_state( &mut self, state: Vec<u8> ) fn restore_state( &mut self, state: Vec<u8> )
{ {
let Ok( text ) = String::from_utf8( state ) else { return }; let Ok( text ) = String::from_utf8( state ) else { return };
let mut lines = text.splitn( 4, '\n' ); let mut lines = text.splitn( 5, '\n' );
if lines.next() != Some( "1" ) { return; } if lines.next() != Some( "2" ) { return; }
if let Some( tab ) = lines.next().and_then( |l| l.parse().ok() ) { self.tab = tab; } if let Some( tab ) = lines.next().and_then( |l| l.parse().ok() ) { self.tab = tab; }
if let Some( pressed ) = lines.next() { self.last_pressed = pressed.to_string(); }
if let Some( v ) = lines.next().and_then( |l| l.parse().ok() ) { self.slider_value = v; } if let Some( v ) = lines.next().and_then( |l| l.parse().ok() ) { self.slider_value = v; }
if let Some( note ) = lines.next() { self.note = note.to_string(); } if let Some( note ) = lines.next() { self.note = note.to_string(); }
} }

View File

@@ -48,6 +48,13 @@ pub enum Layer
Background, Background,
/// Below normal windows but above background. /// Below normal windows but above background.
Bottom, Bottom,
/// Not a layer-shell surface at all: an ordinary `xdg_toplevel`
/// window stacked among the applications, decorated and listed by
/// the compositor like any other. [`OverlaySpec::size`] is its fixed
/// size (`0` falls back to a default, it cannot "fill"); anchor,
/// exclusive zone and keyboard exclusivity are ignored. The
/// compositor's close button delivers [`OverlaySpec::on_dismiss`].
Window,
/// Above normal windows (panels, docks). /// Above normal windows (panels, docks).
Top, Top,
/// Above everything (notifications, on-screen displays). /// Above everything (notifications, on-screen displays).
@@ -66,6 +73,8 @@ impl Layer
Layer::Bottom => WlrLayer::Bottom, Layer::Bottom => WlrLayer::Bottom,
Layer::Top => WlrLayer::Top, Layer::Top => WlrLayer::Top,
Layer::Overlay => WlrLayer::Overlay, Layer::Overlay => WlrLayer::Overlay,
// Never reaches layer-shell; the reconciler creates a window first.
Layer::Window => WlrLayer::Top,
} }
} }
} }
@@ -520,6 +529,10 @@ pub trait App: 'static
/// IDs that disappear cause the surface to be destroyed. /// IDs that disappear cause the surface to be destroyed.
fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() } fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() }
/// Title of a [`Layer::Window`] overlay, shown in its decoration and
/// in the compositor's window lists. Default: the [`app_id`](Self::app_id).
fn overlay_title( &self, _id: OverlayId ) -> Option<String> { None }
/// Describe the input-transparent child surfaces composited over the main /// Describe the input-transparent child surfaces composited over the main
/// surface this frame. Each becomes a `wl_subsurface` the compositor moves /// surface this frame. Each becomes a `wl_subsurface` the compositor moves
/// by position; see [`SubsurfaceSpec`]. Diffed across frames by /// by position; see [`SubsurfaceSpec`]. Diffed across frames by

View File

@@ -237,11 +237,19 @@ impl<A: App> WindowHandler for AppData<A>
{ {
fn request_close( fn request_close(
&mut self, &mut self,
_conn: &Connection, _conn: &Connection,
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
_window: &Window, window: &Window,
) )
{ {
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() )
{
if let Some( msg ) = self.overlay_dismiss_msg( id )
{
self.pending_msgs.push( msg );
}
return;
}
if self.app.on_close_requested() if self.app.on_close_requested()
{ {
self.exit_requested = true; self.exit_requested = true;
@@ -257,6 +265,18 @@ impl<A: App> WindowHandler for AppData<A>
_serial: u32, _serial: u32,
) )
{ {
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() )
{
let Some( ss ) = self.overlays.get_mut( &id ) else { return };
let sf = ss.scale_factor.max( 1 ) as f32;
let ( rw, rh ) = ss.last_requested_size;
let fallback = ( ( rw as f32 / sf ).round().max( 1.0 ) as u32, ( rh as f32 / sf ).round().max( 1.0 ) as u32 );
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( fallback.0 );
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( fallback.1 );
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
return;
}
// Mutter ignores set_fullscreen sent before the surface is // Mutter ignores set_fullscreen sent before the surface is
// mapped, so reapply on the first configure. // mapped, so reapply on the first configure.
if self.pending_fullscreen if self.pending_fullscreen

View File

@@ -13,6 +13,7 @@ use smithay_client_toolkit::
{ {
XdgPositioner, XdgSurface, XdgPositioner, XdgSurface,
popup::Popup, popup::Popup,
window::WindowDecorations,
}, },
}, },
}; };
@@ -140,6 +141,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
// `anchor_widget_id` → `Rect` without holding a borrow on `data.main` // `anchor_widget_id` → `Rect` without holding a borrow on `data.main`
// across the overlay-mut loop below. // across the overlay-mut loop below.
let main_widget_rects = &data.main.frame.widget_rects; let main_widget_rects = &data.main.frame.widget_rects;
let app = &data.app;
let overlays_m = &mut data.overlays; let overlays_m = &mut data.overlays;
for spec in &specs for spec in &specs
{ {
@@ -308,6 +310,30 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
overlays_m.insert( spec.id, ss ); overlays_m.insert( spec.id, ss );
continue; continue;
} }
// xdg-toplevel path: a plain window among the applications.
if spec.layer == crate::app::Layer::Window
{
let Some( xdg_shell ) = xdg_shell_opt else
{
eprintln!( "ltk: ignoring window overlay {:?} — xdg_wm_base not available", spec.id );
continue;
};
let ( w, h ) = to_logical_size( resolved_size );
let ( w, h ) = ( if w == 0 { 480 } else { w }, if h == 0 { 320 } else { h } );
let surface = cs.create_surface( qh );
let window = xdg_shell.create_window( surface, WindowDecorations::RequestServer, qh );
let app_id = app.app_id().to_string();
window.set_title( app.overlay_title( spec.id ).unwrap_or_else( || app_id.clone() ) );
window.set_app_id( app_id );
window.set_min_size( Some( ( w, h ) ) );
window.set_max_size( Some( ( w, h ) ) );
window.commit();
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Window( window ), 0.0, String::new() );
ss.scale_factor = parent_scale_i;
ss.last_requested_size = resolved_size;
overlays_m.insert( spec.id, ss );
continue;
}
// wlr-layer-shell path. // wlr-layer-shell path.
let Some( layer_shell ) = layer_shell_opt else let Some( layer_shell ) = layer_shell_opt else
{ {

View File

@@ -218,6 +218,9 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
} }
} }
}; };
// Layer-shell apps still get xdg_wm_base when the compositor has it, so
// a `Layer::Window` overlay can be created later.
let xdg_shell = xdg_shell.or_else( || XdgShell::bind( &globals, &qh ).ok() );
// Bind the session-lock manager and request the lock. We don't keep the // Bind the session-lock manager and request the lock. We don't keep the
// `SessionLockState` (the manager) around: it has no `Drop`, so the // `SessionLockState` (the manager) around: it has no `Drop`, so the