#![allow(unsafe_code)]
mod input;
mod present;
mod window;
#[cfg(feature = "a11y")]
mod a11y;
use std::cell::RefCell;
use std::collections::HashSet;
use std::ffi::c_void;
use std::ptr::NonNull;
use std::rc::Rc;
use block2::RcBlock;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject};
use objc2::{define_class, msg_send, sel, AllocAnyThread, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSApplicationDidResignActiveNotification,
NSColor, NSColorSpace, NSEvent, NSEventMask, NSEventModifierFlags, NSEventType, NSImage,
NSMenuDidBeginTrackingNotification, NSScreen, NSStatusBar, NSStatusItem,
NSVariableStatusItemLength,
};
use objc2_foundation::{
NSData, NSNotification, NSNotificationCenter, NSPoint, NSRect, NSSize, NSString,
};
use crate::anchor::place_popup;
use crate::error::{Error, Result};
use crate::flyout::{next_flyout, place_flyout, HoverTarget};
use crate::geometry::{Edge, LogicalPoint, LogicalRect, LogicalSize};
use crate::keynav::{handle_key, FlyoutFocus, MenuFocus, NavAction, NavKey};
use crate::menu::{Icon, Item, Menu, MenuId};
use crate::platform::{Appearance, Platform};
use crate::render::paint::{render_menu, LaidMenu};
use crate::render::RasterDrawer;
use crate::style::Color;
use crate::theme::{MenuOptions, OsFamily, Theme};
use crate::{Tray, TrayCommand};
use window::{make_panel, MuriView, MuriWindowDelegate};
extern "C" {
static _dispatch_main_q: c_void;
fn dispatch_async_f(
queue: *const c_void,
context: *mut c_void,
work: extern "C" fn(*mut c_void),
);
}
#[link(name = "CoreText", kind = "framework")]
extern "C" {
fn CTFontCopyAttribute(font: *const c_void, attribute: *const c_void) -> *const c_void;
static kCTFontURLAttribute: *const c_void;
}
fn system_ui_font_path(font: &objc2_app_kit::NSFont) -> Option<String> {
let ct_font: *const c_void = (font as *const objc2_app_kit::NSFont).cast();
let url: *const c_void = unsafe { CTFontCopyAttribute(ct_font, kCTFontURLAttribute) };
if url.is_null() {
return None;
}
let nsurl: Retained<objc2_foundation::NSURL> =
unsafe { Retained::from_raw(url as *mut objc2_foundation::NSURL)? };
nsurl.path().map(|p| p.to_string())
}
extern "C" fn drain_trampoline(_ctx: *mut c_void) {
let app = MAIN_APP.with(|slot| slot.borrow().clone());
if let Some(app) = app {
if let Ok(mut state) = app.try_borrow_mut() {
state.drain();
}
}
}
pub(super) fn defer_drain() {
unsafe {
dispatch_async_f(
(&_dispatch_main_q as *const c_void).cast(),
std::ptr::null_mut(),
drain_trampoline,
);
}
}
thread_local! {
static MAIN_APP: RefCell<Option<Rc<RefCell<AppState>>>> = const { RefCell::new(None) };
static EVENTS: RefCell<Vec<UiEvent>> = const { RefCell::new(Vec::new()) };
}
pub(super) fn push_event(event: UiEvent) {
EVENTS.with(|e| e.borrow_mut().push(event));
defer_drain();
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum WindowKind {
Popup,
Flyout(usize),
}
impl WindowKind {
fn menu_level(self) -> usize {
match self {
WindowKind::Popup => 0,
WindowKind::Flyout(depth) => depth + 1,
}
}
}
pub(super) enum UiEvent {
TrayClicked,
MouseMoved {
kind: WindowKind,
x: f64,
y: f64,
},
MouseDown {
kind: WindowKind,
x: f64,
y: f64,
},
MouseExited {
kind: WindowKind,
},
Key(NavKey),
FocusChanged {
kind: WindowKind,
key: bool,
},
OutsideClick {
x: f64,
y: f64,
},
Dismiss,
#[cfg(feature = "a11y")]
A11yAction {
kind: WindowKind,
request: accesskit::ActionRequest,
},
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "MuriTrayTarget"]
#[thread_kind = MainThreadOnly]
struct TrayTarget;
impl TrayTarget {
#[unsafe(method(trayClicked:))]
fn tray_clicked(&self, _sender: Option<&AnyObject>) {
push_event(UiEvent::TrayClicked);
}
}
);
impl TrayTarget {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![super(mtm.alloc::<Self>().set_ivars(())), init] }
}
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "MuriDismissObserver"]
#[thread_kind = MainThreadOnly]
struct DismissObserver;
impl DismissObserver {
#[unsafe(method(muriDismiss:))]
fn muri_dismiss(&self, _n: &NSNotification) {
push_event(UiEvent::Dismiss);
}
}
);
impl DismissObserver {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![super(mtm.alloc::<Self>().set_ivars(())), init] }
}
}
struct DismissWatchers {
global_monitor: Option<Retained<AnyObject>>,
local_monitor: Option<Retained<AnyObject>>,
observer: Retained<DismissObserver>,
}
impl DismissWatchers {
fn install(mtm: MainThreadMarker) -> Self {
let mask = NSEventMask::LeftMouseDown | NSEventMask::RightMouseDown;
let global_block = RcBlock::new(|_event: NonNull<NSEvent>| {
let p = NSEvent::mouseLocation();
push_event(UiEvent::OutsideClick { x: p.x, y: p.y });
});
let global_monitor =
NSEvent::addGlobalMonitorForEventsMatchingMask_handler(mask, &global_block);
let local_block = RcBlock::new(|event: NonNull<NSEvent>| -> *mut NSEvent {
let p = NSEvent::mouseLocation();
push_event(UiEvent::OutsideClick { x: p.x, y: p.y });
event.as_ptr()
});
let local_monitor =
unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(mask, &local_block) };
let observer = DismissObserver::new(mtm);
let center = NSNotificationCenter::defaultCenter();
unsafe {
center.addObserver_selector_name_object(
&observer,
sel!(muriDismiss:),
Some(NSMenuDidBeginTrackingNotification),
None,
);
center.addObserver_selector_name_object(
&observer,
sel!(muriDismiss:),
Some(NSApplicationDidResignActiveNotification),
None,
);
}
unsafe {
objc2_app_kit::NSWorkspace::sharedWorkspace()
.notificationCenter()
.addObserver_selector_name_object(
&observer,
sel!(muriDismiss:),
Some(objc2_app_kit::NSWorkspaceActiveSpaceDidChangeNotification),
None,
);
}
DismissWatchers {
global_monitor,
local_monitor,
observer,
}
}
}
impl Drop for DismissWatchers {
fn drop(&mut self) {
unsafe {
if let Some(m) = self.global_monitor.take() {
NSEvent::removeMonitor(&m);
}
if let Some(m) = self.local_monitor.take() {
NSEvent::removeMonitor(&m);
}
NSNotificationCenter::defaultCenter().removeObserver(&self.observer);
objc2_app_kit::NSWorkspace::sharedWorkspace()
.notificationCenter()
.removeObserver(&self.observer);
}
}
}
fn point_outside_all(frames: &[(f64, f64, f64, f64)], x: f64, y: f64) -> bool {
!frames
.iter()
.any(|&(min_x, min_y, max_x, max_y)| x >= min_x && x <= max_x && y >= min_y && y <= max_y)
}
pub struct MacosAnchor {
mtm: MainThreadMarker,
status_item: Option<Retained<NSStatusItem>>,
_target: Option<Retained<TrayTarget>>,
}
impl std::fmt::Debug for MacosAnchor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MacosAnchor")
.field("installed", &self.status_item.is_some())
.finish()
}
}
#[derive(Clone, Copy)]
struct AnchorGeometry {
button_frame: NSRect,
screen_frame: NSRect,
visible_frame: NSRect,
scale: f32,
}
impl AnchorGeometry {
fn anchor_rect_local(&self) -> LogicalRect {
let sf = self.screen_frame;
let bf = self.button_frame;
let x = (bf.origin.x - sf.origin.x) as f32;
let top = (sf.origin.y + sf.size.height - (bf.origin.y + bf.size.height)) as f32;
LogicalRect::new(
LogicalPoint::new(x, top),
LogicalSize::new(bf.size.width as f32, bf.size.height as f32),
)
}
fn work_area_local(&self) -> LogicalRect {
let sf = self.screen_frame;
let vf = self.visible_frame;
let x = (vf.origin.x - sf.origin.x) as f32;
let top = (sf.origin.y + sf.size.height - (vf.origin.y + vf.size.height)) as f32;
LogicalRect::new(
LogicalPoint::new(x, top),
LogicalSize::new(vf.size.width as f32, vf.size.height as f32),
)
}
fn to_screen(self, origin: LogicalPoint, size: LogicalSize) -> NSPoint {
let sf = self.screen_frame;
let x = sf.origin.x + origin.x as f64;
let y = sf.origin.y + sf.size.height - (origin.y as f64 + size.height as f64);
NSPoint::new(x, y)
}
fn for_rect(mtm: MainThreadMarker, rect: LogicalRect) -> Option<Self> {
let screen = NSScreen::screens(mtm).firstObject()?;
let sf = screen.frame();
let bf = NSRect::new(
NSPoint::new(
sf.origin.x + rect.origin.x as f64,
sf.origin.y + sf.size.height - (rect.origin.y + rect.size.height) as f64,
),
NSSize::new(rect.size.width as f64, rect.size.height as f64),
);
Some(AnchorGeometry {
button_frame: bf,
screen_frame: sf,
visible_frame: screen.visibleFrame(),
scale: screen.backingScaleFactor() as f32,
})
}
}
impl MacosAnchor {
pub fn new(mtm: MainThreadMarker) -> Self {
MacosAnchor {
mtm,
status_item: None,
_target: None,
}
}
fn set_status(&self, icon: &Icon, title: Option<&str>, tooltip: Option<&str>) {
let Some(item) = &self.status_item else {
return;
};
let Some(button) = item.button(self.mtm) else {
return;
};
let mut has_image = false;
let png_bytes: Option<std::borrow::Cow<'_, [u8]>> = match icon {
Icon::Png(bytes) => Some(std::borrow::Cow::Borrowed(&bytes[..])),
Icon::Svg(bytes) => svg_to_png(&bytes[..]).map(std::borrow::Cow::Owned),
_ => None,
};
if let Some(bytes) = png_bytes {
let data = NSData::with_bytes(&bytes);
if let Some(image) =
NSImage::initWithData(NSImage::alloc(), &data).filter(|i| i.isValid())
{
image.setSize(NSSize::new(18.0, 18.0));
button.setImage(Some(&image));
has_image = true;
}
}
if !has_image {
button.setImage(None);
}
let title = title.filter(|t| !t.is_empty());
let text = match title {
Some(t) => t,
None if has_image => "",
None => "●",
};
button.setTitle(&NSString::from_str(text));
if has_image && title.is_some() {
button.setImagePosition(objc2_app_kit::NSCellImagePosition::ImageLeft);
}
if let Some(tip) = tooltip {
button.setToolTip(Some(&NSString::from_str(tip)));
}
}
fn set_tooltip(&self, tooltip: Option<&str>) {
if let Some(item) = &self.status_item {
if let Some(button) = item.button(self.mtm) {
button.setToolTip(tooltip.map(NSString::from_str).as_deref());
}
}
}
fn set_visible(&self, visible: bool) {
if let Some(item) = &self.status_item {
item.setVisible(visible);
}
}
fn remove(&mut self) {
if let Some(item) = self.status_item.take() {
NSStatusBar::systemStatusBar().removeStatusItem(&item);
}
}
fn geometry(&self) -> Option<AnchorGeometry> {
let item = self.status_item.as_ref()?;
let button = item.button(self.mtm)?;
let window = button.window()?;
let screen = window
.screen()
.or_else(|| NSScreen::screens(self.mtm).firstObject())?;
Some(AnchorGeometry {
button_frame: window.frame(),
screen_frame: screen.frame(),
visible_frame: screen.visibleFrame(),
scale: screen.backingScaleFactor() as f32,
})
}
}
impl MacosAnchor {
fn install(&mut self, tooltip: Option<&str>) -> Result<()> {
let status_bar = NSStatusBar::systemStatusBar();
let item = status_bar.statusItemWithLength(NSVariableStatusItemLength);
let target = TrayTarget::new(self.mtm);
if let Some(button) = item.button(self.mtm) {
unsafe {
button.setTarget(Some(&target));
button.setAction(Some(sel!(trayClicked:)));
}
if let Some(tip) = tooltip {
button.setToolTip(Some(&NSString::from_str(tip)));
}
}
self.status_item = Some(item);
self._target = Some(target);
Ok(())
}
fn anchor_rect(&self) -> Result<LogicalRect> {
self.geometry()
.map(|g| g.anchor_rect_local())
.ok_or_else(|| Error::Platform("status item not installed".into()))
}
fn work_area(&self) -> Result<LogicalRect> {
self.geometry()
.map(|g| g.work_area_local())
.ok_or_else(|| Error::Platform("status item not installed".into()))
}
}
impl Drop for MacosAnchor {
fn drop(&mut self) {
self.remove();
}
}
struct Panel {
panel: Retained<objc2_app_kit::NSPanel>,
view: Retained<MuriView>,
#[allow(dead_code)]
delegate: Retained<MuriWindowDelegate>,
drawer: RasterDrawer,
laid: Option<LaidMenu>,
cursor: LogicalPoint,
hovered: Option<usize>,
origin: LogicalPoint,
scale: f32,
#[cfg(feature = "a11y")]
adapter: accesskit_macos::SubclassingAdapter,
#[cfg(feature = "a11y")]
snapshot: Rc<RefCell<a11y::A11ySnapshot>>,
}
impl Panel {
fn order_out(&self) {
self.panel.orderOut(None);
}
}
struct Flyout {
parent: usize,
panel: Panel,
}
enum Anchor {
Tray(MacosAnchor),
Fixed(AnchorGeometry),
}
impl Anchor {
fn geometry(&self) -> Option<AnchorGeometry> {
match self {
Anchor::Tray(a) => a.geometry(),
Anchor::Fixed(g) => Some(*g),
}
}
}
struct PopupSession<'a> {
mtm: MainThreadMarker,
menu: Menu,
options: MenuOptions,
dispatch: Box<dyn Fn(&MenuId) + 'a>,
anchor: Anchor,
edge: Edge,
popup: Option<Panel>,
flyouts: Vec<Flyout>,
focused: HashSet<WindowKind>,
dismiss_armed: bool,
watchers: Option<DismissWatchers>,
}
impl PopupSession<'_> {
fn theme(&self) -> Theme {
let mut theme = self
.options
.theme
.resolve(OsFamily::MacOs, system_is_dark());
if self.options.theme.injects_system() {
if let Some((r, g, b, a)) = system_accent(self.mtm) {
theme.accent = Color::Rgba(r, g, b, a);
}
read_system_palette().apply_to(&mut theme);
{
use crate::style::{Color, Rgba};
let sub = theme.resolve(theme.background);
let flatten = |t: Rgba| -> Color {
let a = t.a as f32 / 255.0;
let mix =
|tc: u8, sc: u8| (tc as f32 * a + sc as f32 * (1.0 - a)).round() as u8;
Color::Rgba(mix(t.r, sub.r), mix(t.g, sub.g), mix(t.b, sub.b), 255)
};
let label = flatten(theme.resolve(theme.label));
let secondary = flatten(theme.resolve(theme.secondary_label));
theme.label = label;
theme.secondary_label = secondary;
}
if let Some(font) = read_system_menu_font() {
font.apply_size_to(&mut theme);
}
let metrics = native_menu_metrics();
theme.row_height = metrics.row_height;
theme.corner_radius = metrics.corner_radius;
theme.padding.left = metrics.leading_inset;
theme.padding.right = metrics.leading_inset;
theme.row_font.letter_spacing = 0.0;
theme.header_font.letter_spacing = 0.0;
if increase_contrast_enabled() {
theme.make_opaque();
let dark = system_is_dark();
theme.label = if dark {
Color::rgb(255, 255, 255)
} else {
Color::rgb(0, 0, 0)
};
theme.secondary_label = if dark {
Color::rgb(216, 216, 216)
} else {
Color::rgb(40, 40, 40)
};
theme.separator = if dark {
Color::rgb(255, 255, 255)
} else {
Color::rgb(0, 0, 0)
};
} else if !transparency_enabled() {
theme.make_opaque();
} else {
theme.background = Color::Rgba(0, 0, 0, 0);
}
}
theme
}
fn menu_at_level(&self, level: usize) -> Option<&Menu> {
crate::menu::descend(
&self.menu,
self.flyouts.iter().take(level).map(|f| f.parent),
)
}
fn panel(&self, kind: WindowKind) -> Option<&Panel> {
match kind {
WindowKind::Popup => self.popup.as_ref(),
WindowKind::Flyout(d) => self.flyouts.get(d).map(|f| &f.panel),
}
}
fn panel_mut(&mut self, kind: WindowKind) -> Option<&mut Panel> {
match kind {
WindowKind::Popup => self.popup.as_mut(),
WindowKind::Flyout(d) => self.flyouts.get_mut(d).map(|f| &mut f.panel),
}
}
fn flyout_stack(&self) -> Vec<usize> {
self.flyouts.iter().map(|f| f.parent).collect()
}
fn current_focus(&self) -> MenuFocus {
MenuFocus {
top: self.popup.as_ref().and_then(|p| p.hovered),
flyout: self
.flyouts
.iter()
.map(|f| FlyoutFocus {
parent: f.parent,
child: f.panel.hovered,
})
.collect(),
}
}
fn open_popup(&mut self) {
if self.popup.is_some() {
return;
}
let theme = self.theme();
let Some(geom) = self.anchor.geometry() else {
return;
};
let scale = geom.scale.max(1.0);
let mut drawer = RasterDrawer::for_menu_options(scale, &self.options);
let laid = render_menu(&mut drawer, &self.menu, &theme, &self.options, None);
let origin = place_popup(
geom.anchor_rect_local(),
laid.size,
geom.work_area_local(),
self.edge,
2.0,
);
let screen_origin = geom.to_screen(origin, laid.size);
let content_rect = NSRect::new(
screen_origin,
NSSize::new(laid.size.width as f64, laid.size.height as f64),
);
let native = make_panel(
self.mtm,
content_rect,
theme.corner_radius,
WindowKind::Popup,
);
#[cfg(feature = "a11y")]
let snapshot = Rc::new(RefCell::new(a11y::A11ySnapshot {
menu: self.menu.clone(),
focus: MenuFocus {
top: None,
flyout: Vec::new(),
},
}));
#[cfg(feature = "a11y")]
let adapter = a11y::make_adapter(&native.view, Rc::clone(&snapshot), WindowKind::Popup);
self.popup = Some(Panel {
panel: native.panel,
view: native.view,
delegate: native.delegate,
drawer,
laid: Some(laid),
cursor: LogicalPoint::default(),
hovered: None,
origin,
scale,
#[cfg(feature = "a11y")]
adapter,
#[cfg(feature = "a11y")]
snapshot,
});
self.redraw(WindowKind::Popup);
if let Some(popup) = self.popup.as_ref() {
popup.panel.makeKeyAndOrderFront(None);
}
self.watchers = Some(DismissWatchers::install(self.mtm));
self.sync_a11y();
}
fn push_flyout(&mut self, parent_index: usize) {
let depth = self.flyouts.len();
let Some(parent_menu) = self.menu_at_level(depth) else {
return;
};
let Some(child) = (match parent_menu.items.get(parent_index) {
Some(Item::Submenu { menu, .. }) => Some(menu.clone()),
_ => None,
}) else {
return;
};
let (parent_origin, parent_size, scale, row_rect) = {
let parent_panel = if depth == 0 {
self.popup.as_ref()
} else {
self.flyouts.get(depth - 1).map(|f| &f.panel)
};
let Some(pp) = parent_panel else {
return;
};
let Some(rect) = pp
.laid
.as_ref()
.and_then(|l| l.rows.iter().find(|r| r.index == parent_index))
.map(|r| r.rect)
else {
return;
};
(
pp.origin,
pp.laid.as_ref().map(|l| l.size).unwrap_or_default(),
pp.scale,
rect,
)
};
let theme = self.theme();
let Some(geom) = self.anchor.geometry() else {
return;
};
let mut drawer = RasterDrawer::for_menu_options(scale, &self.options);
let child_laid = render_menu(&mut drawer, &child, &theme, &self.options, None);
let parent_rect = LogicalRect::new(parent_origin, parent_size);
let placement = place_flyout(
parent_rect,
row_rect,
child_laid.size,
geom.work_area_local(),
);
let screen_origin = geom.to_screen(placement.origin, child_laid.size);
let content_rect = NSRect::new(
screen_origin,
NSSize::new(child_laid.size.width as f64, child_laid.size.height as f64),
);
let kind = WindowKind::Flyout(depth);
let native = make_panel(self.mtm, content_rect, theme.corner_radius, kind);
#[cfg(feature = "a11y")]
let snapshot = Rc::new(RefCell::new(a11y::A11ySnapshot {
menu: child.clone(),
focus: MenuFocus {
top: None,
flyout: Vec::new(),
},
}));
#[cfg(feature = "a11y")]
let adapter = a11y::make_adapter(&native.view, Rc::clone(&snapshot), kind);
self.flyouts.push(Flyout {
parent: parent_index,
panel: Panel {
panel: native.panel,
view: native.view,
delegate: native.delegate,
drawer,
laid: None,
cursor: LogicalPoint::default(),
hovered: None,
origin: placement.origin,
scale,
#[cfg(feature = "a11y")]
adapter,
#[cfg(feature = "a11y")]
snapshot,
},
});
self.redraw(kind);
if let Some(f) = self.flyouts.last() {
f.panel.panel.orderFrontRegardless();
}
self.sync_a11y();
}
fn truncate_flyouts(&mut self, len: usize) {
while self.flyouts.len() > len {
let depth = self.flyouts.len() - 1;
if let Some(f) = self.flyouts.pop() {
f.panel.order_out();
}
let _ = depth;
}
}
fn close_popup(&mut self) {
self.truncate_flyouts(0);
if let Some(popup) = self.popup.take() {
popup.order_out();
}
self.focused.clear();
self.dismiss_armed = false;
self.watchers = None;
}
fn apply_flyout_stack(&mut self, target: &[usize]) {
let mut common = 0;
while common < target.len()
&& common < self.flyouts.len()
&& self.flyouts[common].parent == target[common]
{
common += 1;
}
self.truncate_flyouts(common);
for &parent in &target[common..] {
self.push_flyout(parent);
}
}
fn redraw(&mut self, kind: WindowKind) {
let theme = self.theme();
let options = self.options.clone();
let Some(menu) = crate::menu::descend(
&self.menu,
self.flyouts
.iter()
.take(kind.menu_level())
.map(|f| f.parent),
) else {
return;
};
let panel = match kind {
WindowKind::Popup => self.popup.as_mut(),
WindowKind::Flyout(d) => self.flyouts.get_mut(d).map(|f| &mut f.panel),
};
let Some(panel) = panel else {
return;
};
let laid = render_menu(&mut panel.drawer, menu, &theme, &options, panel.hovered);
if let Some(image) = present::framebuffer_to_cgimage(panel.drawer.framebuffer()) {
present::set_layer_contents(&panel.view, &image, panel.scale);
}
panel.laid = Some(laid);
}
fn redraw_all(&mut self) {
self.redraw(WindowKind::Popup);
for d in 0..self.flyouts.len() {
self.redraw(WindowKind::Flyout(d));
}
}
fn on_cursor(&mut self, kind: WindowKind, pt: LogicalPoint) {
let (hovered, changed) = {
let Some(p) = self.panel_mut(kind) else {
return;
};
p.cursor = pt;
let h = p.laid.as_ref().and_then(|l| l.hit(pt));
let changed = h != p.hovered;
if changed {
p.hovered = h;
}
(h, changed)
};
if changed {
self.redraw(kind);
}
let panel_depth = kind.menu_level();
let level_menu = self.menu_at_level(panel_depth);
let target = match hovered {
Some(i)
if level_menu
.is_some_and(|m| matches!(m.items.get(i), Some(Item::Submenu { .. }))) =>
{
HoverTarget::ParentRow {
panel: panel_depth,
index: i,
}
}
Some(_) => HoverTarget::OtherRow { panel: panel_depth },
None => HoverTarget::Outside,
};
let next = next_flyout(&self.flyout_stack(), target);
self.apply_flyout_stack(&next);
self.sync_a11y();
}
fn on_exit(&mut self, _kind: WindowKind) {
if self.popup.is_none() {
return;
}
let loc = NSEvent::mouseLocation();
let frames = self.panel_screen_frames();
if !point_outside_all(&frames, loc.x, loc.y) {
return;
}
self.truncate_flyouts(0);
let cleared = self
.popup
.as_mut()
.map(|p| p.hovered.take().is_some())
.unwrap_or(false);
if cleared {
self.redraw(WindowKind::Popup);
}
self.sync_a11y();
}
fn on_click(&mut self, kind: WindowKind) {
let level = kind.menu_level();
let Some(menu) = self.menu_at_level(level) else {
return;
};
let (hit, id) = {
let Some(p) = self.panel(kind) else {
return;
};
(
p.laid.as_ref().and_then(|l| l.hit(p.cursor)),
p.laid.as_ref().and_then(|l| l.id_at(p.cursor)),
)
};
if let Some(i) = hit {
if matches!(menu.items.get(i), Some(Item::Submenu { .. })) {
self.truncate_flyouts(level);
self.push_flyout(i);
return;
}
}
if let Some(id) = id {
if !id.is_none() {
(self.dispatch)(&id);
}
self.close_popup();
}
}
fn on_key_nav(&mut self, key: NavKey) {
let mut focus = self.current_focus();
let action = handle_key(&self.menu, &mut focus, key);
if let Some(popup) = self.popup.as_mut() {
popup.hovered = focus.top;
}
match action {
NavAction::None => return,
NavAction::Redraw => {}
NavAction::OpenFlyout(i) => self.push_flyout(i),
NavAction::CloseFlyout => {
let keep = self.flyouts.len().saturating_sub(1);
self.truncate_flyouts(keep);
}
NavAction::Activate(id) => {
if !id.is_none() {
(self.dispatch)(&id);
}
self.close_popup();
return;
}
NavAction::CloseAll => {
self.close_popup();
return;
}
}
for (k, f) in self.flyouts.iter_mut().enumerate() {
if let Some(ff) = focus.flyout.get(k) {
f.panel.hovered = ff.child;
}
}
self.redraw_all();
self.sync_a11y();
}
#[cfg(feature = "a11y")]
fn is_submenu_at_path(&self, path: &[usize]) -> bool {
let mut menu = &self.menu;
for (k, &idx) in path.iter().enumerate() {
match menu.items.get(idx) {
Some(Item::Submenu { menu: child, .. }) => {
if k + 1 == path.len() {
return true;
}
menu = child;
}
_ => return false,
}
}
false
}
#[cfg(feature = "a11y")]
fn menu_id_at_path(&self, path: &[usize]) -> Option<MenuId> {
let mut menu = &self.menu;
for (k, &idx) in path.iter().enumerate() {
match menu.items.get(idx)? {
Item::Row(row) if k + 1 == path.len() => return Some(row.id.clone()),
Item::Submenu { menu: child, .. } => menu = child,
_ => return None,
}
}
None
}
#[cfg(feature = "a11y")]
fn sync_a11y(&mut self) {
let full = self.current_focus();
let root = &self.menu;
if let Some(popup) = self.popup.as_mut() {
a11y::sync(
&mut popup.adapter,
&popup.snapshot,
|| root.clone(),
MenuFocus {
top: full.top,
flyout: full.flyout.clone(),
},
);
}
let parents: Vec<usize> = self.flyouts.iter().map(|f| f.parent).collect();
for d in 0..self.flyouts.len() {
let Some(menu) = crate::menu::descend(&self.menu, parents[..=d].iter().copied()) else {
continue;
};
let top = full.flyout.get(d).and_then(|f| f.child);
let sub: Vec<FlyoutFocus> = full.flyout.iter().skip(d + 1).copied().collect();
if let Some(f) = self.flyouts.get_mut(d) {
a11y::sync(
&mut f.panel.adapter,
&f.panel.snapshot,
|| menu.clone(),
MenuFocus { top, flyout: sub },
);
}
}
}
#[cfg(not(feature = "a11y"))]
#[inline]
fn sync_a11y(&mut self) {}
#[cfg(feature = "a11y")]
fn on_a11y_action(&mut self, kind: WindowKind, request: accesskit::ActionRequest) {
let target = crate::a11y::AxId(request.target.0);
let level = kind.menu_level();
let Some(menu) = self.menu_at_level(level) else {
return;
};
let tree = crate::a11y::build_tree(menu);
let Some(rel_path) = crate::a11y::locate_path(&tree, target) else {
return;
};
let mut abs: Vec<usize> = self.flyouts.iter().take(level).map(|f| f.parent).collect();
abs.extend_from_slice(&rel_path);
self.apply_a11y(&abs, request.action);
}
#[cfg(feature = "a11y")]
fn apply_a11y(&mut self, abs: &[usize], action: accesskit::Action) {
use accesskit::Action;
if abs.is_empty() {
return;
}
match action {
Action::Focus => {
let target = &abs[..abs.len() - 1];
self.apply_flyout_stack(target);
let final_kind = if target.is_empty() {
WindowKind::Popup
} else {
WindowKind::Flyout(target.len() - 1)
};
if let (Some(&last), Some(p)) = (abs.last(), self.panel_mut(final_kind)) {
p.hovered = Some(last);
}
if let (Some(&first), Some(p)) = (abs.first(), self.popup.as_mut()) {
p.hovered = Some(first);
}
self.redraw_all();
self.sync_a11y();
}
Action::Click => {
if self.is_submenu_at_path(abs) {
self.apply_flyout_stack(abs);
if let (Some(&first), Some(p)) = (abs.first(), self.popup.as_mut()) {
p.hovered = Some(first);
}
self.sync_a11y();
return;
}
if let Some(id) = self.menu_id_at_path(abs) {
if !id.is_none() {
(self.dispatch)(&id);
}
}
self.close_popup();
}
_ => {}
}
}
fn apply_event(&mut self, event: UiEvent) {
match event {
UiEvent::TrayClicked => {
if self.popup.is_some() {
self.close_popup();
} else {
self.open_popup();
}
}
UiEvent::MouseMoved { kind, x, y } => {
self.on_cursor(kind, LogicalPoint::new(x as f32, y as f32));
}
UiEvent::MouseExited { kind } => self.on_exit(kind),
UiEvent::MouseDown { kind, x, y } => {
if let Some(p) = self.panel_mut(kind) {
p.cursor = LogicalPoint::new(x as f32, y as f32);
}
self.on_click(kind);
}
UiEvent::Key(key) => self.on_key_nav(key),
UiEvent::FocusChanged { kind, key } => {
if key {
self.focused.insert(kind);
self.dismiss_armed = false;
} else {
self.focused.remove(&kind);
if self.focused.is_empty() {
self.dismiss_armed = true;
}
}
}
UiEvent::OutsideClick { x, y } => self.on_outside_click(x, y),
UiEvent::Dismiss => {
if self.popup.is_some() {
self.close_popup();
}
}
#[cfg(feature = "a11y")]
UiEvent::A11yAction { kind, request } => self.on_a11y_action(kind, request),
}
}
fn panel_screen_frames(&self) -> Vec<(f64, f64, f64, f64)> {
let mut frames = Vec::with_capacity(1 + self.flyouts.len());
let mut push = |panel: &Panel| {
let f = panel.panel.frame();
frames.push((
f.origin.x,
f.origin.y,
f.origin.x + f.size.width,
f.origin.y + f.size.height,
));
};
if let Some(p) = self.popup.as_ref() {
push(p);
}
for fl in &self.flyouts {
push(&fl.panel);
}
frames
}
fn on_outside_click(&mut self, x: f64, y: f64) {
if self.popup.is_none() {
return;
}
let mut frames = self.panel_screen_frames();
if let Anchor::Tray(a) = &self.anchor {
if let Some(g) = a.geometry() {
let bf = g.button_frame;
frames.push((
bf.origin.x,
bf.origin.y,
bf.origin.x + bf.size.width,
bf.origin.y + bf.size.height,
));
}
}
if point_outside_all(&frames, x, y) {
self.close_popup();
}
}
fn finalize_dismiss(&mut self) -> bool {
let dismissed = self.dismiss_armed && self.focused.is_empty() && self.popup.is_some();
if dismissed {
self.close_popup();
}
self.dismiss_armed = false;
dismissed
}
}
struct AppState {
session: PopupSession<'static>,
tray: Tray,
owns_run_loop: bool,
}
impl AppState {
fn apply_command(&mut self, command: TrayCommand) {
match command {
TrayCommand::SetMenu(menu) => {
self.tray.menu = menu.clone();
self.session.menu = menu;
if self.session.popup.is_some() {
self.session.truncate_flyouts(0);
self.session.redraw(WindowKind::Popup);
self.session.sync_a11y();
}
}
TrayCommand::SetIcon(icon) => {
self.tray.icon = icon;
let icon = self.tray.icon.clone();
let title = self.tray.title.clone();
let tooltip = self.tray.tooltip.clone();
if let Anchor::Tray(a) = &self.session.anchor {
a.set_status(&icon, title.as_deref(), tooltip.as_deref());
}
}
TrayCommand::SetTooltip(tooltip) => {
self.tray.tooltip = tooltip;
if let Anchor::Tray(a) = &self.session.anchor {
a.set_tooltip(self.tray.tooltip.as_deref());
}
}
TrayCommand::SetTitle(title) => {
self.tray.title = title;
let icon = self.tray.icon.clone();
let title = self.tray.title.clone();
let tooltip = self.tray.tooltip.clone();
if let Anchor::Tray(a) = &self.session.anchor {
a.set_status(&icon, title.as_deref(), tooltip.as_deref());
}
}
TrayCommand::SetVisible(visible) => {
if let Anchor::Tray(a) = &self.session.anchor {
a.set_visible(visible);
}
}
TrayCommand::Open => {
if self.session.popup.is_none() {
self.session.open_popup();
}
}
TrayCommand::Close => self.session.close_popup(),
TrayCommand::Shutdown => {
self.session.close_popup();
if let Anchor::Tray(a) = &mut self.session.anchor {
a.remove();
}
if self.owns_run_loop {
stop_run_loop(self.session.mtm);
}
}
TrayCommand::SetTheme(theme) => {
self.session.options.theme = theme;
self.repaint_open_popup();
}
TrayCommand::SetOptions(options) => {
self.session.options = options;
self.repaint_open_popup();
}
TrayCommand::QueryAnchorRect(reply) => {
let rect = match &self.session.anchor {
Anchor::Tray(a) => a.anchor_rect().ok(),
_ => None,
};
let _ = reply.send(rect);
}
}
}
fn repaint_open_popup(&mut self) {
if self.session.popup.is_some() {
self.session.truncate_flyouts(0);
self.session.redraw(WindowKind::Popup);
self.session.sync_a11y();
}
}
fn drain(&mut self) {
loop {
let events = EVENTS.with(|e| std::mem::take(&mut *e.borrow_mut()));
let commands: Vec<TrayCommand> = self
.tray
.commands
.lock()
.map(|mut q| std::mem::take(&mut *q))
.unwrap_or_default();
if events.is_empty() && commands.is_empty() {
break;
}
for command in commands {
self.apply_command(command);
}
for event in events {
self.session.apply_event(event);
}
}
self.session.finalize_dismiss();
}
}
pub struct MacPlatform {
mtm: Option<MainThreadMarker>,
anchor: Option<MacosAnchor>,
}
impl Default for MacPlatform {
fn default() -> Self {
Self::new()
}
}
impl MacPlatform {
pub fn new() -> Self {
MacPlatform {
mtm: MainThreadMarker::new(),
anchor: None,
}
}
fn require_mtm(&self) -> Result<MainThreadMarker> {
self.mtm.ok_or(Error::MainThread)
}
}
impl Platform for MacPlatform {
fn install_tray(&mut self, icon: &Icon, tooltip: Option<&str>) -> Result<()> {
let mtm = self.require_mtm()?;
let mut anchor = MacosAnchor::new(mtm);
anchor.install(tooltip)?;
anchor.set_status(icon, None, tooltip);
self.anchor = Some(anchor);
Ok(())
}
fn tray_anchor_rect(&self) -> Result<LogicalRect> {
self.anchor
.as_ref()
.ok_or_else(|| Error::Platform("tray not installed".into()))?
.anchor_rect()
}
fn supports_tray_anchor(&self) -> bool {
true
}
fn cursor_position(&self) -> Option<LogicalPoint> {
let mtm = self.require_mtm().ok()?;
let p = NSEvent::mouseLocation();
let sf = NSScreen::screens(mtm).firstObject()?.frame();
Some(LogicalPoint::new(
(p.x - sf.origin.x) as f32,
(sf.origin.y + sf.size.height - p.y) as f32,
))
}
fn appearance(&self) -> Appearance {
Appearance::from_is_dark(system_is_dark())
}
fn system_menu_font(&self) -> Option<crate::platform::SystemFont> {
self.require_mtm().ok()?;
read_system_menu_font()
}
fn system_palette(&self) -> crate::platform::SystemPalette {
if self.require_mtm().is_err() {
return crate::platform::SystemPalette::default();
}
read_system_palette()
}
fn work_area(&self) -> LogicalRect {
self.anchor
.as_ref()
.and_then(|a| a.work_area().ok())
.unwrap_or_else(|| {
LogicalRect::new(LogicalPoint::new(0.0, 0.0), LogicalSize::new(1440.0, 900.0))
})
}
fn run_tray(self, tray: Tray) -> Result<()> {
run_event_loop(tray)
}
fn spawn_tray(self, tray: Tray) -> Result<()> {
let mtm = self.require_mtm()?;
install_tray_session(tray, mtm, false)
}
fn open_popup_session(
&mut self,
menu: Menu,
options: MenuOptions,
on_click: &(dyn Fn(&MenuId) + '_),
anchor: LogicalRect,
edge: Edge,
) -> Result<()> {
let mtm = self.require_mtm()?;
run_popup_session(mtm, menu, options, on_click, anchor, edge)
}
}
fn stop_run_loop(mtm: MainThreadMarker) {
let app = NSApplication::sharedApplication(mtm);
app.stop(None);
let event = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
NSEventType::ApplicationDefined,
NSPoint::new(0.0, 0.0),
NSEventModifierFlags::empty(),
0.0,
0,
None,
0,
0,
0,
);
if let Some(event) = event {
app.postEvent_atStart(&event, true);
}
}
fn run_event_loop(tray: Tray) -> Result<()> {
let mtm = MainThreadMarker::new().ok_or(Error::MainThread)?;
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
install_tray_session(tray, mtm, true)?;
app.run();
Ok(())
}
fn install_tray_session(mut tray: Tray, mtm: MainThreadMarker, owns_run_loop: bool) -> Result<()> {
let mut anchor = MacosAnchor::new(mtm);
anchor.install(tray.tooltip.as_deref())?;
anchor.set_status(&tray.icon, tray.title.as_deref(), tray.tooltip.as_deref());
if let Ok(mut waker) = tray.waker.lock() {
*waker = Some(Box::new(defer_drain));
}
let surface = tray.surface_id;
let dispatch: Box<dyn Fn(&MenuId) + 'static> = match tray.on_click.take() {
Some(handler) => Box::new(move |id| {
handler(id);
crate::event::emit(id.clone(), surface);
}),
None => Box::new(move |id| crate::event::emit(id.clone(), surface)),
};
let session = PopupSession {
mtm,
menu: tray.menu.clone(),
options: tray.options.clone(),
dispatch,
anchor: Anchor::Tray(anchor),
edge: Edge::Bottom,
popup: None,
flyouts: Vec::new(),
focused: HashSet::new(),
dismiss_armed: false,
watchers: None,
};
let state = Rc::new(RefCell::new(AppState {
session,
tray,
owns_run_loop,
}));
MAIN_APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(&state)));
defer_drain();
Ok(())
}
fn run_popup_session(
mtm: MainThreadMarker,
menu: Menu,
options: MenuOptions,
on_click: &(dyn Fn(&MenuId) + '_),
anchor: LogicalRect,
edge: Edge,
) -> Result<()> {
let app = NSApplication::sharedApplication(mtm);
let geom = AnchorGeometry::for_rect(mtm, anchor)
.ok_or_else(|| Error::Platform("no screen available for the popup".into()))?;
let mut session = PopupSession {
mtm,
menu,
options,
dispatch: Box::new(move |id| on_click(id)),
anchor: Anchor::Fixed(geom),
edge,
popup: None,
flyouts: Vec::new(),
focused: HashSet::new(),
dismiss_armed: false,
watchers: None,
};
session.open_popup();
if session.popup.is_none() {
return Err(Error::Platform("failed to open the popup window".into()));
}
loop {
let event = app.nextEventMatchingMask_untilDate_inMode_dequeue(
NSEventMask::Any,
Some(&objc2_foundation::NSDate::distantFuture()),
&objc2_foundation::NSString::from_str("kCFRunLoopDefaultMode"),
true,
);
if let Some(event) = event {
app.sendEvent(&event);
}
let events = EVENTS.with(|e| std::mem::take(&mut *e.borrow_mut()));
for e in events {
session.apply_event(e);
}
session.finalize_dismiss();
if session.popup.is_none() {
break;
}
}
Ok(())
}
fn svg_to_png(bytes: &[u8]) -> Option<Vec<u8>> {
let (rgba, w, h) = crate::render::rasterize_svg(bytes)?;
crate::render::encode_rgba_png(&rgba, w, h)
}
const MACOS_SYSTEM_ROW_HEIGHT_FACTOR: f32 = 1.82;
fn macos_system_row_height(point_size: f32) -> f32 {
if let Some(pitch) = measure_nsmenu_row_pitch() {
if pitch.is_finite() && pitch >= crate::theme::MACOS_ROW_HEIGHT {
return pitch;
}
}
if point_size > 0.0 {
(point_size * MACOS_SYSTEM_ROW_HEIGHT_FACTOR).max(crate::theme::MACOS_ROW_HEIGHT)
} else {
crate::theme::MACOS_ROW_HEIGHT
}
}
fn measure_nsmenu_row_pitch() -> Option<f32> {
let mtm = MainThreadMarker::new()?;
let two = nsmenu_layout_height(mtm, 2)?;
let three = nsmenu_layout_height(mtm, 3)?;
let pitch = three - two;
(pitch.is_finite() && pitch > 0.0).then_some(pitch)
}
fn nsmenu_layout_height(mtm: MainThreadMarker, items: usize) -> Option<f32> {
let menu = objc2_app_kit::NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(""));
for _ in 0..items {
menu.addItem(&objc2_app_kit::NSMenuItem::new(mtm));
}
let height = menu.size().height as f32;
(height > 0.0).then_some(height)
}
const MACOS_CORNER_RADIUS_TAHOE: f32 = 12.0;
fn read_system_corner_radius() -> f32 {
let major = objc2_foundation::NSProcessInfo::processInfo()
.operatingSystemVersion()
.majorVersion;
match major {
..=10 => 0.0,
11..=25 => crate::theme::MACOS_CORNER_RADIUS,
_ => MACOS_CORNER_RADIUS_TAHOE,
}
}
#[derive(Clone, Copy)]
struct NativeMenuMetrics {
corner_radius: f32,
leading_inset: f32,
row_height: f32,
}
fn native_menu_metrics() -> NativeMenuMetrics {
use std::sync::OnceLock;
static CACHE: OnceLock<NativeMenuMetrics> = OnceLock::new();
*CACHE.get_or_init(|| NativeMenuMetrics {
corner_radius: read_system_corner_radius(),
leading_inset: crate::theme::MACOS_LEADING_INSET,
row_height: macos_system_row_height(13.0),
})
}
fn dual_face_source(
mtm: MainThreadMarker,
font: &objc2_app_kit::NSFont,
regular_path: &str,
) -> Option<crate::platform::SystemFontSource> {
let regular_bytes = std::fs::read(regular_path).ok()?;
let regular_is_variable = swash::FontRef::from_index(®ular_bytes, 0)
.map(|f| {
f.variations()
.find_by_tag(crate::render::WGHT_AXIS_TAG)
.is_some()
})
.unwrap_or(false);
if regular_is_variable {
return None;
}
let manager = objc2_app_kit::NSFontManager::sharedFontManager(mtm);
let bold = manager.convertFont_toHaveTrait(font, objc2_app_kit::NSFontTraitMask::BoldFontMask);
let bold_path = system_ui_font_path(&bold)?;
if bold_path == regular_path {
return None;
}
let bold_bytes = std::fs::read(&bold_path).ok()?;
Some(pack_dual_face(regular_bytes, bold_bytes))
}
fn pack_dual_face(regular: Vec<u8>, bold: Vec<u8>) -> crate::platform::SystemFontSource {
let magic = crate::render::DUAL_FACE_MAGIC;
let mut buf = Vec::with_capacity(magic.len() + 4 + regular.len() + bold.len());
buf.extend_from_slice(magic);
buf.extend_from_slice(&(regular.len() as u32).to_le_bytes());
buf.extend_from_slice(®ular);
buf.extend_from_slice(&bold);
crate::platform::SystemFontSource::Data(buf)
}
fn read_system_menu_font() -> Option<crate::platform::SystemFont> {
use crate::platform::{SystemFont, SystemFontSource};
let mtm = MainThreadMarker::new()?;
let font = objc2_app_kit::NSFont::menuFontOfSize(0.0);
let point_size = font.pointSize() as f32;
let source = match system_ui_font_path(&font) {
Some(path) => dual_face_source(mtm, &font, &path)
.unwrap_or_else(|| SystemFontSource::Path(std::path::PathBuf::from(path))),
None => {
let family = font.familyName()?.to_string();
if family.is_empty() {
return None;
}
SystemFontSource::Family(family)
}
};
Some(SystemFont { source, point_size })
}
fn read_system_palette() -> crate::platform::SystemPalette {
let mut pal = crate::platform::SystemPalette::default();
if MainThreadMarker::new().is_none() {
return pal;
}
pal.label = nscolor_srgba(&NSColor::labelColor());
pal.secondary_label = nscolor_srgba(&NSColor::secondaryLabelColor());
pal.separator = nscolor_srgba(&NSColor::separatorColor());
pal
}
fn nscolor_srgba(color: &NSColor) -> Option<(u8, u8, u8, u8)> {
let srgb = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
let to_u8 = |c: f64| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
Some((
to_u8(srgb.redComponent()),
to_u8(srgb.greenComponent()),
to_u8(srgb.blueComponent()),
to_u8(srgb.alphaComponent()),
))
}
fn system_is_dark() -> bool {
let Some(mtm) = MainThreadMarker::new() else {
return false;
};
let app = NSApplication::sharedApplication(mtm);
let name = app.effectiveAppearance().name();
name.to_string().to_lowercase().contains("dark")
}
fn transparency_enabled() -> bool {
use objc2_app_kit::NSWorkspace;
if MainThreadMarker::new().is_none() {
return true;
}
let ws = NSWorkspace::sharedWorkspace();
!ws.accessibilityDisplayShouldReduceTransparency()
}
fn increase_contrast_enabled() -> bool {
use objc2_app_kit::NSWorkspace;
if MainThreadMarker::new().is_none() {
return false;
}
let ws = NSWorkspace::sharedWorkspace();
unsafe { msg_send![&*ws, accessibilityDisplayShouldIncreaseContrast] }
}
fn system_accent(_mtm: MainThreadMarker) -> Option<(u8, u8, u8, u8)> {
let accent = NSColor::controlAccentColor();
let srgb = accent.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
let to_u8 = |c: f64| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
Some((
to_u8(srgb.redComponent()),
to_u8(srgb.greenComponent()),
to_u8(srgb.blueComponent()),
to_u8(srgb.alphaComponent()),
))
}
#[cfg(test)]
mod row_pitch_tests {
use super::{macos_system_row_height, MACOS_SYSTEM_ROW_HEIGHT_FACTOR};
use crate::theme::MACOS_ROW_HEIGHT;
#[test]
fn system_row_height_is_never_tighter_than_the_legacy_floor() {
for size in [13.5_f32, 13.0, 1.0, 0.0, -3.0] {
let h = macos_system_row_height(size);
assert!(
h.is_finite() && h >= MACOS_ROW_HEIGHT,
"size {size} must not derive a pitch below the {MACOS_ROW_HEIGHT}pt floor, got {h}"
);
}
}
#[test]
fn ratio_fallback_scales_with_font_size_above_the_floor() {
let fallback = |pt: f32| (pt * MACOS_SYSTEM_ROW_HEIGHT_FACTOR).max(MACOS_ROW_HEIGHT);
assert!((fallback(13.5) - 24.57).abs() < 0.1);
assert!(fallback(13.5) > MACOS_ROW_HEIGHT);
assert_eq!(fallback(1.0), MACOS_ROW_HEIGHT);
}
}
#[cfg(test)]
mod font_tests {
use super::system_ui_font_path;
#[test]
fn system_menu_font_resolves_to_a_real_sf_file() {
let font = objc2_app_kit::NSFont::menuFontOfSize(0.0);
let path =
system_ui_font_path(&font).expect("the macOS system menu font has an on-disk URL");
assert!(
path.starts_with("/System/") || path.starts_with("/Library/"),
"expected a system font path, got {path}"
);
let lower = path.to_ascii_lowercase();
assert!(
lower.ends_with(".ttf")
|| lower.ends_with(".ttc")
|| lower.ends_with(".otf")
|| lower.ends_with(".otc"),
"expected a font-file extension, got {path}"
);
assert!(
std::path::Path::new(&path).exists(),
"resolved font file must exist on disk: {path}"
);
}
#[test]
fn system_palette_reads_label_when_on_main_thread() {
use objc2::MainThreadMarker;
let pal = super::read_system_palette();
if MainThreadMarker::new().is_some() {
assert!(
pal.label.is_some(),
"on the main thread the label color must resolve"
);
let (_r, _g, _b, a) = pal.label.unwrap();
assert!(a > 0, "label color must not be fully transparent");
}
}
}
#[cfg(test)]
mod dismiss_tests {
use super::point_outside_all;
#[test]
fn point_outside_all_matches_panel_frames() {
let frames = [(100.0, 100.0, 300.0, 400.0), (300.0, 200.0, 480.0, 380.0)];
assert!(!point_outside_all(&frames, 150.0, 250.0));
assert!(!point_outside_all(&frames, 400.0, 300.0));
assert!(!point_outside_all(&frames, 300.0, 300.0));
assert!(point_outside_all(&frames, 150.0, 50.0));
assert!(point_outside_all(&frames, 600.0, 300.0));
assert!(point_outside_all(&[], 150.0, 250.0));
}
}
#[cfg(test)]
mod descend_tests {
use crate::menu::{Item, Menu, Row};
fn first_row_id(menu: &Menu) -> &str {
match &menu.items[0] {
Item::Row(row) => row.id.as_str(),
other => panic!("expected a row, got {other:?}"),
}
}
#[test]
fn descend_borrows_nested_submenus_without_cloning() {
let grandchild = Menu::new().row(Row::new("c").label("C"));
let child = Menu::new()
.row(Row::new("b").label("B"))
.submenu(Row::new("sub2").label("Sub2"), grandchild);
let top = Menu::new()
.row(Row::new("a").label("A"))
.submenu(Row::new("sub1").label("Sub1"), child);
assert_eq!(
first_row_id(crate::menu::descend(&top, [0usize; 0]).unwrap()),
"a"
);
assert_eq!(first_row_id(crate::menu::descend(&top, [1]).unwrap()), "b");
assert_eq!(
first_row_id(crate::menu::descend(&top, [1, 1]).unwrap()),
"c"
);
assert!(crate::menu::descend(&top, [0]).is_none());
assert!(crate::menu::descend(&top, [9]).is_none());
let borrowed = crate::menu::descend(&top, [1]).unwrap();
let Item::Submenu { menu, .. } = &top.items[1] else {
panic!("index 1 should be a submenu");
};
assert!(std::ptr::eq(borrowed, menu));
}
}