crcbl-shell 0.3.0

Crucible windowing shell: own Wayland, X11, Web/canvas and headless backends
Documentation
//! Panels on AppKit: non-activating `NSPanel`s placed by their anchors.
//!
//! What each [`PanelDesc`] field becomes here:
//!
//! | field | AppKit |
//! | --- | --- |
//! | `layer` | a window level from [`geometry::panel_level`]: the desktop picture's for `Background`, just above the desktop icons for `Bottom`, above the menu bar for `Top`, the pop-up menu level for `Overlay`. Every panel is on every space and stays put in Mission Control; only `Overlay` may join a full-screen window's space |
//! | `anchor`, `margins`, the size | a frame on the screen, from [`geometry::panel_frame`], in points |
//! | `reserve` | `None` places the panel in the screen's visible frame, clear of the menu bar and the Dock; `IgnoreOthers` places it on the whole screen. `OwnExtent` is placed as `None` is and reserves nothing: macOS lets nothing but the menu bar and the Dock reserve space, which [`ShellCaps::PANEL_RESERVE`](crate::ShellCaps::PANEL_RESERVE) being clear says |
//! | `keyboard` | `None` is a panel class that can never become the key window, so clicks and hover leave the keyboard alone. `OnDemand` becomes key on a click. `Exclusive` is made key when shown. All are `NSWindowStyleMaskNonactivatingPanel`, so taking the keyboard does not bring the application forward |
//! | `monitor` | the one asked for, else the screen under the pointer |
//! | `namespace` | nothing: macOS has no rules keyed by one |
//!
//! `hidesOnDeactivate` is turned off: an `NSPanel` hides whenever its
//! application stops being active by default, which for a bar is always.
//!
//! A panel's frame is computed again when the screens change, and a panel
//! whose screen was detached is asked to close; see
//! [`ShellEvent::MonitorRemoved`](crate::ShellEvent::MonitorRemoved).

use core::ptr;

use crate::{
    LogicalSize, Margins, MonitorId, PanelAnchor, PanelDesc, PanelKeyboard, PanelLayer, Reserve,
    WindowId,
};

use super::ffi::{self, Id, NSPoint, NSRect, NSSize, Sel};
use super::geometry;
use super::monitors::Screen;
use super::shell::AppKitShell;

/// What a panel was created with, kept so it can be placed again.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Panel {
    /// The screen asked for. `None` was the one under the pointer when the
    /// panel was made, and is then whichever one the panel is on.
    pub monitor: Option<MonitorId>,
    pub layer: PanelLayer,
    pub anchor: PanelAnchor,
    pub reserve: Reserve,
    pub margins: Margins,
    pub keyboard: PanelKeyboard,
    /// [`WindowDesc::size`](crate::WindowDesc::size): the size along every
    /// axis that is not stretched, in points.
    pub size: LogicalSize,
}

impl Panel {
    pub(super) fn new(desc: &PanelDesc<'_>, size: LogicalSize) -> Self {
        Self {
            monitor: desc.monitor,
            layer: desc.layer,
            anchor: desc.anchor,
            reserve: desc.reserve,
            margins: desc.margins,
            keyboard: desc.keyboard,
            size,
        }
    }

    /// Its frame on `screen`, in AppKit points with Y up.
    pub(super) fn frame_on(&self, screen: &Screen) -> NSRect {
        let area = match self.reserve {
            Reserve::IgnoreOthers => screen.frame,
            Reserve::None | Reserve::OwnExtent => screen.visible,
        };
        geometry::panel_frame(
            area,
            self.anchor,
            NSSize::new(self.size.width, self.size.height),
            self.margins,
        )
    }
}

/// Whether `point` is inside `rect`, both in AppKit points: the left and
/// bottom edges in, the right and top edges out, as `NSPointInRect` has it.
fn contains(rect: NSRect, point: NSPoint) -> bool {
    point.x >= rect.origin.x
        && point.x < rect.origin.x + rect.size.width
        && point.y >= rect.origin.y
        && point.y < rect.origin.y + rect.size.height
}

impl AppKitShell {
    /// The screen `panel` belongs on: the one it asked for, else the one under
    /// the pointer — where the user is, which is what a launcher has to open
    /// on — else the menu-bar screen.
    pub(super) fn panel_screen(&self, panel: &Panel) -> Option<Screen> {
        if let Some(id) = panel.monitor {
            return self.screens.iter().find(|screen| screen.id == id).copied();
        }
        let under = ffi::class(c"NSEvent").map(|class| {
            // SAFETY: `+[NSEvent mouseLocation]` takes nothing and answers an
            // `NSPoint` in screen points, Y up; on the main thread.
            unsafe {
                let location: unsafe extern "C" fn(Id, Sel) -> NSPoint = ffi::msg_send();
                location(class.cast(), ffi::sel(c"mouseLocation"))
            }
        });
        under
            .and_then(|point| {
                self.screens
                    .iter()
                    .find(|screen| contains(screen.frame, point))
            })
            .or_else(|| self.screens.first())
            .copied()
    }

    /// Gives a just-created panel window its level, its place in spaces, and
    /// the one `NSPanel` default a panel must not keep.
    ///
    /// # Safety
    ///
    /// `window` must be a live `NSPanel`, on the main thread.
    pub(super) unsafe fn configure_panel(window: Id, panel: &Panel) {
        let (key, above) = geometry::panel_level(panel.layer);
        // SAFETY: a pure query of the window server.
        let level = unsafe { ffi::CGWindowLevelForKey(key) } as ffi::NSInteger + above;
        // SAFETY: the caller guarantees a live panel; each setter takes the
        // type it is given here.
        unsafe {
            ffi::msg_set_isize(window, ffi::sel(c"setLevel:"), level);
            ffi::msg_set_usize(
                window,
                ffi::sel(c"setCollectionBehavior:"),
                geometry::panel_collection(panel.layer),
            );
            ffi::msg_set_bool(window, ffi::sel(c"setHidesOnDeactivate:"), false);
            // A panel that takes the keyboard on a click, and not before.
            ffi::msg_set_bool(
                window,
                ffi::sel(c"setBecomesKeyOnlyIfNeeded:"),
                panel.keyboard == PanelKeyboard::OnDemand,
            );
        }
    }

    /// Shows a panel: made key if it takes the keyboard outright, otherwise
    /// brought to the front without becoming key.
    ///
    /// # Safety
    ///
    /// `window` must be a live panel of this shell, on the main thread.
    pub(super) unsafe fn show_panel(window: Id, keyboard: PanelKeyboard) {
        // SAFETY: the caller guarantees a live window; `nil` is the
        // documented sender, and `orderFrontRegardless` takes nothing.
        unsafe {
            match keyboard {
                PanelKeyboard::Exclusive => {
                    ffi::msg1_void(window, ffi::sel(c"makeKeyAndOrderFront:"), ptr::null_mut());
                }
                PanelKeyboard::None | PanelKeyboard::OnDemand => {
                    ffi::msg_void(window, ffi::sel(c"orderFrontRegardless"));
                }
            }
        }
    }

    /// Places every panel again after the screens changed, and asks each
    /// panel created for a screen in `removed` to close.
    pub(super) fn place_panels(&mut self, removed: &[MonitorId]) {
        let panels: Vec<(WindowId, Panel)> = self
            .windows_iter()
            .filter_map(|(handle, window)| Some((handle, window.panel?)))
            .collect();
        for (window, panel) in panels {
            if panel
                .monitor
                .is_some_and(|monitor| removed.contains(&monitor))
            {
                self.close_requested(window);
                continue;
            }
            self.place_panel(window, &panel);
        }
    }

    /// Moves one panel to where it belongs now. The resize it causes is
    /// reported as any other, through `windowDidResize:`.
    fn place_panel(&self, handle: WindowId, panel: &Panel) {
        let Ok(state) = self.window(handle) else {
            return;
        };
        let window = state.window;
        let screen = match panel.monitor {
            Some(_) => self.panel_screen(panel),
            None => self
                .screen_of(window)
                .and_then(|id| self.screens.iter().find(|screen| screen.id == id).copied()),
        };
        let Some(screen) = screen else { return };
        let frame = panel.frame_on(&screen);
        // SAFETY: a live window of this shell; `setFrame:display:` takes an
        // `NSRect` and a `BOOL`.
        unsafe {
            let set: unsafe extern "C" fn(Id, Sel, NSRect, ffi::ObjcBool) = ffi::msg_send();
            set(window, ffi::sel(c"setFrame:display:"), frame, ffi::YES);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_point_is_inside_a_rect_by_its_left_and_bottom_edges() {
        let rect = NSRect::new(-1440.0, 0.0, 1440.0, 900.0);
        assert!(contains(rect, NSPoint { x: -1440.0, y: 0.0 }));
        assert!(contains(rect, NSPoint { x: -0.5, y: 899.5 }));
        assert!(
            !contains(rect, NSPoint { x: 0.0, y: 10.0 }),
            "the right edge is out"
        );
        assert!(
            !contains(rect, NSPoint { x: -10.0, y: 900.0 }),
            "the top edge is out"
        );
    }
}