Skip to main content

crcbl_shell/
role.rs

1//! What kind of window a [`WindowDesc`] asks for: an
2//! application window, a panel, or a popup.
3//!
4//! Most programs want [`WindowRole::Toplevel`], the window every desktop has.
5//! The other two are for desktop furniture — a status bar, a launcher, a
6//! notification, a menu opened from a bar — which every window system has a
7//! different mechanism for, and which no program should have to write three
8//! times:
9//!
10//! | role | Wayland | Win32 | AppKit |
11//! | --- | --- | --- | --- |
12//! | [`Panel`](WindowRole::Panel) | `zwlr_layer_surface_v1` | a topmost tool window; an appbar when it reserves an edge | a non-activating `NSPanel` |
13//! | [`Popup`](WindowRole::Popup) | `xdg_popup` | a `WS_POPUP` tool window | a child `NSPanel` |
14//!
15//! Which roles a backend can build is in its caps —
16//! [`ShellCaps::PANEL`] and
17//! [`ShellCaps::POPUP`] — and asking for one it cannot
18//! is [`ShellError::Unsupported`], never a
19//! toplevel in its place: a bar that silently became a normal window would
20//! cover the desktop it was meant to sit at the edge of.
21
22use crate::geom::LogicalRect;
23use crate::monitor::MonitorId;
24use crate::window::{WindowDesc, WindowId};
25use crate::{ShellBackend, ShellCaps, ShellError};
26
27/// What kind of window to create — see the [module docs](self).
28#[derive(Clone, Copy, Debug, Default, PartialEq)]
29#[non_exhaustive]
30pub enum WindowRole<'a> {
31    /// An ordinary application window: decorated, in the taskbar, moved and
32    /// sized by the user and the window manager.
33    #[default]
34    Toplevel,
35    /// Desktop furniture placed against a monitor's edges — a bar, a dock, a
36    /// launcher, an overlay. See [`PanelDesc`].
37    Panel(PanelDesc<'a>),
38    /// A window anchored to a rectangle of another, above it — a menu, a
39    /// tooltip, a calendar opened from a bar. See [`PopupDesc`].
40    Popup(PopupDesc),
41}
42
43/// Where a [panel](WindowRole::Panel) goes and how it behaves.
44///
45/// Laid out the way `wlr-layer-shell` describes a surface, because that
46/// protocol is the most complete statement of what a panel is: the other
47/// window systems implement a subset, and the backend docs say which.
48///
49/// **Its size is [`WindowDesc::size`], with one
50/// exception: along an axis where it is anchored to both opposite edges, that
51/// size is ignored and the panel stretches between them.** A bar anchored
52/// `TOP | LEFT | RIGHT` with a size of `(0, 50)` is 50 high and as wide as the
53/// monitor. Either way the size it actually gets arrives as the first
54/// [`Resized`](crate::ShellEvent::Resized), as for any window, and is the one
55/// to draw at.
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub struct PanelDesc<'a> {
58    /// The name window-system rules match the panel by: the layer-shell
59    /// namespace on Wayland, which Sway's and Hyprland's per-layer rules key
60    /// on. Empty means [`WindowDesc::app_id`].
61    pub namespace: &'a str,
62    /// The monitor it is placed on, or `None` for the system's choice, which
63    /// is **where the user is** rather than the primary monitor: the
64    /// compositor's pick on Wayland (the focused output on Sway, Hyprland and
65    /// niri), and the monitor under the pointer on Win32 and AppKit. A launcher
66    /// opening on a screen the user is not looking at is the failure this
67    /// avoids.
68    pub monitor: Option<MonitorId>,
69    /// Which band of the stack it lives in.
70    pub layer: PanelLayer,
71    /// The edges it is held against. A panel held against two opposite edges
72    /// stretches between them; one held against none is centred.
73    pub anchor: PanelAnchor,
74    /// Whether, and how, it keeps other windows out of its space.
75    pub reserve: Reserve,
76    /// Space between the panel and the edges it is anchored to, in logical
77    /// units. A margin on an edge it is not anchored to does nothing.
78    pub margins: Margins,
79    /// Whether it takes the keyboard.
80    pub keyboard: PanelKeyboard,
81}
82
83/// The band of the stack a [panel](PanelDesc) lives in, bottom to top.
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
85pub enum PanelLayer {
86    /// Below every application window: a wallpaper, a desktop widget.
87    Background,
88    /// Above the background, below application windows.
89    Bottom,
90    /// Above application windows, below full-screen ones: a status bar.
91    #[default]
92    Top,
93    /// Above everything, full-screen windows included: a launcher, a lock
94    /// screen, a notification.
95    ///
96    /// Win32 has one topmost band, which `Top` uses too, so there an overlay
97    /// is above application windows and shares the band with full-screen
98    /// ones that are topmost themselves.
99    Overlay,
100}
101
102bitflags::bitflags! {
103    /// The monitor edges a [panel](PanelDesc) is held against.
104    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
105    pub struct PanelAnchor: u8 {
106        /// The top edge.
107        const TOP = 1 << 0;
108        /// The bottom edge.
109        const BOTTOM = 1 << 1;
110        /// The left edge.
111        const LEFT = 1 << 2;
112        /// The right edge.
113        const RIGHT = 1 << 3;
114    }
115}
116
117/// Whether a [panel](PanelDesc) keeps other windows out of its space.
118///
119/// **Reserving is a request a window system may be unable to grant**, which is
120/// different from being unable to build the panel at all:
121/// [`ShellCaps::PANEL_RESERVE`] says whether
122/// this backend can. Where it cannot — macOS, which lets nothing but the menu
123/// bar and the Dock reserve screen space — the panel is still created and
124/// simply overlaps what is behind it.
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
126pub enum Reserve {
127    /// It reserves nothing, and keeps clear of the space other panels have
128    /// reserved — a launcher placed below a bar rather than over it.
129    /// Layer-shell's exclusive zone 0.
130    #[default]
131    None,
132    /// The edge it is anchored to gives up the panel's own extent: maximised
133    /// windows stop at it. A bar is this. Layer-shell's exclusive zone equal
134    /// to the panel's extent across that edge.
135    ///
136    /// **The panel must name one edge**: anchored to exactly one, or to one and
137    /// both of the edges beside it — `TOP`, or `TOP | LEFT | RIGHT`, reserves
138    /// the top. Any other anchoring has no edge to reserve, and creating it is
139    /// [`ShellError::Unsupported`] on every
140    /// backend; see [`PanelDesc::reserved_edge`].
141    OwnExtent,
142    /// It reserves nothing and is placed as if no other panel reserved
143    /// anything — at the very edge, over other bars. Layer-shell's exclusive
144    /// zone -1.
145    IgnoreOthers,
146}
147
148impl PanelDesc<'_> {
149    /// The edge [`Reserve::OwnExtent`] would reserve: the one anchored edge,
150    /// alone or with both of its neighbours. `None` for any other anchoring,
151    /// which has no single edge to give up.
152    #[must_use]
153    pub fn reserved_edge(&self) -> Option<PanelAnchor> {
154        let vertical = PanelAnchor::LEFT | PanelAnchor::RIGHT;
155        let horizontal = PanelAnchor::TOP | PanelAnchor::BOTTOM;
156        [
157            (PanelAnchor::TOP, vertical),
158            (PanelAnchor::BOTTOM, vertical),
159            (PanelAnchor::LEFT, horizontal),
160            (PanelAnchor::RIGHT, horizontal),
161        ]
162        .into_iter()
163        .find(|&(edge, beside)| self.anchor == edge || self.anchor == edge | beside)
164        .map(|(edge, _)| edge)
165    }
166}
167
168/// Space around a [panel](PanelDesc), in logical units.
169#[derive(Clone, Copy, Debug, Default, PartialEq)]
170pub struct Margins {
171    /// From the top edge.
172    pub top: f64,
173    /// From the right edge.
174    pub right: f64,
175    /// From the bottom edge.
176    pub bottom: f64,
177    /// From the left edge.
178    pub left: f64,
179}
180
181/// Whether a [panel](PanelDesc) takes the keyboard.
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
183pub enum PanelKeyboard {
184    /// Never: a click leaves focus where it was. A bar is this.
185    #[default]
186    None,
187    /// When clicked, like an application window.
188    OnDemand,
189    /// Takes it on mapping: a launcher, a lock screen. Keys typed the moment
190    /// it appears are its, or
191    /// [`FocusRefused`](crate::ShellEvent::FocusRefused) says the window
192    /// system would not hand it over.
193    ///
194    /// Held while mapped only where
195    /// [`ShellCaps::PANEL_EXCLUSIVE_KEYBOARD`](crate::ShellCaps::PANEL_EXCLUSIVE_KEYBOARD)
196    /// is set. Elsewhere the user can click another window and the panel
197    /// hears [`Focus`](crate::ShellEvent::Focus) with `focused: false`;
198    /// nothing takes the keyboard back, since that would fight the user and
199    /// the system's foreground rules.
200    Exclusive,
201}
202
203/// Where a [popup](WindowRole::Popup) goes.
204#[derive(Clone, Copy, Debug, PartialEq)]
205pub struct PopupDesc {
206    /// The window it belongs to and moves with. Destroying the parent
207    /// destroys the popup.
208    pub parent: WindowId,
209    /// The rectangle it is placed against, in the parent's logical
210    /// coordinates — the module or button that opened it.
211    pub anchor: LogicalRect,
212    /// Which side of the anchor it opens on. The system flips it to the other
213    /// side, or slides it along this one, rather than let it leave the screen.
214    pub placement: PopupPlacement,
215    /// What input it takes while open.
216    pub input: PopupInput,
217}
218
219/// What input a [popup](PopupDesc) takes.
220#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
221pub enum PopupInput {
222    /// None at all: the pointer passes through it to whatever is beneath, and
223    /// it never activates. A tooltip is this, so hovering stays on the bar
224    /// that showed it.
225    #[default]
226    Passthrough,
227    /// The pointer over it, as any window has; never the keyboard, and a click
228    /// elsewhere leaves it open.
229    Pointer,
230    /// The pointer and the keyboard while it is open. A click outside it or
231    /// `Escape` dismisses it, which arrives as a
232    /// [`CloseRequested`](crate::ShellEvent::CloseRequested) for it. A menu or
233    /// a calendar is this.
234    ///
235    /// `Escape` is still delivered: the popup hears the
236    /// [`Key`](crate::ShellEvent::Key) press first and the `CloseRequested`
237    /// after it, so a consumer can tell a dismissal by the keyboard from one by
238    /// a click, and can answer [`CloseReply::Keep`](crate::CloseReply::Keep)
239    /// to back out of a submenu instead of closing the whole menu. Only a
240    /// fresh press dismisses — an auto-repeat or a release does not.
241    Grab,
242}
243
244/// Whether `key`, delivered to a window whose popup input is `popup`, asks
245/// for that window to close: a fresh press of `Escape` on a
246/// [`PopupInput::Grab`] popup, as that variant documents. `popup` is `None`
247/// for a window that is not a popup.
248///
249/// Every backend that builds popups calls this after queueing the key, and on
250/// `true` queues the window's
251/// [`CloseRequested`](crate::ShellEvent::CloseRequested) through its own
252/// close-request path, so a request already pending is not repeated.
253pub(crate) fn escape_dismisses(popup: Option<PopupInput>, key: &crate::ShellEvent) -> bool {
254    popup == Some(PopupInput::Grab)
255        && matches!(
256            key,
257            crate::ShellEvent::Key {
258                key_code: Some(crcbl_core::KeyCode::Escape),
259                state: crcbl_core::input::ButtonState::Pressed,
260                repeat: false,
261                ..
262            }
263        )
264}
265
266/// What every backend checks before it builds a window: that its caps cover
267/// the role and the transparency asked for, that a reserved panel names one
268/// edge, and that a panel's monitor exists.
269///
270/// The popup's parent is the backend's to check, since only it knows its
271/// windows.
272pub(crate) fn check(
273    desc: &WindowDesc<'_>,
274    caps: ShellCaps,
275    backend: ShellBackend,
276    has_monitor: impl Fn(MonitorId) -> bool,
277) -> Result<(), ShellError> {
278    let unsupported = |what| Err(ShellError::Unsupported { backend, what });
279    if desc.transparent && !caps.contains(ShellCaps::TRANSPARENT) {
280        return unsupported("transparent windows");
281    }
282    match desc.role {
283        WindowRole::Toplevel => Ok(()),
284        WindowRole::Panel(panel) => {
285            if !caps.contains(ShellCaps::PANEL) {
286                return unsupported("panel windows");
287            }
288            if panel.reserve == Reserve::OwnExtent && panel.reserved_edge().is_none() {
289                return unsupported("a reserved panel that is not held against one edge");
290            }
291            match panel.monitor {
292                Some(monitor) if !has_monitor(monitor) => Err(ShellError::NoSuchMonitor(monitor.0)),
293                _ => Ok(()),
294            }
295        }
296        WindowRole::Popup(_) if !caps.contains(ShellCaps::POPUP) => unsupported("popup windows"),
297        WindowRole::Popup(_) => Ok(()),
298    }
299}
300
301/// Which side of its anchor a [popup](PopupDesc) opens on.
302#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
303pub enum PopupPlacement {
304    /// Below the anchor, left edges aligned.
305    #[default]
306    Below,
307    /// Above the anchor, left edges aligned.
308    Above,
309    /// To the left of the anchor, top edges aligned.
310    Left,
311    /// To the right of the anchor, top edges aligned: a submenu.
312    Right,
313}
314
315/// A rectangle in a backend's own Y-down space — desktop pixels on Win32,
316/// flipped screen points on AppKit — for [`place_popup`].
317///
318/// Compiled where its callers are: the two backends that place popups
319/// themselves.
320#[cfg(any(target_os = "windows", target_os = "macos", test))]
321#[derive(Clone, Copy, Debug, PartialEq)]
322pub(crate) struct Placed {
323    pub x: f64,
324    pub y: f64,
325    pub width: f64,
326    pub height: f64,
327}
328
329/// Where a popup of `width × height` goes against `anchor`: on the
330/// `placement` side of it, flipped to the other side when it does not fit in
331/// `area` and the other side does, and slid along the side it is on to stay
332/// in `area`.
333///
334/// The placement `xdg_positioner` is asked for on Wayland, worked out here
335/// for the window systems that have no one to ask. Y grows downwards.
336/// What [`Shell::reposition_popup`](crate::Shell::reposition_popup) answers
337/// for a window that is not a popup, in every backend's words alike.
338pub(crate) fn not_a_popup(window: WindowId) -> ShellError {
339    ShellError::InvalidDescriptor(format!("reposition_popup: {window:?} is not a popup"))
340}
341
342#[cfg(any(target_os = "windows", target_os = "macos", test))]
343pub(crate) fn place_popup(
344    anchor: Placed,
345    (width, height): (f64, f64),
346    placement: PopupPlacement,
347    area: Placed,
348) -> (f64, f64) {
349    let (left, top) = (anchor.x, anchor.y);
350    let (right, bottom) = (left + anchor.width, top + anchor.height);
351    let (area_right, area_bottom) = (area.x + area.width, area.y + area.height);
352    // The near start on the placement's axis, or the flip when only that fits.
353    let side = |near: f64, far: f64, extent: f64, low: f64, high: f64| {
354        if near >= low && near + extent <= high {
355            near
356        } else if far >= low && far + extent <= high {
357            far
358        } else {
359            near
360        }
361    };
362    let slide = |start: f64, extent: f64, low: f64, high: f64| start.min(high - extent).max(low);
363    match placement {
364        PopupPlacement::Below => (
365            slide(left, width, area.x, area_right),
366            side(bottom, top - height, height, area.y, area_bottom),
367        ),
368        PopupPlacement::Above => (
369            slide(left, width, area.x, area_right),
370            side(top - height, bottom, height, area.y, area_bottom),
371        ),
372        PopupPlacement::Left => (
373            side(left - width, right, width, area.x, area_right),
374            slide(top, height, area.y, area_bottom),
375        ),
376        PopupPlacement::Right => (
377            side(right, left - width, width, area.x, area_right),
378            slide(top, height, area.y, area_bottom),
379        ),
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::{
386        PanelAnchor, PanelDesc, PopupDesc, PopupInput, PopupPlacement, Reserve, WindowRole,
387    };
388    use crate::{
389        HeadlessShell, LogicalRect, MonitorId, Shell, ShellCaps, ShellError, ShellEvent, WindowDesc,
390    };
391
392    #[test]
393    fn only_a_fresh_escape_press_on_a_grabbing_popup_dismisses_it() {
394        use crcbl_core::KeyCode;
395        use crcbl_core::input::{ButtonState, DeviceId, Keysym, Modifiers, Scancode};
396
397        let window = crate::WindowId::from_bits(1 << 32).expect("a valid handle");
398        let key = |key_code, state, repeat| ShellEvent::Key {
399            window,
400            device: DeviceId(1),
401            time: crcbl_core::EventTime::ZERO,
402            scancode: Scancode(0),
403            key_code,
404            keysym: Keysym::NONE,
405            state,
406            repeat,
407            modifiers: Modifiers::empty(),
408        };
409        let escape = key(Some(KeyCode::Escape), ButtonState::Pressed, false);
410        let grab = Some(PopupInput::Grab);
411        assert!(super::escape_dismisses(grab, &escape));
412
413        // Not a release, a repeat, another key, or a key with no name.
414        let released = key(Some(KeyCode::Escape), ButtonState::Released, false);
415        let repeated = key(Some(KeyCode::Escape), ButtonState::Pressed, true);
416        let enter = key(Some(KeyCode::Enter), ButtonState::Pressed, false);
417        let unnamed = key(None, ButtonState::Pressed, false);
418        for other in [&released, &repeated, &enter, &unnamed] {
419            assert!(!super::escape_dismisses(grab, other), "{other:?}");
420        }
421        // And not on a popup that does not hold the keyboard, or on a window
422        // that is no popup at all.
423        for input in [
424            Some(PopupInput::Pointer),
425            Some(PopupInput::Passthrough),
426            None,
427        ] {
428            assert!(!super::escape_dismisses(input, &escape), "{input:?}");
429        }
430        // Only a key event is a keystroke.
431        assert!(!super::escape_dismisses(
432            grab,
433            &ShellEvent::CloseRequested { window }
434        ));
435    }
436
437    fn panel(anchor: PanelAnchor) -> PanelDesc<'static> {
438        PanelDesc {
439            anchor,
440            ..PanelDesc::default()
441        }
442    }
443
444    #[test]
445    fn a_reserved_edge_is_one_edge_alone_or_with_both_neighbours() {
446        let top = PanelAnchor::TOP;
447        let sides = PanelAnchor::LEFT | PanelAnchor::RIGHT;
448        assert_eq!(panel(top).reserved_edge(), Some(top));
449        assert_eq!(panel(top | sides).reserved_edge(), Some(top));
450        let left = PanelAnchor::LEFT;
451        let ends = PanelAnchor::TOP | PanelAnchor::BOTTOM;
452        assert_eq!(panel(left | ends).reserved_edge(), Some(left));
453        assert_eq!(
454            panel(PanelAnchor::BOTTOM).reserved_edge(),
455            Some(PanelAnchor::BOTTOM)
456        );
457        // A corner, a centre and an everything name no single edge.
458        assert_eq!(panel(top | PanelAnchor::LEFT).reserved_edge(), None);
459        assert_eq!(panel(PanelAnchor::empty()).reserved_edge(), None);
460        assert_eq!(panel(PanelAnchor::all()).reserved_edge(), None);
461        assert_eq!(panel(sides).reserved_edge(), None);
462    }
463
464    fn with_role(role: WindowRole<'_>) -> WindowDesc<'_> {
465        WindowDesc {
466            role,
467            ..WindowDesc::default()
468        }
469    }
470
471    fn refused(result: Result<crate::WindowId, ShellError>) -> &'static str {
472        match result {
473            Err(ShellError::Unsupported { what, .. }) => what,
474            other => panic!("expected Unsupported, got {other:?}"),
475        }
476    }
477
478    #[test]
479    fn a_role_or_transparency_the_caps_lack_is_refused_not_downgraded() {
480        let mut shell = HeadlessShell::new().with_caps(
481            ShellCaps::DESKTOP - (ShellCaps::PANEL | ShellCaps::POPUP | ShellCaps::TRANSPARENT),
482        );
483        let parent = shell
484            .create_window(&WindowDesc::default())
485            .expect("a toplevel");
486        assert_eq!(
487            refused(shell.create_window(&with_role(WindowRole::Panel(PanelDesc::default())))),
488            "panel windows"
489        );
490        let popup = WindowRole::Popup(PopupDesc {
491            parent,
492            anchor: LogicalRect::default(),
493            placement: PopupPlacement::Below,
494            input: PopupInput::Grab,
495        });
496        assert_eq!(
497            refused(shell.create_window(&with_role(popup))),
498            "popup windows"
499        );
500        let transparent = WindowDesc {
501            transparent: true,
502            ..WindowDesc::default()
503        };
504        assert_eq!(
505            refused(shell.create_window(&transparent)),
506            "transparent windows"
507        );
508    }
509
510    #[test]
511    fn a_reserved_panel_needs_one_edge_and_a_real_monitor() {
512        let mut shell = HeadlessShell::new();
513        let cornered = PanelDesc {
514            reserve: Reserve::OwnExtent,
515            ..panel(PanelAnchor::TOP | PanelAnchor::LEFT)
516        };
517        assert_eq!(
518            refused(shell.create_window(&with_role(WindowRole::Panel(cornered)))),
519            "a reserved panel that is not held against one edge"
520        );
521        let bar = PanelDesc {
522            reserve: Reserve::OwnExtent,
523            ..panel(PanelAnchor::TOP | PanelAnchor::LEFT | PanelAnchor::RIGHT)
524        };
525        shell
526            .create_window(&with_role(WindowRole::Panel(bar)))
527            .expect("a bar names the top edge");
528        let elsewhere = PanelDesc {
529            monitor: Some(MonitorId(u32::MAX)),
530            ..PanelDesc::default()
531        };
532        assert!(matches!(
533            shell.create_window(&with_role(WindowRole::Panel(elsewhere))),
534            Err(ShellError::NoSuchMonitor(_))
535        ));
536    }
537
538    #[test]
539    fn a_popup_needs_a_live_parent_and_goes_with_it() {
540        let mut shell = HeadlessShell::new();
541        let parent = shell
542            .create_window(&WindowDesc::default())
543            .expect("a toplevel");
544        let popup_of = |parent| {
545            WindowRole::Popup(PopupDesc {
546                parent,
547                anchor: LogicalRect::default(),
548                placement: PopupPlacement::Below,
549                input: PopupInput::Passthrough,
550            })
551        };
552        let popup = shell
553            .create_window(&with_role(popup_of(parent)))
554            .expect("a popup");
555        let submenu = shell
556            .create_window(&with_role(popup_of(popup)))
557            .expect("a popup's popup");
558        shell.pump(&mut |_| {});
559
560        shell.destroy_window(parent).expect("destroy");
561        let mut destroyed = Vec::new();
562        shell.pump(&mut |event| {
563            if let ShellEvent::WindowDestroyed { window } = event {
564                destroyed.push(window);
565            }
566        });
567        assert_eq!(
568            destroyed,
569            [submenu, popup, parent],
570            "children first, each reported"
571        );
572        assert!(
573            shell.create_window(&with_role(popup_of(parent))).is_err(),
574            "a dead parent"
575        );
576    }
577}