Skip to main content

max / shop

Open shop on a wlr-layer-shell surface with --layer
Author: Max Johnson <me@maxj.phd> · 2026-08-28 20:04 UTC
Signed with PGP, not checked
Commit: 22354687752495dd4e232b55755f5cf8e6cb201b
Parent: ace19a7
3 files changed, +257 insertions, -40 deletions
M Cargo.lock +17 -17
@@ -1179,7 +1179,7 @@
1179 1179
1180 1180 [[package]]
1181 1181 name = "quasi-type"
1182 - version = "0.1.1"
1182 + version = "0.1.2"
1183 1183 dependencies = [
1184 1184 "brotli",
1185 1185 "font-types",
@@ -2382,14 +2382,6 @@
2382 2382 "simd-adler32",
2383 2383 ]
2384 2384
2385 - [[patch.unused]]
2386 - name = "synckit-client"
2387 - version = "0.9.1"
2388 -
2389 - [[patch.unused]]
2390 - name = "synckit-config"
2391 - version = "0.2.0"
2392 -
2393 2385 [[patch.unused]]
2394 2386 name = "docengine"
2395 2387 version = "0.7.0"
@@ -2410,29 +2402,37 @@
2410 2402 name = "tagtree"
2411 2403 version = "0.4.1"
2412 2404
2405 + [[patch.unused]]
2406 + name = "synckit-client"
2407 + version = "0.10.0"
2408 +
2409 + [[patch.unused]]
2410 + name = "synckit-config"
2411 + version = "0.2.0"
2412 +
2413 2413 [[patch.unused]]
2414 2414 name = "quasi-axum"
2415 - version = "0.71.0"
2415 + version = "0.75.0"
2416 2416
2417 2417 [[patch.unused]]
2418 2418 name = "quasi-basics"
2419 - version = "0.71.0"
2419 + version = "0.75.0"
2420 2420
2421 2421 [[patch.unused]]
2422 2422 name = "quasi-http"
2423 - version = "0.71.0"
2423 + version = "0.75.0"
2424 2424
2425 2425 [[patch.unused]]
2426 2426 name = "quasi-immediate"
2427 - version = "0.71.0"
2427 + version = "0.75.0"
2428 2428
2429 2429 [[patch.unused]]
2430 2430 name = "quasi-notifs"
2431 - version = "0.71.0"
2431 + version = "0.75.0"
2432 2432
2433 2433 [[patch.unused]]
2434 2434 name = "quasi-router"
2435 - version = "0.71.0"
2435 + version = "0.75.0"
2436 2436
2437 2437 [[patch.unused]]
2438 2438 name = "quasi-store"
@@ -2440,8 +2440,8 @@
2440 2440
2441 2441 [[patch.unused]]
2442 2442 name = "quasi-tauri"
2443 - version = "0.71.0"
2443 + version = "0.75.0"
2444 2444
2445 2445 [[patch.unused]]
2446 2446 name = "quasi-webview"
2447 - version = "0.71.0"
2447 + version = "0.75.0"
@@ -1,7 +1,7 @@
1 1 //! Wayland primitives for shop.
2 2 //!
3 - //! Owns the SCTK client-side objects (compositor, xdg-shell, output, seat,
4 - //! registry, top-level window). Does NOT own the wayland event queue or
3 + //! Owns the SCTK client-side objects (compositor, xdg-shell or
4 + //! wlr-layer-shell, output, seat, registry, the surface). Does NOT own the wayland event queue or
5 5 //! implement any SCTK Handler traits — those live in the binary so a single
6 6 //! app state can be dispatched from a shared event loop (calloop), which is
7 7 //! also where the PTY fd and any other sources land.
@@ -14,12 +14,17 @@
14 14 pub use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
15 15 use raw_window_handle::{WaylandDisplayHandle, WaylandWindowHandle};
16 16 pub use smithay_client_toolkit::{
17 + compositor::Region,
17 18 compositor::{CompositorHandler, CompositorState},
18 19 output::{OutputHandler, OutputState},
19 20 registry::{ProvidesRegistryState, RegistryState},
20 21 seat::{Capability, SeatHandler, SeatState},
21 22 shell::{
22 23 WaylandSurface,
24 + wlr_layer::{
25 + Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface,
26 + LayerSurfaceConfigure,
27 + },
23 28 xdg::{
24 29 XdgShell,
25 30 window::{Window as XdgWindow, WindowConfigure, WindowDecorations, WindowHandler},
@@ -41,12 +46,85 @@
41 46 CloseRequested,
42 47 }
43 48
44 - /// Spec for creating the top-level window.
49 + /// Where a layer surface sits and how much of the output it claims.
50 + ///
51 + /// Only meaningful for [`ShopSurface::Layer`]. Anchoring all four edges with a
52 + /// size of `(0, 0)` is how a surface asks for the whole output, which is what
53 + /// the background case wants.
54 + #[derive(Debug, Clone, Copy)]
55 + pub struct LayerConfig {
56 + pub layer: Layer,
57 + pub anchor: Anchor,
58 + pub exclusive_zone: i32,
59 + pub keyboard_interactivity: KeyboardInteractivity,
60 + /// Refuse every pointer event by committing an empty input region. The
61 + /// background surface is output only, and a surface that swallows clicks
62 + /// over the whole screen is worse than no surface at all.
63 + pub no_input: bool,
64 + }
65 +
66 + impl LayerConfig {
67 + /// Output-sized, output-only: every edge anchored, no exclusive zone, no
68 + /// keyboard focus, no pointer events.
69 + #[must_use]
70 + pub fn background(layer: Layer) -> Self {
71 + Self {
72 + layer,
73 + anchor: Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT,
74 + exclusive_zone: 0,
75 + keyboard_interactivity: KeyboardInteractivity::None,
76 + no_input: true,
77 + }
78 + }
79 + }
80 +
81 + /// Spec for creating the window, whichever shell it lands on.
45 82 pub struct WindowSpec<'a> {
46 83 pub title: &'a str,
47 84 pub app_id: &'a str,
48 85 pub min_size: (u32, u32),
49 86 pub initial_size: (u32, u32),
87 + /// `None` opens an ordinary xdg toplevel. `Some` opens a wlr-layer-shell
88 + /// surface instead.
89 + pub layer: Option<LayerConfig>,
90 + }
91 +
92 + /// The surface shop draws to: an xdg toplevel, or a wlr-layer-shell surface.
93 + ///
94 + /// An enum rather than a `WaylandSurface` bound because [`WaylandChrome`] is a
95 + /// concrete struct composed into the binary's app state, and a type parameter
96 + /// there would spread through every SCTK handler impl for no gain: there are
97 + /// two shells and there will not be a third.
98 + pub enum ShopSurface {
99 + Toplevel(XdgWindow),
100 + Layer(LayerSurface),
101 + }
102 +
103 + impl ShopSurface {
104 + #[must_use]
105 + pub fn wl_surface(&self) -> &wayland_client::protocol::wl_surface::WlSurface {
106 + match self {
107 + Self::Toplevel(window) => window.wl_surface(),
108 + Self::Layer(layer) => layer.wl_surface(),
109 + }
110 + }
111 +
112 + /// A title the compositor shows. Layer surfaces have nowhere to put one,
113 + /// so an escape sequence that sets the title is dropped rather than an
114 + /// error: a program run on the background should not fail for asking.
115 + pub fn set_title(&self, title: impl Into<String>) {
116 + match self {
117 + Self::Toplevel(window) => window.set_title(title.into()),
118 + Self::Layer(_) => {}
119 + }
120 + }
121 +
122 + pub fn commit(&self) {
123 + match self {
124 + Self::Toplevel(window) => window.commit(),
125 + Self::Layer(layer) => layer.commit(),
126 + }
127 + }
50 128 }
51 129
52 130 /// Chrome (SCTK client-side state) for one top-level window. Composed into
@@ -57,8 +135,12 @@
57 135 pub seat: SeatState,
58 136 pub output: OutputState,
59 137 pub compositor: CompositorState,
60 - pub xdg_shell: XdgShell,
61 - pub xdg_window: XdgWindow,
138 + /// Held for as long as the surface it made. Both are `Option` because a
139 + /// run binds one shell, not both, and the unbound global must not be a
140 + /// hard requirement: wlr-layer-shell is missing on plenty of compositors.
141 + pub xdg_shell: Option<XdgShell>,
142 + pub layer_shell: Option<LayerShell>,
143 + pub surface: ShopSurface,
62 144 pub pending: VecDeque<Pending>,
63 145 pub width: u32,
64 146 pub height: u32,
@@ -69,7 +151,7 @@
69 151 // all the Dispatch impls that `delegate_dispatch2!(App)` provides, and those
70 152 // impls are only visible in the crate that invokes the macro.
71 153
72 - /// Raw handles for the compositor connection and the top-level surface.
154 + /// Raw handles for the compositor connection and the drawing surface.
73 155 /// Both reference wayland pointers owned by `conn`/`chrome`; do not use
74 156 /// after either is dropped.
75 157 pub fn raw_handles(
@@ -79,7 +161,7 @@
79 161 let display_ptr =
80 162 NonNull::new(conn.backend().display_ptr().cast()).expect("wayland display ptr");
81 163 let surface_ptr =
82 - NonNull::new(chrome.xdg_window.wl_surface().id().as_ptr().cast()).expect("wl_surface ptr");
164 + NonNull::new(chrome.surface.wl_surface().id().as_ptr().cast()).expect("wl_surface ptr");
83 165 let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr));
84 166 let window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr));
85 167 (display, window)
@@ -33,8 +33,9 @@
33 33 use shop_pty::{Pty, PtySize};
34 34 use shop_render::{BgFill, CellMetrics, CellText, ImagePlacement, ImageRenderer, TextRenderer};
35 35 use shop_wayland::{
36 - Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState,
37 - Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState,
36 + Capability, CompositorHandler, CompositorState, Connection, Layer, LayerConfig, LayerShell,
37 + LayerShellHandler, LayerSurface, LayerSurfaceConfigure, OutputHandler, OutputState, Pending,
38 + ProvidesRegistryState, QueueHandle, Region, RegistryState, SeatHandler, SeatState, ShopSurface,
38 39 WaylandChrome, WaylandSurface, WindowConfigure, WindowDecorations, WindowHandler, WindowSpec,
39 40 XdgShell, XdgWindow, registry_queue_init,
40 41 };
@@ -160,6 +161,28 @@
160 161 /// matches it, and a terminal that cannot say who it is makes that rule
161 162 /// unwritable: every shop window would be the same window to sway.
162 163 app_id: Option<String>,
164 + /// `--layer background|bottom|top|overlay` opens on wlr-layer-shell instead
165 + /// of as an ordinary window: sized to the output, anchored to all four
166 + /// edges, and taking no input at all.
167 + ///
168 + /// What Alloy's desktop background is. The surface never takes keyboard or
169 + /// pointer focus, so managing the instance is somebody else's job; a
170 + /// background that swallowed clicks across the whole output would be worse
171 + /// than no background.
172 + layer: Option<Layer>,
173 + }
174 +
175 + /// `--layer` values, spelled as the protocol names them.
176 + fn parse_layer(value: &str) -> Result<Layer, CliExit> {
177 + match value {
178 + "background" => Ok(Layer::Background),
179 + "bottom" => Ok(Layer::Bottom),
180 + "top" => Ok(Layer::Top),
181 + "overlay" => Ok(Layer::Overlay),
182 + other => Err(CliExit::Reject(format!(
183 + "unknown layer {other}\nExpected background, bottom, top or overlay."
184 + ))),
185 + }
163 186 }
164 187
165 188 /// Every way argv can end the process before a window exists.
@@ -182,6 +205,10 @@
182 205 --exec CMD Run CMD through sh -c instead of the login shell.
183 206 --theme ID Use theme ID for this run, ignoring the config file.
184 207 --app-id ID Report ID as the window's app id, for window rules.
208 + --layer LAYER Open on the wlr-layer-shell layer LAYER, one of
209 + background, bottom, top or overlay, sized to the
210 + output and taking no input. Needs a compositor with
211 + wlr-layer-shell.
185 212 --record PATH Tee the terminal output to PATH as raw bytes.
186 213 -h, --help Print this help.
187 214 -V, --version Print the version.
@@ -255,6 +282,7 @@
255 282 "--theme" => cli.theme = Some(value("--theme")?),
256 283 "--record" => cli.record_path = Some(value("--record")?),
257 284 "--app-id" => cli.app_id = Some(value("--app-id")?),
285 + "--layer" => cli.layer = Some(parse_layer(&value("--layer")?)?),
258 286 other => {
259 287 return Err(CliExit::Reject(format!(
260 288 "unknown option {other}\nTry 'shop --help'."
@@ -312,6 +340,7 @@
312 340 record_path,
313 341 theme: theme_arg,
314 342 app_id,
343 + layer,
315 344 } = cli;
316 345 let config = Config::load().with_theme(theme_arg);
317 346 let scrollback_lines = config.scrollback_lines;
@@ -350,24 +379,70 @@
350 379 app_id: app_id.as_deref().unwrap_or("dev.makecreative.shop"),
351 380 min_size: (320, 240),
352 381 initial_size: INITIAL,
382 + layer: layer.map(LayerConfig::background),
353 383 };
354 384 let (globals, event_queue) = registry_queue_init::<App>(&conn)?;
355 385 let qh = event_queue.handle();
356 386 let compositor = CompositorState::bind(&globals, &qh)?;
357 - let xdg_shell = XdgShell::bind(&globals, &qh)?;
358 387 let wl_surface = compositor.create_surface(&qh);
359 - let xdg_window = xdg_shell.create_window(wl_surface, WindowDecorations::ServerDefault, &qh);
360 - xdg_window.set_title(spec.title.to_string());
361 - xdg_window.set_app_id(spec.app_id.to_string());
362 - xdg_window.set_min_size(Some(spec.min_size));
363 - xdg_window.commit();
388 + let (xdg_shell, layer_shell, surface) = match spec.layer {
389 + None => {
390 + let xdg_shell = XdgShell::bind(&globals, &qh)?;
391 + let window = xdg_shell.create_window(wl_surface, WindowDecorations::ServerDefault, &qh);
392 + window.set_title(spec.title.to_string());
393 + window.set_app_id(spec.app_id.to_string());
394 + window.set_min_size(Some(spec.min_size));
395 + window.commit();
396 + (Some(xdg_shell), None, ShopSurface::Toplevel(window))
397 + }
398 + Some(config) => {
399 + // Named rather than passed through, because falling back to a
400 + // toplevel would drop a background in the middle of the screen and
401 + // look like shop, not the compositor, is broken. What to do
402 + // instead is the caller's call.
403 + let layer_shell = LayerShell::bind(&globals, &qh).map_err(|e| {
404 + anyhow::anyhow!(
405 + "--layer needs a compositor with wlr-layer-shell (zwlr_layer_shell_v1): {e}"
406 + )
407 + })?;
408 + let layer = layer_shell.create_layer_surface(
409 + &qh,
410 + wl_surface,
411 + config.layer,
412 + Some(spec.app_id),
413 + None,
414 + );
415 + // Zero size plus every anchor is how the protocol spells "the
416 + // whole output"; the compositor answers with the real size in the
417 + // first configure.
418 + layer.set_size(0, 0);
419 + layer.set_anchor(config.anchor);
420 + layer.set_exclusive_zone(config.exclusive_zone);
421 + layer.set_keyboard_interactivity(config.keyboard_interactivity);
422 + // An empty input region, committed with the surface, so no pointer
423 + // event ever reaches a surface that covers the whole output. The
424 + // region is a one-shot: the compositor copies it at commit, so
425 + // dropping it after is right.
426 + let empty = if config.no_input {
427 + let region = Region::new(&compositor)?;
428 + layer.set_input_region(Some(region.wl_region()));
429 + Some(region)
430 + } else {
431 + None
432 + };
433 + layer.commit();
434 + drop(empty);
435 + (None, Some(layer_shell), ShopSurface::Layer(layer))
436 + }
437 + };
364 438 let chrome = WaylandChrome {
365 439 registry: RegistryState::new(&globals),
366 440 seat: SeatState::new(&globals, &qh),
367 441 output: OutputState::new(&globals, &qh),
368 442 compositor,
369 443 xdg_shell,
370 - xdg_window,
444 + layer_shell,
445 + surface,
371 446 pending: VecDeque::new(),
372 447 width: spec.initial_size.0,
373 448 height: spec.initial_size.1,
@@ -566,7 +641,7 @@
566 641 }
567 642 }
568 643 if let Some(title) = app.grid.take_pending_title() {
569 - app.chrome.xdg_window.set_title(title);
644 + app.chrome.surface.set_title(title);
570 645 }
571 646 // Answers the program is waiting on, from queries in
572 647 // the bytes just parsed. Written before the frame,
@@ -621,10 +696,7 @@
621 696 app.text.resize(&app.queue, physical_w, physical_h);
622 697 app.images.resize(&app.queue, physical_w, physical_h);
623 698 // Tell the compositor our buffer is N logical px per buffer px.
624 - app.chrome
625 - .xdg_window
626 - .wl_surface()
627 - .set_buffer_scale(s as i32);
699 + app.chrome.surface.wl_surface().set_buffer_scale(s as i32);
628 700 let cols = grid_cols(logical_w, app.cell);
629 701 let rows = grid_rows(logical_h, app.cell);
630 702 app.grid.resize(cols, rows);
@@ -1258,7 +1330,7 @@
1258 1330 // Requested before presenting so it rides the commit `present` issues.
1259 1331 // Both go down the same connection in the order they were made, so this
1260 1332 // is the last point at which the request still lands on this frame.
1261 - app.chrome.xdg_window.wl_surface().frame(&app.qh, ());
1333 + app.chrome.surface.wl_surface().frame(&app.qh, ());
1262 1334 app.queue.present(frame);
1263 1335 Ok(())
1264 1336 }
@@ -1742,7 +1814,7 @@
1742 1814 // A drag that ends outside the window still delivers its release
1743 1815 // to us, but anything addressed to another surface is not ours to
1744 1816 // act on.
1745 - if &event.surface != self.chrome.xdg_window.wl_surface() {
1817 + if &event.surface != self.chrome.surface.wl_surface() {
1746 1818 continue;
1747 1819 }
1748 1820 self.pointer_at = event.position;
@@ -2371,6 +2443,37 @@
2371 2443 }
2372 2444 }
2373 2445
2446 + impl LayerShellHandler for App {
2447 + fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {
2448 + self.chrome.exit = true;
2449 + self.chrome.pending.push_back(Pending::CloseRequested);
2450 + }
2451 +
2452 + fn configure(
2453 + &mut self,
2454 + _: &Connection,
2455 + _: &QueueHandle<Self>,
2456 + _: &LayerSurface,
2457 + configure: LayerSurfaceConfigure,
2458 + _serial: u32,
2459 + ) {
2460 + // Zero on an axis means the compositor left the choice to us, so keep
2461 + // what we already have rather than collapsing the surface.
2462 + let (new_w, new_h) = configure.new_size;
2463 + let width = if new_w == 0 { self.chrome.width } else { new_w };
2464 + let height = if new_h == 0 {
2465 + self.chrome.height
2466 + } else {
2467 + new_h
2468 + };
2469 + self.chrome.width = width;
2470 + self.chrome.height = height;
2471 + self.chrome
2472 + .pending
2473 + .push_back(Pending::Resized { width, height });
2474 + }
2475 + }
2476 +
2374 2477 impl ProvidesRegistryState for App {
2375 2478 fn registry(&mut self) -> &mut RegistryState {
2376 2479 &mut self.chrome.registry
@@ -2602,6 +2705,38 @@
2602 2705 }
2603 2706 }
2604 2707
2708 + #[test]
2709 + fn the_layer_flag_takes_the_four_protocol_names() {
2710 + for (name, want) in [
2711 + ("background", Layer::Background),
2712 + ("bottom", Layer::Bottom),
2713 + ("top", Layer::Top),
2714 + ("overlay", Layer::Overlay),
2715 + ] {
2716 + let cli = parse_args(cmdline(&["--layer", name])).expect("a layer we have");
2717 + assert_eq!(cli.layer, Some(want));
2718 + }
2719 + assert_eq!(
2720 + parse_args(cmdline(&["--layer=overlay"]))
2721 + .expect("the equals spelling too")
2722 + .layer,
2723 + Some(Layer::Overlay)
2724 + );
2725 + }
2726 +
2727 + #[test]
2728 + fn an_unknown_layer_is_refused_by_name() {
2729 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--layer", "wallpaper"])) else {
2730 + panic!("an unknown layer must not open a window");
2731 + };
2732 + assert!(message.contains("wallpaper"), "{message}");
2733 + }
2734 +
2735 + #[test]
2736 + fn nothing_opens_on_a_layer_by_default() {
2737 + assert_eq!(parse_args(cmdline(&[])).expect("no args").layer, None);
2738 + }
2739 +
2605 2740 #[test]
2606 2741 fn an_unknown_option_is_refused_by_name() {
2607 2742 let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--colour", "red"])) else {