crcbl_shell/caps.rs
1//! [`ShellCaps`] — what a backend can actually do, asked as a question about
2//! capability rather than about identity.
3//!
4//! # The regression this exists to prevent
5//!
6//! ```ignore
7//! if cfg!(target_os = "linux") && std::env::var("WAYLAND_DISPLAY").is_ok() {
8//! // …use the viewport path
9//! }
10//! ```
11//!
12//! That code is wrong three ways and all three are invisible until someone
13//! files a bug: it is wrong on a Wayland compositor that does not implement
14//! `wp_viewporter`, wrong under XWayland, and wrong the day the shell grows a
15//! backend nobody thought about. The windowing rules in `docs/notes/backends.md`
16//! are explicit — "the
17//! renderer picks blit-vs-viewport and windowed-aspect behavior from caps, never
18//! from *am I on Wayland*" — and this module is the mechanism. It mirrors
19//! `crcbl-hal`'s `Features`, where the renderer asks for `DRAW_INDIRECT_COUNT`
20//! rather than for Vulkan.
21//!
22//! # Caps are per-shell, not per-window
23//!
24//! A capability here is a property of the connection and the protocols the
25//! server advertised, so it is fixed for the shell's lifetime and reported by
26//! [`Shell::caps`](crate::Shell::caps) rather than per window. The one thing
27//! that genuinely varies per window — its current size and scale — is
28//! [`WindowState`](crate::WindowState), not a capability.
29//!
30//! There is a real edge here, and it is stated rather than hidden: a Wayland
31//! compositor can bind a global *after* startup, so a protocol could in
32//! principle appear mid-session. Backends must not flip caps bits when that
33//! happens; a renderer that picked the blit path at init cannot be told
34//! halfway through a frame that it should have picked the viewport path.
35//! Capabilities are latched at connection time, and a missing protocol stays
36//! missing until the next run.
37
38bitflags::bitflags! {
39 /// What a shell backend supports.
40 ///
41 /// Each flag names a *behaviour a consumer would otherwise have to guess
42 /// from the platform*. A flag that no consumer would branch on does not
43 /// belong here.
44 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
45 pub struct ShellCaps: u32 {
46 /// The compositor can scale a smaller buffer to the window's size in
47 /// display hardware, so borderless render-scale costs no shader work.
48 ///
49 /// Wayland's `wp_viewporter`; the browser's CSS-sized canvas. Absent on
50 /// X11, where the renderer must do an upscale blit instead. This is the
51 /// exact decision the windowing rules call out as the reason caps
52 /// exist. Nothing asks for it: the engine does its own upscale on every
53 /// platform (`docs/backlog.md`, _The engine owns scaling_).
54 const HW_UPSCALE = 1 << 0;
55
56 /// The window system will enforce an aspect ratio during an interactive
57 /// resize.
58 ///
59 /// X11 `WM_NORMAL_HINTS`, Win32 `WM_SIZING`, macOS
60 /// `setContentAspectRatio`. **Wayland has no aspect hint**, so a
61 /// Wayland backend clears this bit and the renderer letterboxes.
62 /// Letterboxing must work regardless — a tiling window manager ignores
63 /// the hint on every backend that has one — so this flag chooses
64 /// between "good" and "correct", never between "works" and "broken".
65 const ASPECT_HINT_HONORED = 1 << 1;
66
67 /// [`Shell::warp_pointer`](crate::Shell::warp_pointer) moves the
68 /// pointer.
69 ///
70 /// X11 `XWarpPointer`, Win32 `SetCursorPos`. Wayland deliberately
71 /// forbids it (a client that can move the cursor can spoof clicks); the
72 /// engine's answer there is pointer lock plus relative motion, which is
73 /// what a camera wanted anyway.
74 const POINTER_WARP = 1 << 2;
75
76 /// [`PointerMode::Locked`](crate::PointerMode) is available: the
77 /// pointer can be hidden and pinned while relative motion keeps
78 /// flowing.
79 ///
80 /// Wayland `pointer-constraints` + `relative-pointer`, X11 pointer
81 /// grab, Win32 clip-and-recentre, the browser's Pointer Lock API.
82 ///
83 /// The browser is the one that cannot take the lock when asked: it
84 /// grants `requestPointerLock` only inside a user gesture, so
85 /// [`set_pointer_mode`](crate::Shell::set_pointer_mode) there records
86 /// the request and the player's next click completes it. The bit still
87 /// means what it says — the mode is available — and a consumer that
88 /// needs to know whether the pointer is pinned *now* reads the shape of
89 /// the motion events, as
90 /// [`PointerMode::Locked`](crate::PointerMode::Locked) describes.
91 const POINTER_LOCK = 1 << 3;
92
93 /// [`PointerMode::Confined`](crate::PointerMode) is available: the
94 /// pointer stays inside the window but keeps moving visibly.
95 ///
96 /// A separate bit from [`POINTER_LOCK`](Self::POINTER_LOCK) because
97 /// Wayland's `pointer-constraints` advertises lock and confine
98 /// independently, and a strategy game wants confine specifically.
99 const POINTER_CONFINE = 1 << 4;
100
101 /// Relative pointer motion is delivered separately from absolute
102 /// position, unaccelerated and unclamped by the screen edge.
103 ///
104 /// This is what a first-person camera must read;
105 /// the windowing rules make raw motion a P0 feature, not an
106 /// afterthought, on the grounds that an esports-grade engine cannot
107 /// derive aim from clamped absolute positions. Without this bit,
108 /// [`ShellEvent::PointerMotion`](crate::ShellEvent::PointerMotion)
109 /// carries `raw_delta: None` and the camera has to difference absolute
110 /// positions — which is wrong at the screen edge and wrong under
111 /// pointer acceleration.
112 ///
113 /// # The one platform that can decline the bypass
114 ///
115 /// On the desktop backends the unaccelerated stream is a separate
116 /// device interface and either exists or does not. In a browser it is
117 /// an *option* on the lock — `requestPointerLock({ unadjustedMovement:
118 /// true })` — and a browser that does not implement it hands back the
119 /// OS-adjusted deltas instead, either by ignoring the key or by
120 /// rejecting with `NotSupportedError` and being asked again without it.
121 /// The web backend sets this bit because it reads a relative stream and
122 /// asks for it unadjusted, which is the decision a camera branches on:
123 /// differencing absolute positions is wrong there whatever the browser
124 /// answered. It is not a promise that every browser granted the bypass;
125 /// the `web` backend's module docs name which browsers do.
126 const RAW_POINTER_MOTION = 1 << 5;
127
128 /// The backend is wired to the platform's input method, the framework
129 /// CJK and other complex-script input runs through, end to end.
130 ///
131 /// For a window that named a field with
132 /// [`Shell::set_text_input`](crate::Shell::set_text_input), the
133 /// composition in progress arrives as
134 /// [`ShellEvent::TextPreedit`](crate::ShellEvent::TextPreedit), the
135 /// conversion the user picks as
136 /// [`ShellEvent::TextCommit`](crate::ShellEvent::TextCommit), and
137 /// [`Shell::set_text_input_area`](crate::Shell::set_text_input_area)
138 /// is passed on so the input method can place its candidate window.
139 /// Whether the user runs an input method is not the shell's to know;
140 /// the bit says one would reach the engine.
141 ///
142 /// Wayland sets it when the compositor offers `text-input-v3`, Win32
143 /// through `imm32`, and AppKit through `NSTextInputClient`. Clear on
144 /// X11, which has no XIM, IBus or Fcitx client, and on the web. Dead keys and Compose sequences are
145 /// [`TEXT_COMPOSE`](Self::TEXT_COMPOSE), which a backend can have
146 /// without this.
147 const TEXT_IME = 1 << 6;
148
149 /// Clipboard get/set works. See
150 /// [`clipboard`](crate::clipboard).
151 ///
152 /// Explicitly a capability because the browser gates clipboard reads
153 /// behind a permission prompt and a user gesture, which
154 /// the windowing rules call out by name: the editor UI has to
155 /// degrade gracefully in-browser rather than offering a paste button
156 /// that cannot work.
157 const CLIPBOARD = 1 << 7;
158
159 /// Files dragged onto a window arrive as
160 /// [`ShellEvent::DroppedFile`](crate::ShellEvent::DroppedFile).
161 const DRAG_DROP = 1 << 8;
162
163 /// The shell can create more than one window.
164 ///
165 /// The MVP editor is single-window, and a canvas backend has exactly
166 /// one canvas, so this is not universal. A consumer that wants a
167 /// tear-off panel checks this rather than assuming.
168 const MULTI_WINDOW = 1 << 9;
169
170 /// Window position in desktop coordinates can be read and set.
171 ///
172 /// **Wayland cannot do either** — a client is not told where it is and
173 /// cannot ask to be moved. Anything that wants to restore a window's
174 /// position across sessions (the editor, topic 14's settings store)
175 /// must check this bit and degrade to "the compositor decides".
176 const WINDOW_POSITION = 1 << 10;
177
178 /// The window system draws the title bar and borders.
179 ///
180 /// Wayland with `xdg-decoration` and a compositor that opts in, and
181 /// every other desktop platform. Without it the UI layer has to draw
182 /// its own decorations, which is a layout decision, not a rendering
183 /// detail — so it belongs in caps rather than being discovered by
184 /// noticing the window looks wrong.
185 const SERVER_DECORATIONS = 1 << 11;
186
187 /// Scale factors other than whole integers are reported.
188 ///
189 /// Wayland `fractional-scale-v1` (in 1/120ths), Win32 per-monitor-v2,
190 /// browser `devicePixelRatio`. Without it the shell reports integer
191 /// scales only, and a 125% desktop shows up as 1.0 or 2.0 — which the
192 /// UI needs to know before it decides whether to snap its layout to a
193 /// pixel grid.
194 const FRACTIONAL_SCALE = 1 << 12;
195
196 /// [`Shell::wait_events`](crate::Shell::wait_events) genuinely blocks.
197 ///
198 /// An editor idling at zero frames per second on battery depends on
199 /// it; a game never calls it. Backends that cannot block — the browser
200 /// above all, where blocking the main thread is not a thing that
201 /// happens — leave this clear and their `wait_events` returns
202 /// immediately, which is always a correct implementation.
203 const EVENT_WAIT = 1 << 13;
204
205 /// Fingers arrive as [`ShellEvent::Touch`](crate::ShellEvent::Touch),
206 /// with a contact id and a phase, so more than one can be tracked at a
207 /// time.
208 ///
209 /// **Win32 is the one desktop backend that sets it** (`WM_POINTER*`),
210 /// beside the browser. Clear on the others, and that is a statement
211 /// about the backends rather than about the platforms: a Linux tablet
212 /// has a touchscreen, and X11 (XInput2 `XI_TouchBegin`), Wayland
213 /// (`wl_touch`) and AppKit (`NSTouch`) all have a way to deliver one.
214 /// None of those paths is written, so a game on them sees no contacts
215 /// at all — an input that is *absent*, which is a different thing from
216 /// one that silently fails.
217 ///
218 /// A backend that does set it owes the emulated pointer stream for the
219 /// primary contact as well; [`ShellEvent::Touch`](crate::ShellEvent::Touch)
220 /// says why, and that obligation is what stops a backend turning this on
221 /// from breaking every game that binds only the pointer.
222 const TOUCH = 1 << 14;
223
224 /// [`WindowRole::Panel`](crate::WindowRole::Panel) can be created:
225 /// desktop furniture held against a monitor's edges.
226 const PANEL = 1 << 15;
227
228 /// A panel's [`Reserve::OwnExtent`](crate::Reserve::OwnExtent) is
229 /// honoured: maximised windows stop at the panel.
230 ///
231 /// Separate from [`PANEL`](Self::PANEL) because a window system can
232 /// have panels and still give nothing but its own furniture reserved
233 /// space — macOS, where the panel is created and overlaps what is
234 /// behind it.
235 const PANEL_RESERVE = 1 << 16;
236
237 /// [`WindowRole::Popup`](crate::WindowRole::Popup) can be created:
238 /// a window anchored to another's rectangle.
239 const POPUP = 1 << 17;
240
241 /// [`WindowDesc::transparent`](crate::WindowDesc::transparent) is
242 /// honoured: where the frame's alpha is below one, the desktop shows
243 /// through.
244 ///
245 /// The window system's half. The frame's half is a swapchain that
246 /// carries alpha, premultiplied — which the HAL's surface caps say
247 /// whether the driver offers, and which `crcbl::engine::GpuContext`
248 /// asks for when the window's surface target says it is transparent
249 /// — and a renderer that clears to alpha zero. See
250 /// [`WindowDesc::transparent`](crate::WindowDesc::transparent) for the
251 /// whole recipe.
252 const TRANSPARENT = 1 << 18;
253
254 /// [`Shell::reposition_popup`](crate::Shell::reposition_popup) moves
255 /// and resizes a live popup.
256 ///
257 /// Separate from [`POPUP`](Self::POPUP) because Wayland's
258 /// `xdg_popup.reposition` arrived in `xdg_wm_base` 3, and a compositor
259 /// offering only 1 or 2 has popups that stay where they opened.
260 const POPUP_REPOSITION = 1 << 19;
261
262 /// `Shell::update_accessibility` reaches the platform's assistive
263 /// technologies, and the `Accessibility*` events are raised.
264 ///
265 /// Set only by a backend built with the `accesskit` feature, which is
266 /// where that method and those events are, and an adapter for its
267 /// platform.
268 const ACCESSIBILITY = 1 << 20;
269
270 /// Dead keys and Compose sequences compose: a dead accent and the next
271 /// key, or a Compose-key sequence, arrive as one
272 /// [`ShellEvent::TextCommit`](crate::ShellEvent::TextCommit) carrying
273 /// the composed text.
274 ///
275 /// libxkbcommon's compose module on Wayland and X11, with the user's
276 /// locale's table; `TranslateMessage` on Win32; `interpretKeyEvents:`
277 /// on AppKit. Without it a backend may still commit plain typing, but
278 /// an accent typed through a dead key never lands on its letter. It
279 /// says nothing about an input method, which is
280 /// [`TEXT_IME`](Self::TEXT_IME): X11 composes and has none.
281 const TEXT_COMPOSE = 1 << 21;
282
283 /// A panel with
284 /// [`PanelKeyboard::Exclusive`](crate::PanelKeyboard::Exclusive)
285 /// keeps the keyboard while it is mapped: the window system will not
286 /// give it to another window, whatever the user clicks.
287 ///
288 /// Wayland's layer-shell enforces it in the compositor, on the
289 /// [`Top`](crate::PanelLayer::Top) and
290 /// [`Overlay`](crate::PanelLayer::Overlay) layers; the protocol lets a
291 /// compositor use ordinary focus on the lower two. Clear on Win32 and
292 /// AppKit, where an exclusive panel takes the keyboard when it is
293 /// shown (or hears
294 /// [`FocusRefused`](crate::ShellEvent::FocusRefused)) and the user
295 /// can click it away, which arrives as
296 /// [`Focus`](crate::ShellEvent::Focus) with `focused: false`. A
297 /// launcher that should close when it loses the keyboard closes on
298 /// that event; a lock screen cannot be built without this bit.
299 const PANEL_EXCLUSIVE_KEYBOARD = 1 << 22;
300
301 /// [`Shell::set_cursor`](crate::Shell::set_cursor) with a shape
302 /// changes the cursor the user sees, to the platform's own themed
303 /// cursor for that [`CursorIcon`](crate::CursorIcon).
304 ///
305 /// Win32's stock `user32` cursors, AppKit's `NSCursor`, and the
306 /// canvas's CSS `cursor` on the web; on Wayland, `cursor-shape-v1`
307 /// where the compositor offers it, which hands the compositor the
308 /// shape's name and lets it draw the user's theme; on X11, the user's
309 /// XCursor theme loaded through libxcb-cursor, where that library is
310 /// installed. Without it, `set_cursor` still
311 /// accepts and records a shape — a UI calls it on every hover, and an
312 /// error there would be one every caller had to handle — and hiding
313 /// with `None` works everywhere; only the shape goes unseen.
314 const CURSOR_SHAPE = 1 << 23;
315
316 /// [`Shell::set_input_region`](crate::Shell::set_input_region) is
317 /// acted on: the pointer outside the region goes to whatever is behind
318 /// the window, another program's window included.
319 ///
320 /// Wayland's `wl_surface.set_input_region`; X11's SHAPE input region,
321 /// where libxcb-shape is installed and the server has SHAPE 1.1; a
322 /// window region on Win32, which also clips what the window draws.
323 /// Clear on AppKit, where an `NSWindow` has no input region, and on the
324 /// web, whose one canvas has nothing behind it to reach. Without it the
325 /// region is accepted and the whole window keeps the pointer, so a
326 /// launcher's transparent corners stay its own.
327 const INPUT_REGION = 1 << 24;
328
329 /// [`ShellEvent::FrameReady`](crate::ShellEvent::FrameReady) is paced
330 /// by the compositor: it comes when the compositor wants the window's
331 /// next frame, and is held back while nothing of the window is shown.
332 ///
333 /// Wayland's `wl_surface.frame`, which a compositor does not answer
334 /// for a surface on an output it has turned off — the case where a
335 /// FIFO present can block forever. Clear everywhere else, where
336 /// `FrameReady` comes at the next [`pump`](crate::Shell::pump) after
337 /// the request whatever is on screen, so the loop that waits for it
338 /// runs unchanged and the pacing is the present mode's.
339 const FRAME_CALLBACK = 1 << 25;
340
341 /// [`Shell::set_idle_inhibit`](crate::Shell::set_idle_inhibit) is
342 /// acted on: while a window asks, the display stays on and the
343 /// screensaver does not start, however long nobody touches the
344 /// keyboard or the mouse.
345 ///
346 /// What a gamepad-only player and a cutscene need, since neither moves
347 /// the input the system counts as activity. Win32 holds
348 /// `SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED |
349 /// ES_SYSTEM_REQUIRED)` on the loop thread while any window asks.
350 /// Clear on Wayland (`zwp_idle_inhibit_manager_v1`), X11
351 /// (`XScreenSaverSuspend`), AppKit (`IOPMAssertionCreateWithName`) and
352 /// the web (Screen Wake Lock), none of which is wired; there the call
353 /// answers [`ShellError::Unsupported`](crate::ShellError::Unsupported)
354 /// and the display sleeps on the user's schedule.
355 const IDLE_INHIBIT = 1 << 26;
356
357 /// [`Shell::open_url`](crate::Shell::open_url) is acted on: the
358 /// [`WebUrl`](crate::WebUrl) is handed to the user's web browser.
359 ///
360 /// Win32 passes it to `ShellExecuteW`, which starts whatever is
361 /// registered for `http` and `https`. Clear on Wayland and X11
362 /// (`org.freedesktop.portal.OpenURI` or `xdg-open`), AppKit
363 /// (`NSWorkspace openURL:`) and the web (`window.open`), none of which
364 /// is wired; there the call answers
365 /// [`ShellError::Unsupported`](crate::ShellError::Unsupported), and a
366 /// game can show the address for the player to copy instead.
367 const OPEN_URL = 1 << 27;
368 }
369}
370
371impl ShellCaps {
372 /// The set a full-featured desktop backend is expected to report.
373 ///
374 /// Not a promise about any particular platform — it is the reference point
375 /// tests and [`HeadlessShell`](crate::HeadlessShell) start from, and the
376 /// value a doc example can name without pretending to know what the machine
377 /// running it supports.
378 pub const DESKTOP: Self = Self::all();
379
380 /// Whether pointer capture in either form is available.
381 #[inline]
382 #[must_use]
383 pub const fn has_pointer_capture(self) -> bool {
384 self.intersects(Self::POINTER_LOCK.union(Self::POINTER_CONFINE))
385 }
386
387 /// Whether a first-person camera can get trustworthy aim input: the pointer
388 /// can be locked *and* relative motion is delivered.
389 ///
390 /// Both halves are required, and this helper exists because forgetting the
391 /// second one produces a camera that works until the pointer reaches the
392 /// edge of the screen.
393 #[inline]
394 #[must_use]
395 pub const fn has_mouselook(self) -> bool {
396 self.contains(Self::POINTER_LOCK.union(Self::RAW_POINTER_MOTION))
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn mouselook_needs_both_halves() {
406 assert!(ShellCaps::DESKTOP.has_mouselook());
407 // Pointer lock alone is the trap: aim breaks at the screen edge.
408 assert!(!ShellCaps::POINTER_LOCK.has_mouselook());
409 assert!(!ShellCaps::RAW_POINTER_MOTION.has_mouselook());
410 assert!((ShellCaps::POINTER_LOCK | ShellCaps::RAW_POINTER_MOTION).has_mouselook());
411 }
412
413 #[test]
414 fn pointer_capture_is_either_form() {
415 assert!(ShellCaps::POINTER_LOCK.has_pointer_capture());
416 assert!(ShellCaps::POINTER_CONFINE.has_pointer_capture());
417 assert!(!ShellCaps::POINTER_WARP.has_pointer_capture());
418 assert!(!ShellCaps::empty().has_pointer_capture());
419 }
420
421 #[test]
422 fn a_wayland_shaped_cap_set_is_expressible() {
423 // The point of the type: the exact combination Wayland produces — hardware
424 // upscale yes, aspect hint no, pointer warp no — is a value, not a
425 // `#[cfg]`.
426 let wayland = ShellCaps::HW_UPSCALE
427 | ShellCaps::POINTER_LOCK
428 | ShellCaps::POINTER_CONFINE
429 | ShellCaps::RAW_POINTER_MOTION
430 | ShellCaps::TEXT_IME
431 | ShellCaps::TEXT_COMPOSE
432 | ShellCaps::CLIPBOARD
433 | ShellCaps::DRAG_DROP
434 | ShellCaps::MULTI_WINDOW
435 | ShellCaps::SERVER_DECORATIONS
436 | ShellCaps::FRACTIONAL_SCALE
437 | ShellCaps::EVENT_WAIT
438 | ShellCaps::CURSOR_SHAPE;
439 assert!(wayland.has_mouselook());
440 assert!(!wayland.contains(ShellCaps::POINTER_WARP));
441 assert!(!wayland.contains(ShellCaps::ASPECT_HINT_HONORED));
442 assert!(!wayland.contains(ShellCaps::WINDOW_POSITION));
443 // `wl_touch` exists and this backend does not bind it, which is the
444 // whole difference between a platform and a backend.
445 assert!(!wayland.contains(ShellCaps::TOUCH));
446 assert!(ShellCaps::DESKTOP.contains(wayland));
447 assert_eq!(ShellCaps::default(), ShellCaps::empty());
448 }
449}