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    Overlay,
96}
97
98bitflags::bitflags! {
99    /// The monitor edges a [panel](PanelDesc) is held against.
100    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
101    pub struct PanelAnchor: u8 {
102        /// The top edge.
103        const TOP = 1 << 0;
104        /// The bottom edge.
105        const BOTTOM = 1 << 1;
106        /// The left edge.
107        const LEFT = 1 << 2;
108        /// The right edge.
109        const RIGHT = 1 << 3;
110    }
111}
112
113/// Whether a [panel](PanelDesc) keeps other windows out of its space.
114///
115/// **Reserving is a request a window system may be unable to grant**, which is
116/// different from being unable to build the panel at all:
117/// [`ShellCaps::PANEL_RESERVE`] says whether
118/// this backend can. Where it cannot — macOS, which lets nothing but the menu
119/// bar and the Dock reserve screen space — the panel is still created and
120/// simply overlaps what is behind it.
121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
122pub enum Reserve {
123    /// It reserves nothing, and keeps clear of the space other panels have
124    /// reserved — a launcher placed below a bar rather than over it.
125    /// Layer-shell's exclusive zone 0.
126    #[default]
127    None,
128    /// The edge it is anchored to gives up the panel's own extent: maximised
129    /// windows stop at it. A bar is this. Layer-shell's exclusive zone equal
130    /// to the panel's extent across that edge.
131    ///
132    /// **The panel must name one edge**: anchored to exactly one, or to one and
133    /// both of the edges beside it — `TOP`, or `TOP | LEFT | RIGHT`, reserves
134    /// the top. Any other anchoring has no edge to reserve, and creating it is
135    /// [`ShellError::Unsupported`] on every
136    /// backend; see [`PanelDesc::reserved_edge`].
137    OwnExtent,
138    /// It reserves nothing and is placed as if no other panel reserved
139    /// anything — at the very edge, over other bars. Layer-shell's exclusive
140    /// zone -1.
141    IgnoreOthers,
142}
143
144impl PanelDesc<'_> {
145    /// The edge [`Reserve::OwnExtent`] would reserve: the one anchored edge,
146    /// alone or with both of its neighbours. `None` for any other anchoring,
147    /// which has no single edge to give up.
148    #[must_use]
149    pub fn reserved_edge(&self) -> Option<PanelAnchor> {
150        let vertical = PanelAnchor::LEFT | PanelAnchor::RIGHT;
151        let horizontal = PanelAnchor::TOP | PanelAnchor::BOTTOM;
152        [
153            (PanelAnchor::TOP, vertical),
154            (PanelAnchor::BOTTOM, vertical),
155            (PanelAnchor::LEFT, horizontal),
156            (PanelAnchor::RIGHT, horizontal),
157        ]
158        .into_iter()
159        .find(|&(edge, beside)| self.anchor == edge || self.anchor == edge | beside)
160        .map(|(edge, _)| edge)
161    }
162}
163
164/// Space around a [panel](PanelDesc), in logical units.
165#[derive(Clone, Copy, Debug, Default, PartialEq)]
166pub struct Margins {
167    /// From the top edge.
168    pub top: f64,
169    /// From the right edge.
170    pub right: f64,
171    /// From the bottom edge.
172    pub bottom: f64,
173    /// From the left edge.
174    pub left: f64,
175}
176
177/// Whether a [panel](PanelDesc) takes the keyboard.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
179pub enum PanelKeyboard {
180    /// Never: a click leaves focus where it was. A bar is this.
181    #[default]
182    None,
183    /// When clicked, like an application window.
184    OnDemand,
185    /// Takes it on mapping and holds it while mapped: a launcher, a lock
186    /// screen. Keys typed the moment it appears are its.
187    Exclusive,
188}
189
190/// Where a [popup](WindowRole::Popup) goes.
191#[derive(Clone, Copy, Debug, PartialEq)]
192pub struct PopupDesc {
193    /// The window it belongs to and moves with. Destroying the parent
194    /// destroys the popup.
195    pub parent: WindowId,
196    /// The rectangle it is placed against, in the parent's logical
197    /// coordinates — the module or button that opened it.
198    pub anchor: LogicalRect,
199    /// Which side of the anchor it opens on. The system flips it to the other
200    /// side, or slides it along this one, rather than let it leave the screen.
201    pub placement: PopupPlacement,
202    /// What input it takes while open.
203    pub input: PopupInput,
204}
205
206/// What input a [popup](PopupDesc) takes.
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
208pub enum PopupInput {
209    /// None at all: the pointer passes through it to whatever is beneath, and
210    /// it never activates. A tooltip is this, so hovering stays on the bar
211    /// that showed it.
212    #[default]
213    Passthrough,
214    /// The pointer over it, as any window has; never the keyboard, and a click
215    /// elsewhere leaves it open.
216    Pointer,
217    /// The pointer and the keyboard while it is open. A click outside it or
218    /// `Escape` dismisses it, which arrives as a
219    /// [`CloseRequested`](crate::ShellEvent::CloseRequested) for it. A menu or
220    /// a calendar is this.
221    Grab,
222}
223
224/// What every backend checks before it builds a window: that its caps cover
225/// the role and the transparency asked for, that a reserved panel names one
226/// edge, and that a panel's monitor exists.
227///
228/// The popup's parent is the backend's to check, since only it knows its
229/// windows.
230pub(crate) fn check(
231    desc: &WindowDesc<'_>,
232    caps: ShellCaps,
233    backend: ShellBackend,
234    has_monitor: impl Fn(MonitorId) -> bool,
235) -> Result<(), ShellError> {
236    let unsupported = |what| Err(ShellError::Unsupported { backend, what });
237    if desc.transparent && !caps.contains(ShellCaps::TRANSPARENT) {
238        return unsupported("transparent windows");
239    }
240    match desc.role {
241        WindowRole::Toplevel => Ok(()),
242        WindowRole::Panel(panel) => {
243            if !caps.contains(ShellCaps::PANEL) {
244                return unsupported("panel windows");
245            }
246            if panel.reserve == Reserve::OwnExtent && panel.reserved_edge().is_none() {
247                return unsupported("a reserved panel that is not held against one edge");
248            }
249            match panel.monitor {
250                Some(monitor) if !has_monitor(monitor) => Err(ShellError::NoSuchMonitor(monitor.0)),
251                _ => Ok(()),
252            }
253        }
254        WindowRole::Popup(_) if !caps.contains(ShellCaps::POPUP) => unsupported("popup windows"),
255        WindowRole::Popup(_) => Ok(()),
256    }
257}
258
259/// Which side of its anchor a [popup](PopupDesc) opens on.
260#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
261pub enum PopupPlacement {
262    /// Below the anchor, left edges aligned.
263    #[default]
264    Below,
265    /// Above the anchor, left edges aligned.
266    Above,
267    /// To the left of the anchor, top edges aligned.
268    Left,
269    /// To the right of the anchor, top edges aligned: a submenu.
270    Right,
271}
272
273/// A rectangle in a backend's own Y-down space — desktop pixels on Win32,
274/// flipped screen points on AppKit — for [`place_popup`].
275///
276/// Compiled where its callers are: the two backends that place popups
277/// themselves.
278#[cfg(any(target_os = "windows", target_os = "macos", test))]
279#[derive(Clone, Copy, Debug, PartialEq)]
280pub(crate) struct Placed {
281    pub x: f64,
282    pub y: f64,
283    pub width: f64,
284    pub height: f64,
285}
286
287/// Where a popup of `width × height` goes against `anchor`: on the
288/// `placement` side of it, flipped to the other side when it does not fit in
289/// `area` and the other side does, and slid along the side it is on to stay
290/// in `area`.
291///
292/// The placement `xdg_positioner` is asked for on Wayland, worked out here
293/// for the window systems that have no one to ask. Y grows downwards.
294/// What [`Shell::reposition_popup`](crate::Shell::reposition_popup) answers
295/// for a window that is not a popup, in every backend's words alike.
296pub(crate) fn not_a_popup(window: WindowId) -> ShellError {
297    ShellError::InvalidDescriptor(format!("reposition_popup: {window:?} is not a popup"))
298}
299
300#[cfg(any(target_os = "windows", target_os = "macos", test))]
301pub(crate) fn place_popup(
302    anchor: Placed,
303    (width, height): (f64, f64),
304    placement: PopupPlacement,
305    area: Placed,
306) -> (f64, f64) {
307    let (left, top) = (anchor.x, anchor.y);
308    let (right, bottom) = (left + anchor.width, top + anchor.height);
309    let (area_right, area_bottom) = (area.x + area.width, area.y + area.height);
310    // The near start on the placement's axis, or the flip when only that fits.
311    let side = |near: f64, far: f64, extent: f64, low: f64, high: f64| {
312        if near >= low && near + extent <= high {
313            near
314        } else if far >= low && far + extent <= high {
315            far
316        } else {
317            near
318        }
319    };
320    let slide = |start: f64, extent: f64, low: f64, high: f64| start.min(high - extent).max(low);
321    match placement {
322        PopupPlacement::Below => (
323            slide(left, width, area.x, area_right),
324            side(bottom, top - height, height, area.y, area_bottom),
325        ),
326        PopupPlacement::Above => (
327            slide(left, width, area.x, area_right),
328            side(top - height, bottom, height, area.y, area_bottom),
329        ),
330        PopupPlacement::Left => (
331            side(left - width, right, width, area.x, area_right),
332            slide(top, height, area.y, area_bottom),
333        ),
334        PopupPlacement::Right => (
335            side(right, left - width, width, area.x, area_right),
336            slide(top, height, area.y, area_bottom),
337        ),
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::{
344        PanelAnchor, PanelDesc, PopupDesc, PopupInput, PopupPlacement, Reserve, WindowRole,
345    };
346    use crate::{
347        HeadlessShell, LogicalRect, MonitorId, Shell, ShellCaps, ShellError, ShellEvent, WindowDesc,
348    };
349
350    fn panel(anchor: PanelAnchor) -> PanelDesc<'static> {
351        PanelDesc {
352            anchor,
353            ..PanelDesc::default()
354        }
355    }
356
357    #[test]
358    fn a_reserved_edge_is_one_edge_alone_or_with_both_neighbours() {
359        let top = PanelAnchor::TOP;
360        let sides = PanelAnchor::LEFT | PanelAnchor::RIGHT;
361        assert_eq!(panel(top).reserved_edge(), Some(top));
362        assert_eq!(panel(top | sides).reserved_edge(), Some(top));
363        let left = PanelAnchor::LEFT;
364        let ends = PanelAnchor::TOP | PanelAnchor::BOTTOM;
365        assert_eq!(panel(left | ends).reserved_edge(), Some(left));
366        assert_eq!(
367            panel(PanelAnchor::BOTTOM).reserved_edge(),
368            Some(PanelAnchor::BOTTOM)
369        );
370        // A corner, a centre and an everything name no single edge.
371        assert_eq!(panel(top | PanelAnchor::LEFT).reserved_edge(), None);
372        assert_eq!(panel(PanelAnchor::empty()).reserved_edge(), None);
373        assert_eq!(panel(PanelAnchor::all()).reserved_edge(), None);
374        assert_eq!(panel(sides).reserved_edge(), None);
375    }
376
377    fn with_role(role: WindowRole<'_>) -> WindowDesc<'_> {
378        WindowDesc {
379            role,
380            ..WindowDesc::default()
381        }
382    }
383
384    fn refused(result: Result<crate::WindowId, ShellError>) -> &'static str {
385        match result {
386            Err(ShellError::Unsupported { what, .. }) => what,
387            other => panic!("expected Unsupported, got {other:?}"),
388        }
389    }
390
391    #[test]
392    fn a_role_or_transparency_the_caps_lack_is_refused_not_downgraded() {
393        let mut shell = HeadlessShell::new().with_caps(
394            ShellCaps::DESKTOP - (ShellCaps::PANEL | ShellCaps::POPUP | ShellCaps::TRANSPARENT),
395        );
396        let parent = shell
397            .create_window(&WindowDesc::default())
398            .expect("a toplevel");
399        assert_eq!(
400            refused(shell.create_window(&with_role(WindowRole::Panel(PanelDesc::default())))),
401            "panel windows"
402        );
403        let popup = WindowRole::Popup(PopupDesc {
404            parent,
405            anchor: LogicalRect::default(),
406            placement: PopupPlacement::Below,
407            input: PopupInput::Grab,
408        });
409        assert_eq!(
410            refused(shell.create_window(&with_role(popup))),
411            "popup windows"
412        );
413        let transparent = WindowDesc {
414            transparent: true,
415            ..WindowDesc::default()
416        };
417        assert_eq!(
418            refused(shell.create_window(&transparent)),
419            "transparent windows"
420        );
421    }
422
423    #[test]
424    fn a_reserved_panel_needs_one_edge_and_a_real_monitor() {
425        let mut shell = HeadlessShell::new();
426        let cornered = PanelDesc {
427            reserve: Reserve::OwnExtent,
428            ..panel(PanelAnchor::TOP | PanelAnchor::LEFT)
429        };
430        assert_eq!(
431            refused(shell.create_window(&with_role(WindowRole::Panel(cornered)))),
432            "a reserved panel that is not held against one edge"
433        );
434        let bar = PanelDesc {
435            reserve: Reserve::OwnExtent,
436            ..panel(PanelAnchor::TOP | PanelAnchor::LEFT | PanelAnchor::RIGHT)
437        };
438        shell
439            .create_window(&with_role(WindowRole::Panel(bar)))
440            .expect("a bar names the top edge");
441        let elsewhere = PanelDesc {
442            monitor: Some(MonitorId(u32::MAX)),
443            ..PanelDesc::default()
444        };
445        assert!(matches!(
446            shell.create_window(&with_role(WindowRole::Panel(elsewhere))),
447            Err(ShellError::NoSuchMonitor(_))
448        ));
449    }
450
451    #[test]
452    fn a_popup_needs_a_live_parent_and_goes_with_it() {
453        let mut shell = HeadlessShell::new();
454        let parent = shell
455            .create_window(&WindowDesc::default())
456            .expect("a toplevel");
457        let popup_of = |parent| {
458            WindowRole::Popup(PopupDesc {
459                parent,
460                anchor: LogicalRect::default(),
461                placement: PopupPlacement::Below,
462                input: PopupInput::Passthrough,
463            })
464        };
465        let popup = shell
466            .create_window(&with_role(popup_of(parent)))
467            .expect("a popup");
468        let submenu = shell
469            .create_window(&with_role(popup_of(popup)))
470            .expect("a popup's popup");
471        shell.pump(&mut |_| {});
472
473        shell.destroy_window(parent).expect("destroy");
474        let mut destroyed = Vec::new();
475        shell.pump(&mut |event| {
476            if let ShellEvent::WindowDestroyed { window } = event {
477                destroyed.push(window);
478            }
479        });
480        assert_eq!(
481            destroyed,
482            [submenu, popup, parent],
483            "children first, each reported"
484        );
485        assert!(
486            shell.create_window(&with_role(popup_of(parent))).is_err(),
487            "a dead parent"
488        );
489    }
490}