#![allow(unused_unsafe)]
#![cfg(target_os = "macos")]
mod controls;
mod applications;
mod nav_progress;
use std::any::Any;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use linkme::distributed_slice;
use objc2::Message as _;
use objc2::rc::Retained;
use objc2::runtime::{NSObjectProtocol, ProtocolObject};
use objc2::{
AllocAnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel,
};
use objc2_app_kit::NSAccessibility as _;
use objc2_app_kit::NSAppearanceCustomization as _;
use objc2_app_kit::NSDraggingInfo as _;
use objc2_app_kit::NSSplitViewDelegate;
use objc2_app_kit::NSTextContent as _;
use objc2_app_kit::NSUserInterfaceItemIdentification as _;
use objc2_app_kit::{
NSAffineTransformNSAppKitAdditions, NSClickGestureRecognizer, NSGestureRecognizer,
NSGestureRecognizerState, NSPanGestureRecognizer, NSPasteboard,
};
use objc2_app_kit::{
NSAnimationContext, NSApplication, NSApplicationActivationPolicy, NSBackingStoreType,
NSBitmapImageFileType, NSBox, NSBoxType, NSButton, NSColor, NSControl, NSControlStateValueOff,
NSControlStateValueOn, NSControlTextEditingDelegate, NSCursor, NSCursorFrameResizeDirections,
NSCursorFrameResizePosition, NSEvent, NSEventModifierFlags, NSEventType, NSFont,
NSGraphicsContext, NSLineBreakMode, NSMenu, NSMenuItem, NSProgressIndicator,
NSProgressIndicatorStyle, NSResponder, NSScrollView, NSSecureTextField, NSSlider, NSSwitch,
NSTabViewDelegate, NSText, NSTextField, NSTextFieldDelegate, NSTextMovement,
NSTextMovementUserInfoKey, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow,
NSWindowDelegate, NSWindowStyleMask,
};
use objc2_app_kit::{
NSApplicationDidBecomeActiveNotification, NSApplicationWillResignActiveNotification,
NSApplicationWillTerminateNotification,
};
use objc2_app_kit::{
NSOutlineViewDataSource, NSOutlineViewDelegate, NSTextDelegate, NSTextView, NSTextViewDelegate,
};
use objc2_app_kit::{NSTableColumn, NSTableView, NSTableViewDataSource, NSTableViewDelegate};
use objc2_core_foundation::CGAffineTransform;
use objc2_foundation::{
NSAffineTransform, NSAffineTransformStruct, NSArray, NSData, NSDictionary, NSNotification,
NSNumber, NSObject, NSPoint, NSRect, NSSize, NSString,
};
use objc2_quartz_core::{
CAMediaTimingFunction, CATransaction, kCAMediaTimingFunctionEaseIn,
kCAMediaTimingFunctionEaseInEaseOut, kCAMediaTimingFunctionEaseOut,
kCAMediaTimingFunctionLinear,
};
use day_spec::ffi_guard;
use day_spec::present;
use day_spec::props::*;
use day_spec::sidetable::SideTable;
use day_spec::{
A11yProps, AnimSpec, Builtin, Cap, Cursor, Curve, DrawOp, Event, EventSink, Font, InputPurpose,
InputTraits, ListSource, MoveVerdict, NodeId, PieceKind, Platform, Point, Proposal, RawHandle,
Rect, Registry, Renderer, Size, Support, Toolkit, Transform, TreeSource, WINDOW_NODE,
WindowOptions, kinds, props_of,
};
pub type Handle = Retained<NSView>;
pub use toolbar::image_for as toolbar_image;
mod dock;
mod documents;
mod frame;
mod picker;
mod status_item;
mod textarea;
mod toolbar;
mod transfer;
mod window_chrome;
use objc2_app_kit::NSDraggingSource;
pub mod ext;
pub use ext::*;
type Sink = Rc<dyn Fn(NodeId, Event)>;
day_core::tls_group! {
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
static TARGETS: RefCell<HashMap<usize, Retained<DayTarget>>> = RefCell::new(HashMap::new());
static BUTTON_STYLES: RefCell<HashMap<usize, day_spec::props::ButtonStyleSpec>> =
RefCell::new(HashMap::new());
static BUTTON_IMAGES: SideTable<Retained<objc2_app_kit::NSImage>> = SideTable::new();
static BUTTON_SELECTED: RefCell<HashMap<usize, bool>> = RefCell::new(HashMap::new());
static OPS: SideTable<Vec<DrawOp>> = SideTable::new();
static APP_LABELED: SideTable<()> = SideTable::new();
static IMAGE_MODES: SideTable<ImageMode> = SideTable::new();
static TRANSFORMS: SideTable<Transform> = SideTable::new();
static BASE_FRAMES: SideTable<[f64; 4]> = SideTable::new();
static COVER_SHEETS: SideTable<CoverSheet> = SideTable::new();
static PAN_NODES: SideTable<NodeId> = SideTable::new();
static PAN_MONITOR: RefCell<Option<PanMonitor>> = const { RefCell::new(None) };
static HOVER_NODES: SideTable<NodeId> = SideTable::new();
static KEY_NODES: SideTable<NodeId> = SideTable::new();
static FOCUSABLE_NODES: SideTable<NodeId> = SideTable::new();
static GESTURES: RefCell<HashMap<usize, Vec<Retained<DayGesture>>>> =
RefCell::new(HashMap::new());
static NAV_STATE: RefCell<HashMap<usize, NavState>> = RefCell::new(HashMap::new());
static NAV_PAGES: RefCell<std::collections::HashSet<usize>> =
RefCell::new(std::collections::HashSet::new());
static PAGE_PANE: SideTable<day_spec::props::Pane> = SideTable::new();
static INSPECTOR_STATE: RefCell<HashMap<usize, InspectorState>> = RefCell::new(HashMap::new());
static INSPECTOR_PANES: SideTable<bool> = SideTable::new();
static SPLIT_STATE: RefCell<HashMap<usize, SplitState>> = RefCell::new(HashMap::new());
static SPLIT_PANES: SideTable<bool> = SideTable::new();
static NAV_OUTLINE_MENUS: SideTable<Retained<DayNavMenuData>> = SideTable::new();
static LIST_STATE: RefCell<HashMap<usize, ListEntry>> = RefCell::new(HashMap::new());
static TREE_STATE: RefCell<HashMap<usize, TreeEntry>> = RefCell::new(HashMap::new());
static BITMAPS: RefCell<HashMap<u64, AppKitBitmap>> = RefCell::new(HashMap::new());
static CTX_MENU_FNS: SideTable<day_spec::ContextMenuFn> = SideTable::new();
static NAV_MENUS: RefCell<HashMap<usize, NavMenuEntry>> = RefCell::new(HashMap::new());
static EDIT_STATE: std::cell::Cell<day_spec::EditState> =
const { std::cell::Cell::new(day_spec::EditState { can_cut: false, can_copy: false, can_paste: false, can_select_all: false }) };
static UNDO_FRONT: std::cell::RefCell<Option<Retained<DayUndoManager>>> =
const { std::cell::RefCell::new(None) };
static PRESENT_ALERTS: RefCell<HashMap<u64, Retained<objc2_app_kit::NSAlert>>> =
RefCell::new(HashMap::new());
static PRESENT_PANELS: RefCell<HashMap<u64, Retained<objc2_app_kit::NSSavePanel>>> =
RefCell::new(HashMap::new());
static MENU_TARGET: std::cell::RefCell<Option<Retained<DayMenuTarget>>> =
const { std::cell::RefCell::new(None) };
static PRIMARY_CONTENT: std::cell::RefCell<Option<Retained<NSView>>> =
const { std::cell::RefCell::new(None) };
}
pub fn emit(id: NodeId, ev: Event) {
let sink = SINK.with(|s| s.borrow().clone());
if let Some(sink) = sink {
sink(id, ev);
}
}
fn ptr_of(v: &NSView) -> usize {
(v as *const NSView).cast::<()>() as usize
}
#[derive(Clone, Copy)]
struct ImageMode {
template: bool,
tinted: bool,
}
impl ImageMode {
fn renders_template(self) -> bool {
self.template || self.tinted
}
}
struct AppKitBitmap {
image: Retained<objc2_app_kit::NSImage>,
rep: Option<Retained<objc2_app_kit::NSBitmapImageRep>>,
info: day_spec::BitmapInfo,
}
fn scaled_rep(
image: &objc2_app_kit::NSImage,
pixels: Size,
scale: f64,
) -> Option<Retained<objc2_app_kit::NSBitmapImageRep>> {
use objc2::AllocAnyThread as _;
let w = (pixels.width * scale).round().max(1.0);
let h = (pixels.height * scale).round().max(1.0);
let rep = unsafe {
objc2_app_kit::NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel(
objc2_app_kit::NSBitmapImageRep::alloc(),
std::ptr::null_mut(),
w as isize,
h as isize,
8,
4,
true,
false,
objc2_app_kit::NSDeviceRGBColorSpace,
0,
0,
)
}?;
let ctx = objc2_app_kit::NSGraphicsContext::graphicsContextWithBitmapImageRep(&rep)?;
NSGraphicsContext::saveGraphicsState_class();
objc2_app_kit::NSGraphicsContext::setCurrentContext(Some(&ctx));
unsafe {
image.drawInRect_fromRect_operation_fraction(
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(w, h)),
NSRect::ZERO,
objc2_app_kit::NSCompositingOperation::SourceOver,
1.0,
);
}
NSGraphicsContext::restoreGraphicsState_class();
Some(rep)
}
fn encode_bitmap(
id: day_spec::BitmapId,
spec: &day_spec::EncodeSpec,
) -> Result<Vec<u8>, day_spec::ImageError> {
use day_spec::ImageFormat as F;
use objc2_app_kit::NSBitmapImageFileType as T;
let file_type = match spec.format {
F::Png => T::PNG,
F::Jpeg => T::JPEG,
F::Tiff => T::TIFF,
F::Bmp => T::BMP,
F::Gif => T::GIF,
_ => return Err(day_spec::ImageError::Encode),
};
let held = BITMAPS.with(|m| {
m.borrow()
.get(&id.0)
.map(|b| (b.rep.clone(), b.image.clone(), b.info))
});
let (rep, image, info) = held.ok_or(day_spec::ImageError::Gone)?;
let rep = rep.ok_or(day_spec::ImageError::Encode)?;
let rep = match spec.fit {
Some(fit) if info.pixels.width > 0.0 && info.pixels.height > 0.0 => {
let scale = (fit.width / info.pixels.width)
.min(fit.height / info.pixels.height)
.min(1.0);
if scale < 1.0 {
scaled_rep(&image, info.pixels, scale).ok_or(day_spec::ImageError::Encode)?
} else {
rep
}
}
_ => rep,
};
let props: Retained<NSDictionary<NSString, objc2::runtime::AnyObject>> = match spec.quality {
Some(q) => {
let key: &NSString = unsafe { objc2_app_kit::NSImageCompressionFactor };
let number = NSNumber::new_f64(q.clamp(0.0, 1.0));
let value: &objc2::runtime::AnyObject = &number;
NSDictionary::from_slices::<NSString>(&[key], &[value])
}
None => NSDictionary::new(),
};
let data = unsafe { rep.representationUsingType_properties(file_type, &props) }
.ok_or(day_spec::ImageError::Encode)?;
Ok(data.to_vec())
}
fn paint_ops(ops: &[DrawOp]) {
for op in ops {
draw_op(op);
}
}
fn paint_flipped(cg: &objc2_core_graphics::CGContext, height: f64, scale: f64, ops: &[DrawOp]) {
use objc2_core_graphics::CGContext;
CGContext::save_g_state(Some(cg));
CGContext::translate_ctm(Some(cg), 0.0, height);
CGContext::scale_ctm(Some(cg), scale, -scale);
let ns = NSGraphicsContext::graphicsContextWithCGContext_flipped(cg, true);
NSGraphicsContext::saveGraphicsState_class();
NSGraphicsContext::setCurrentContext(Some(&ns));
paint_ops(ops);
NSGraphicsContext::restoreGraphicsState_class();
CGContext::restore_g_state(Some(cg));
}
fn render_bitmap_image(
ops: &[DrawOp],
size: Size,
scale: f64,
) -> Result<AppKitBitmap, day_spec::ExportError> {
use objc2::AllocAnyThread as _;
use objc2_core_graphics::{
CGBitmapContextCreate, CGBitmapContextCreateImage, CGColorSpace, CGImageAlphaInfo,
};
let failed = |what: &str| day_spec::ExportError::Failed(what.to_string());
let (pw, ph) = day_spec::export_pixels(size, scale);
let space = CGColorSpace::with_name(Some(unsafe { objc2_core_graphics::kCGColorSpaceSRGB }))
.ok_or_else(|| failed("CoreGraphics has no sRGB color space"))?;
let cg = unsafe {
CGBitmapContextCreate(
std::ptr::null_mut(),
pw as usize,
ph as usize,
8,
0,
Some(&space),
CGImageAlphaInfo::PremultipliedLast.0,
)
}
.ok_or_else(|| {
day_spec::ExportError::Failed(format!("CGBitmapContextCreate refused {pw}x{ph}"))
})?;
paint_flipped(&cg, ph as f64, scale, ops);
let cg_image = CGBitmapContextCreateImage(Some(&cg))
.ok_or_else(|| failed("CGBitmapContextCreateImage returned nothing"))?;
let rep = unsafe {
objc2_app_kit::NSBitmapImageRep::initWithCGImage(
objc2_app_kit::NSBitmapImageRep::alloc(),
&cg_image,
)
};
let points = NSSize::new(pw as f64 / scale, ph as f64 / scale);
unsafe { rep.setSize(points) };
let image =
unsafe { objc2_app_kit::NSImage::initWithSize(objc2_app_kit::NSImage::alloc(), points) };
unsafe { image.addRepresentation(&rep) };
Ok(AppKitBitmap {
image,
rep: Some(rep),
info: day_spec::BitmapInfo {
pixels: Size::new(pw as f64, ph as f64),
scale,
format: None,
has_alpha: true,
},
})
}
fn render_pdf_bytes(
pages: &[day_spec::PdfPage],
info: &day_spec::PdfInfo,
) -> Result<Vec<u8>, day_spec::ExportError> {
use objc2_core_foundation::{
CFDictionary, CFMutableData, CFRetained, CFString, CGPoint, CGRect, CGSize,
};
use objc2_core_graphics::{CGContext, CGDataConsumer, CGPDFContextClose, CGPDFContextCreate};
let failed = |what: &str| day_spec::ExportError::Failed(what.to_string());
let media =
|size: Size| CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(size.width, size.height));
let first = pages.first().ok_or(day_spec::ExportError::Invalid)?;
let data = CFMutableData::new(None, 0)
.ok_or_else(|| failed("CFDataCreateMutable returned nothing"))?;
let consumer = CGDataConsumer::with_cf_data(Some(&data))
.ok_or_else(|| failed("CGDataConsumerCreateWithCFData returned nothing"))?;
let mut keys: Vec<&CFString> = Vec::new();
let mut values: Vec<CFRetained<CFString>> = Vec::new();
let (title_key, author_key) = unsafe {
(
objc2_core_graphics::kCGPDFContextTitle,
objc2_core_graphics::kCGPDFContextAuthor,
)
};
for (key, value) in [(title_key, &info.title), (author_key, &info.author)] {
if let Some(value) = value {
keys.push(key);
values.push(CFString::from_str(value));
}
}
let values: Vec<&CFString> = values.iter().map(|v| &**v).collect();
let aux = CFDictionary::<CFString, CFString>::from_slices(&keys, &values);
let first_box = media(first.size);
let pdf = unsafe { CGPDFContextCreate(Some(&consumer), &first_box, Some(aux.as_opaque())) }
.ok_or_else(|| failed("CGPDFContextCreate refused the document"))?;
for page in pages {
let page_box = media(page.size);
unsafe { CGContext::begin_page(Some(&pdf), &page_box) };
paint_flipped(&pdf, page.size.height, 1.0, &page.ops);
CGContext::end_page(Some(&pdf));
}
CGPDFContextClose(Some(&pdf));
drop(pdf);
drop(consumer);
Ok(data.to_vec())
}
thread_local! {
static LIST_ITEMS: std::cell::RefCell<std::collections::HashMap<usize, day_spec::props::ListItemProps>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
fn make_list_item(p: &day_spec::props::ListItemProps, mtm: MainThreadMarker) -> Retained<NSView> {
use objc2_app_kit::{
NSImageView, NSLayoutAttribute, NSStackView, NSUserInterfaceLayoutOrientation,
};
let label = |text: &str, size: f64, secondary: bool| {
let tf = unsafe { NSTextField::labelWithString(&NSString::from_str(text), mtm) };
unsafe {
tf.setFont(Some(&objc2_app_kit::NSFont::systemFontOfSize(size)));
if secondary {
tf.setTextColor(Some(&objc2_app_kit::NSColor::secondaryLabelColor()));
}
tf.setLineBreakMode(objc2_app_kit::NSLineBreakMode::ByTruncatingTail);
}
tf
};
let size = unsafe { objc2_app_kit::NSFont::systemFontSize() };
let small = unsafe { objc2_app_kit::NSFont::smallSystemFontSize() };
const ACROSS: objc2_app_kit::NSLayoutConstraintOrientation =
objc2_app_kit::NSLayoutConstraintOrientation::Horizontal;
const STRETCH: f32 = 1.0;
const HOLD: f32 = 750.0;
let stack = NSStackView::new(mtm);
stack.setOrientation(NSUserInterfaceLayoutOrientation::Horizontal);
stack.setDistribution(objc2_app_kit::NSStackViewDistribution::Fill);
stack.setAlignment(NSLayoutAttribute::CenterY);
stack.setHuggingPriority_forOrientation(HOLD, ACROSS);
stack.setSpacing(6.0);
stack.setEdgeInsets(objc2_foundation::NSEdgeInsets {
top: 4.0,
left: 0.0,
bottom: 4.0,
right: 0.0,
});
let icon = unsafe { NSImageView::new(mtm) };
unsafe {
icon.setImageScaling(objc2_app_kit::NSImageScaling::ScaleProportionallyUpOrDown);
icon.widthAnchor()
.constraintEqualToConstant(16.0)
.setActive(true);
icon.heightAnchor()
.constraintEqualToConstant(16.0)
.setActive(true);
}
stack.addArrangedSubview(&icon);
let texts = NSStackView::new(mtm);
texts.setOrientation(NSUserInterfaceLayoutOrientation::Vertical);
texts.setAlignment(NSLayoutAttribute::Width);
texts.setSpacing(1.0);
texts.setHuggingPriority_forOrientation(
HOLD,
objc2_app_kit::NSLayoutConstraintOrientation::Vertical,
);
let title = label("", size, false);
let subtitle = label("", small, true);
for line in [&title, &subtitle] {
line.setContentHuggingPriority_forOrientation(STRETCH, ACROSS);
line.setContentCompressionResistancePriority_forOrientation(250.0, ACROSS);
}
texts.addArrangedSubview(&title);
texts.addArrangedSubview(&subtitle);
stack.addArrangedSubview(&texts);
let detail = label("", size, true);
detail.setContentHuggingPriority_forOrientation(HOLD, ACROSS);
detail.setContentCompressionResistancePriority_forOrientation(HOLD, ACROSS);
stack.addArrangedSubview(&detail);
let check = unsafe { NSImageView::new(mtm) };
unsafe {
check.setImage(
objc2_app_kit::NSImage::imageWithSystemSymbolName_accessibilityDescription(
&NSString::from_str("checkmark"),
None,
)
.as_deref(),
);
check.setContentTintColor(Some(&objc2_app_kit::NSColor::controlAccentColor()));
}
check.setContentHuggingPriority_forOrientation(HOLD, ACROSS);
check.setContentCompressionResistancePriority_forOrientation(HOLD, ACROSS);
stack.addArrangedSubview(&check);
let view = Retained::from(<NSStackView as AsRef<NSView>>::as_ref(&stack));
LIST_ITEMS.with(|m| m.borrow_mut().insert(ptr_of(&view), p.clone()));
dress_list_item(&view, p, mtm);
view
}
fn dress_list_item(view: &NSView, p: &day_spec::props::ListItemProps, mtm: MainThreadMarker) {
use objc2_app_kit::{NSImageView, NSStackView};
let Some(stack) = view.downcast_ref::<NSStackView>() else {
return;
};
let parts = stack.arrangedSubviews();
if parts.len() < 4 {
return;
}
let icon = parts.objectAtIndex(0);
if let Some(iv) = icon.downcast_ref::<NSImageView>() {
let image = p
.icon
.as_ref()
.and_then(|i| toolbar::image_for(i, &p.title, mtm));
unsafe {
iv.setImage(image.as_deref());
iv.setContentTintColor(p.icon_tint.map(nscolor).as_deref());
}
}
icon.setHidden(p.icon.is_none());
if let Some(texts) = parts.objectAtIndex(1).downcast_ref::<NSStackView>() {
let lines = texts.arrangedSubviews();
if lines.len() == 2 {
if let Some(tf) = lines.objectAtIndex(0).downcast_ref::<NSTextField>() {
tf.setStringValue(&NSString::from_str(&p.title));
}
let sub = lines.objectAtIndex(1);
if let Some(tf) = sub.downcast_ref::<NSTextField>() {
tf.setStringValue(&NSString::from_str(&p.subtitle));
}
sub.setHidden(p.subtitle.is_empty());
}
}
let detail = parts.objectAtIndex(2);
if let Some(tf) = detail.downcast_ref::<NSTextField>() {
tf.setStringValue(&NSString::from_str(&p.detail));
}
detail.setHidden(p.detail.is_empty());
parts.objectAtIndex(3).setHidden(!p.checked);
let words = [&p.title, &p.subtitle, &p.detail]
.into_iter()
.filter(|w| !w.is_empty())
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
unsafe {
view.setAccessibilityElement(true);
view.setAccessibilityLabel(Some(&NSString::from_str(&words)));
view.setAccessibilitySelected(p.checked);
}
}
fn appkit_image_for(source: &day_spec::ImageSource) -> Option<Retained<objc2_app_kit::NSImage>> {
use objc2::AllocAnyThread as _;
match source {
day_spec::ImageSource::Named(name) => day_spec::resource::resolve_vector_svg(name)
.or_else(|| day_spec::resource::resolve_image_file(name))
.and_then(|path| unsafe {
objc2_app_kit::NSImage::initWithContentsOfFile(
objc2_app_kit::NSImage::alloc(),
&NSString::from_str(&path.to_string_lossy()),
)
}),
day_spec::ImageSource::Bytes(bytes) => unsafe {
objc2_app_kit::NSImage::initWithData(
objc2_app_kit::NSImage::alloc(),
&NSData::with_bytes(bytes),
)
},
day_spec::ImageSource::Decoded(id) => {
BITMAPS.with(|m| m.borrow().get(&id.0).map(|b| b.image.clone()))
}
}
}
fn ns_role(role: day_spec::Role) -> Option<&'static objc2_app_kit::NSAccessibilityRole> {
use day_spec::Role;
use objc2_app_kit::{
NSAccessibilityButtonRole, NSAccessibilityCheckBoxRole, NSAccessibilityGroupRole,
NSAccessibilityHeadingRole, NSAccessibilityImageRole, NSAccessibilityLevelIndicatorRole,
NSAccessibilityOutlineRole, NSAccessibilityRadioButtonRole, NSAccessibilityRowRole,
NSAccessibilitySliderRole, NSAccessibilityTabGroupRole, NSAccessibilityTextFieldRole,
};
unsafe {
Some(match role {
Role::Button => NSAccessibilityButtonRole,
Role::Toggle => NSAccessibilityCheckBoxRole,
Role::Tab => NSAccessibilityRadioButtonRole,
Role::TabList => NSAccessibilityTabGroupRole,
Role::Slider => NSAccessibilitySliderRole,
Role::TextInput => NSAccessibilityTextFieldRole,
Role::Heading(_) => NSAccessibilityHeadingRole,
Role::Image => NSAccessibilityImageRole,
Role::Meter => NSAccessibilityLevelIndicatorRole,
Role::Group => NSAccessibilityGroupRole,
Role::Tree => NSAccessibilityOutlineRole,
Role::TreeItem => NSAccessibilityRowRole,
Role::None => return None,
})
}
}
fn day_role_from_ns(ax: &str) -> day_spec::Role {
use day_spec::Role;
match ax {
"AXButton" => Role::Button,
"AXCheckBox" => Role::Toggle,
"AXSlider" => Role::Slider,
"AXTextField" => Role::TextInput,
"AXStaticText" | "AXHeading" => Role::Heading(0),
"AXOutline" => Role::Tree,
"AXImage" => Role::Image,
"AXLevelIndicator" | "AXProgressIndicator" => Role::Meter,
"AXGroup" => Role::Group,
"AXRadioButton" => Role::Tab,
"AXTabGroup" => Role::TabList,
_ => Role::None,
}
}
struct TargetIvars {
node: NodeId,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayTarget"]
#[ivars = TargetIvars]
struct DayTarget;
unsafe impl NSObjectProtocol for DayTarget {}
unsafe impl NSTextFieldDelegate for DayTarget {}
unsafe impl NSTabViewDelegate for DayTarget {
#[unsafe(method(tabView:didSelectTabViewItem:))]
fn tab_view_did_select(
&self,
tab_view: &objc2_app_kit::NSTabView,
item: Option<&objc2_app_kit::NSTabViewItem>,
) {
ffi_guard::contain((), || {
if TAB_PICK_SUPPRESS.with(|s| s.get()) {
return;
}
if let Some(item) = item {
let idx = unsafe { tab_view.indexOfTabViewItem(item) };
if idx >= 0 {
emit(self.ivars().node, Event::SelectionChanged(idx as i64));
}
}
})
}
}
impl DayTarget {
#[unsafe(method(action:))]
fn action(&self, sender: &NSControl) {
ffi_guard::contain((), || {
let node = self.ivars().node;
if sender.downcast_ref::<NSSwitch>().is_some() {
emit(node, Event::ToggleChanged(unsafe { sender.integerValue() } != 0));
} else if sender.downcast_ref::<NSSlider>().is_some() {
let value = unsafe { sender.doubleValue() };
emit(node, Event::ValueChanged(value));
if slider_value_settled() {
emit(node, Event::ValueCommitted(value));
}
} else {
emit(node, Event::Pressed);
let wanted = BUTTON_SELECTED.with(|m| m.borrow().get(&ptr_of(sender)).copied());
if let (Some(on), Some(btn)) = (wanted, sender.downcast_ref::<NSButton>()) {
unsafe {
btn.setState(if on {
NSControlStateValueOn
} else {
NSControlStateValueOff
})
};
}
}
})
}
#[unsafe(method(navBack:))]
fn nav_back(&self, _sender: &NSControl) {
ffi_guard::contain((), || {
emit(
self.ivars().node,
Event::NavBack {
already_popped: false,
},
);
})
}
}
unsafe impl NSControlTextEditingDelegate for DayTarget {
#[unsafe(method(controlTextDidChange:))]
fn control_text_did_change(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
let node = self.ivars().node;
if let Some(obj) = unsafe { notification.object() }
&& let Ok(tf) = obj.downcast::<NSTextField>() {
emit(node, Event::TextChanged(unsafe { tf.stringValue() }.to_string()));
}
})
}
#[unsafe(method(controlTextDidEndEditing:))]
fn control_text_did_end_editing(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
let node = self.ivars().node;
let movement = unsafe { notification.userInfo() }
.and_then(|ui| ui.objectForKey(unsafe { NSTextMovementUserInfoKey }.as_ref()))
.and_then(|n| n.downcast::<NSNumber>().ok())
.map(|n| NSTextMovement(n.integerValue()))
.unwrap_or(NSTextMovement::Other);
if movement == NSTextMovement::Return {
emit(node, Event::Submitted);
} else {
emit(node, Event::FocusChanged(false));
}
})
}
}
);
impl DayTarget {
fn new(mtm: MainThreadMarker, node: NodeId) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(TargetIvars { node });
unsafe { msg_send![super(this), init] }
}
}
struct CursorIvars {
cursor: RefCell<Option<Retained<NSCursor>>>,
hidden: Cell<bool>,
hiding: Cell<bool>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayCursorOwner"]
#[ivars = CursorIvars]
struct DayCursorOwner;
unsafe impl NSObjectProtocol for DayCursorOwner {}
impl DayCursorOwner {
#[unsafe(method(cursorUpdate:))]
fn cursor_update(&self, _event: &NSEvent) {
let iv = self.ivars();
if iv.hidden.get() {
if !iv.hiding.replace(true) {
NSCursor::hide();
}
return;
}
if let Some(c) = iv.cursor.borrow().as_ref() {
c.set();
}
}
#[unsafe(method(mouseExited:))]
fn mouse_exited(&self, _event: &NSEvent) {
if self.ivars().hiding.replace(false) {
NSCursor::unhide();
}
}
}
);
impl DayCursorOwner {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(CursorIvars {
cursor: RefCell::new(None),
hidden: Cell::new(false),
hiding: Cell::new(false),
});
unsafe { msg_send![super(this), init] }
}
}
#[allow(deprecated)]
fn ns_cursor(c: &Cursor) -> Option<Retained<NSCursor>> {
let has = |sel: objc2::runtime::Sel| <NSCursor as objc2::ClassType>::class().responds_to(sel);
Some(match c {
Cursor::Default | Cursor::Wait | Cursor::Progress | Cursor::Help => NSCursor::arrowCursor(),
Cursor::Pointer => NSCursor::pointingHandCursor(),
Cursor::Text => NSCursor::IBeamCursor(),
Cursor::VerticalText => NSCursor::IBeamCursorForVerticalLayout(),
Cursor::Crosshair | Cursor::Cell => NSCursor::crosshairCursor(),
Cursor::Move | Cursor::Grab => NSCursor::openHandCursor(),
Cursor::Grabbing => NSCursor::closedHandCursor(),
Cursor::NotAllowed => NSCursor::operationNotAllowedCursor(),
Cursor::ContextMenu => NSCursor::contextualMenuCursor(),
Cursor::Copy => NSCursor::dragCopyCursor(),
Cursor::Alias => NSCursor::dragLinkCursor(),
Cursor::ZoomIn if has(sel!(zoomInCursor)) => NSCursor::zoomInCursor(),
Cursor::ZoomOut if has(sel!(zoomOutCursor)) => NSCursor::zoomOutCursor(),
Cursor::ZoomIn | Cursor::ZoomOut => NSCursor::arrowCursor(),
Cursor::NsResize => NSCursor::resizeUpDownCursor(),
Cursor::EwResize => NSCursor::resizeLeftRightCursor(),
Cursor::NeswResize if has(sel!(frameResizeCursorFromPosition:inDirections:)) => {
NSCursor::frameResizeCursorFromPosition_inDirections(
NSCursorFrameResizePosition::TopRight,
NSCursorFrameResizeDirections::All,
)
}
Cursor::NwseResize if has(sel!(frameResizeCursorFromPosition:inDirections:)) => {
NSCursor::frameResizeCursorFromPosition_inDirections(
NSCursorFrameResizePosition::TopLeft,
NSCursorFrameResizeDirections::All,
)
}
Cursor::NeswResize | Cursor::NwseResize => NSCursor::arrowCursor(),
Cursor::ColResize if has(sel!(columnResizeCursor)) => NSCursor::columnResizeCursor(),
Cursor::ColResize => NSCursor::resizeLeftRightCursor(),
Cursor::RowResize if has(sel!(rowResizeCursor)) => NSCursor::rowResizeCursor(),
Cursor::RowResize => NSCursor::resizeUpDownCursor(),
Cursor::None => return None,
Cursor::Native(name) => match &**name {
"disappearingItem" => NSCursor::disappearingItemCursor(),
"dragCopy" => NSCursor::dragCopyCursor(),
"dragLink" => NSCursor::dragLinkCursor(),
"contextualMenu" => NSCursor::contextualMenuCursor(),
"IBeamCursorForVerticalLayout" => NSCursor::IBeamCursorForVerticalLayout(),
_ => return None,
},
})
}
fn slider_value_settled() -> bool {
let Some(mtm) = MainThreadMarker::new() else {
return false;
};
let Some(event) = NSApplication::sharedApplication(mtm).currentEvent() else {
return false;
};
matches!(
unsafe { event.r#type() },
NSEventType::LeftMouseUp | NSEventType::KeyDown | NSEventType::KeyUp
)
}
struct FieldIvars {
node: NodeId,
}
define_class!(
#[unsafe(super(NSTextField))]
#[thread_kind = MainThreadOnly]
#[name = "DayTextField"]
#[ivars = FieldIvars]
struct DayTextField;
impl DayTextField {
#[unsafe(method(becomeFirstResponder))]
fn become_first_responder(&self) -> bool {
let ok: bool = unsafe { msg_send![super(self), becomeFirstResponder] };
if ok {
field_gained_focus(self.ivars().node);
}
ok
}
}
);
impl DayTextField {
fn new(mtm: MainThreadMarker, node: NodeId) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(FieldIvars { node });
unsafe { msg_send![super(this), init] }
}
}
define_class!(
#[unsafe(super(NSSecureTextField))]
#[thread_kind = MainThreadOnly]
#[name = "DaySecureTextField"]
#[ivars = FieldIvars]
struct DaySecureTextField;
impl DaySecureTextField {
#[unsafe(method(becomeFirstResponder))]
fn become_first_responder(&self) -> bool {
let ok: bool = unsafe { msg_send![super(self), becomeFirstResponder] };
if ok {
field_gained_focus(self.ivars().node);
}
ok
}
}
);
impl DaySecureTextField {
fn new(mtm: MainThreadMarker, node: NodeId) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(FieldIvars { node });
unsafe { msg_send![super(this), init] }
}
}
thread_local! {
static FIELD_SWAP: Cell<bool> = const { Cell::new(false) };
}
fn field_gained_focus(node: NodeId) {
if FIELD_SWAP.with(|s| s.get()) {
return;
}
ffi_guard::contain((), || emit(node, Event::FocusChanged(true)));
}
fn text_field_node(v: &NSView) -> Option<NodeId> {
v.downcast_ref::<DayTextField>()
.map(|f| f.ivars().node)
.or_else(|| {
v.downcast_ref::<DaySecureTextField>()
.map(|f| f.ivars().node)
})
}
fn new_text_field(mtm: MainThreadMarker, node: NodeId, secure: bool) -> Retained<NSTextField> {
let target = DayTarget::new(mtm, node);
let tf: Retained<NSTextField> = if secure {
Retained::into_super(Retained::into_super(DaySecureTextField::new(mtm, node)))
} else {
Retained::into_super(DayTextField::new(mtm, node))
};
unsafe {
tf.setEditable(true);
tf.setBezeled(true);
tf.setDelegate(Some(ProtocolObject::from_ref(&*target)));
}
TARGETS.with(|m| m.borrow_mut().insert(ptr_of(&tf), target));
tf
}
fn owns_first_responder(window: &NSWindow, h: &NSView) -> bool {
window.firstResponder().is_some_and(|fr| {
if Retained::as_ptr(&fr) as *const () as usize == ptr_of(h) {
return true;
}
fr.downcast::<NSText>().is_ok_and(|text| {
unsafe { text.delegate() }
.is_some_and(|d| Retained::as_ptr(&d) as *const () as usize == ptr_of(h))
})
})
}
fn text_content_type(symbol: &std::ffi::CStr) -> Option<&'static NSString> {
unsafe extern "C" {
fn dlsym(
handle: *mut std::ffi::c_void,
symbol: *const std::ffi::c_char,
) -> *mut std::ffi::c_void;
}
const RTLD_DEFAULT: *mut std::ffi::c_void = -2isize as *mut std::ffi::c_void;
unsafe {
let slot = dlsym(RTLD_DEFAULT, symbol.as_ptr()).cast::<*const NSString>();
if slot.is_null() {
None
} else {
(*slot).as_ref()
}
}
}
fn rekey<V>(table: &SideTable<V>, old: usize, new: usize) {
if let Some(v) = table.take(old) {
table.insert(new, v);
}
}
struct LabelIvars {
node: NodeId,
has_links: Cell<bool>,
has_runs: Cell<bool>,
selectable: Cell<bool>,
}
define_class!(
#[unsafe(super(NSTextField))]
#[thread_kind = MainThreadOnly]
#[name = "DayLabel"]
#[ivars = LabelIvars]
struct DayLabel;
impl DayLabel {
#[unsafe(method_id(menuForEvent:))]
fn menu_for_event(&self, event: &objc2_app_kit::NSEvent) -> Option<Retained<NSMenu>> {
ffi_guard::contain(None, || {
if !self.isSelectable()
&& let Some(menu) = inherited_context_menu(self, event)
{
return menu;
}
unsafe { msg_send![super(self), menuForEvent: event] }
})
}
#[unsafe(method(drawRect:))]
fn draw_rect(&self, dirty: NSRect) {
let mut ancestor = unsafe { self.superview() };
let mut selection_color = None;
if !self.ivars().has_runs.get() {
while let Some(view) = ancestor {
if let Some(row) = view.downcast_ref::<objc2_app_kit::NSTableRowView>() {
if unsafe { row.isSelected() } {
selection_color = Some(unsafe {
if row.isEmphasized() {
objc2_app_kit::NSColor::alternateSelectedControlTextColor()
} else {
objc2_app_kit::NSColor::unemphasizedSelectedTextColor()
}
});
}
break;
}
ancestor = unsafe { view.superview() };
}
}
let cell = unsafe { self.cell() }
.and_then(|cell| cell.downcast::<objc2_app_kit::NSTextFieldCell>().ok());
if let (Some(color), Some(cell)) = (selection_color, cell) {
let original = unsafe { cell.textColor() };
unsafe {
cell.setTextColor(Some(&color));
let _: () = msg_send![super(self), drawRect: dirty];
cell.setTextColor(original.as_deref());
}
} else {
let _: () = unsafe { msg_send![super(self), drawRect: dirty] };
}
}
}
unsafe impl NSObjectProtocol for DayLabel {}
unsafe impl NSTextDelegate for DayLabel {}
unsafe impl NSTextViewDelegate for DayLabel {
#[unsafe(method(textView:clickedOnLink:atIndex:))]
fn clicked_on_link(
&self,
_tv: &NSTextView,
link: &objc2::runtime::AnyObject,
_index: usize,
) -> bool {
ffi_guard::contain(true, || {
let url: Retained<NSString> = unsafe { msg_send![link, description] };
emit(self.ivars().node, Event::LinkActivated(url.to_string()));
true })
}
}
);
impl DayLabel {
fn new(mtm: MainThreadMarker, node: NodeId, text: &str) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(LabelIvars {
node,
has_links: Cell::new(false),
has_runs: Cell::new(false),
selectable: Cell::new(false),
});
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
unsafe {
this.setStringValue(&NSString::from_str(text));
this.setEditable(false);
this.setSelectable(false);
this.setBordered(false);
this.setBezeled(false);
this.setDrawsBackground(false);
}
this
}
fn set_links(&self, has_links: bool) {
self.ivars().has_links.set(has_links);
unsafe {
self.setSelectable(has_links || self.ivars().selectable.get());
self.setAllowsEditingTextAttributes(has_links);
}
}
}
struct PanMonitor(Retained<objc2::runtime::AnyObject>);
impl Drop for PanMonitor {
fn drop(&mut self) {
unsafe { NSEvent::removeMonitor(&self.0) };
}
}
fn install_container_pan_monitor() {
PAN_MONITOR.with(|slot| {
if slot.borrow().is_some() {
return;
}
let block = block2::RcBlock::new(|raw: std::ptr::NonNull<NSEvent>| -> *mut NSEvent {
let event = unsafe { raw.as_ref() };
ffi_guard::contain(raw.as_ptr(), || {
let dx = unsafe { event.scrollingDeltaX() };
let dy = unsafe { event.scrollingDeltaY() };
if dx.abs() <= dy.abs() {
return raw.as_ptr();
}
let Some(mtm) = MainThreadMarker::new() else {
return raw.as_ptr();
};
let Some(window) = event.window(mtm) else {
return raw.as_ptr();
};
let Some(content) = window.contentView() else {
return raw.as_ptr();
};
let point =
content.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
let mut view = content.hitTest(point);
while let Some(v) = view {
if let Some(node) = PAN_NODES.with(|t| t.get(ptr_of(&v))) {
if v.downcast_ref::<DayCanvas>().is_some() {
return raw.as_ptr();
}
let raw_phase = unsafe { event.phase() };
let phase = if raw_phase.contains(objc2_app_kit::NSEventPhase::Began) {
day_spec::DragPhase::Began
} else if raw_phase.intersects(
objc2_app_kit::NSEventPhase::Ended
| objc2_app_kit::NSEventPhase::Cancelled,
) {
day_spec::DragPhase::Ended
} else {
day_spec::DragPhase::Changed
};
let p = v.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
emit(
node,
Event::Pan {
phase,
delta: Point::new(dx, dy),
location: Point::new(p.x, p.y),
},
);
return std::ptr::null_mut();
}
view = unsafe { v.superview() };
}
raw.as_ptr()
})
});
*slot.borrow_mut() = unsafe {
NSEvent::addLocalMonitorForEventsMatchingMask_handler(
objc2_app_kit::NSEventMask::ScrollWheel,
&block,
)
}
.map(PanMonitor);
});
}
type Surface = (f64, f64, f64, f64, f64);
#[derive(Default)]
struct FlippedIvars {
surface: Cell<Option<Surface>>,
section_card: Cell<Option<f64>>,
pane_backdrop: Cell<bool>,
}
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "DayFlipped"]
#[ivars = FlippedIvars]
struct DayFlipped;
impl DayFlipped {
#[unsafe(method_id(menuForEvent:))]
fn menu_for_event(&self, event: &objc2_app_kit::NSEvent) -> Option<Retained<NSMenu>> {
ffi_guard::contain(None, || {
inherited_context_menu(self, event)
.unwrap_or_else(|| unsafe { msg_send![super(self), menuForEvent: event] })
})
}
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method(acceptsFirstResponder))]
fn accepts_first_responder(&self) -> bool {
let ptr = (self as *const DayFlipped).cast::<NSView>() as usize;
FOCUSABLE_NODES.with(|t| t.get(ptr)).is_some()
}
#[unsafe(method(mouseDown:))]
fn mouse_down(&self, event: &objc2_app_kit::NSEvent) {
if transfer::pressed(self, event) { return; }
let ptr = (self as *const DayFlipped).cast::<NSView>() as usize;
if FOCUSABLE_NODES.with(|t| t.get(ptr)).is_some()
&& let Some(window) = self.window()
{
window.makeFirstResponder(Some(self));
}
let _: () = unsafe { msg_send![super(self), mouseDown: event] };
}
#[unsafe(method(becomeFirstResponder))]
fn become_first_responder(&self) -> bool {
let ptr = (self as *const DayFlipped).cast::<NSView>() as usize;
if let Some(node) = FOCUSABLE_NODES.with(|t| t.get(ptr)) {
ffi_guard::contain((), || emit(node, Event::FocusChanged(true)));
}
true
}
#[unsafe(method(resignFirstResponder))]
fn resign_first_responder(&self) -> bool {
let ptr = (self as *const DayFlipped).cast::<NSView>() as usize;
if let Some(node) = FOCUSABLE_NODES.with(|t| t.get(ptr)) {
ffi_guard::contain((), || emit(node, Event::FocusChanged(false)));
}
true
}
#[unsafe(method(keyDown:))]
fn key_down(&self, event: &objc2_app_kit::NSEvent) {
let ptr = (self as *const DayFlipped).cast::<NSView>() as usize;
let handled = ffi_guard::contain(false, || {
let Some(node) = FOCUSABLE_NODES.with(|t| t.get(ptr)) else {
return false;
};
let Some(key) = key_name(event) else {
return false;
};
if !day_spec::keys::handled(node) {
return false;
}
emit(
node,
Event::Key(day_spec::KeyEvent {
key: key.to_string(),
modifiers: key_modifiers(event.modifierFlags()),
}),
);
true
});
if !handled {
let _: () = unsafe { msg_send![super(self), keyDown: event] };
}
}
#[unsafe(method(rightMouseDown:))]
fn right_mouse_down(&self, event: &objc2_app_kit::NSEvent) {
if let Some(menu) = self.menuForEvent(event) {
unsafe {
objc2_app_kit::NSMenu::popUpContextMenu_withEvent_forView(&menu, event, self)
};
} else {
let _: () = unsafe { msg_send![super(self), rightMouseDown: event] };
}
}
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty: NSRect) {
let paint = || {
if self.ivars().pane_backdrop.get() {
let bounds = self.bounds();
unsafe {
NSColor::windowBackgroundColor().setFill();
objc2_app_kit::NSBezierPath::bezierPathWithRect(bounds).fill();
}
}
if let Some(radius) = self.ivars().section_card.get() {
let bounds = self.bounds();
unsafe {
let cls = objc2::class!(NSColor);
let has: bool = msg_send![cls, respondsToSelector: objc2::sel!(quaternarySystemFillColor)];
let color: objc2::rc::Retained<NSColor> = if has {
msg_send![cls, quaternarySystemFillColor]
} else {
msg_send![cls, controlBackgroundColor]
};
color.setFill();
let path = objc2_app_kit::NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(
bounds, radius, radius,
);
path.fill();
}
}
if let Some((r, g, b, a, radius)) = self.ivars().surface.get() {
let bounds = self.bounds();
unsafe {
NSColor::colorWithSRGBRed_green_blue_alpha(r, g, b, a).setFill();
let path = if radius > 0.0 {
objc2_app_kit::NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(
bounds, radius, radius,
)
} else {
objc2_app_kit::NSBezierPath::bezierPathWithRect(bounds)
};
path.fill();
}
}
};
unsafe {
let names = objc2_foundation::NSArray::from_slice(&[
objc2_app_kit::NSAppearanceNameAqua,
objc2_app_kit::NSAppearanceNameDarkAqua,
]);
let standard = self
.effectiveAppearance()
.bestMatchFromAppearancesWithNames(&names)
.and_then(|n| objc2_app_kit::NSAppearance::appearanceNamed(&n));
match standard {
Some(appearance) => {
let block = block2::StackBlock::new(paint);
appearance.performAsCurrentDrawingAppearance(&block);
}
None => paint(),
}
}
}
}
unsafe impl NSObjectProtocol for DayFlipped {}
unsafe impl NSDraggingSource for DayFlipped {
#[unsafe(method(draggingSession:sourceOperationMaskForDraggingContext:))]
fn source_mask(&self, _session: &objc2_app_kit::NSDraggingSession, _context: objc2_app_kit::NSDraggingContext) -> objc2_app_kit::NSDragOperation { objc2_app_kit::NSDragOperation::Copy }
}
impl DayFlipped {
#[unsafe(method(mouseDragged:))]
fn transfer_dragged(&self, event: &NSEvent) {
if !transfer::dragged(self, event) { let _: () = unsafe { msg_send![super(self), mouseDragged: event] }; }
}
#[unsafe(method(draggingEntered:))]
fn transfer_entered(&self, info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>) -> objc2_app_kit::NSDragOperation { transfer::proposal(self, info) }
#[unsafe(method(draggingUpdated:))]
fn transfer_updated(&self, info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>) -> objc2_app_kit::NSDragOperation { transfer::proposal(self, info) }
#[unsafe(method(performDragOperation:))]
fn transfer_drop(&self, info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>) -> bool { transfer::receive(self, info) }
}
);
impl DayFlipped {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(FlippedIvars::default());
unsafe { msg_send![super(this), init] }
}
fn set_section_card(&self, corner_radius: f64) {
self.ivars().section_card.set(Some(corner_radius));
unsafe {
let _: () = msg_send![self, setNeedsDisplay: true];
}
}
fn set_pane_backdrop(&self) {
self.ivars().pane_backdrop.set(true);
unsafe {
let _: () = msg_send![self, setNeedsDisplay: true];
}
}
fn set_surface(&self, bg: Option<day_spec::Color>, corner_radius: f64, clips: bool) {
self.ivars()
.surface
.set(bg.map(|c| (c.r, c.g, c.b, c.a, corner_radius)));
unsafe {
let _: () = msg_send![self, setNeedsDisplay: true];
if clips || corner_radius > 0.0 {
let _: () = msg_send![self, setWantsLayer: true];
let layer: *mut objc2::runtime::AnyObject = msg_send![self, layer];
if !layer.is_null() {
let _: () = msg_send![layer, setCornerRadius: corner_radius];
let _: () = msg_send![layer, setMasksToBounds: true];
}
}
}
}
}
struct CanvasIvars;
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "DayCanvas"]
#[ivars = CanvasIvars]
struct DayCanvas;
impl DayCanvas {
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method_id(menuForEvent:))]
fn menu_for_event(
&self,
event: &objc2_app_kit::NSEvent,
) -> Option<Retained<NSMenu>> {
let f = CTX_MENU_FNS.with(|t| t.get(self as *const Self as usize));
let Some(f) = f else {
return unsafe { msg_send![super(self), menuForEvent: event] };
};
ffi_guard::contain(None, || {
let p = self.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
let items = f(day_spec::Point::new(p.x, p.y));
if items.is_empty() {
return None;
}
Some(build_ns_menu(self.mtm(), "", &items))
})
}
#[unsafe(method(acceptsFirstResponder))]
fn accepts_first_responder(&self) -> bool {
true
}
#[unsafe(method(mouseDown:))]
fn mouse_down(&self, event: &objc2_app_kit::NSEvent) {
if let Some(window) = self.window() {
window.makeFirstResponder(Some(self));
}
let _: () = unsafe { msg_send![super(self), mouseDown: event] };
}
#[unsafe(method(becomeFirstResponder))]
fn become_first_responder(&self) -> bool {
let ptr = (self as *const DayCanvas).cast::<NSView>() as usize;
if let Some(node) = KEY_NODES.with(|t| t.get(ptr)) {
ffi_guard::contain((), || emit(node, Event::FocusChanged(true)));
}
true
}
#[unsafe(method(resignFirstResponder))]
fn resign_first_responder(&self) -> bool {
let ptr = (self as *const DayCanvas).cast::<NSView>() as usize;
if let Some(node) = KEY_NODES.with(|t| t.get(ptr)) {
ffi_guard::contain((), || emit(node, Event::FocusChanged(false)));
}
true
}
#[unsafe(method(keyDown:))]
fn key_down(&self, event: &objc2_app_kit::NSEvent) {
let ptr = (self as *const DayCanvas).cast::<NSView>() as usize;
let handled = ffi_guard::contain(false, || {
let Some(node) = KEY_NODES.with(|t| t.get(ptr)) else {
return false;
};
let Some(key) = key_name(event) else {
return false;
};
if !day_spec::keys::handled(node) {
return false;
}
emit(
node,
Event::Key(day_spec::KeyEvent {
key: key.to_string(),
modifiers: key_modifiers(event.modifierFlags()),
}),
);
true
});
if !handled {
let _: () = unsafe { msg_send![super(self), keyDown: event] };
}
}
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty: NSRect) {
let ptr = (self as *const DayCanvas).cast::<NSView>() as usize;
let ops = OPS.with(|t| t.get(ptr)).unwrap_or_default();
paint_ops(&ops);
}
#[unsafe(method(mouseEntered:))]
fn mouse_entered(&self, event: &objc2_app_kit::NSEvent) {
self.report_hover(event, day_spec::DragPhase::Began);
}
#[unsafe(method(mouseMoved:))]
fn mouse_moved(&self, event: &objc2_app_kit::NSEvent) {
self.report_hover(event, day_spec::DragPhase::Changed);
}
#[unsafe(method(mouseExited:))]
fn mouse_exited(&self, event: &objc2_app_kit::NSEvent) {
self.report_hover(event, day_spec::DragPhase::Ended);
}
#[unsafe(method(scrollWheel:))]
fn scroll_wheel(&self, event: &objc2_app_kit::NSEvent) {
let ptr = (self as *const DayCanvas).cast::<NSView>() as usize;
let Some(node) = PAN_NODES.with(|t| t.get(ptr)) else {
let _: () = unsafe { msg_send![super(self), scrollWheel: event] };
return;
};
ffi_guard::contain((), || {
let raw = unsafe { event.phase() };
let phase = if raw.contains(objc2_app_kit::NSEventPhase::Began) {
day_spec::DragPhase::Began
} else if raw.contains(objc2_app_kit::NSEventPhase::Ended)
|| raw.contains(objc2_app_kit::NSEventPhase::Cancelled)
{
day_spec::DragPhase::Ended
} else {
day_spec::DragPhase::Changed
};
let delta = Point::new(unsafe { event.scrollingDeltaX() }, unsafe {
event.scrollingDeltaY()
});
let win = unsafe { event.locationInWindow() };
let loc = self.convertPoint_fromView(win, None);
emit(
node,
Event::Pan {
phase,
delta,
location: Point::new(loc.x, loc.y),
},
);
})
}
}
);
impl DayCanvas {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(CanvasIvars);
unsafe { msg_send![super(this), init] }
}
fn report_hover(&self, event: &objc2_app_kit::NSEvent, phase: day_spec::DragPhase) {
let ptr = (self as *const DayCanvas).cast::<NSView>() as usize;
let Some(node) = HOVER_NODES.with(|t| t.get(ptr)) else {
return;
};
ffi_guard::contain((), || {
let win = unsafe { event.locationInWindow() };
let loc = self.convertPoint_fromView(win, None);
let b = self.bounds();
let inside = loc.x >= b.origin.x
&& loc.x <= b.origin.x + b.size.width
&& loc.y >= b.origin.y
&& loc.y <= b.origin.y + b.size.height;
if !inside && phase != day_spec::DragPhase::Ended {
return;
}
emit(
node,
Event::Hover {
phase,
location: Point::new(loc.x, loc.y),
},
);
});
}
}
fn install_tracking_area(h: &NSView) {
let opts = NSTrackingAreaOptions::MouseEnteredAndExited
| NSTrackingAreaOptions::MouseMoved
| NSTrackingAreaOptions::ActiveInKeyWindow
| NSTrackingAreaOptions::InVisibleRect;
let any: &objc2::runtime::AnyObject = h;
let area = unsafe {
NSTrackingArea::initWithRect_options_owner_userInfo(
NSTrackingArea::alloc(),
NSRect::ZERO,
opts,
Some(any),
None,
)
};
unsafe { h.addTrackingArea(&area) };
}
struct GestureIvars {
node: NodeId,
kind: day_spec::GestureKind,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayGesture"]
#[ivars = GestureIvars]
struct DayGesture;
unsafe impl NSObjectProtocol for DayGesture {}
impl DayGesture {
#[unsafe(method(fire:))]
fn fire(&self, g: &NSGestureRecognizer) {
ffi_guard::contain((), || {
let node = self.ivars().node;
let view = g.view();
let loc = g.locationInView(view.as_deref());
let at = Point::new(loc.x, loc.y);
let phase = match g.state() {
NSGestureRecognizerState::Began => day_spec::DragPhase::Began,
NSGestureRecognizerState::Ended
| NSGestureRecognizerState::Cancelled
| NSGestureRecognizerState::Failed => day_spec::DragPhase::Ended,
_ => day_spec::DragPhase::Changed,
};
let obj: &objc2::runtime::AnyObject = g.as_ref();
match self.ivars().kind {
day_spec::GestureKind::Drag => {
let translation = if let Some(pan) = obj.downcast_ref::<NSPanGestureRecognizer>() {
let t = unsafe { pan.translationInView(view.as_deref()) };
Point::new(t.x, t.y)
} else {
Point::ZERO
};
emit(node, Event::Drag { phase, location: at, translation });
}
day_spec::GestureKind::Pinch => {
let scale = if let Some(m) =
obj.downcast_ref::<objc2_app_kit::NSMagnificationGestureRecognizer>()
{
1.0 + unsafe { m.magnification() }
} else {
1.0
};
emit(node, Event::Pinch { phase, scale, location: at });
}
_ => emit(node, Event::Tap(at)),
}
})
}
}
);
impl DayGesture {
fn new(mtm: MainThreadMarker, node: NodeId, kind: day_spec::GestureKind) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(GestureIvars { node, kind });
unsafe { msg_send![super(this), init] }
}
}
struct NavHeader {
bar: Retained<NSView>,
title: Retained<NSTextField>,
titles: Vec<String>,
_target: Retained<DayTarget>,
}
const NAV_HEADER_H: f64 = 34.0;
use day_spec::{NAV_LIST_MAX_W, NAV_LIST_MIN_W, NAV_SIDEBAR_MAX_W, NAV_SIDEBAR_MIN_W};
fn take_list_placement(state: &mut NavState) -> Option<f64> {
if !state.list_visible || state.list_positioned {
return None;
}
state.list_positioned = true;
state.list_width
}
const NAV_TABVIEW_INSET: f64 = 12.0;
const NAV_TABVIEW_INSET_TOP: f64 = 8.0;
struct TabHost {
view: Retained<objc2_app_kit::NSTabView>,
target: Option<Retained<DayTarget>>,
}
thread_local! {
static TAB_PICK_SUPPRESS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn sync_tab_labels(tabs: &TabHost, titles: &[String], selected: usize) {
unsafe {
let n = tabs.view.numberOfTabViewItems().max(0) as usize;
for (i, title) in titles.iter().enumerate().take(n) {
tabs.view
.tabViewItemAtIndex(i as isize)
.setLabel(&NSString::from_str(title));
}
if n > 0 {
TAB_PICK_SUPPRESS.with(|s| s.set(true));
tabs.view
.selectTabViewItemAtIndex(selected.min(n - 1) as isize);
TAB_PICK_SUPPRESS.with(|s| s.set(false));
}
}
}
fn adopt_menu_for_tabs(mtm: MainThreadMarker, menu: &NSView) {
let Some((titles, node)) = NAV_MENUS.with(|m| {
m.borrow().get(&ptr_of(menu)).map(|(_, d)| {
let ivars = d.ivars();
(
ivars
.items
.borrow()
.iter()
.map(|s| s.to_string())
.collect::<Vec<String>>(),
ivars.node,
)
})
}) else {
return;
};
let mut up = unsafe { menu.superview() };
while let Some(v) = up {
let reached = NAV_STATE.with(|m| {
let mut m = m.borrow_mut();
let Some(s) = m.get_mut(&ptr_of(&v)) else {
return false;
};
let selected = s.selected;
if let Some(tabs) = s.tabs.as_mut() {
if tabs.target.is_none() {
let target = DayTarget::new(mtm, node);
unsafe {
tabs.view
.setDelegate(Some(ProtocolObject::from_ref(&*target)))
};
tabs.target = Some(target);
}
sync_tab_labels(tabs, &titles, selected);
}
true
});
if reached {
return;
}
up = unsafe { v.superview() };
}
}
struct NavState {
sidebar_wrap: Retained<NSView>,
detail_wrap: Retained<NSView>,
_split_vc: Retained<objc2_app_kit::NSSplitViewController>,
sidebar_item: Retained<objc2_app_kit::NSSplitViewItem>,
list_wrap: Option<Retained<NSView>>,
list_item: Option<Retained<objc2_app_kit::NSSplitViewItem>>,
list_width: Option<f64>,
list_visible: bool,
list_positioned: bool,
pages: Vec<Retained<NSView>>,
sidebar_page: Option<Retained<NSView>>,
list_page: Option<Retained<NSView>>,
positioned: bool,
presentation: NavPresentation,
selected: usize,
tabs: Option<TabHost>,
header: Option<NavHeader>,
root_title: String,
node: NodeId,
title_strip: f64,
}
fn pane_under_title_bar(mtm: MainThreadMarker, wrap: &NSView) -> Retained<NSView> {
let pane = view_of(DayFlipped::new(mtm));
unsafe {
wrap.setFrame(pane.bounds());
wrap.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
pane.addSubview(wrap);
}
pane
}
fn place_below_strip(wrap: &NSView, strip: f64) {
let Some(pane) = (unsafe { wrap.superview() }) else {
return;
};
let b = pane.bounds();
unsafe {
wrap.setFrame(NSRect::new(
NSPoint::new(0.0, strip),
NSSize::new(b.size.width, (b.size.height - strip).max(0.0)),
))
};
}
fn build_nav_header(
mtm: MainThreadMarker,
id: NodeId,
detail_wrap: &NSView,
root_title: &str,
) -> NavHeader {
let bar = view_of(DayFlipped::new(mtm));
let target = DayTarget::new(mtm, id);
let back = unsafe {
let img = objc2_app_kit::NSImage::imageNamed(objc2_app_kit::NSImageNameGoBackTemplate)
.expect("NSGoBackTemplate");
let tobj: &objc2::runtime::AnyObject = target.as_ref();
objc2_app_kit::NSButton::buttonWithImage_target_action(
&img,
Some(tobj),
Some(sel!(navBack:)),
mtm,
)
};
let title = unsafe { NSTextField::labelWithString(&NSString::from_str(root_title), mtm) };
unsafe {
back.setFrame(NSRect::new(
NSPoint::new(6.0, 4.0),
NSSize::new(30.0, NAV_HEADER_H - 8.0),
));
title.setFont(Some(&NSFont::boldSystemFontOfSize(13.0)));
title.setAlignment(objc2_app_kit::NSTextAlignment::Center);
title.setLineBreakMode(objc2_app_kit::NSLineBreakMode::ByTruncatingTail);
title.setFrame(NSRect::new(
NSPoint::new(44.0, 9.0),
NSSize::new((detail_wrap.bounds().size.width - 88.0).max(0.0), 18.0),
));
title.setAutoresizingMask(objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable);
bar.setFrame(NSRect::new(
NSPoint::new(0.0, 0.0),
NSSize::new(detail_wrap.bounds().size.width, NAV_HEADER_H),
));
bar.setAutoresizingMask(objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable);
bar.addSubview(&back);
bar.addSubview(&title);
bar.setHidden(true);
detail_wrap.addSubview(&bar);
}
NavHeader {
bar,
title,
titles: vec![root_title.to_string()],
_target: target,
}
}
fn nav_page_frame(wrap: &NSView, header_visible: bool) -> NSRect {
let b = wrap.bounds();
let top = if header_visible { NAV_HEADER_H } else { 0.0 };
NSRect::new(
NSPoint::new(0.0, top),
NSSize::new(b.size.width, (b.size.height - top).max(0.0)),
)
}
fn nav_menu_rows(page: &NSView) -> Option<(Vec<String>, NodeId)> {
fn walk(v: &NSView, out: &mut Option<(Vec<String>, NodeId)>) {
if out.is_some() {
return;
}
let found = NAV_MENUS.with(|m| {
m.borrow()
.get(&(v as *const NSView as usize))
.map(|(_, d)| {
let ivars = d.ivars();
(
ivars.items.borrow().iter().map(|s| s.to_string()).collect(),
ivars.node,
)
})
});
if found.is_some() {
*out = found;
return;
}
for sub in unsafe { v.subviews() }.iter() {
walk(&sub, out);
}
}
let mut out = None;
walk(page, &mut out);
out
}
fn apply_sidebar_item(item: &objc2_app_kit::NSSplitViewItem, pres: NavPresentation) {
let shown = matches!(pres, NavPresentation::Split | NavPresentation::Rail);
unsafe {
item.setCanCollapse(shown);
item.setCollapsed(!shown);
match pres {
NavPresentation::Split => {
item.setAllowsFullHeightLayout(true);
item.setMinimumThickness(NAV_SIDEBAR_MIN_W);
item.setMaximumThickness(NAV_SIDEBAR_MAX_W);
}
NavPresentation::Rail => {
item.setAllowsFullHeightLayout(true);
item.setMinimumThickness(NAV_SIDEBAR_MIN_W);
item.setMaximumThickness(NAV_SIDEBAR_MAX_W);
}
NavPresentation::Stack | NavPresentation::Tabs => {
item.setMinimumThickness(0.0);
item.setMaximumThickness(0.0);
}
}
}
}
pub(crate) fn list_split_view(window: usize) -> Option<Retained<objc2_app_kit::NSSplitView>> {
NAV_STATE.with(|m| {
m.borrow()
.values()
.find(|st| {
st.list_item.is_some()
&& st
.sidebar_wrap
.window()
.is_some_and(|w| Retained::as_ptr(&w) as usize == window)
})
.map(|st| unsafe { st._split_vc.splitView() })
})
}
fn sync_nav_header(hdr: &NavHeader, wrap: &NSView, pages: &[Retained<NSView>]) {
let visible = pages.len() >= 2;
hdr.bar.setHidden(!visible);
unsafe {
hdr.title.setStringValue(&NSString::from_str(
hdr.titles.last().map(String::as_str).unwrap_or(""),
));
}
let frame = nav_page_frame(wrap, visible);
for page in pages {
unsafe { page.setFrame(frame) };
}
}
fn nav_present(mtm: MainThreadMarker, host: &Handle, next: NavPresentation) {
let Some((sidebar_page, detail_wrap, sidebar_wrap, sidebar_item, was, root_title, id, sel)) =
NAV_STATE.with(|m| {
let st = m.borrow();
let s = st.get(&ptr_of(host))?;
Some((
s.sidebar_page.clone(),
s.detail_wrap.clone(),
s.sidebar_wrap.clone(),
s.sidebar_item.clone(),
s.presentation,
s.root_title.clone(),
s.node,
s.selected,
))
})
else {
return;
};
if was == next {
return;
}
apply_sidebar_item(&sidebar_item, next);
NAV_STATE.with(|m| {
if let Some(s) = m.borrow().get(&ptr_of(host)) {
unsafe { s._split_vc.splitView().layoutSubtreeIfNeeded() };
}
});
let stacked = next == NavPresentation::Stack;
let mut header = NAV_STATE.with(|m| m.borrow_mut().get_mut(&ptr_of(host))?.header.take());
if stacked && header.is_none() {
header = Some(build_nav_header(mtm, id, &detail_wrap, &root_title));
}
if let Some(hdr) = header.as_ref() {
hdr.bar.setHidden(true);
}
let mut pages = NAV_STATE.with(|m| {
m.borrow()
.get(&ptr_of(host))
.map(|s| s.pages.clone())
.unwrap_or_default()
});
let shown_page = pages.get(sel).cloned();
if let Some(page) = sidebar_page {
pages.retain(|p| ptr_of(p) != ptr_of(&page));
unsafe {
if stacked {
detail_wrap.addSubview(&page);
pages.insert(0, page.clone());
} else {
page.setFrame(sidebar_wrap.bounds());
sidebar_wrap.addSubview(&page);
}
}
page.setHidden(false);
}
let shown = shown_page
.and_then(|p| pages.iter().position(|q| ptr_of(q) == ptr_of(&p)))
.unwrap_or_else(|| pages.len().saturating_sub(1));
for (i, page) in pages.iter().enumerate() {
page.setHidden(i != shown);
}
NAV_STATE.with(|m| {
let mut m = m.borrow_mut();
if let Some(s) = m.get_mut(&ptr_of(host)) {
s.presentation = next;
s.pages = pages;
s.selected = shown;
s.header = header;
if let Some(hdr) = s.header.as_ref() {
if stacked {
sync_nav_header(hdr, &s.detail_wrap, &s.pages);
} else {
hdr.bar.setHidden(true);
}
}
if !stacked {
let frame = nav_page_frame(&s.detail_wrap, false);
for page in &s.pages {
unsafe { page.setFrame(frame) };
}
}
}
});
}
struct CoverSheet(Retained<NSWindow>);
impl Drop for CoverSheet {
fn drop(&mut self) {
let raw = Retained::into_raw(self.0.clone()) as usize;
dispatch2::DispatchQueue::main().exec_async(move || {
ffi_guard::contain((), || unsafe {
let window = Retained::from_raw(raw as *mut NSWindow).unwrap();
if let Some(parent) = window.sheetParent() {
parent.endSheet(&window);
}
window.orderOut(None);
window.setContentView(None);
});
});
}
}
struct NavPageIvars {
node: NodeId,
top_inset: Cell<f64>,
sheet: Cell<bool>,
half: Cell<bool>,
}
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "DayNavPage"]
#[ivars = NavPageIvars]
struct DayNavPage;
impl DayNavPage {
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method(setFrameSize:))]
fn set_frame_size(&self, size: NSSize) {
let _: () = unsafe { msg_send![super(self), setFrameSize: size] };
ffi_guard::contain((), || {
let inset = self.ivars().top_inset.get();
emit(
self.ivars().node,
Event::FrameChanged(Size::new(size.width, (size.height - inset).max(0.0))),
);
})
}
}
);
impl DayNavPage {
fn new(mtm: MainThreadMarker, node: NodeId) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(NavPageIvars {
node,
top_inset: Cell::new(0.0),
sheet: Cell::new(false),
half: Cell::new(false),
});
unsafe { msg_send![super(this), init] }
}
}
struct InspectorState {
content_wrap: Handle,
panel_wrap: Handle,
_split_vc: Retained<objc2_app_kit::NSSplitViewController>,
panel_item: Retained<objc2_app_kit::NSSplitViewItem>,
}
struct SplitState {
split: Retained<objc2_app_kit::NSSplitView>,
first_wrap: Handle,
second_wrap: Handle,
delegate: Retained<DaySplitDelegate>,
}
struct SplitDelegateIvars {
node: NodeId,
min_pane: f64,
share: Cell<f64>,
positioned: Cell<bool>,
suppress: Cell<bool>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DaySplitDelegate"]
#[ivars = SplitDelegateIvars]
struct DaySplitDelegate;
unsafe impl NSObjectProtocol for DaySplitDelegate {}
unsafe impl NSSplitViewDelegate for DaySplitDelegate {
#[unsafe(method(splitViewDidResizeSubviews:))]
fn did_resize(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
let Some(obj) = notification.object() else {
return;
};
let Ok(sv) = obj.downcast::<objc2_app_kit::NSSplitView>() else {
return;
};
let iv = self.ivars();
let usable = split_usable(&sv);
if usable <= 0.0 {
return;
}
if !iv.positioned.replace(true) {
split_place(&sv, iv);
return;
}
if iv.suppress.get() {
return;
}
let Some(first) = sv.subviews().iter().next() else {
return;
};
let f = first.frame();
let a = if sv.isVertical() {
f.size.width
} else {
f.size.height
};
let share = (a / usable).clamp(0.0, 1.0);
if (share - iv.share.get()).abs() > 1e-4 {
iv.share.set(share);
emit(iv.node, Event::ValueChanged(share));
}
})
}
#[unsafe(method(splitView:constrainMinCoordinate:ofSubviewAt:))]
fn constrain_min(
&self,
_sv: &objc2_app_kit::NSSplitView,
proposed: f64,
_index: isize,
) -> f64 {
proposed.max(self.ivars().min_pane)
}
#[unsafe(method(splitView:constrainMaxCoordinate:ofSubviewAt:))]
fn constrain_max(
&self,
sv: &objc2_app_kit::NSSplitView,
proposed: f64,
_index: isize,
) -> f64 {
proposed.min((split_usable(sv) - self.ivars().min_pane).max(0.0))
}
}
);
impl DaySplitDelegate {
fn new(mtm: MainThreadMarker, node: NodeId, min_pane: f64, share: f64) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(SplitDelegateIvars {
node,
min_pane,
share: Cell::new(share.clamp(0.0, 1.0)),
positioned: Cell::new(false),
suppress: Cell::new(false),
});
unsafe { msg_send![super(this), init] }
}
}
fn split_usable(sv: &objc2_app_kit::NSSplitView) -> f64 {
let frame = sv.frame();
let len = if sv.isVertical() {
frame.size.width
} else {
frame.size.height
};
(len - sv.dividerThickness()).max(0.0)
}
fn split_place(sv: &objc2_app_kit::NSSplitView, iv: &SplitDelegateIvars) {
let usable = split_usable(sv);
if usable <= 0.0 {
return;
}
iv.suppress.set(true);
unsafe { sv.setPosition_ofDividerAtIndex(iv.share.get() * usable, 0) };
iv.suppress.set(false);
}
struct NavMenuIvars {
node: NodeId,
items: RefCell<Vec<Retained<NSString>>>,
icons: RefCell<Vec<Option<Retained<objc2_app_kit::NSImage>>>>,
icon_fills: RefCell<Vec<f64>>,
tints: RefCell<Vec<Option<day_spec::Color>>>,
badges: RefCell<Vec<Option<Retained<NSString>>>>,
icon_progress: RefCell<Vec<(usize, Option<f64>)>>,
badge_icons: RefCell<Vec<Option<Retained<objc2_app_kit::NSImage>>>>,
badge_tints: RefCell<Vec<Option<day_spec::Color>>>,
menus: RefCell<Vec<Vec<day_spec::MenuItem>>>,
movable: RefCell<Vec<bool>>,
regroup: std::cell::Cell<bool>,
sorted: RefCell<Vec<bool>>,
rows: RefCell<Vec<Option<usize>>>,
row_objects: RefCell<Vec<Retained<NSString>>>,
row_ids: RefCell<Vec<Retained<NSNumber>>>,
suppress: std::cell::Cell<bool>,
}
struct NavCellIvars {
accessory: RefCell<Option<Retained<NSView>>>,
count: RefCell<Option<Retained<NSTextField>>>,
icon_fill: std::cell::Cell<f64>,
}
define_class!(
#[unsafe(super(objc2_app_kit::NSTableCellView))]
#[thread_kind = MainThreadOnly]
#[name = "DayNavCell"]
#[ivars = NavCellIvars]
struct DayNavCell;
unsafe impl NSObjectProtocol for DayNavCell {}
impl DayNavCell {
#[unsafe(method(layout))]
fn layout(&self) {
let _: () = unsafe { msg_send![super(self), layout] };
ffi_guard::contain((), || self.fit_accessories());
}
}
);
impl DayNavCell {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(NavCellIvars {
accessory: RefCell::new(None),
count: RefCell::new(None),
icon_fill: std::cell::Cell::new(1.0),
});
unsafe { msg_send![super(this), init] }
}
fn fit_accessories(&self) {
let label = unsafe { self.textField() };
if let (Some(label), Some(count)) = (&label, self.ivars().count.borrow().as_ref()) {
let size = unsafe { label.font() }.map_or(NSFont::smallSystemFontSize(), |f| {
(f.pointSize() - 2.0).max(9.0)
});
if unsafe { count.font() }.is_none_or(|f| f.pointSize() != size) {
unsafe {
count.setFont(Some(&NSFont::monospacedDigitSystemFontOfSize_weight(
size,
objc2_app_kit::NSFontWeightRegular,
)));
}
self.layoutSubtreeIfNeeded();
}
}
if let (Some(label), Some(accessory)) = (label, self.ivars().accessory.borrow().as_ref()) {
let mut frame = label.frame();
let end = accessory.frame().origin.x - 6.0;
if frame.origin.x + frame.size.width > end {
frame.size.width = (end - frame.origin.x).max(0.0);
label.setFrame(frame);
}
}
if let Some(icon) = unsafe { self.imageView() } {
fit_nav_icon(&icon, self.ivars().icon_fill.get());
nav_progress::fit(&icon);
}
}
}
thread_local! {
static NAV_GLYPH_SIDES: RefCell<HashMap<u64, f64>> = RefCell::new(HashMap::new());
}
fn fit_nav_icon(icon: &objc2_app_kit::NSImageView, fill: f64) {
let Some(config) = (unsafe { icon.symbolConfiguration() }) else {
return;
};
let slot = icon.frame();
let key = config.hash() as u64;
let side = NAV_GLYPH_SIDES
.with(|m| m.borrow().get(&key).copied())
.or_else(|| {
let symbol =
objc2_app_kit::NSImage::imageWithSystemSymbolName_accessibilityDescription(
&NSString::from_str("gearshape"),
None,
)?;
let side = symbol.imageWithSymbolConfiguration(&config)?.size().height;
NAV_GLYPH_SIDES.with(|m| m.borrow_mut().insert(key, side));
Some(side)
});
let Some(side) = side
.filter(|s| *s > 0.0)
.map(|s| (s / fill.clamp(0.25, 1.0)).round())
else {
return;
};
if slot.size.width <= side && slot.size.height <= side {
return;
}
icon.setFrame(NSRect::new(
NSPoint::new(
(slot.origin.x + (slot.size.width - side) / 2.0).round(),
(slot.origin.y + (slot.size.height - side) / 2.0).round(),
),
NSSize::new(side, side),
));
}
define_class!(
#[unsafe(super(objc2_app_kit::NSTableRowView))]
#[thread_kind = MainThreadOnly]
#[name = "DayNavRowView"]
struct DayNavRowView;
unsafe impl NSObjectProtocol for DayNavRowView {}
impl DayNavRowView {
#[unsafe(method(isEmphasized))]
fn is_emphasized(&self) -> bool {
true
}
}
);
define_class!(
#[unsafe(super(objc2_app_kit::NSOutlineView))]
#[thread_kind = MainThreadOnly]
#[name = "DayNavOutlineView"]
struct DayNavOutlineView;
unsafe impl NSObjectProtocol for DayNavOutlineView {}
impl DayNavOutlineView {
#[unsafe(method_id(menuForEvent:))]
fn menu_for_event(
&self,
event: &objc2_app_kit::NSEvent,
) -> Option<Retained<NSMenu>> {
ffi_guard::contain(None, || {
let point = self.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
let row = unsafe { self.rowAtPoint(point) };
let data = NAV_OUTLINE_MENUS.with(|t| t.get(self as *const _ as usize));
match data {
None => None,
Some(d) => {
let items = d
.item_of_row(row)
.and_then(|i| d.ivars().menus.borrow().get(i).cloned())
.unwrap_or_default();
if items.is_empty() {
None
} else {
Some(build_ns_menu(d.mtm(), "", &items))
}
}
}
})
}
}
);
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayNavMenuData"]
#[ivars = NavMenuIvars]
struct DayNavMenuData;
unsafe impl NSObjectProtocol for DayNavMenuData {}
unsafe impl NSOutlineViewDataSource for DayNavMenuData {
#[unsafe(method(outlineView:numberOfChildrenOfItem:))]
fn number_of_children(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: Option<&objc2::runtime::AnyObject>,
) -> isize {
if item.is_none() {
self.ivars().row_objects.borrow().len() as isize
} else {
0
}
}
#[unsafe(method_id(outlineView:child:ofItem:))]
fn child_of_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
index: isize,
_item: Option<&objc2::runtime::AnyObject>,
) -> Retained<objc2::runtime::AnyObject> {
let ids = self.ivars().row_ids.borrow();
let id = ids
.get(index as usize)
.cloned()
.unwrap_or_else(|| NSNumber::new_isize(-1));
unsafe { objc2::rc::Retained::cast_unchecked(id) }
}
#[unsafe(method(outlineView:isItemExpandable:))]
fn is_expandable(
&self,
_ov: &objc2_app_kit::NSOutlineView,
_item: &objc2::runtime::AnyObject,
) -> bool {
false
}
#[unsafe(method_id(outlineView:pasteboardWriterForItem:))]
fn pasteboard_writer_for_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> Option<Retained<ProtocolObject<dyn objc2_app_kit::NSPasteboardWriting>>> {
let make = || {
let row = Self::outline_row(item);
let index = self.item_of_row(row)?;
if !self.is_movable(index) {
return None;
}
let pbitem = objc2_app_kit::NSPasteboardItem::new();
unsafe {
pbitem.setString_forType(
&NSString::from_str(&row.to_string()),
&NSString::from_str(DAY_NAV_PASTEBOARD_TYPE),
);
}
Some(ProtocolObject::from_retained(pbitem))
};
ffi_guard::contain(None, make)
}
#[unsafe(method(outlineView:validateDrop:proposedItem:proposedChildIndex:))]
fn validate_drop(
&self,
ov: &objc2_app_kit::NSOutlineView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
item: Option<&objc2::runtime::AnyObject>,
child_index: isize,
) -> objc2_app_kit::NSDragOperation {
ffi_guard::contain(objc2_app_kit::NSDragOperation::None, || {
let Some(drop) = self.nav_drop(ov, info, item, child_index) else {
return objc2_app_kit::NSDragOperation::None;
};
if drop.retarget {
unsafe { ov.setDropItem_dropChildIndex(None, drop.gap as isize) };
}
objc2_app_kit::NSDragOperation::Move
})
}
#[unsafe(method(outlineView:acceptDrop:item:childIndex:))]
fn accept_drop(
&self,
ov: &objc2_app_kit::NSOutlineView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
item: Option<&objc2::runtime::AnyObject>,
child_index: isize,
) -> bool {
ffi_guard::contain(false, || {
let Some(drop) = self.nav_drop(ov, info, item, child_index) else {
return false;
};
match drop.moves {
Some(NavMove::Reorder { from, to }) => {
emit(self.ivars().node, Event::ListReorder { from, to });
}
Some(NavMove::Regroup {
from,
section,
before,
}) => emit(
self.ivars().node,
Event::NavRegroup {
from,
section,
before,
},
),
None => {}
}
true
})
}
}
unsafe impl NSControlTextEditingDelegate for DayNavMenuData {}
unsafe impl NSOutlineViewDelegate for DayNavMenuData {
#[unsafe(method(outlineView:isGroupItem:))]
fn is_group_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> bool {
let row = Self::outline_row(item);
row >= 0 && self.item_of_row(row).is_none()
}
#[unsafe(method(outlineView:shouldSelectItem:))]
fn should_select(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> bool {
self.item_of_row(Self::outline_row(item)).is_some()
}
#[unsafe(method_id(outlineView:viewForTableColumn:item:))]
fn view_for(
&self,
ov: &objc2_app_kit::NSOutlineView,
_col: Option<&objc2_app_kit::NSTableColumn>,
item: &objc2::runtime::AnyObject,
) -> Option<Retained<NSView>> {
let mtm = self.mtm();
let row = Self::outline_row(item);
let text = self
.ivars()
.row_objects
.borrow()
.get(usize::try_from(row).unwrap_or(usize::MAX))
.cloned();
text.map(|text| {
let text: &NSString = &text;
let Some(index) = self.item_of_row(row) else {
let cell = DayNavCell::new(mtm);
let label = unsafe { NSTextField::labelWithString(text, mtm) };
unsafe {
label.cell().inspect(|c| {
c.setLineBreakMode(objc2_app_kit::NSLineBreakMode::ByTruncatingTail)
});
cell.addSubview(&label);
cell.setTextField(Some(&label));
}
return objc2::rc::Retained::into_super(objc2::rc::Retained::into_super(cell));
};
let icon = self
.ivars()
.icons
.borrow()
.get(index)
.and_then(|o| o.clone());
let badge = self
.ivars()
.badges
.borrow()
.get(index)
.and_then(|o| o.clone());
let badge_icon = self
.ivars()
.badge_icons
.borrow()
.get(index)
.and_then(|o| o.clone());
let badge_tint = self
.ivars()
.badge_tints
.borrow()
.get(index)
.copied()
.flatten();
let cell = DayNavCell::new(mtm);
let label = unsafe { NSTextField::labelWithString(text, mtm) };
unsafe {
label.cell().inspect(|c| {
c.setLineBreakMode(objc2_app_kit::NSLineBreakMode::ByTruncatingTail)
});
cell.addSubview(&label);
cell.setTextField(Some(&label));
let accessory = objc2_app_kit::NSStackView::new(mtm);
accessory.setTranslatesAutoresizingMaskIntoConstraints(false);
accessory.setOrientation(
objc2_app_kit::NSUserInterfaceLayoutOrientation::Horizontal,
);
accessory.setSpacing(4.0);
let mut has_accessory = false;
let trailing: Retained<NSView> = match &badge {
Some(text) => {
let b = NSTextField::labelWithString(text, mtm);
b.setTranslatesAutoresizingMaskIntoConstraints(false);
b.setTextColor(Some(&objc2_app_kit::NSColor::secondaryLabelColor()));
b.setAlignment(objc2_app_kit::NSTextAlignment::Right);
b.setFont(Some(
&objc2_app_kit::NSFont::monospacedDigitSystemFontOfSize_weight(
objc2_app_kit::NSFont::smallSystemFontSize(),
objc2_app_kit::NSFontWeightRegular,
),
));
b.setContentCompressionResistancePriority_forOrientation(
objc2_app_kit::NSLayoutPriorityRequired,
objc2_app_kit::NSLayoutConstraintOrientation::Horizontal,
);
*cell.ivars().count.borrow_mut() = Some(b.clone());
accessory.addArrangedSubview(&objc2::rc::Retained::into_super(
objc2::rc::Retained::into_super(b),
));
has_accessory = true;
let v: Retained<NSView> =
objc2::rc::Retained::into_super(accessory.clone());
v
}
None => objc2::rc::Retained::into_super(accessory.clone()),
};
if let Some(img) = &badge_icon {
let iv = objc2_app_kit::NSImageView::new(mtm);
iv.setImage(Some(img));
iv.setTranslatesAutoresizingMaskIntoConstraints(false);
if let Some(c) = badge_tint {
iv.setContentTintColor(Some(&nscolor(c)));
}
iv.setContentCompressionResistancePriority_forOrientation(
objc2_app_kit::NSLayoutPriorityRequired,
objc2_app_kit::NSLayoutConstraintOrientation::Horizontal,
);
accessory.addArrangedSubview(&objc2::rc::Retained::into_super(iv));
has_accessory = true;
}
cell.addSubview(&trailing);
if has_accessory {
*cell.ivars().accessory.borrow_mut() = Some(trailing.clone());
}
objc2_app_kit::NSLayoutConstraint::activateConstraints(
&objc2_foundation::NSArray::from_retained_slice(&[
trailing
.trailingAnchor()
.constraintEqualToAnchor_constant(&cell.trailingAnchor(), -6.0),
trailing
.centerYAnchor()
.constraintEqualToAnchor(&cell.centerYAnchor()),
]),
);
}
if let Some(img) = icon {
let fill = self.ivars().icon_fills.borrow().get(index).copied();
cell.ivars().icon_fill.set(fill.unwrap_or(1.0));
let tint = self.ivars().tints.borrow().get(index).copied().flatten();
let iv = unsafe { objc2_app_kit::NSImageView::new(mtm) };
unsafe {
iv.setImage(Some(&img));
if let Some(t) = tint {
iv.setContentTintColor(Some(&nscolor(t)));
}
iv.setImageScaling(
objc2_app_kit::NSImageScaling::ScaleProportionallyUpOrDown,
);
cell.addSubview(&iv);
cell.setImageView(Some(&iv));
let progress = self
.ivars()
.icon_progress
.borrow()
.iter()
.find(|(row, _)| *row == index)
.map(|(_, f)| *f);
nav_progress::update(&iv, progress);
}
}
objc2::rc::Retained::into_super(objc2::rc::Retained::into_super(cell))
})
}
#[unsafe(method_id(outlineView:rowViewForItem:))]
fn row_view_for(
&self,
_ov: &objc2_app_kit::NSOutlineView,
_item: &objc2::runtime::AnyObject,
) -> Retained<objc2_app_kit::NSTableRowView> {
let row: Retained<DayNavRowView> =
unsafe { msg_send![DayNavRowView::alloc(self.mtm()), init] };
objc2::rc::Retained::into_super(row)
}
#[unsafe(method(outlineViewSelectionDidChange:))]
fn selection_did_change(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
if self.ivars().suppress.get() {
return;
}
let Some(obj) = (unsafe { notification.object() }) else {
return;
};
let Ok(ov) = obj.downcast::<objc2_app_kit::NSOutlineView>() else {
return;
};
let row = unsafe { ov.selectedRow() };
if let Some(item) = self.item_of_row(row) {
emit(self.ivars().node, Event::SelectionChanged(item as i64));
}
})
}
}
);
thread_local! {
static NAV_ICON_FILLS: RefCell<HashMap<String, f64>> = RefCell::new(HashMap::new());
}
fn nav_icon_fills(
names: &[Option<String>],
images: &[Option<Retained<objc2_app_kit::NSImage>>],
) -> Vec<f64> {
names
.iter()
.zip(images)
.map(|(name, image)| match (name, image) {
(Some(name), Some(image)) => NAV_ICON_FILLS.with(|m| {
*m.borrow_mut()
.entry(name.clone())
.or_insert_with(|| nav_icon_fill(image))
}),
_ => 1.0,
})
.collect()
}
fn nav_icon_fill(image: &objc2_app_kit::NSImage) -> f64 {
const PX: f64 = 64.0;
let Some(rep) = scaled_rep(image, Size::new(PX, PX), 1.0) else {
return 1.0;
};
let (w, h) = (
rep.pixelsWide().max(0) as usize,
rep.pixelsHigh().max(0) as usize,
);
let stride = rep.bytesPerRow().max(0) as usize;
let data = unsafe { rep.bitmapData() };
if data.is_null() || stride < w * 4 {
return 1.0;
}
let bytes = unsafe { std::slice::from_raw_parts(data, stride * h) };
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for y in 0..h {
for x in 0..w {
if bytes[y * stride + x * 4 + 3] > 24 {
(x0, x1, y0, y1) = (x0.min(x), x1.max(x + 1), y0.min(y), y1.max(y + 1));
}
}
}
if x1 <= x0 || y1 <= y0 {
return 1.0;
}
let size = image.size();
let (iw, ih) = (size.width.max(1.0), size.height.max(1.0));
let (fx, fy) = ((x1 - x0) as f64 / w as f64, (y1 - y0) as f64 / h as f64);
((fx * iw).max(fy * ih) / iw.max(ih)).clamp(0.25, 1.0)
}
fn resolve_nav_icons(icons: &[Option<String>]) -> Vec<Option<Retained<objc2_app_kit::NSImage>>> {
icons
.iter()
.map(|ic| {
let name = ic.as_deref()?;
let path = day_spec::resource::resolve_vector_svg(name)
.or_else(|| day_spec::resource::resolve_image_file(name))?;
use objc2::AllocAnyThread as _;
let img = unsafe {
objc2_app_kit::NSImage::initWithContentsOfFile(
objc2_app_kit::NSImage::alloc(),
&NSString::from_str(&path.to_string_lossy()),
)
}?;
unsafe { img.setTemplate(!std::path::Path::new(name).is_absolute()) };
Some(img)
})
.collect()
}
impl DayNavMenuData {
#[allow(clippy::too_many_arguments)]
fn new(
mtm: MainThreadMarker,
node: NodeId,
items: &[String],
icons: &[Option<String>],
badges: &[Option<String>],
badge_icons: &[Option<String>],
badge_tints: &[Option<day_spec::Color>],
sections: &[Option<String>],
tints: &[Option<day_spec::Color>],
menus: &[Vec<day_spec::MenuItem>],
) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(NavMenuIvars {
node,
items: RefCell::new(items.iter().map(|s| NSString::from_str(s)).collect()),
icon_fills: RefCell::new(nav_icon_fills(icons, &resolve_nav_icons(icons))),
icons: RefCell::new(resolve_nav_icons(icons)),
tints: RefCell::new(tints.to_vec()),
menus: RefCell::new(menus.to_vec()),
movable: RefCell::new(Vec::new()),
regroup: std::cell::Cell::new(false),
sorted: RefCell::new(Vec::new()),
badges: RefCell::new(ns_badges(badges)),
icon_progress: RefCell::new(Vec::new()),
badge_icons: RefCell::new(resolve_nav_icons(badge_icons)),
badge_tints: RefCell::new(badge_tints.to_vec()),
rows: RefCell::new(Vec::new()),
row_objects: RefCell::new(Vec::new()),
row_ids: RefCell::new(Vec::new()),
suppress: std::cell::Cell::new(false),
});
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
this.rebuild_rows(items, sections);
this
}
#[allow(clippy::too_many_arguments)]
fn set_items(
&self,
items: &[String],
icons: &[Option<String>],
badges: &[Option<String>],
badge_icons: &[Option<String>],
badge_tints: &[Option<day_spec::Color>],
sections: &[Option<String>],
tints: &[Option<day_spec::Color>],
menus: &[Vec<day_spec::MenuItem>],
) {
*self.ivars().items.borrow_mut() = items.iter().map(|s| NSString::from_str(s)).collect();
let resolved = resolve_nav_icons(icons);
*self.ivars().icon_fills.borrow_mut() = nav_icon_fills(icons, &resolved);
*self.ivars().icons.borrow_mut() = resolved;
*self.ivars().tints.borrow_mut() = tints.to_vec();
*self.ivars().menus.borrow_mut() = menus.to_vec();
*self.ivars().badges.borrow_mut() = ns_badges(badges);
self.ivars().icon_progress.borrow_mut().clear();
*self.ivars().badge_icons.borrow_mut() = resolve_nav_icons(badge_icons);
*self.ivars().badge_tints.borrow_mut() = badge_tints.to_vec();
self.rebuild_rows(items, sections);
}
fn rebuild_rows(&self, items: &[String], sections: &[Option<String>]) {
let mut rows = Vec::with_capacity(items.len());
let mut objects = Vec::with_capacity(items.len());
for (i, label) in items.iter().enumerate() {
if let Some(Some(header)) = sections.get(i) {
rows.push(None);
objects.push(NSString::from_str(header));
}
rows.push(Some(i));
objects.push(NSString::from_str(label));
}
let ids = (0..objects.len())
.map(|i| NSNumber::new_isize(i as isize))
.collect();
*self.ivars().rows.borrow_mut() = rows;
*self.ivars().row_objects.borrow_mut() = objects;
*self.ivars().row_ids.borrow_mut() = ids;
}
fn outline_row(item: &objc2::runtime::AnyObject) -> isize {
item.downcast_ref::<NSNumber>()
.map(|n| n.integerValue())
.unwrap_or(-1)
}
fn item_of_row(&self, row: isize) -> Option<usize> {
if row < 0 {
return None;
}
self.ivars()
.rows
.borrow()
.get(row as usize)
.copied()
.flatten()
}
fn row_of_item(&self, item: usize) -> Option<usize> {
self.ivars()
.rows
.borrow()
.iter()
.position(|r| *r == Some(item))
}
fn is_movable(&self, item: usize) -> bool {
self.ivars()
.movable
.borrow()
.get(item)
.copied()
.unwrap_or(false)
}
fn nav_drop(
&self,
ov: &objc2_app_kit::NSOutlineView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
item: Option<&objc2::runtime::AnyObject>,
child_index: isize,
) -> Option<NavDrop> {
let src = unsafe { info.draggingSource() }?;
if Retained::as_ptr(&src).cast::<std::ffi::c_void>()
!= std::ptr::from_ref(ov).cast::<std::ffi::c_void>()
{
return None;
}
let pb = unsafe { info.draggingPasteboard() };
let from_row = unsafe { pb.stringForType(&NSString::from_str(DAY_NAV_PASTEBOARD_TYPE)) }?
.to_string()
.parse::<usize>()
.ok()?;
let rows = self.ivars().rows.borrow();
let proposed = match item {
Some(item) => Self::outline_row(item),
None => child_index,
};
let proposed = usize::try_from(proposed).unwrap_or(rows.len());
if self.ivars().regroup.get() {
regroup_gap(
&rows,
&self.ivars().movable.borrow(),
&self.ivars().sorted.borrow(),
from_row,
proposed,
item.is_some(),
)
} else {
reorder_gap(
&rows,
&self.ivars().movable.borrow(),
from_row,
proposed,
item.is_some(),
)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct NavDrop {
gap: usize,
retarget: bool,
moves: Option<NavMove>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NavMove {
Reorder { from: usize, to: usize },
Regroup {
from: usize,
section: usize,
before: Option<usize>,
},
}
fn reorder_gap(
rows: &[Option<usize>],
movable: &[bool],
from_row: usize,
proposed: usize,
onto: bool,
) -> Option<NavDrop> {
let is_movable = |i: usize| movable.get(i).copied().unwrap_or(false);
let from = (*rows.get(from_row)?).filter(|i| is_movable(*i))?;
let in_run = |r: usize| rows[r].is_some_and(is_movable);
let first = (0..from_row)
.rev()
.take_while(|r| in_run(*r))
.last()
.unwrap_or(from_row);
let last = (from_row + 1..rows.len())
.take_while(|r| in_run(*r))
.last()
.unwrap_or(from_row);
let gap = proposed.clamp(first, last + 1);
let target_row = if gap > from_row { gap - 1 } else { gap };
let moves = (target_row != from_row)
.then(|| rows[target_row].map(|to| NavMove::Reorder { from, to }))
.flatten();
Some(NavDrop {
gap,
retarget: gap != proposed || onto,
moves,
})
}
fn regroup_gap(
rows: &[Option<usize>],
movable: &[bool],
sorted: &[bool],
from_row: usize,
proposed: usize,
onto: bool,
) -> Option<NavDrop> {
let flag = |v: &[bool], i: usize| v.get(i).copied().unwrap_or(false);
let from = (*rows.get(from_row)?).filter(|i| flag(movable, *i))?;
let gap = match rows.get(proposed) {
Some(None) if onto => proposed + 1,
_ => proposed.min(rows.len()),
};
let section_start = |gap: usize| match (0..gap).rev().find(|r| rows[*r].is_none()) {
Some(header) => Some(header + 1),
None => rows.first().copied().flatten().map(|_| 0),
};
let first = section_start(gap)?;
let end = (first..rows.len())
.find(|r| rows[*r].is_none())
.unwrap_or(rows.len());
let section = rows.get(first).copied().flatten()?;
if !rows[first..end]
.iter()
.all(|r| r.is_some_and(|i| flag(movable, i)))
{
return None;
}
let own = section_start(from_row) == Some(first);
if own && flag(sorted, from) {
return None;
}
let moves = (!own || (gap != from_row && gap != from_row + 1)).then(|| NavMove::Regroup {
from,
section,
before: rows.get(gap).copied().flatten(),
});
Some(NavDrop {
gap,
retarget: gap != proposed || onto,
moves,
})
}
#[cfg(test)]
mod nav_gap_tests {
use super::{NavDrop, NavMove, regroup_gap, reorder_gap};
const ROWS: [Option<usize>; 10] = [
Some(0),
None,
Some(1),
Some(2),
Some(3),
None,
Some(4),
Some(5),
None,
Some(6),
];
const MOVABLE: [bool; 7] = [false, true, true, true, true, true, false];
const SORTED: [bool; 7] = [false, true, true, true, true, true, false];
const UNSORTED: [bool; 7] = [false; 7];
fn regroup(from: usize, section: usize, before: Option<usize>) -> Option<NavMove> {
Some(NavMove::Regroup {
from,
section,
before,
})
}
#[test]
fn regroup_gaps_name_the_section_above_and_the_row_below() {
let at = |from_row, gap| regroup_gap(&ROWS, &MOVABLE, &SORTED, from_row, gap, false);
assert_eq!(
at(2, 6),
Some(NavDrop {
gap: 6,
retarget: false,
moves: regroup(1, 4, Some(4)),
})
);
assert_eq!(at(6, 5).and_then(|d| d.moves), regroup(4, 1, None));
assert_eq!(at(2, 7).and_then(|d| d.moves), regroup(1, 4, Some(5)));
assert_eq!(at(2, 8).and_then(|d| d.moves), regroup(1, 4, None));
}
#[test]
fn regroup_gaps_skip_fixed_sections_fixed_rows_and_a_sorted_rows_own_section() {
let at = |from_row, gap| regroup_gap(&ROWS, &MOVABLE, &SORTED, from_row, gap, false);
for gap in [0, 1, 9, 10] {
assert_eq!(at(2, gap), None, "gap {gap}");
}
for gap in [2, 3, 4, 5] {
assert_eq!(at(2, gap), None, "gap {gap}");
}
assert_eq!(at(0, 6), None);
assert_eq!(at(1, 6), None);
assert_eq!(at(9, 6), None);
}
#[test]
fn regroup_gaps_reorder_an_unsorted_row_in_its_own_section() {
let at = |from_row, gap| regroup_gap(&ROWS, &MOVABLE, &UNSORTED, from_row, gap, false);
assert_eq!(at(2, 4).and_then(|d| d.moves), regroup(1, 1, Some(3)));
assert_eq!(at(2, 5).and_then(|d| d.moves), regroup(1, 1, None));
for gap in [2, 3] {
let drop = at(2, gap).expect("offered");
assert_eq!(drop.moves, None, "gap {gap}");
}
}
#[test]
fn regroup_drops_onto_a_row_move_to_a_gap() {
assert_eq!(
regroup_gap(&ROWS, &MOVABLE, &SORTED, 2, 5, true),
Some(NavDrop {
gap: 6,
retarget: true,
moves: regroup(1, 4, Some(4)),
})
);
assert_eq!(
regroup_gap(&ROWS, &MOVABLE, &SORTED, 2, 7, true),
Some(NavDrop {
gap: 7,
retarget: true,
moves: regroup(1, 4, Some(5)),
})
);
}
#[test]
fn regroup_offers_nothing_above_a_leading_header() {
let rows = [None, Some(0), None, Some(1)];
let movable = [true, true];
let at = |gap| regroup_gap(&rows, &movable, &[false, false], 1, gap, false);
assert_eq!(at(0), None);
assert_eq!(at(1).map(|d| d.moves), Some(None));
assert_eq!(at(2).map(|d| d.moves), Some(None));
assert_eq!(at(3).and_then(|d| d.moves), regroup(0, 1, Some(1)));
assert_eq!(at(4).and_then(|d| d.moves), regroup(0, 1, None));
}
#[test]
fn reorder_gaps_clamp_to_the_run() {
assert_eq!(
reorder_gap(&ROWS, &MOVABLE, 2, 9, false),
Some(NavDrop {
gap: 5,
retarget: true,
moves: Some(NavMove::Reorder { from: 1, to: 3 }),
})
);
assert_eq!(
reorder_gap(&ROWS, &MOVABLE, 2, 3, false).map(|d| d.moves),
Some(None)
);
assert_eq!(
reorder_gap(&ROWS, &MOVABLE, 3, 0, false).and_then(|d| d.moves),
Some(NavMove::Reorder { from: 2, to: 1 })
);
assert_eq!(reorder_gap(&ROWS, &MOVABLE, 0, 3, false), None);
}
}
const DAY_NAV_PASTEBOARD_TYPE: &str = "dev.daybrite.day.nav-row";
fn ns_badges(badges: &[Option<String>]) -> Vec<Option<Retained<NSString>>> {
badges
.iter()
.map(|b| b.as_deref().map(NSString::from_str))
.collect()
}
fn post_realize_visible_rows(key: usize) {
<AppKit as Platform>::post(Box::new(move || {
if let Some((table, data)) = list_entry(key) {
unsafe { table.layoutSubtreeIfNeeded() };
let range = unsafe { table.rowsInRect(table.visibleRect()) };
if let Some(source) = data.ivars().source.borrow().clone()
&& let Some(report) = &source.first_visible
{
report(range.location.min((source.len)().saturating_sub(1)));
}
for row in range.location..range.location + range.length {
let cell = unsafe { table.viewAtColumn_row_makeIfNecessary(0, row as isize, true) };
if let Some(cell) = cell
&& cell.subviews().is_empty()
{
let source = data.ivars().source.borrow().clone();
if let Some(source) = source {
(source.bind_row)(row, Retained::as_ptr(&cell) as RawHandle);
}
}
}
}
day_core::pump_events();
}));
}
fn post_settle_list_drop(key: usize) {
<AppKit as Platform>::post(Box::new(move || {
day_core::pump_events();
let Some((table, data)) = list_entry(key) else {
return;
};
let Some(scroll) = table.enclosingScrollView() else {
return;
};
let clip = scroll.contentView();
let origin = clip.bounds().origin;
let selected = table.selectedRowIndexes();
let suppressed = data.ivars().suppress.replace(true);
table.reloadData();
table.layoutSubtreeIfNeeded();
let restored = objc2_foundation::NSMutableIndexSet::new();
for row in 0..table.numberOfRows().max(0) as usize {
if selected.containsIndex(row) {
restored.addIndex(row);
}
}
table.selectRowIndexes_byExtendingSelection(&restored, false);
let mut bounds = clip.bounds();
bounds.origin = origin;
clip.scrollToPoint(clip.constrainBoundsRect(bounds).origin);
scroll.reflectScrolledClipView(&clip);
data.ivars().suppress.set(suppressed);
post_realize_visible_rows(key);
}));
}
define_class!(
#[unsafe(super(NSTableView))]
#[thread_kind = MainThreadOnly]
#[name = "DayListView"]
struct DayListView;
unsafe impl NSObjectProtocol for DayListView {}
impl DayListView {
#[unsafe(method_id(menuForEvent:))]
fn menu_for_event(
&self,
event: &objc2_app_kit::NSEvent,
) -> Option<Retained<NSMenu>> {
ffi_guard::contain(None, || {
let point = self.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
let row = unsafe { self.rowAtPoint(point) };
let provider = if row >= 0 {
unsafe { self.viewAtColumn_row_makeIfNecessary(0, row, false) }
.and_then(|cell| list_row_menu_provider(&cell))
} else {
None
};
match provider {
Some((view, provider)) => {
let point = view.convertPoint_fromView(
unsafe { event.locationInWindow() }, None,
);
let items = provider(day_spec::Point::new(point.x, point.y));
if items.is_empty() {
None
} else {
Some(build_ns_menu(self.mtm(), "", &items))
}
}
None => unsafe { msg_send![super(self), menuForEvent: event] },
}
})
}
#[unsafe(method(becomeFirstResponder))]
fn become_first_responder(&self) -> bool {
let accepted: bool = unsafe { msg_send![super(self), becomeFirstResponder] };
if accepted {
self.report_focus(true);
}
accepted
}
#[unsafe(method(resignFirstResponder))]
fn resign_first_responder(&self) -> bool {
let accepted: bool = unsafe { msg_send![super(self), resignFirstResponder] };
if accepted {
self.report_focus(false);
}
accepted
}
#[unsafe(method(keyDown:))]
fn key_down(&self, event: &objc2_app_kit::NSEvent) {
ffi_guard::contain((), || {
let key = unsafe { event.keyCode() };
if (key == 36 || key == 76) && unsafe { self.doubleAction() }.is_some() {
let row = unsafe { self.selectedRow() };
if row >= 0 && let Some(target) = unsafe { self.target() } {
let _: () = unsafe { msg_send![&*target, activateRow: self] };
return;
}
}
let _: () = unsafe { msg_send![super(self), keyDown: event] };
});
}
}
);
fn list_row_menu_provider(view: &NSView) -> Option<(Retained<NSView>, day_spec::ContextMenuFn)> {
if let Some(provider) = CTX_MENU_FNS.with(|t| t.get(view as *const NSView as usize)) {
return Some((view.retain(), provider));
}
view.subviews()
.iter()
.find_map(|view| list_row_menu_provider(&view))
}
fn inherited_context_menu(
view: &NSView,
event: &objc2_app_kit::NSEvent,
) -> Option<Option<Retained<NSMenu>>> {
let mut current = Some(view.retain());
while let Some(view) = current {
if let Some(provider) = CTX_MENU_FNS.with(|t| t.get(Retained::as_ptr(&view) as usize)) {
let point = view.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
let items = provider(day_spec::Point::new(point.x, point.y));
return Some((!items.is_empty()).then(|| build_ns_menu(view.mtm(), "", &items)));
}
current = unsafe { view.superview() };
}
None
}
impl DayListView {
fn report_focus(&self, focused: bool) {
ffi_guard::contain((), || {
if let Some(target) = unsafe { self.target() }
&& let Some(data) = target.downcast_ref::<DayListData>()
{
emit(data.ivars().node, Event::FocusChanged(focused));
}
});
}
}
struct ListIvars {
node: NodeId,
source: RefCell<Option<ListSource>>,
selectable: std::cell::Cell<bool>,
multi: std::cell::Cell<bool>,
suppress: std::cell::Cell<bool>,
tokens: RefCell<Vec<u64>>,
automatic: bool,
sections: bool,
header_estimate: std::cell::Cell<f64>,
heights: RefCell<std::collections::HashMap<u64, f64>>,
cell_tokens: RefCell<std::collections::HashMap<usize, u64>>,
estimate: std::cell::Cell<f64>,
heights_pending: std::cell::Cell<bool>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayListData"]
#[ivars = ListIvars]
struct DayListData;
unsafe impl NSObjectProtocol for DayListData {}
unsafe impl NSControlTextEditingDelegate for DayListData {}
impl DayListData {
#[unsafe(method(respondsToSelector:))]
fn responds_to_selector(&self, selector: objc2::runtime::Sel) -> bool {
if selector == objc2::sel!(tableView:heightOfRow:)
&& !self.ivars().automatic
&& !self.ivars().sections
{
false
} else {
unsafe { msg_send![super(self), respondsToSelector: selector] }
}
}
#[unsafe(method(listViewportChanged:))]
fn list_viewport_changed(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
let source = self.ivars().source.borrow().clone();
if let Some(source) = source && let Some(report) = &source.first_visible
&& let Some(object) = notification.object()
&& let Some(clip) = object.downcast_ref::<objc2_app_kit::NSClipView>()
&& let Some(document) = clip.documentView()
&& let Some(table) = document.downcast_ref::<NSTableView>() {
let visible = unsafe { table.rowsInRect(table.visibleRect()) };
report(visible.location.min((source.len)().saturating_sub(1)));
}
});
}
#[unsafe(method(activateRow:))]
fn activate_row(&self, table: &NSTableView) {
ffi_guard::contain((), || {
let row = unsafe { table.selectedRow() };
if row >= 0 { emit(self.ivars().node, Event::ListActivated(row as usize)); }
});
}
#[unsafe(method(doubleClickRow:))]
fn double_click_row(&self, table: &NSTableView) {
ffi_guard::contain((), || {
let row = unsafe { table.clickedRow() };
if row >= 0 { emit(self.ivars().node, Event::ListActivated(row as usize)); }
});
}
}
unsafe impl NSTableViewDataSource for DayListData {
#[unsafe(method(numberOfRowsInTableView:))]
fn number_of_rows(&self, _tv: &NSTableView) -> isize {
ffi_guard::contain(0, || {
self.ivars()
.source
.borrow()
.as_ref()
.map(|s| (s.len)() as isize)
.unwrap_or(0)
})
}
#[unsafe(method_id(tableView:pasteboardWriterForRow:))]
fn pasteboard_writer_for_row(
&self,
_tv: &NSTableView,
row: isize,
) -> Option<Retained<ProtocolObject<dyn objc2_app_kit::NSPasteboardWriting>>> {
let make = || {
let reorderable = self
.ivars()
.source
.borrow()
.as_ref()
.is_some_and(|s| s.reorder.is_some());
if !reorderable || row < 0 || self.is_header(row) {
return None;
}
let item = objc2_app_kit::NSPasteboardItem::new();
unsafe {
item.setString_forType(
&NSString::from_str(&row.to_string()),
&NSString::from_str(DAY_ROW_PASTEBOARD_TYPE),
);
}
Some(ProtocolObject::from_retained(item))
};
ffi_guard::contain(None, make)
}
#[unsafe(method(tableView:validateDrop:proposedRow:proposedDropOperation:))]
fn validate_drop(
&self,
tv: &NSTableView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
row: isize,
_op: objc2_app_kit::NSTableViewDropOperation,
) -> objc2_app_kit::NSDragOperation {
ffi_guard::contain(objc2_app_kit::NSDragOperation::None, || {
let Some((from, len)) = self.drag_context(tv, info) else {
return objc2_app_kit::NSDragOperation::None;
};
let ins = row.clamp(0, len as isize) as usize;
let to = insertion_to_target(from, ins, len);
let accepted = self.can_move(from, to);
if accepted < 0 {
return objc2_app_kit::NSDragOperation::None;
}
let accepted = (accepted as usize).min(len.saturating_sub(1));
if accepted != to {
unsafe {
tv.setDropRow_dropOperation(
target_to_insertion(from, accepted) as isize,
objc2_app_kit::NSTableViewDropOperation::Above,
);
}
}
objc2_app_kit::NSDragOperation::Move
})
}
#[unsafe(method(tableView:acceptDrop:row:dropOperation:))]
fn accept_drop(
&self,
tv: &NSTableView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
row: isize,
_op: objc2_app_kit::NSTableViewDropOperation,
) -> bool {
let drop = || {
let Some((from, len)) = self.drag_context(tv, info) else {
return false;
};
let ins = row.clamp(0, len as isize) as usize;
let to = insertion_to_target(from, ins, len);
let accepted = self.can_move(from, to);
if accepted < 0 {
return false;
}
let accepted = (accepted as usize).min(len.saturating_sub(1));
if accepted == from {
return true; }
let mv = self
.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.reorder.as_ref().map(|r| r.move_row.clone()));
let Some(mv) = mv else { return false };
mv(from, accepted);
{
let mut toks = self.ivars().tokens.borrow_mut();
if from < toks.len() && accepted < toks.len() {
let t = toks.remove(from);
toks.insert(accepted, t);
}
}
unsafe { tv.moveRowAtIndex_toIndex(from as isize, accepted as isize) };
if let Some(scroll) = tv.enclosingScrollView() {
post_settle_list_drop(ptr_of(&scroll));
}
true
};
ffi_guard::contain(false, drop)
}
}
unsafe impl NSTableViewDelegate for DayListData {
#[unsafe(method(tableView:heightOfRow:))]
fn height_of_row(&self, tv: &NSTableView, row: isize) -> f64 {
ffi_guard::contain(44.0, || {
let ivars = self.ivars();
let header = self.is_header(row);
if !header && !ivars.automatic {
return unsafe { tv.rowHeight() };
}
let token = ivars
.source
.borrow()
.as_ref()
.filter(|s| row >= 0 && (row as usize) < (s.len)())
.map(|s| (s.token_at)(row as usize));
token
.and_then(|t| ivars.heights.borrow().get(&t).copied())
.unwrap_or_else(|| {
if header {
ivars.header_estimate.get()
} else {
ivars.estimate.get()
}
})
})
}
#[unsafe(method(tableView:isGroupRow:))]
fn is_group_row(&self, _tv: &NSTableView, row: isize) -> bool {
ffi_guard::contain(false, || self.is_header(row))
}
#[unsafe(method(tableView:shouldSelectRow:))]
fn should_select_row(&self, _tv: &NSTableView, row: isize) -> bool {
ffi_guard::contain(true, || !self.is_header(row))
}
#[unsafe(method_id(tableView:viewForTableColumn:row:))]
fn view_for_row(
&self,
tv: &NSTableView,
_col: Option<&NSTableColumn>,
row: isize,
) -> Option<Retained<NSView>> {
ffi_guard::contain(None, || {
let mtm = self.mtm();
let header = self.is_header(row);
let ident = NSString::from_str(if header { DAY_HEADER_CELL } else { DAY_ROW_CELL });
let cell: Retained<NSView> =
unsafe { tv.makeViewWithIdentifier_owner(&ident, None) }.unwrap_or_else(|| {
let v: Retained<NSView> = Retained::into_super(DayFlipped::new(mtm));
unsafe { v.setIdentifier(Some(&ident)) };
v
});
let source = self.ivars().source.borrow().clone();
if let Some(source) = source {
let raw = Retained::as_ptr(&cell) as RawHandle;
(source.bind_row)(row as usize, raw);
if self.ivars().automatic || header {
self.note_row_height(tv, &source, row, raw, header);
}
}
Some(cell)
})
}
#[unsafe(method(tableView:didRemoveRowView:forRow:))]
fn did_remove_row_view(
&self,
_tv: &NSTableView,
row_view: &objc2_app_kit::NSTableRowView,
_row: isize,
) {
ffi_guard::contain((), || {
let Some(cell) = row_view.subviews().iter().find(|sub| {
sub.identifier()
.map(|i| {
let i = i.to_string();
i == DAY_ROW_CELL || i == DAY_HEADER_CELL
})
.unwrap_or(false)
}) else {
return;
};
let cell = Retained::into_raw(cell) as usize;
let Some(this) = (unsafe {
Retained::retain(self as *const DayListData as *mut DayListData)
}) else {
return;
};
let this = Retained::into_raw(this) as usize;
<AppKit as Platform>::post(Box::new(move || {
let this = unsafe { Retained::from_raw(this as *mut DayListData) };
let cell = unsafe { Retained::from_raw(cell as *mut NSView) };
let (Some(this), Some(cell)) = (this, cell) else {
return;
};
if cell.window().is_some() {
return; }
let recycle = this
.ivars()
.source
.borrow()
.as_ref()
.map(|s| s.recycle.clone());
if let Some(recycle) = recycle {
recycle(Retained::as_ptr(&cell) as RawHandle);
}
}));
})
}
#[unsafe(method(tableViewSelectionDidChange:))]
fn selection_did_change(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
if self.ivars().suppress.get() || !self.ivars().selectable.get() {
return;
}
let Some(obj) = (unsafe { notification.object() }) else {
return;
};
let Ok(tv) = obj.downcast::<NSTableView>() else {
return;
};
if self.ivars().multi.get() {
let idx = unsafe { tv.selectedRowIndexes() };
let mut rows = Vec::with_capacity(idx.count());
let mut i = idx.firstIndex();
while i != objc2_foundation::NSNotFound as usize {
rows.push(i as i64);
i = unsafe { idx.indexGreaterThanIndex(i) };
}
emit(self.ivars().node, Event::SelectionSet(rows));
} else {
let row = unsafe { tv.selectedRow() };
if row >= 0 {
emit(self.ivars().node, Event::SelectionChanged(row as i64));
} else {
emit(self.ivars().node, Event::SelectionSet(Vec::new()));
}
}
})
}
#[unsafe(method_id(tableView:rowActionsForRow:edge:))]
fn row_actions_for_row(
&self,
tv: &NSTableView,
row: isize,
edge: objc2_app_kit::NSTableRowActionEdge,
) -> Retained<NSArray<objc2_app_kit::NSTableViewRowAction>> {
ffi_guard::contain(NSArray::new(), || {
let sw = self
.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.swipe.clone());
let Some(sw) = sw else {
return NSArray::new();
};
if row < 0 {
return NSArray::new();
}
let day_edge = if edge == objc2_app_kit::NSTableRowActionEdge::Leading {
day_spec::SwipeEdge::Leading
} else {
day_spec::SwipeEdge::Trailing
};
let offer = (sw.actions_at)(row as usize, day_edge);
let actions: Vec<Retained<objc2_app_kit::NSTableViewRowAction>> = offer
.iter()
.enumerate()
.map(|(i, a)| {
let style = if a.destructive {
objc2_app_kit::NSTableViewRowActionStyle::Destructive
} else {
objc2_app_kit::NSTableViewRowActionStyle::Regular
};
let perform = sw.perform.clone();
let table = tv.retain();
let handler = block2::RcBlock::new(
move |_action: std::ptr::NonNull<
objc2_app_kit::NSTableViewRowAction,
>,
row: isize| {
ffi_guard::contain((), || {
perform(row as usize, day_edge, i);
unsafe { table.setRowActionsVisible(false) };
})
},
);
let action =
objc2_app_kit::NSTableViewRowAction::rowActionWithStyle_title_handler(
style,
&NSString::from_str(&a.label),
&handler,
);
if let Some(t) = a.tint {
unsafe { action.setBackgroundColor(Some(&nscolor(t))) };
}
if let Some(sym) = a.symbol {
let img = objc2_app_kit::NSImage::imageWithSystemSymbolName_accessibilityDescription(
&NSString::from_str(day_spec::sf_symbol_name(sym)),
None,
);
if let Some(img) = img {
unsafe { action.setImage(Some(&img)) };
}
}
action
})
.collect();
NSArray::from_retained_slice(&actions)
})
}
}
);
impl DayListData {
fn new(
mtm: MainThreadMarker,
node: NodeId,
selectable: bool,
multi: bool,
automatic: bool,
sections: bool,
) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(ListIvars {
node,
source: RefCell::new(None),
selectable: std::cell::Cell::new(selectable),
multi: std::cell::Cell::new(multi),
suppress: std::cell::Cell::new(false),
tokens: RefCell::new(Vec::new()),
automatic,
sections,
header_estimate: std::cell::Cell::new(28.0),
heights: RefCell::new(std::collections::HashMap::new()),
cell_tokens: RefCell::new(std::collections::HashMap::new()),
estimate: std::cell::Cell::new(44.0),
heights_pending: std::cell::Cell::new(false),
});
unsafe { msg_send![super(this), init] }
}
fn note_row_height(
&self,
table: &NSTableView,
source: &ListSource,
row: isize,
cell: RawHandle,
header: bool,
) {
let height = (source.cell_height)(cell);
if height <= 0.0 || row < 0 {
return;
}
let token = (source.token_at)(row as usize);
self.ivars()
.cell_tokens
.borrow_mut()
.insert(cell as usize, token);
self.ivars().heights.borrow_mut().insert(token, height);
if header {
self.ivars().header_estimate.set(height);
} else {
self.ivars().estimate.set(height);
}
let drawn = unsafe { table.rectOfRow(row) }.size.height;
if (drawn - height).abs() > 0.5 {
self.post_row_heights(table);
}
}
fn is_header(&self, row: isize) -> bool {
row >= 0
&& self
.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.is_header.clone())
.is_some_and(|is_header| is_header(row as usize))
}
fn row_resized(&self, table: &NSTableView, cell: usize, height: f64) {
let token = self.ivars().cell_tokens.borrow().get(&cell).copied();
if let Some(token) = token {
self.ivars().heights.borrow_mut().insert(token, height);
self.post_row_heights(table);
}
}
fn post_row_heights(&self, table: &NSTableView) {
if self.ivars().heights_pending.replace(true) {
return;
}
let Some(this) = (unsafe { Retained::retain(self as *const Self as *mut Self) }) else {
return;
};
let this = Retained::into_raw(this) as usize;
let table = Retained::into_raw(table.retain()) as usize;
<AppKit as Platform>::post(Box::new(move || {
let this = unsafe { Retained::from_raw(this as *mut DayListData) };
let table = unsafe { Retained::from_raw(table as *mut NSTableView) };
let (Some(this), Some(table)) = (this, table) else {
return;
};
this.ivars().heights_pending.set(false);
ffi_guard::contain((), || this.apply_row_heights(&table));
}));
}
fn apply_row_heights(&self, table: &NSTableView) {
let Some(source) = self.ivars().source.borrow().clone() else {
return;
};
let count = unsafe { table.numberOfRows() }.max(0) as usize;
let shown = unsafe { table.rowsInRect(table.visibleRect()) };
let first = shown.location.saturating_sub(8);
let last = (shown.location + shown.length + 8)
.min(count)
.min((source.len)());
let changed = objc2_foundation::NSMutableIndexSet::new();
{
let heights = self.ivars().heights.borrow();
for row in first..last {
if let Some(&height) = heights.get(&(source.token_at)(row)) {
let drawn = unsafe { table.rectOfRow(row as isize) }.size.height;
if (drawn - height).abs() > 0.5 {
unsafe { changed.addIndex(row) };
}
}
}
}
if changed.count() == 0 {
return;
}
NSAnimationContext::beginGrouping();
NSAnimationContext::currentContext().setDuration(0.0);
unsafe { table.noteHeightOfRowsWithIndexesChanged(&changed) };
NSAnimationContext::endGrouping();
}
fn permutation_moves(&self, table: &NSTableView) -> Option<Vec<(usize, usize)>> {
let source = self.ivars().source.borrow();
let source = source.as_ref()?;
let n = (source.len)();
let new: Vec<u64> = (0..n).map(|i| (source.token_at)(i)).collect();
let old = std::mem::replace(&mut *self.ivars().tokens.borrow_mut(), new.clone());
if old.len() != n || n == 0 || old == new {
return None;
}
let (mut so, mut sn) = (old.clone(), new.clone());
so.sort_unstable();
sn.sort_unstable();
if so != sn {
return None;
}
for i in 0..n {
if old[i] != new[i]
&& unsafe { table.rowViewAtRow_makeIfNecessary(i as isize, false) }.is_none()
{
return None;
}
}
let mut work = old;
let mut moves = Vec::new();
for i in 0..n {
if work[i] == new[i] {
continue;
}
let j = (i + 1..n).find(|&j| work[j] == new[i])?;
let t = work.remove(j);
work.insert(i, t);
moves.push((j, i));
}
Some(moves)
}
fn drag_context(
&self,
tv: &NSTableView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
) -> Option<(usize, usize)> {
let src = unsafe { info.draggingSource() }?;
if Retained::as_ptr(&src) as *const std::ffi::c_void
!= tv as *const NSTableView as *const std::ffi::c_void
{
return None;
}
let pb = unsafe { info.draggingPasteboard() };
let from = unsafe { pb.stringForType(&NSString::from_str(DAY_ROW_PASTEBOARD_TYPE)) }?
.to_string()
.parse::<usize>()
.ok()?;
let len = self.ivars().source.borrow().as_ref().map(|s| (s.len)())?;
(from < len).then_some((from, len))
}
fn can_move(&self, from: usize, to: usize) -> i64 {
self.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.reorder.as_ref().map(|r| (r.can_move)(from, to)))
.unwrap_or(-1)
}
}
const DAY_ROW_CELL: &str = "day.cell";
const DAY_HEADER_CELL: &str = "day.header";
const DAY_ROW_PASTEBOARD_TYPE: &str = "dev.daybrite.day.row";
fn insertion_to_target(from: usize, ins: usize, len: usize) -> usize {
let to = if ins > from { ins - 1 } else { ins };
to.min(len.saturating_sub(1))
}
fn target_to_insertion(from: usize, to: usize) -> usize {
if to > from { to + 1 } else { to }
}
type ListEntry = (Retained<NSTableView>, Retained<DayListData>);
fn list_entry(key: usize) -> Option<ListEntry> {
LIST_STATE.with(|m| m.borrow().get(&key).cloned())
}
struct TreeIvars {
node: NodeId,
source: RefCell<Option<TreeSource>>,
selectable: std::cell::Cell<bool>,
suppress: std::cell::Cell<bool>,
items: RefCell<HashMap<u64, Retained<NSNumber>>>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayTreeData"]
#[ivars = TreeIvars]
struct DayTreeData;
unsafe impl NSObjectProtocol for DayTreeData {}
unsafe impl NSOutlineViewDataSource for DayTreeData {
#[unsafe(method(outlineView:numberOfChildrenOfItem:))]
fn number_of_children(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: Option<&objc2::runtime::AnyObject>,
) -> isize {
ffi_guard::contain(0, || {
let parent = item.and_then(Self::token_of_item);
if item.is_some() && parent.is_none() {
return 0; }
self.ivars()
.source
.borrow()
.as_ref()
.map(|s| (s.children_len)(parent) as isize)
.unwrap_or(0)
})
}
#[unsafe(method_id(outlineView:child:ofItem:))]
fn child_of_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
index: isize,
item: Option<&objc2::runtime::AnyObject>,
) -> Retained<objc2::runtime::AnyObject> {
let token = ffi_guard::contain(0, || {
let parent = item.and_then(Self::token_of_item);
self.ivars()
.source
.borrow()
.as_ref()
.map(|s| (s.child_token)(parent, index.max(0) as usize))
.unwrap_or(0)
});
let ns = self.intern(token);
unsafe { objc2::rc::Retained::cast_unchecked(ns) }
}
#[unsafe(method(outlineView:isItemExpandable:))]
fn is_expandable(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> bool {
ffi_guard::contain(false, || {
let Some(token) = Self::token_of_item(item) else {
return false;
};
self.ivars()
.source
.borrow()
.as_ref()
.map(|s| (s.expandable)(token))
.unwrap_or(false)
})
}
#[unsafe(method_id(outlineView:pasteboardWriterForItem:))]
fn pasteboard_writer_for_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> Option<Retained<ProtocolObject<dyn objc2_app_kit::NSPasteboardWriting>>> {
let make = || {
let movable = self
.ivars()
.source
.borrow()
.as_ref()
.is_some_and(|s| s.moves.is_some());
let token = Self::token_of_item(item)?;
if !movable {
return None;
}
let pbitem = objc2_app_kit::NSPasteboardItem::new();
unsafe {
pbitem.setString_forType(
&NSString::from_str(&token.to_string()),
&NSString::from_str(DAY_TREE_PASTEBOARD_TYPE),
);
}
Some(ProtocolObject::from_retained(pbitem))
};
ffi_guard::contain(None, make)
}
#[unsafe(method(outlineView:validateDrop:proposedItem:proposedChildIndex:))]
fn validate_drop(
&self,
ov: &objc2_app_kit::NSOutlineView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
item: Option<&objc2::runtime::AnyObject>,
child_index: isize,
) -> objc2_app_kit::NSDragOperation {
ffi_guard::contain(objc2_app_kit::NSDragOperation::None, || {
match self.drop_proposal(ov, info, item, child_index) {
Some((token, parent, index)) if self.can_move(token, parent, index) => {
objc2_app_kit::NSDragOperation::Move
}
_ => objc2_app_kit::NSDragOperation::None,
}
})
}
#[unsafe(method(outlineView:acceptDrop:item:childIndex:))]
fn accept_drop(
&self,
ov: &objc2_app_kit::NSOutlineView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
item: Option<&objc2::runtime::AnyObject>,
child_index: isize,
) -> bool {
let drop = || {
let Some((token, parent, index)) = self.drop_proposal(ov, info, item, child_index)
else {
return false;
};
if !self.can_move(token, parent, index) {
return false;
}
let mv = self
.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.moves.as_ref().map(|m| m.move_node.clone()));
let Some(mv) = mv else { return false };
mv(token, parent, index);
true
};
ffi_guard::contain(false, drop)
}
}
unsafe impl NSControlTextEditingDelegate for DayTreeData {}
unsafe impl NSOutlineViewDelegate for DayTreeData {
#[unsafe(method(outlineView:isGroupItem:))]
fn is_group_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> bool {
Self::token_of_item(item).is_some_and(|t| {
self.ivars()
.source
.borrow()
.as_ref()
.is_some_and(|s| (s.section_header)(t))
})
}
#[unsafe(method(outlineView:shouldSelectItem:))]
fn should_select_item(
&self,
_ov: &objc2_app_kit::NSOutlineView,
item: &objc2::runtime::AnyObject,
) -> bool {
Self::token_of_item(item).is_some_and(|t| {
self.ivars()
.source
.borrow()
.as_ref()
.is_some_and(|s| !(s.section_header)(t))
})
}
#[unsafe(method_id(outlineView:viewForTableColumn:item:))]
fn view_for_item(
&self,
ov: &objc2_app_kit::NSOutlineView,
_col: Option<&NSTableColumn>,
item: &objc2::runtime::AnyObject,
) -> Option<Retained<NSView>> {
ffi_guard::contain(None, || {
let mtm = self.mtm();
let token = Self::token_of_item(item)?;
let ident = NSString::from_str("day.tree.cell");
let cell: Retained<NSView> =
unsafe { ov.makeViewWithIdentifier_owner(&ident, None) }.unwrap_or_else(|| {
let v: Retained<NSView> =
Retained::into_super(Retained::into_super(DayTreeCell::new(mtm)));
unsafe { v.setIdentifier(Some(&ident)) };
v
});
if let Some(source) = self.ivars().source.borrow().as_ref() {
let raw = Retained::as_ptr(&cell) as RawHandle;
if let Some(cell) = cell.downcast_ref::<DayTreeCell>() {
unsafe {
cell.setTextField(None);
}
}
(source.bind_row)(token, raw);
if (source.section_header)(token) {
fn label_in(view: &NSView) -> Option<Retained<NSTextField>> {
for child in unsafe { view.subviews() }.iter() {
if let Ok(label) = child.clone().downcast::<NSTextField>() {
return Some(label);
}
if let Some(label) = label_in(&child) {
return Some(label);
}
}
None
}
if let Some(label) = label_in(&cell) {
let cell = cell.downcast_ref::<DayTreeCell>()?;
unsafe {
cell.setTextField(Some(&label));
}
}
}
}
Some(cell)
})
}
#[unsafe(method_id(outlineView:typeSelectStringForTableColumn:item:))]
fn type_select_string(
&self,
_ov: &objc2_app_kit::NSOutlineView,
_col: Option<&NSTableColumn>,
item: &objc2::runtime::AnyObject,
) -> Option<Retained<NSString>> {
ffi_guard::contain(None, || {
let token = Self::token_of_item(item)?;
let text = self
.ivars()
.source
.borrow()
.as_ref()
.map(|s| (s.type_select_text)(token))
.unwrap_or_default();
(!text.is_empty()).then(|| NSString::from_str(&text))
})
}
#[unsafe(method_id(outlineView:rowViewForItem:))]
fn row_view_for_item(
&self,
ov: &objc2_app_kit::NSOutlineView,
_item: &objc2::runtime::AnyObject,
) -> Retained<objc2_app_kit::NSTableRowView> {
if unsafe { ov.style() } == objc2_app_kit::NSTableViewStyle::SourceList {
unsafe { objc2_app_kit::NSTableRowView::new(self.mtm()) }
} else {
let rv: Retained<DayTreeRowView> =
unsafe { msg_send![DayTreeRowView::alloc(self.mtm()), init] };
Retained::into_super(rv)
}
}
#[unsafe(method(outlineViewSelectionDidChange:))]
fn selection_did_change(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
if self.ivars().suppress.get() || !self.ivars().selectable.get() {
return;
}
let Some(obj) = (unsafe { notification.object() }) else {
return;
};
let Ok(ov) = obj.downcast::<objc2_app_kit::NSOutlineView>() else {
return;
};
let idx = unsafe { ov.selectedRowIndexes() };
let mut tokens = Vec::with_capacity(idx.count());
let mut i = idx.firstIndex();
while i != objc2_foundation::NSNotFound as usize {
if let Some(item) = unsafe { ov.itemAtRow(i as isize) }
&& let Some(tok) = Self::token_of_item(&item)
{
tokens.push(tok);
}
i = unsafe { idx.indexGreaterThanIndex(i) };
}
emit(self.ivars().node, Event::TreeSelection(tokens));
})
}
#[unsafe(method(outlineView:didRemoveRowView:forRow:))]
fn did_remove_row_view(
&self,
ov: &objc2_app_kit::NSOutlineView,
row_view: &objc2_app_kit::NSTableRowView,
_row: isize,
) {
ffi_guard::contain((), || {
let mut cell_ptr: Option<usize> = None;
for sub in row_view.subviews().iter() {
let is_cell = sub
.identifier()
.map(|i| i.to_string() == "day.tree.cell")
.unwrap_or(false);
if is_cell {
cell_ptr = Some(Retained::as_ptr(&sub) as usize);
}
}
let Some(cell) = cell_ptr else { return };
let Some(scroll) = (unsafe { ov.enclosingScrollView() }) else {
return;
};
let key = Retained::as_ptr(&scroll) as usize;
let retained =
unsafe { Retained::into_raw(Retained::retain(cell as *mut NSView).unwrap()) }
as usize;
<AppKit as Platform>::post(Box::new(move || {
let cell_view: Retained<NSView> =
match unsafe { Retained::from_raw(retained as *mut NSView) } {
Some(v) => v,
None => return,
};
let recycle = tree_entry(key).and_then(|(_, data)| {
data.ivars()
.source
.borrow()
.as_ref()
.map(|s| s.recycle.clone())
});
let Some(recycle) = recycle else { return };
if cell_view.window().is_some() {
return; }
recycle(cell as RawHandle);
}));
})
}
#[unsafe(method(outlineViewItemDidExpand:))]
fn item_did_expand(&self, notification: &NSNotification) {
self.report_disclosure(notification, true);
}
#[unsafe(method(outlineViewItemDidCollapse:))]
fn item_did_collapse(&self, notification: &NSNotification) {
self.report_disclosure(notification, false);
}
}
);
impl DayTreeData {
fn new(mtm: MainThreadMarker, node: NodeId, selectable: bool) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(TreeIvars {
node,
source: RefCell::new(None),
selectable: std::cell::Cell::new(selectable),
suppress: std::cell::Cell::new(false),
items: RefCell::new(HashMap::new()),
});
unsafe { msg_send![super(this), init] }
}
fn intern(&self, token: u64) -> Retained<NSNumber> {
self.ivars()
.items
.borrow_mut()
.entry(token)
.or_insert_with(|| NSNumber::new_u64(token))
.clone()
}
fn token_of_item(item: &objc2::runtime::AnyObject) -> Option<u64> {
item.downcast_ref::<NSNumber>().map(|n| n.as_u64())
}
fn report_disclosure(&self, notification: &NSNotification, expanded: bool) {
ffi_guard::contain((), || {
if self.ivars().suppress.get() {
return;
}
let token = unsafe { notification.userInfo() }
.and_then(|ui| ui.objectForKey(&*NSString::from_str("NSObject")))
.and_then(|item| Self::token_of_item(&item));
if let Some(token) = token {
emit(self.ivars().node, Event::TreeExpanded { token, expanded });
}
})
}
fn drop_proposal(
&self,
ov: &objc2_app_kit::NSOutlineView,
info: &ProtocolObject<dyn objc2_app_kit::NSDraggingInfo>,
item: Option<&objc2::runtime::AnyObject>,
child_index: isize,
) -> Option<(u64, Option<u64>, Option<usize>)> {
let src = unsafe { info.draggingSource() }?;
if Retained::as_ptr(&src) as *const std::ffi::c_void
!= ov as *const objc2_app_kit::NSOutlineView as *const std::ffi::c_void
{
return None;
}
let pb = unsafe { info.draggingPasteboard() };
let token = unsafe { pb.stringForType(&NSString::from_str(DAY_TREE_PASTEBOARD_TYPE)) }?
.to_string()
.parse::<u64>()
.ok()?;
let parent = match item {
Some(i) => Some(Self::token_of_item(i)?), None => None,
};
let index = (child_index >= 0).then_some(child_index as usize);
Some((token, parent, index))
}
fn can_move(&self, token: u64, parent: Option<u64>, index: Option<usize>) -> bool {
self.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.moves.as_ref().map(|m| (m.can_move)(token, parent, index)))
.map(|v| v == MoveVerdict::Allow)
.unwrap_or(false)
}
}
define_class!(
#[unsafe(super(objc2_app_kit::NSOutlineView))]
#[thread_kind = MainThreadOnly]
#[name = "DayOutlineView"]
struct DayOutlineView;
impl DayOutlineView {
#[unsafe(method(layout))]
fn layout(&self) {
sync_tree_width(self);
let _: () = unsafe { msg_send![super(self), layout] };
}
#[unsafe(method_id(menuForEvent:))]
fn menu_for_event(
&self,
event: &objc2_app_kit::NSEvent,
) -> Option<Retained<NSMenu>> {
ffi_guard::contain(None, || {
let scroll = unsafe { self.enclosingScrollView() }?;
let (_, data) = tree_entry(Retained::as_ptr(&scroll) as usize)?;
let row_menu = data
.ivars()
.source
.borrow()
.as_ref()
.and_then(|s| s.row_menu.clone())?;
let p = self.convertPoint_fromView(unsafe { event.locationInWindow() }, None);
let row = unsafe { self.rowAtPoint(p) };
if row < 0 {
return None;
}
let token =
unsafe { self.itemAtRow(row) }.and_then(|i| DayTreeData::token_of_item(&i))?;
let items = row_menu(token);
if items.is_empty() {
return None;
}
Some(build_ns_menu(self.mtm(), "", &items))
})
}
}
);
define_class!(
#[unsafe(super(objc2_app_kit::NSTableRowView))]
#[thread_kind = MainThreadOnly]
#[name = "DayTreeRowView"]
struct DayTreeRowView;
impl DayTreeRowView {
#[unsafe(method(drawSelectionInRect:))]
fn draw_selection_in_rect(&self, _dirty: NSRect) {
if !unsafe { self.isSelected() } {
return;
}
let color = if unsafe { self.isEmphasized() } {
unsafe { objc2_app_kit::NSColor::selectedContentBackgroundColor() }
} else {
unsafe { objc2_app_kit::NSColor::unemphasizedSelectedContentBackgroundColor() }
};
let b = self.bounds();
let pill = NSRect::new(
NSPoint::new(b.origin.x + 5.0, b.origin.y + 1.0),
NSSize::new(
(b.size.width - 10.0).max(0.0),
(b.size.height - 2.0).max(0.0),
),
);
unsafe {
color.setFill();
objc2_app_kit::NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(
pill, 5.0, 5.0,
)
.fill();
}
}
}
);
define_class!(
#[unsafe(super(objc2_app_kit::NSTableCellView))]
#[thread_kind = MainThreadOnly]
#[name = "DayTreeCell"]
struct DayTreeCell;
impl DayTreeCell {
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method(layout))]
fn layout(&self) {
let _: () = unsafe { msg_send![super(self), layout] };
ffi_guard::contain((), || {
let width = self.bounds().size.width;
if width <= 0.0 {
return;
}
let mut cur = unsafe { self.superview() };
let mut outline: Option<Retained<objc2_app_kit::NSOutlineView>> = None;
while let Some(v) = cur {
cur = unsafe { v.superview() };
if let Ok(ov) = v.downcast::<objc2_app_kit::NSOutlineView>() {
outline = Some(ov);
break;
}
}
let Some(ov) = outline else { return };
let Some(scroll) = (unsafe { ov.enclosingScrollView() }) else {
return;
};
let f = tree_entry(Retained::as_ptr(&scroll) as usize)
.and_then(|(_, data)| {
data.ivars()
.source
.borrow()
.as_ref()
.map(|s| s.layout_cell_size.clone())
});
if let Some(f) = f {
f(self as *const Self as RawHandle, Size::new(width, self.bounds().size.height));
}
})
}
}
);
impl DayTreeCell {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![Self::alloc(mtm), init] }
}
}
const DAY_TREE_PASTEBOARD_TYPE: &str = "dev.daybrite.day.tree.node";
type TreeEntry = (
Retained<objc2_app_kit::NSOutlineView>,
Retained<DayTreeData>,
);
fn tree_entry(key: usize) -> Option<TreeEntry> {
TREE_STATE.with(|m| m.borrow().get(&key).cloned())
}
fn sync_tree_width(outline: &objc2_app_kit::NSOutlineView) {
let Some(scroll) = (unsafe { outline.enclosingScrollView() }) else {
return;
};
let clip_w = unsafe { scroll.contentView() }.bounds().size.width;
let f = outline.frame();
if clip_w > 0.0 && (f.size.width - clip_w).abs() > 0.5 {
unsafe {
outline.setFrameSize(NSSize::new(clip_w, f.size.height));
outline.sizeLastColumnToFit();
}
}
}
fn post_realize_visible_tree_rows(key: usize) {
<AppKit as Platform>::post(Box::new(move || {
if let Some((outline, _)) = tree_entry(key) {
let range = unsafe { outline.rowsInRect(outline.visibleRect()) };
for row in range.location..range.location + range.length {
let _ = unsafe { outline.viewAtColumn_row_makeIfNecessary(0, row as isize, true) };
}
}
day_core::pump_events();
}));
}
type NavMenuEntry = (
Retained<objc2_app_kit::NSOutlineView>,
Retained<DayNavMenuData>,
);
fn ns_rect(r: day_spec::Rect) -> NSRect {
NSRect::new(
NSPoint::new(r.origin.x, r.origin.y),
NSSize::new(r.size.width, r.size.height),
)
}
fn draw_op(op: &DrawOp) {
unsafe {
match op {
DrawOp::Stamp(st) => {
let batch = unsafe { objc2_app_kit::NSBezierPath::bezierPath() };
for p in &st.at {
if let Some(copy) = bezier(&st.shape.translated(p.x, p.y)) {
unsafe { batch.appendBezierPath(©) };
}
}
let color = match &st.paint {
day_spec::Paint::Solid(c) => *c,
_ => day_spec::Color::WHITE,
};
match &st.stroke {
None => {
nscolor(color).setFill();
unsafe { batch.fill() };
}
Some(style) => {
nscolor(color).setStroke();
apply_stroke_style(&batch, style);
unsafe { batch.stroke() };
}
}
}
DrawOp::Fill(shape, paint) => match paint {
day_spec::Paint::Solid(color) => {
nscolor(*color).setFill();
if let Some(p) = bezier(shape) {
p.fill();
}
}
day_spec::Paint::Linear(g) => {
if let (Some(p), Some(grad)) = (bezier(shape), nsgradient(&g.stops)) {
let b = shape.bounds();
let (s, e) = (g.start.resolve(b), g.end.resolve(b));
NSGraphicsContext::saveGraphicsState_class();
p.addClip();
grad.drawFromPoint_toPoint_options(
NSPoint::new(s.x, s.y),
NSPoint::new(e.x, e.y),
objc2_app_kit::NSGradientDrawingOptions::DrawsBeforeStartingLocation
| objc2_app_kit::NSGradientDrawingOptions::DrawsAfterEndingLocation,
);
NSGraphicsContext::restoreGraphicsState_class();
}
}
day_spec::Paint::Radial(g) => {
if let (Some(p), Some(grad)) = (bezier(shape), nsgradient(&g.stops)) {
let b = shape.bounds();
NSGraphicsContext::saveGraphicsState_class();
p.addClip();
concat_unit_to_bounds(b);
let c = NSPoint::new(g.center.x, g.center.y);
grad.drawFromCenter_radius_toCenter_radius_options(
c,
0.0,
c,
g.radius,
objc2_app_kit::NSGradientDrawingOptions::DrawsBeforeStartingLocation
| objc2_app_kit::NSGradientDrawingOptions::DrawsAfterEndingLocation,
);
NSGraphicsContext::restoreGraphicsState_class();
}
}
},
DrawOp::Stroke(shape, paint, style) => {
if let Some(p) = bezier(shape) {
apply_stroke_style(&p, style);
match paint {
day_spec::Paint::Solid(color) => {
nscolor(*color).setStroke();
p.stroke();
}
_ => {
let Some(ctx) = NSGraphicsContext::currentContext() else {
return;
};
let cg = ctx.CGContext();
NSGraphicsContext::saveGraphicsState_class();
objc2_core_graphics::CGContext::add_path(Some(&cg), Some(&p.CGPath()));
objc2_core_graphics::CGContext::replace_path_with_stroked_path(Some(
&cg,
));
objc2_core_graphics::CGContext::clip(Some(&cg));
draw_gradient_in(paint, shape.bounds());
NSGraphicsContext::restoreGraphicsState_class();
}
}
}
}
DrawOp::Clip(shape) => {
if let Some(p) = bezier(shape) {
p.addClip();
}
}
DrawOp::Text {
text,
at,
size,
color,
anchor,
font,
} => {
let font = canvas_nsfont(*size, font);
let attrs = canvas_text_attrs(&font, *color);
let ns = NSString::from_str(text);
let mut origin = NSPoint::new(at.x, at.y);
if *anchor != day_spec::TextAnchor::LEADING {
let sz: NSSize = msg_send![&ns, sizeWithAttributes: &*attrs];
let (dx, dy) = anchor.offset(sz.width, sz.height, font.ascender());
origin.x += dx;
origin.y += dy;
}
let _: () = msg_send![&ns, drawAtPoint: origin, withAttributes: &*attrs];
}
DrawOp::Image {
image,
rect,
opacity,
} => {
let img = BITMAPS.with(|m| m.borrow().get(&image.0).map(|b| b.image.clone()));
if let Some(img) = img {
let dest = NSRect::new(
NSPoint::new(rect.origin.x, rect.origin.y),
NSSize::new(rect.size.width, rect.size.height),
);
img.drawInRect_fromRect_operation_fraction_respectFlipped_hints(
dest,
NSRect::ZERO,
objc2_app_kit::NSCompositingOperation::SourceOver,
*opacity,
true,
None,
);
}
}
DrawOp::Save => NSGraphicsContext::saveGraphicsState_class(),
DrawOp::Restore => NSGraphicsContext::restoreGraphicsState_class(),
DrawOp::Concat(m) => {
let t = NSAffineTransform::new();
t.setTransformStruct(NSAffineTransformStruct {
m11: m.a,
m12: m.b,
m21: m.c,
m22: m.d,
tX: m.tx,
tY: m.ty,
});
t.concat();
}
}
}
}
fn nsgradient(
stops: &[(f64, day_spec::Color)],
) -> Option<objc2::rc::Retained<objc2_app_kit::NSGradient>> {
if stops.is_empty() {
return None;
}
let colors = objc2_foundation::NSArray::from_retained_slice(
&stops.iter().map(|(_, c)| nscolor(*c)).collect::<Vec<_>>(),
);
let locations: Vec<f64> = stops.iter().map(|(o, _)| *o).collect();
unsafe {
objc2_app_kit::NSGradient::initWithColors_atLocations_colorSpace(
objc2_app_kit::NSGradient::alloc(),
&colors,
locations.as_ptr(),
&objc2_app_kit::NSColorSpace::sRGBColorSpace(),
)
}
}
fn concat_unit_to_bounds(b: day_spec::Rect) {
let t = NSAffineTransform::new();
t.setTransformStruct(NSAffineTransformStruct {
m11: b.size.width,
m12: 0.0,
m21: 0.0,
m22: b.size.height,
tX: b.origin.x,
tY: b.origin.y,
});
unsafe { t.concat() };
}
fn apply_stroke_style(p: &objc2_app_kit::NSBezierPath, style: &day_spec::StrokeStyle) {
use day_spec::{LineCap, LineJoin};
unsafe {
p.setLineWidth(style.width);
p.setLineCapStyle(match style.cap {
LineCap::Butt => objc2_app_kit::NSLineCapStyle::Butt,
LineCap::Round => objc2_app_kit::NSLineCapStyle::Round,
LineCap::Square => objc2_app_kit::NSLineCapStyle::Square,
});
if style.is_plain() {
return;
}
p.setLineJoinStyle(match style.join {
LineJoin::Miter => objc2_app_kit::NSLineJoinStyle::Miter,
LineJoin::Round => objc2_app_kit::NSLineJoinStyle::Round,
LineJoin::Bevel => objc2_app_kit::NSLineJoinStyle::Bevel,
});
p.setMiterLimit(style.miter_limit);
if !style.dash.is_empty() {
let pattern: Vec<objc2_core_foundation::CGFloat> = style.dash.to_vec();
p.setLineDash_count_phase(pattern.as_ptr(), pattern.len() as isize, style.dash_phase);
}
}
}
fn draw_gradient_in(paint: &day_spec::Paint, bounds: day_spec::Rect) {
let opts = objc2_app_kit::NSGradientDrawingOptions::DrawsBeforeStartingLocation
| objc2_app_kit::NSGradientDrawingOptions::DrawsAfterEndingLocation;
match paint {
day_spec::Paint::Linear(g) => {
if let Some(grad) = nsgradient(&g.stops) {
let (s, e) = (g.start.resolve(bounds), g.end.resolve(bounds));
unsafe {
grad.drawFromPoint_toPoint_options(
NSPoint::new(s.x, s.y),
NSPoint::new(e.x, e.y),
opts,
)
};
}
}
day_spec::Paint::Radial(g) => {
if let Some(grad) = nsgradient(&g.stops) {
NSGraphicsContext::saveGraphicsState_class();
concat_unit_to_bounds(bounds);
let c = NSPoint::new(g.center.x, g.center.y);
unsafe {
grad.drawFromCenter_radius_toCenter_radius_options(c, 0.0, c, g.radius, opts)
};
NSGraphicsContext::restoreGraphicsState_class();
}
}
day_spec::Paint::Solid(_) => {}
}
}
fn apply_button_content(
btn: &NSButton,
title: &str,
icon: Option<&day_spec::Icon>,
icon_only: bool,
) {
let image = icon.and_then(|i| crate::toolbar::image_for(i, title, btn.mtm()));
if let Some(image) = &image {
let size = image.size();
let longest = size.width.max(size.height);
if longest > 0.0 {
image.setSize(objc2_foundation::NSSize::new(
size.width * 20.0 / longest,
size.height * 20.0 / longest,
));
}
}
let style = BUTTON_STYLES
.with(|m| m.borrow().get(&ptr_of(btn)).copied())
.unwrap_or_default();
BUTTON_IMAGES.with(|images| {
images.remove(ptr_of(btn));
if let Some(image) = &image {
images.insert(ptr_of(btn), image.clone());
}
});
set_button_title(btn, title, style);
unsafe {
btn.setImagePosition(if image.is_none() {
objc2_app_kit::NSCellImagePosition::NoImage
} else if icon_only {
objc2_app_kit::NSCellImagePosition::ImageOnly
} else {
objc2_app_kit::NSCellImagePosition::ImageLeading
});
}
set_button_image(btn, style);
unsafe {
if !APP_LABELED.with(|t| t.contains(ptr_of(btn))) {
btn.setAccessibilityLabel(Some(&NSString::from_str(title)));
}
let tooltip = (icon_only && image.is_some()).then(|| NSString::from_str(title));
btn.setToolTip(tooltip.as_deref());
}
}
fn set_button_image(btn: &NSButton, style: day_spec::props::ButtonStyleSpec) {
use day_spec::props::ButtonStyleSpec as S;
let image = BUTTON_IMAGES.with(|images| images.with(ptr_of(btn), |image| image.clone()));
let image = image.map(|source| {
let S::Tinted(fill) = style else {
return source;
};
let size = source.size();
let ink = nscolor(S::on_tint(fill));
let draw = block2::RcBlock::new(move |rect: NSRect| {
source.drawInRect_fromRect_operation_fraction(
rect,
NSRect::ZERO,
objc2_app_kit::NSCompositingOperation::SourceOver,
1.0,
);
unsafe {
ink.setFill();
objc2_app_kit::NSRectFillUsingOperation(
rect,
objc2_app_kit::NSCompositingOperation::SourceIn,
);
}
objc2::runtime::Bool::YES
});
objc2_app_kit::NSImage::imageWithSize_flipped_drawingHandler(size, false, &draw)
});
unsafe {
let position = btn.imagePosition();
let image = image.map(|source| {
if position == objc2_app_kit::NSCellImagePosition::ImageOnly {
return source;
}
let size = source.size();
let template = source.isTemplate();
let padded = NSSize::new(size.width + 8.0, size.height);
let draw = block2::RcBlock::new(move |_rect: NSRect| {
source.drawInRect_fromRect_operation_fraction(
NSRect::new(NSPoint::new(0.0, 0.0), size),
NSRect::ZERO,
objc2_app_kit::NSCompositingOperation::SourceOver,
1.0,
);
objc2::runtime::Bool::YES
});
let out =
objc2_app_kit::NSImage::imageWithSize_flipped_drawingHandler(padded, false, &draw);
out.setTemplate(template);
out
});
btn.setImage(image.as_deref());
btn.setImagePosition(position);
btn.setImageHugsTitle(true);
}
}
fn set_button_selected(btn: &objc2_app_kit::NSButton, on: bool) {
BUTTON_SELECTED.with(|m| m.borrow_mut().insert(ptr_of(btn), on));
unsafe {
if on {
btn.setButtonType(objc2_app_kit::NSButtonType::PushOnPushOff);
}
btn.setState(if on {
NSControlStateValueOn
} else {
NSControlStateValueOff
});
}
}
const TINT_CORNER_RADIUS: f64 = 6.0;
fn apply_button_style(btn: &objc2_app_kit::NSButton, style: day_spec::props::ButtonStyleSpec) {
use day_spec::props::ButtonStyleSpec as S;
let title = unsafe { btn.title() }.to_string();
unsafe {
btn.setKeyEquivalent(&NSString::from_str(""));
btn.setBezelColor(None);
btn.setBordered(!matches!(style, S::Plain));
match style {
S::Prominent => btn.setKeyEquivalent(&NSString::from_str("\r")),
S::Tinted(c) => btn.setBezelColor(Some(&nscolor(c))),
S::Bordered | S::Automatic | S::Compact | S::Plain => {}
}
btn.setWantsLayer(true);
if let Some(layer) = btn.layer() {
match style {
S::Tinted(c) => {
layer.setBackgroundColor(Some(&nscolor(c).CGColor()));
layer.setCornerRadius(TINT_CORNER_RADIUS);
}
_ => layer.setBackgroundColor(None),
}
}
}
BUTTON_STYLES.with(|m| m.borrow_mut().insert(ptr_of(btn), style));
let ink = match style {
S::Tinted(c) => Some(nscolor(S::on_tint(c))),
_ => None,
};
unsafe {
btn.setContentTintColor(ink.as_deref());
}
set_button_title(btn, &title, style);
set_button_image(btn, style);
}
fn set_button_title(
btn: &objc2_app_kit::NSButton,
title: &str,
style: day_spec::props::ButtonStyleSpec,
) {
use day_spec::props::ButtonStyleSpec as S;
let position = unsafe { btn.imagePosition() };
let S::Tinted(fill) = style else {
unsafe {
btn.setTitle(&NSString::from_str(title));
btn.setImagePosition(position);
}
return;
};
unsafe {
let col = nscolor(S::on_tint(fill));
let font = btn
.font()
.unwrap_or_else(|| NSFont::systemFontOfSize(NSFont::systemFontSize()));
let para = objc2_app_kit::NSMutableParagraphStyle::new();
para.setAlignment(objc2_app_kit::NSTextAlignment::Center);
let keys: [&NSString; 3] = [
objc2_app_kit::NSFontAttributeName,
objc2_app_kit::NSForegroundColorAttributeName,
objc2_app_kit::NSParagraphStyleAttributeName,
];
let objs: [&objc2::runtime::AnyObject; 3] = [
font.as_ref() as &objc2::runtime::AnyObject,
col.as_ref() as &objc2::runtime::AnyObject,
para.as_ref() as &objc2::runtime::AnyObject,
];
let attrs = objc2_foundation::NSDictionary::from_slices::<NSString>(&keys, &objs);
let s = objc2_foundation::NSAttributedString::initWithString_attributes(
objc2_foundation::NSAttributedString::alloc(),
&NSString::from_str(title),
Some(&attrs),
);
btn.setAttributedTitle(&s);
btn.setImagePosition(position);
}
}
define_class!(
#[unsafe(super(objc2_app_kit::NSImageView))]
#[thread_kind = MainThreadOnly]
#[name = "DayFillImageView"]
struct FillImageView;
impl FillImageView {
#[unsafe(method(drawRect:))]
fn draw_rect(&self, dirty: NSRect) {
let bounds = self.bounds();
let image = unsafe { self.image() };
let Some(size) = image.map(|image| image.size()).filter(|size| {
size.width > 0.0 && size.height > 0.0
&& bounds.size.width > 0.0 && bounds.size.height > 0.0
}) else {
let _: () = unsafe { msg_send![super(self), drawRect: dirty] };
return;
};
let x = bounds.size.width / size.width;
let y = bounds.size.height / size.height;
let scale = x.max(y) / x.min(y);
let cx = bounds.origin.x + bounds.size.width / 2.0;
let cy = bounds.origin.y + bounds.size.height / 2.0;
NSGraphicsContext::saveGraphicsState_class();
objc2_app_kit::NSBezierPath::bezierPathWithRect(bounds).addClip();
let transform = NSAffineTransform::transform();
transform.translateXBy_yBy(cx, cy);
transform.scaleBy(scale);
transform.translateXBy_yBy(-cx, -cy);
transform.concat();
let _: () = unsafe { msg_send![super(self), drawRect: bounds] };
NSGraphicsContext::restoreGraphicsState_class();
}
#[unsafe(method(setFrameSize:))]
fn set_frame_size(&self, size: NSSize) {
let _: () = unsafe { msg_send![super(self), setFrameSize: size] };
NSView::setNeedsDisplay(self, true);
}
}
);
fn bezier(shape: &day_spec::Shape) -> Option<objc2::rc::Retained<objc2_app_kit::NSBezierPath>> {
use day_spec::Shape;
use objc2_app_kit::NSBezierPath;
unsafe {
Some(match shape {
Shape::Rect(r) => NSBezierPath::bezierPathWithRect(ns_rect(*r)),
Shape::RoundedRect(r, rad) => {
NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(ns_rect(*r), *rad, *rad)
}
Shape::Ellipse(r) => NSBezierPath::bezierPathWithOvalInRect(ns_rect(*r)),
Shape::Arc {
rect,
start_deg,
sweep_deg,
} => {
let p = NSBezierPath::new();
let center = NSPoint::new(
rect.origin.x + rect.size.width / 2.0,
rect.origin.y + rect.size.height / 2.0,
);
let radius = rect.size.width.min(rect.size.height) / 2.0;
p.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle(
center,
radius,
*start_deg,
*start_deg + *sweep_deg,
);
p
}
Shape::Line(a, b) => {
let p = NSBezierPath::new();
p.moveToPoint(NSPoint::new(a.x, a.y));
p.lineToPoint(NSPoint::new(b.x, b.y));
p
}
Shape::Polygon(pts) => {
if pts.len() < 2 {
return None;
}
let p = NSBezierPath::new();
p.moveToPoint(NSPoint::new(pts[0].x, pts[0].y));
for pt in &pts[1..] {
p.lineToPoint(NSPoint::new(pt.x, pt.y));
}
p.closePath();
p
}
Shape::Path(path) => {
use day_spec::PathSeg;
if path.segs.is_empty() {
return None;
}
let p = NSBezierPath::new();
let mut cur = NSPoint::new(0.0, 0.0);
for seg in &path.segs {
match seg {
PathSeg::Move(a) => {
cur = NSPoint::new(a.x, a.y);
p.moveToPoint(cur);
}
PathSeg::Line(a) => {
cur = NSPoint::new(a.x, a.y);
p.lineToPoint(cur);
}
PathSeg::Quad(c, a) => {
let (end, ctl) = (NSPoint::new(a.x, a.y), NSPoint::new(c.x, c.y));
let c1 = NSPoint::new(
cur.x + 2.0 / 3.0 * (ctl.x - cur.x),
cur.y + 2.0 / 3.0 * (ctl.y - cur.y),
);
let c2 = NSPoint::new(
end.x + 2.0 / 3.0 * (ctl.x - end.x),
end.y + 2.0 / 3.0 * (ctl.y - end.y),
);
p.curveToPoint_controlPoint1_controlPoint2(end, c1, c2);
cur = end;
}
PathSeg::Cubic(c1, c2, a) => {
cur = NSPoint::new(a.x, a.y);
p.curveToPoint_controlPoint1_controlPoint2(
cur,
NSPoint::new(c1.x, c1.y),
NSPoint::new(c2.x, c2.y),
);
}
PathSeg::Close => p.closePath(),
}
}
p.setWindingRule(match path.rule {
day_spec::FillRule::NonZero => objc2_app_kit::NSWindingRule::NonZero,
day_spec::FillRule::EvenOdd => objc2_app_kit::NSWindingRule::EvenOdd,
});
p
}
})
}
}
struct UndoIvars {
state: std::cell::RefCell<day_spec::UndoState>,
}
define_class!(
#[unsafe(super(objc2_foundation::NSUndoManager))]
#[thread_kind = MainThreadOnly]
#[name = "DayUndoManager"]
#[ivars = UndoIvars]
struct DayUndoManager;
impl DayUndoManager {
#[unsafe(method(canUndo))]
fn can_undo(&self) -> bool {
self.ivars().state.borrow().can_undo
}
#[unsafe(method(canRedo))]
fn can_redo(&self) -> bool {
self.ivars().state.borrow().can_redo
}
#[unsafe(method(undo))]
fn do_undo(&self) {
ffi_guard::contain((), || emit(WINDOW_NODE, Event::Undo { redo: false }))
}
#[unsafe(method(redo))]
fn do_redo(&self) {
ffi_guard::contain((), || emit(WINDOW_NODE, Event::Undo { redo: true }))
}
#[unsafe(method_id(undoMenuItemTitle))]
fn undo_menu_item_title(&self) -> Retained<NSString> {
let label = self.ivars().state.borrow().undo_label.clone();
unsafe { self.undoMenuTitleForUndoActionName(&NSString::from_str(&label)) }
}
#[unsafe(method_id(redoMenuItemTitle))]
fn redo_menu_item_title(&self) -> Retained<NSString> {
let label = self.ivars().state.borrow().redo_label.clone();
unsafe { self.redoMenuTitleForUndoActionName(&NSString::from_str(&label)) }
}
}
);
fn undo_front(mtm: MainThreadMarker) -> Retained<DayUndoManager> {
UNDO_FRONT.with(|u| {
u.borrow_mut()
.get_or_insert_with(|| {
let this = DayUndoManager::alloc(mtm).set_ivars(UndoIvars {
state: std::cell::RefCell::new(day_spec::UndoState::default()),
});
unsafe { msg_send![super(this), init] }
})
.clone()
})
}
struct WinIvars {
node: Option<NodeId>,
group: Option<String>,
last_state: Cell<i32>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayWinDelegate"]
#[ivars = WinIvars]
struct DayWinDelegate;
unsafe impl NSObjectProtocol for DayWinDelegate {}
impl DayWinDelegate {
#[unsafe(method(undo:))]
fn edit_undo(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || {
if let Some(m) = self.acting_undo_manager() {
unsafe { m.undo() };
}
});
}
#[unsafe(method(redo:))]
fn edit_redo(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || {
if let Some(m) = self.acting_undo_manager() {
unsafe { m.redo() };
}
});
}
#[unsafe(method(cut:))]
fn edit_cut(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || emit(WINDOW_NODE, Event::Edit(day_spec::EditOp::Cut)));
}
#[unsafe(method(copy:))]
fn edit_copy(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || emit(WINDOW_NODE, Event::Edit(day_spec::EditOp::Copy)));
}
#[unsafe(method(paste:))]
fn edit_paste(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || emit(WINDOW_NODE, Event::Edit(day_spec::EditOp::Paste)));
}
#[unsafe(method(selectAll:))]
fn edit_select_all(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || {
emit(WINDOW_NODE, Event::Edit(day_spec::EditOp::SelectAll));
});
}
#[unsafe(method(validateMenuItem:))]
fn validate_menu_item(&self, item: &NSMenuItem) -> bool {
ffi_guard::contain(false, || {
let state = EDIT_STATE.with(|s| s.get());
match unsafe { item.action() } {
Some(a) if a == sel!(undo:) => match self.acting_undo_manager() {
Some(m) => {
item.setTitle(unsafe { m.undoMenuItemTitle() }.as_ref());
unsafe { m.canUndo() }
}
None => false,
},
Some(a) if a == sel!(redo:) => match self.acting_undo_manager() {
Some(m) => {
item.setTitle(unsafe { m.redoMenuItemTitle() }.as_ref());
unsafe { m.canRedo() }
}
None => false,
},
Some(a) if a == sel!(selectAll:) => state.can_select_all,
Some(a) if a == sel!(cut:) => state.can_cut,
Some(a) if a == sel!(copy:) => state.can_copy,
Some(a) if a == sel!(paste:) => {
state.can_paste && {
let pb = unsafe { NSPasteboard::generalPasteboard() };
pb.types().is_some_and(|types| !types.is_empty())
}
}
_ => true,
}
})
}
}
unsafe impl NSWindowDelegate for DayWinDelegate {
#[unsafe(method(windowDidResize:))]
fn window_did_resize(&self, notification: &NSNotification) {
ffi_guard::contain((), || {
if let Some(obj) = unsafe { notification.object() }
&& let Ok(win) = obj.downcast::<NSWindow>()
&& let Some(size) = pin_below_title_bar(&win)
{
let target = self.ivars().node.unwrap_or(WINDOW_NODE);
emit(target, Event::WindowResized(size));
}
self.report_state_of(notification);
})
}
#[unsafe(method(windowShouldClose:))]
fn window_should_close(&self, _window: &NSWindow) -> bool {
ffi_guard::contain(false, || day_core::windows::native_close_requested(self.ivars().node.unwrap_or(WINDOW_NODE)))
}
#[unsafe(method(windowWillClose:))]
fn window_will_close(&self, _notification: &NSNotification) {
ffi_guard::contain((), || {
match self.ivars().node {
Some(node) => emit(node, Event::WindowClosed),
None if status_item::keeps_running() => emit(WINDOW_NODE, Event::WindowClosed),
None => {
let app = NSApplication::sharedApplication(self.mtm());
unsafe { app.terminate(None) };
}
}
})
}
#[unsafe(method_id(windowWillReturnUndoManager:))]
fn window_will_return_undo_manager(
&self,
_window: &NSWindow,
) -> Option<Retained<objc2_foundation::NSUndoManager>> {
ffi_guard::contain(None, || {
UNDO_FRONT.with(|u| {
u.borrow()
.as_ref()
.map(|m| Retained::into_super(m.clone()))
})
})
}
#[unsafe(method(windowDidBecomeKey:))]
fn window_did_become_key(&self, _notification: &NSNotification) {
ffi_guard::contain((), || {
if let Some(node) = self.ivars().node {
emit(node, Event::WindowFocused(true));
}
})
}
#[unsafe(method(windowDidResignKey:))]
fn window_did_resign_key(&self, _notification: &NSNotification) {
ffi_guard::contain((), || {
if let Some(node) = self.ivars().node {
emit(node, Event::WindowFocused(false));
}
})
}
#[unsafe(method(windowDidMiniaturize:))]
fn window_did_miniaturize(&self, notification: &NSNotification) {
ffi_guard::contain((), || self.report_state_of(notification))
}
#[unsafe(method(windowDidDeminiaturize:))]
fn window_did_deminiaturize(&self, notification: &NSNotification) {
ffi_guard::contain((), || self.report_state_of(notification))
}
#[unsafe(method(windowDidEnterFullScreen:))]
fn window_did_enter_full_screen(&self, notification: &NSNotification) {
ffi_guard::contain((), || self.report_state_of(notification))
}
#[unsafe(method(windowDidExitFullScreen:))]
fn window_did_exit_full_screen(&self, notification: &NSNotification) {
ffi_guard::contain((), || self.report_state_of(notification))
}
}
impl DayWinDelegate {
#[unsafe(method(newWindowForTab:))]
fn new_window_for_tab(&self, _sender: Option<&NSObject>) {
ffi_guard::contain((), || {
let _ = match self.ivars().group.as_deref() {
Some(group) => day_core::windows::open_new_window_for_group(group),
None => day_core::windows::open_new_window(),
};
})
}
}
);
impl DayWinDelegate {
fn report_state_of(&self, notification: &NSNotification) {
if let Some(obj) = unsafe { notification.object() }
&& let Ok(win) = obj.downcast::<NSWindow>()
{
self.report_state(&win);
}
}
}
fn key_name(event: &objc2_app_kit::NSEvent) -> Option<&'static str> {
match unsafe { event.keyCode() } {
123 => Some("ArrowLeft"),
124 => Some("ArrowRight"),
125 => Some("ArrowDown"),
126 => Some("ArrowUp"),
_ => {
let held = NSEventModifierFlags::Command
| NSEventModifierFlags::Option
| NSEventModifierFlags::Control;
if event.modifierFlags().intersects(held) {
return None;
}
let typed = event.characters()?.to_string();
let mut chars = typed.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => day_spec::KeyEvent::digit_name(c),
_ => None,
}
}
}
}
fn key_modifiers(f: NSEventModifierFlags) -> u8 {
let mut modifiers = 0u8;
if f.contains(NSEventModifierFlags::Shift) {
modifiers |= day_spec::KeyEvent::SHIFT;
}
if f.contains(NSEventModifierFlags::Command) {
modifiers |= day_spec::KeyEvent::PRIMARY;
}
if f.contains(NSEventModifierFlags::Option) {
modifiers |= day_spec::KeyEvent::ALT;
}
modifiers
}
impl DayWinDelegate {
fn acting_undo_manager(&self) -> Option<Retained<objc2_foundation::NSUndoManager>> {
let responder = NSApplication::sharedApplication(self.mtm())
.keyWindow()
.and_then(|w| w.firstResponder());
responder
.and_then(|r| unsafe { r.undoManager() })
.or_else(|| {
UNDO_FRONT.with(|u| u.borrow().as_ref().map(|m| Retained::into_super(m.clone())))
})
}
}
impl DayWinDelegate {
fn new(mtm: MainThreadMarker, node: Option<NodeId>, group: Option<String>) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(WinIvars {
node,
group,
last_state: Cell::new(day_spec::WindowState::Normal.code()),
});
unsafe { msg_send![super(this), init] }
}
fn report_state(&self, window: &NSWindow) {
let state = ns_window_state(window);
if self.ivars().last_state.replace(state.code()) != state.code() {
log::debug!("appkit: window state now {state:?}");
let target = self.ivars().node.unwrap_or(WINDOW_NODE);
emit(target, Event::WindowStateChanged(state));
}
}
}
fn ns_window_state(window: &NSWindow) -> day_spec::WindowState {
if window.isMiniaturized() {
day_spec::WindowState::Minimized
} else if window
.styleMask()
.contains(objc2_app_kit::NSWindowStyleMask::FullScreen)
{
day_spec::WindowState::Fullscreen
} else if window.isZoomed() {
day_spec::WindowState::Maximized
} else {
day_spec::WindowState::Normal
}
}
fn set_ns_window_state(window: &NSWindow, state: day_spec::WindowState) {
use day_spec::WindowState as S;
let current = ns_window_state(window);
if current == state {
return;
}
unsafe {
match current {
S::Minimized => window.deminiaturize(None),
S::Fullscreen => {
window.toggleFullScreen(None);
return;
}
S::Maximized if state != S::Minimized => window.zoom(None),
_ => {}
}
match state {
S::Normal => {}
S::Minimized => window.miniaturize(None),
S::Maximized => {
if !window.isZoomed() {
window.zoom(None)
}
}
S::Fullscreen => window.toggleFullScreen(None),
}
}
}
#[distributed_slice]
pub static RENDERERS: [fn() -> Renderer<AppKit>];
struct SecondaryWin {
window: Retained<NSWindow>,
#[allow(dead_code)] delegate: Retained<DayWinDelegate>,
content: Handle,
}
struct MenuEscapeMonitor(Retained<objc2::runtime::AnyObject>);
impl Drop for MenuEscapeMonitor {
fn drop(&mut self) {
unsafe { NSEvent::removeMonitor(&self.0) };
}
}
pub struct AppKit {
menu_escape_monitor: Option<MenuEscapeMonitor>,
sharing: Option<objc2::rc::Retained<objc2::runtime::AnyObject>>,
mtm: MainThreadMarker,
registry: Registry<AppKit>,
window: Option<Retained<NSWindow>>,
content: Option<Handle>,
secondary: Vec<SecondaryWin>,
app_name: String,
cascade: Option<NSPoint>,
cursors: HashMap<usize, (Retained<DayCursorOwner>, Retained<NSTrackingArea>)>,
}
impl AppKit {
fn swap_text_field(
&mut self,
h: &Handle,
old: &NSTextField,
node: NodeId,
secure: bool,
) -> Retained<NSTextField> {
let new = new_text_field(self.mtm, node, secure);
let (old_key, new_key) = (ptr_of(h), ptr_of(&new));
let window = h.window();
let focused = window
.as_deref()
.is_some_and(|w| owns_first_responder(w, h));
let selection = old.currentEditor().map(|e| e.selectedRange());
unsafe {
new.setStringValue(&old.stringValue());
new.setPlaceholderString(old.placeholderString().as_deref());
new.setEnabled(old.isEnabled());
new.setFont(old.font().as_deref());
new.setFrame(h.frame());
new.setHidden(h.isHidden());
new.setMenu(h.menu().as_deref());
new.setToolTip(h.toolTip().as_deref());
if h.wantsLayer() {
new.setWantsLayer(true);
new.setAlphaValue(h.alphaValue());
let from: *mut objc2::runtime::AnyObject = msg_send![h, layer];
let to: *mut objc2::runtime::AnyObject = msg_send![&*new, layer];
if !from.is_null() && !to.is_null() {
let t: CGAffineTransform = msg_send![from, affineTransform];
let _: () = msg_send![to, setAffineTransform: t];
}
}
if let Some(id) = h.accessibilityIdentifier().filter(|s| !s.is_empty()) {
new.setAccessibilityIdentifier(Some(&id));
}
if let Some(label) = h.accessibilityLabel().filter(|s| !s.is_empty()) {
new.setAccessibilityLabel(Some(&label));
}
if let Some(help) = h.accessibilityHelp().filter(|s| !s.is_empty()) {
new.setAccessibilityHelp(Some(&help));
}
if !h.isAccessibilityElement() {
new.setAccessibilityElement(false);
}
}
for r in unsafe { h.gestureRecognizers() }.iter() {
unsafe {
h.removeGestureRecognizer(&r);
new.addGestureRecognizer(&r);
}
}
GESTURES.with(|m| {
let mut m = m.borrow_mut();
if let Some(targets) = m.remove(&old_key) {
m.insert(new_key, targets);
}
});
if let Some((owner, area)) = self.cursors.remove(&old_key) {
unsafe {
h.removeTrackingArea(&area);
new.addTrackingArea(&area);
}
self.cursors.insert(new_key, (owner, area));
}
PAN_NODES.with(|t| rekey(t, old_key, new_key));
HOVER_NODES.with(|t| rekey(t, old_key, new_key));
KEY_NODES.with(|t| rekey(t, old_key, new_key));
FOCUSABLE_NODES.with(|t| rekey(t, old_key, new_key));
CTX_MENU_FNS.with(|t| rekey(t, old_key, new_key));
transfer::moved(h, &new);
unsafe { old.setDelegate(None) };
TARGETS.with(|m| {
m.borrow_mut().remove(&old_key);
});
if let Some(parent) = unsafe { h.superview() } {
unsafe {
parent.addSubview_positioned_relativeTo(
&new,
objc2_app_kit::NSWindowOrderingMode::Above,
Some(h),
);
h.removeFromSuperview();
}
}
if focused && let Some(window) = window {
FIELD_SWAP.with(|s| s.set(true));
let taken = window.makeFirstResponder(Some(&new));
FIELD_SWAP.with(|s| s.set(false));
if taken && let Some(editor) = new.currentEditor() {
let len = unsafe { new.stringValue() }.length();
let range = selection
.filter(|r| r.location.saturating_add(r.length) <= len)
.unwrap_or(objc2_foundation::NSRange::new(len, 0));
editor.setSelectedRange(range);
}
}
day_spec::sidetable::sweep(old_key);
new
}
pub fn new() -> Self {
let mtm =
MainThreadMarker::new().expect("day-toolkit-appkit must start on the main thread");
let mut registry = Registry::default();
for f in RENDERERS {
registry.register(f());
}
AppKit {
menu_escape_monitor: None,
mtm,
registry,
window: None,
sharing: None,
content: None,
secondary: Vec::new(),
app_name: "Day".into(),
cascade: None,
cursors: HashMap::new(),
}
}
pub fn mtm(&self) -> MainThreadMarker {
self.mtm
}
fn ns_window_for(&self, host: &Handle) -> Option<Retained<NSWindow>> {
if let Some(w) = self
.secondary
.iter()
.find(|w| ptr_of(&w.content) == ptr_of(host))
{
return Some(w.window.clone());
}
self.content
.as_ref()
.filter(|c| ptr_of(c) == ptr_of(host))
.and(self.window.clone())
}
fn make_window(
&self,
options: &WindowOptions,
prefs_style: bool,
node: Option<NodeId>,
) -> (Retained<NSWindow>, Retained<DayWinDelegate>, Handle) {
let mtm = self.mtm;
let (title, size, min_size, tabbing) = (
options.title.as_str(),
options.size,
options.min_size,
&options.tabbing,
);
let content_rect =
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(size.width, size.height));
let style = window_chrome::style_mask(options, prefs_style);
let window = window_chrome::alloc_window(
mtm,
content_rect,
style,
options.chrome == day_spec::WindowChrome::Frameless,
);
unsafe { window.setReleasedWhenClosed(false) };
window.setTitle(&NSString::from_str(title));
if let Some(min) = min_size {
unsafe { window.setContentMinSize(NSSize::new(min.width, min.height)) };
}
if let Some(max) = options.max_size {
unsafe { window.setContentMaxSize(NSSize::new(max.width, max.height)) };
}
use day_spec::WindowTabbing as T;
use objc2_app_kit::NSWindowTabbingMode as M;
let group = match tabbing {
T::Group(g) | T::Preferred(g) => Some(g.clone()),
T::Automatic | T::Disallowed => None,
};
match tabbing {
_ if prefs_style => window.setTabbingMode(M::Disallowed),
T::Disallowed => window.setTabbingMode(M::Disallowed),
T::Automatic => unsafe {
window.setTabbingIdentifier(&NSString::from_str("day.normal"))
},
T::Group(g) => unsafe { window.setTabbingIdentifier(&NSString::from_str(g)) },
T::Preferred(g) => {
unsafe { window.setTabbingIdentifier(&NSString::from_str(g)) };
window.setTabbingMode(M::Preferred);
}
}
if group.is_some() {
unsafe { NSWindow::setAllowsAutomaticWindowTabbing(true, mtm) };
}
let delegate = DayWinDelegate::new(mtm, node, group);
window.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
let content = view_of(DayFlipped::new(mtm));
window.setContentView(Some(&content));
window_chrome::apply(&window, &content, options);
window.setAutorecalculatesKeyViewLoop(true);
(window, delegate, content)
}
}
impl Default for AppKit {
fn default() -> Self {
Self::new()
}
}
fn view_of<T: AsRef<NSView>>(x: Retained<T>) -> Handle {
Retained::from(x.as_ref())
}
fn nscolor(c: day_spec::Color) -> Retained<NSColor> {
unsafe { NSColor::colorWithSRGBRed_green_blue_alpha(c.r, c.g, c.b, c.a) }
}
fn ns_text_style(f: Font) -> Option<&'static objc2_app_kit::NSFontTextStyle> {
use objc2_app_kit::*;
unsafe {
Some(match f {
Font::LargeTitle => NSFontTextStyleLargeTitle,
Font::Title => NSFontTextStyleTitle1,
Font::Title2 => NSFontTextStyleTitle2,
Font::Title3 => NSFontTextStyleTitle3,
Font::Headline => NSFontTextStyleHeadline,
Font::Subheadline => NSFontTextStyleSubheadline,
Font::Body => NSFontTextStyleBody,
Font::Callout => NSFontTextStyleCallout,
Font::Footnote => NSFontTextStyleFootnote,
Font::Caption => NSFontTextStyleCaption1,
Font::Caption2 => NSFontTextStyleCaption2,
Font::System(_) | Font::Custom(..) => return None,
})
}
}
fn ns_weight(w: day_spec::FontWeight) -> objc2_app_kit::NSFontWeight {
use day_spec::FontWeight as W;
use objc2_app_kit::*;
unsafe {
match w {
W::UltraLight => NSFontWeightUltraLight,
W::Thin => NSFontWeightThin,
W::Light => NSFontWeightLight,
W::Regular => NSFontWeightRegular,
W::Medium => NSFontWeightMedium,
W::Semibold => NSFontWeightSemibold,
W::Bold => NSFontWeightBold,
W::Heavy => NSFontWeightHeavy,
W::Black => NSFontWeightBlack,
}
}
}
fn nsfm_weight(w: day_spec::FontWeight) -> isize {
use day_spec::FontWeight as W;
match w {
W::UltraLight => 2,
W::Thin => 3,
W::Light => 4,
W::Regular => 5,
W::Medium => 6,
W::Semibold => 8,
W::Bold => 9,
W::Heavy => 10,
W::Black => 12,
}
}
fn canvas_nsfont(size: f64, font: &day_spec::CanvasFont) -> Retained<NSFont> {
use objc2_app_kit::*;
let weight = font.weight.unwrap_or(day_spec::FontWeight::Regular);
let mtm = objc2::MainThreadMarker::new().expect("canvas draws on the main thread");
let manager = unsafe { NSFontManager::sharedFontManager(mtm) };
let mut traits = NSFontTraitMask::empty();
if weight >= day_spec::FontWeight::Semibold {
traits |= NSFontTraitMask::BoldFontMask;
}
if font.italic {
traits |= NSFontTraitMask::ItalicFontMask;
}
let Some(family) = font.family.as_deref() else {
let base = unsafe { NSFont::systemFontOfSize_weight(size, ns_weight(weight)) };
return if font.italic {
unsafe { manager.convertFont_toHaveTrait(&base, NSFontTraitMask::ItalicFontMask) }
} else {
base
};
};
let ns_family = NSString::from_str(family);
if let Some(f) = unsafe {
manager.fontWithFamily_traits_weight_size(&ns_family, traits, nsfm_weight(weight), size)
} {
return f;
}
if let Some(f) = unsafe { NSFont::fontWithName_size(&ns_family, size) } {
return if traits.is_empty() {
f
} else {
unsafe { manager.convertFont_toHaveTrait(&f, traits) }
};
}
log::warn!("unknown font family {family:?} — drawing canvas text in the system font");
unsafe { NSFont::systemFontOfSize_weight(size, ns_weight(weight)) }
}
fn canvas_text_attrs(
font: &NSFont,
color: day_spec::Color,
) -> Retained<objc2_foundation::NSDictionary<NSString, objc2::runtime::AnyObject>> {
let col = nscolor(color);
let keys: [&NSString; 2] = unsafe {
[
objc2_app_kit::NSFontAttributeName,
objc2_app_kit::NSForegroundColorAttributeName,
]
};
let objs: [&objc2::runtime::AnyObject; 2] = [
font as &objc2::runtime::AnyObject,
col.as_ref() as &objc2::runtime::AnyObject,
];
objc2_foundation::NSDictionary::from_slices::<NSString>(&keys, &objs)
}
fn nsfont(spec: day_spec::FontSpec) -> Retained<NSFont> {
use objc2_app_kit::*;
let base: Retained<NSFont> = match spec.style {
Font::System(pt) => {
let w = spec
.weight
.map(ns_weight)
.unwrap_or(unsafe { NSFontWeightRegular });
unsafe { NSFont::systemFontOfSize_weight(pt, w) }
}
Font::Custom(name, pt) => {
match unsafe { NSFont::fontWithName_size(&NSString::from_str(name), pt) } {
Some(f) => {
if spec
.weight
.is_some_and(|w| w >= day_spec::FontWeight::Semibold)
{
let mtm = objc2::MainThreadMarker::new()
.expect("labels realize on the main thread");
unsafe {
NSFontManager::sharedFontManager(mtm)
.convertFont_toHaveTrait(&f, NSFontTraitMask::BoldFontMask)
}
} else {
f
}
}
None => {
log::warn!(
"unknown font family {name:?} — falling back to the system font \
(is the file in the project's fonts/ directory?)"
);
let w = spec
.weight
.map(ns_weight)
.unwrap_or(unsafe { NSFontWeightRegular });
unsafe { NSFont::systemFontOfSize_weight(pt, w) }
}
}
}
style => {
let ts = ns_text_style(style).expect("semantic style");
let opts = objc2_foundation::NSDictionary::new();
let f = unsafe { NSFont::preferredFontForTextStyle_options(ts, &opts) };
match spec.weight {
Some(w) => unsafe { NSFont::systemFontOfSize_weight(f.pointSize(), ns_weight(w)) },
None => f,
}
}
};
let base = if spec.tabular && !matches!(spec.style, Font::Custom(..)) {
let w = spec
.weight
.map(ns_weight)
.unwrap_or(unsafe { NSFontWeightRegular });
unsafe { NSFont::monospacedDigitSystemFontOfSize_weight(base.pointSize(), w) }
} else {
base
};
let base = if spec.monospace && !matches!(spec.style, Font::Custom(..)) {
let w = spec
.weight
.map(ns_weight)
.unwrap_or(unsafe { NSFontWeightRegular });
unsafe { NSFont::monospacedSystemFontOfSize_weight(base.pointSize(), w) }
} else {
base
};
let base = if spec.scale != 1.0 {
let mtm = objc2::MainThreadMarker::new().expect("labels realize on the main thread");
let pts = spec.resolved_points(base.pointSize());
unsafe { NSFontManager::sharedFontManager(mtm).convertFont_toSize(&base, pts) }
} else {
base
};
if spec.italic {
let mtm = objc2::MainThreadMarker::new().expect("labels realize on the main thread");
unsafe {
NSFontManager::sharedFontManager(mtm)
.convertFont_toHaveTrait(&base, NSFontTraitMask::ItalicFontMask)
}
} else {
base
}
}
fn set_paragraph_alignment(tf: &NSTextField, align: objc2_app_kit::NSTextAlignment) {
use objc2_foundation::{NSMutableAttributedString, NSRange};
unsafe {
let cur = tf.attributedStringValue();
let m = NSMutableAttributedString::initWithAttributedString(
NSMutableAttributedString::alloc(),
&cur,
);
let style = objc2_app_kit::NSMutableParagraphStyle::new();
style.setAlignment(align);
style.setLineBreakMode(objc2_app_kit::NSLineBreakMode::ByWordWrapping);
m.addAttribute_value_range(
objc2_app_kit::NSParagraphStyleAttributeName,
&style,
NSRange::new(0, cur.length()),
);
tf.setAttributedStringValue(&m);
}
}
fn attributed_label(
text: &str,
base_font: &NSFont,
color: Option<day_spec::Color>,
role: day_spec::props::TextRole,
runs: &[day_spec::TextRun],
) -> Retained<objc2_foundation::NSAttributedString> {
use objc2_foundation::{NSMutableAttributedString, NSRange};
let ns = NSString::from_str(text);
let s = unsafe {
NSMutableAttributedString::initWithString(NSMutableAttributedString::alloc(), &ns)
};
let whole = NSRange::new(0, ns.length());
unsafe {
s.addAttribute_value_range(objc2_app_kit::NSFontAttributeName, base_font, whole);
let fg = color.map(nscolor).unwrap_or_else(|| match role {
day_spec::props::TextRole::Secondary => objc2_app_kit::NSColor::secondaryLabelColor(),
day_spec::props::TextRole::Primary => objc2_app_kit::NSColor::labelColor(),
});
s.addAttribute_value_range(objc2_app_kit::NSForegroundColorAttributeName, &fg, whole);
}
for r in runs {
let Some(range) = utf16_range(text, &r.range) else {
continue;
};
unsafe {
s.addAttribute_value_range(objc2_app_kit::NSFontAttributeName, &nsfont(r.font), range);
if let Some(c) = r.color {
s.addAttribute_value_range(
objc2_app_kit::NSForegroundColorAttributeName,
&nscolor(c),
range,
);
}
if let Some(c) = r.background {
s.addAttribute_value_range(
objc2_app_kit::NSBackgroundColorAttributeName,
&nscolor(c),
range,
);
}
if r.underline.is_on() {
let style = objc2_foundation::NSNumber::new_i64(ns_underline(r.underline));
s.addAttribute_value_range(
objc2_app_kit::NSUnderlineStyleAttributeName,
&style,
range,
);
}
if r.strikethrough {
let one = objc2_foundation::NSNumber::new_i64(1);
s.addAttribute_value_range(
objc2_app_kit::NSStrikethroughStyleAttributeName,
&one,
range,
);
}
if let Some(url) = r.link.as_deref() {
let value = NSString::from_str(url);
s.addAttribute_value_range(objc2_app_kit::NSLinkAttributeName, &value, range);
}
}
}
s.into_super()
}
fn ns_underline(u: day_spec::Underline) -> i64 {
use day_spec::Underline as U;
match u {
U::None => 0,
U::Single => 0x01,
U::Double => 0x09,
U::Dotted => 0x01 | 0x0100,
U::Wavy => 0x01 | 0x0400,
}
}
fn utf16_range(text: &str, r: &std::ops::Range<usize>) -> Option<objc2_foundation::NSRange> {
let start = text.get(..r.start)?.encode_utf16().count();
let len = text.get(r.clone())?.encode_utf16().count();
Some(objc2_foundation::NSRange::new(start, len))
}
fn register_bundled_fonts() {
#[link(name = "CoreText", kind = "framework")]
unsafe extern "C" {
fn CTFontManagerRegisterFontsForURL(
font_url: *const std::ffi::c_void,
scope: u32, error: *mut *const std::ffi::c_void,
) -> bool;
}
for path in day_spec::fonts::bundled_fonts() {
let url = unsafe {
objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&path.to_string_lossy()))
};
let ok = unsafe {
CTFontManagerRegisterFontsForURL(
Retained::as_ptr(&url) as *const std::ffi::c_void,
1,
std::ptr::null_mut(),
)
};
if !ok {
log::warn!("could not register bundled font {}", path.display());
}
}
}
fn nstextalign(a: day_spec::props::TextAlign) -> objc2_app_kit::NSTextAlignment {
match a {
day_spec::props::TextAlign::Leading => objc2_app_kit::NSTextAlignment::Natural,
day_spec::props::TextAlign::Center => objc2_app_kit::NSTextAlignment::Center,
day_spec::props::TextAlign::Trailing => objc2_app_kit::NSTextAlignment::Right,
}
}
fn configure_label_cell(tf: &NSTextField) {
if let Some(cell) = unsafe { tf.cell() } {
unsafe {
cell.setWraps(true);
cell.setUsesSingleLineMode(false);
cell.setLineBreakMode(NSLineBreakMode::ByWordWrapping);
}
}
}
fn content_of(parent: &Handle) -> Retained<NSView> {
if let Some(sv) = parent.downcast_ref::<NSScrollView>()
&& let Some(doc) = unsafe { sv.documentView() }
{
return doc;
}
parent.clone()
}
fn with_appkit_anim(anim: Option<&AnimSpec>, body: impl FnOnce()) {
let Some(a) = anim else {
body();
return;
};
let (dur, timing) = appkit_timing(a);
unsafe {
NSAnimationContext::beginGrouping();
let ctx = NSAnimationContext::currentContext();
ctx.setDuration(dur);
ctx.setTimingFunction(Some(&timing));
ctx.setAllowsImplicitAnimation(true);
body();
NSAnimationContext::endGrouping();
}
}
fn appkit_timing(a: &AnimSpec) -> (f64, Retained<CAMediaTimingFunction>) {
unsafe {
let named = |n| {
(
a.duration_secs().max(0.01),
CAMediaTimingFunction::functionWithName(n),
)
};
match a.curve {
Curve::Linear => named(kCAMediaTimingFunctionLinear),
Curve::EaseIn => named(kCAMediaTimingFunctionEaseIn),
Curve::EaseOut => named(kCAMediaTimingFunctionEaseOut),
Curve::EaseInOut => named(kCAMediaTimingFunctionEaseInEaseOut),
Curve::Spring { damping, .. } => {
let overshoot = 1.0 + (1.0 - damping.clamp(0.0, 1.0)) as f32 * 0.55;
(
a.duration_secs().max(0.05),
CAMediaTimingFunction::functionWithControlPoints(0.34, overshoot, 0.5, 1.0),
)
}
}
}
}
fn warn_missing_renderer(kind: PieceKind) {
day_spec::placeholder::report(kind, "appkit");
}
pub(crate) fn placeholder_view(mtm: MainThreadMarker, kind: PieceKind) -> Handle {
view_of(unsafe {
NSTextField::labelWithString(&NSString::from_str(&format!("⟨{kind}⟩")), mtm)
})
}
impl Toolkit for AppKit {
fn ui_idle(&mut self) -> bool {
toolbar::chrome_settled()
}
type Handle = Handle;
fn dark_mode(&mut self) -> bool {
let app = NSApplication::sharedApplication(self.mtm());
app.effectiveAppearance()
.name()
.to_string()
.contains("Dark")
}
fn reduce_motion(&mut self) -> bool {
objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()
}
fn set_app_badge(&mut self, badge: &day_spec::AppBadge) {
use day_spec::AppBadge;
let label = match badge {
AppBadge::None => None,
AppBadge::Count(0) => None,
AppBadge::Count(n) => Some(n.to_string()),
AppBadge::Text(t) if t.is_empty() => None,
AppBadge::Text(t) => Some(t.clone()),
AppBadge::Dot => Some("\u{2022}".to_string()),
};
let tile = NSApplication::sharedApplication(self.mtm()).dockTile();
let ns = label.map(|l| objc2_foundation::NSString::from_str(&l));
unsafe { tile.setBadgeLabel(ns.as_deref()) };
tile.display();
}
fn set_appearance(&mut self, dark: Option<bool>) {
let app = NSApplication::sharedApplication(self.mtm());
let appearance = dark.and_then(|d| {
objc2_app_kit::NSAppearance::appearanceNamed(unsafe {
if d {
objc2_app_kit::NSAppearanceNameDarkAqua
} else {
objc2_app_kit::NSAppearanceNameAqua
}
})
});
unsafe { app.setAppearance(appearance.as_deref()) };
}
fn attach_refresh(&mut self, handle: &Self::Handle, host: NodeId) {
controls::refresh_gesture::attach(handle, host);
}
fn set_presented(&mut self, handle: &Self::Handle, visible: bool) {
unsafe {
handle.setHidden(!visible);
}
}
fn capability(&self, cap: Cap) -> Support {
match cap {
Cap::SearchField => Support::Native,
Cap::ComboBox => Support::Native,
Cap::Stepper => Support::Native,
Cap::DateTimePicker => Support::Native,
Cap::ColorPicker => Support::Native,
Cap::PullRefresh => Support::Emulated,
Cap::Pager => Support::Emulated,
Cap::PullRefreshGesture => Support::Emulated,
Cap::Cursor => Support::Native,
Cap::FontList => Support::Native,
Cap::ListItem => Support::Native,
Cap::ImageDecode | Cap::ImageEncode | Cap::ImageProperties => Support::Native,
Cap::CanvasBitmap | Cap::CanvasPdf => Support::Native,
Cap::Announce => Support::Native,
Cap::ReduceMotion => Support::Native,
Cap::Snapshot
| Cap::NativeSymbols
| Cap::NavTabs
| Cap::NavSplit
| Cap::NavRepresent
| Cap::NavContentList
| Cap::Dialogs
| Cap::FileDialogs
| Cap::FolderDialog
| Cap::Animation
| Cap::TextEditable
| Cap::TextSelectable
| Cap::TextSpellCheck
| Cap::NavReorder | Cap::ListReorder
| Cap::ListRecycling
| Cap::ListSwipeActions
| Cap::ListSections
| Cap::Tree
| Cap::TreeSections
| Cap::TreeMove
| Cap::MultiWindow
| Cap::WindowStates
| Cap::WindowFullscreen
| Cap::ContentProtection
| Cap::WindowTabbing
| Cap::DockMenu
| Cap::FramelessWindow
| Cap::OverlayTitleBar
| Cap::TransparentWindow
| Cap::WindowMaterial
| Cap::DragRegion
| Cap::StatusItem
| Cap::AppProgress
| Cap::DockVisibility
| Cap::WindowGeometry
| Cap::WindowPosition
| Cap::WindowLevel
| Cap::WindowAppearance
| Cap::RequestAttention
| Cap::Monitors
| Cap::DynamicShortcuts
| Cap::Toolbar
| Cap::ToolbarSearch
| Cap::AppMenu
| Cap::AppBadgeCount
| Cap::AppBadgeText
| Cap::UndoBridge
| Cap::DragDrop | Cap::DragExternalImport | Cap::DragExternalExport | Cap::DragMultipleItems | Cap::DragFileReferences | Cap::EditBridge
| Cap::AppBadgeDot
| Cap::Appearance
| Cap::BaselineAlignment
| Cap::Inspector
| Cap::Split
| Cap::TextRuns
| Cap::TextLinks => Support::Native,
Cap::Cover => Support::Emulated,
Cap::CoverSheet => Support::Native,
_ => Support::Unsupported,
}
}
fn realize(&mut self, kind: PieceKind, props: &dyn Any, id: NodeId) -> Handle {
let mtm = self.mtm;
match Builtin::from_key(kind) {
Some(Builtin::Container) => {
let v = DayFlipped::new(mtm);
if let Some(p) = props.downcast_ref::<ContainerProps>() {
if p.role == Some(day_spec::SurfaceRole::SectionCard) {
v.set_section_card(p.corner_radius);
} else if p.background.is_some() || p.corner_radius > 0.0 || p.clips {
v.set_surface(p.background, p.corner_radius, p.clips);
}
}
view_of(v)
}
Some(Builtin::Scroll) => {
let horizontal = props
.downcast_ref::<day_spec::props::ScrollProps>()
.map(|p| p.horizontal)
.unwrap_or(false);
let sv = unsafe { NSScrollView::new(mtm) };
unsafe {
sv.setHasVerticalScroller(!horizontal);
sv.setHasHorizontalScroller(horizontal);
sv.setDrawsBackground(false);
sv.setScrollerStyle(objc2_app_kit::NSScrollerStyle::Overlay);
}
let doc = DayFlipped::new(mtm);
unsafe { sv.setDocumentView(Some(doc.as_ref())) };
view_of(sv)
}
Some(Builtin::Label) => {
let Some(p) = props_of::<LabelProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let tf = DayLabel::new(mtm, id, &p.text);
configure_label_cell(&tf);
if !p.wraps || p.max_lines > 0 {
unsafe {
tf.setMaximumNumberOfLines(if p.wraps { p.max_lines as isize } else { 1 });
if !p.wraps || p.max_lines == 1 {
tf.setUsesSingleLineMode(true);
tf.setLineBreakMode(objc2_app_kit::NSLineBreakMode::ByTruncatingTail);
}
}
}
unsafe { tf.setFont(Some(&nsfont(p.font))) };
match (p.color, p.role) {
(Some(c), _) => unsafe { tf.setTextColor(Some(&nscolor(c))) },
(None, day_spec::props::TextRole::Secondary) => unsafe {
tf.setTextColor(Some(&objc2_app_kit::NSColor::secondaryLabelColor()))
},
(None, day_spec::props::TextRole::Primary) => {}
}
if !p.runs.is_empty() {
let s = attributed_label(&p.text, &nsfont(p.font), p.color, p.role, &p.runs);
unsafe { tf.setAttributedStringValue(&s) };
}
tf.ivars().has_runs.set(!p.runs.is_empty());
tf.set_links(p.runs.iter().any(|r| r.link.is_some()));
if p.align != day_spec::props::TextAlign::Leading {
unsafe { tf.setAlignment(nstextalign(p.align)) };
if !p.runs.is_empty() {
set_paragraph_alignment(&tf, nstextalign(p.align));
}
}
view_of(Retained::into_super(tf))
}
Some(Builtin::Button) => {
let Some(p) = props_of::<ButtonProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let target = DayTarget::new(mtm, id);
let btn = unsafe {
NSButton::buttonWithTitle_target_action(
&NSString::from_str(&p.title),
Some(&*target),
Some(sel!(action:)),
mtm,
)
};
apply_button_style(&btn, p.style);
if p.icon.is_some() {
apply_button_content(&btn, &p.title, p.icon.as_ref(), p.icon_only);
}
unsafe { btn.setEnabled(p.enabled) };
set_button_selected(&btn, p.selected);
let view = view_of(btn);
TARGETS.with(|m| m.borrow_mut().insert(ptr_of(&view), target));
view
}
Some(Builtin::Toggle) => {
let Some(p) = props_of::<ToggleProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let target = DayTarget::new(mtm, id);
let sw = unsafe { NSSwitch::new(mtm) };
unsafe {
sw.setState(if p.on { 1 } else { 0 });
sw.setEnabled(p.enabled);
sw.setTarget(Some(&*target));
sw.setAction(Some(sel!(action:)));
}
let view = view_of(sw);
TARGETS.with(|m| m.borrow_mut().insert(ptr_of(&view), target));
view
}
Some(Builtin::Slider) => {
let Some(p) = props_of::<SliderProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let target = DayTarget::new(mtm, id);
let sl = unsafe {
NSSlider::sliderWithValue_minValue_maxValue_target_action(
p.value,
p.min,
p.max,
Some(&*target),
Some(sel!(action:)),
mtm,
)
};
unsafe { sl.setContinuous(true) };
let view = view_of(sl);
TARGETS.with(|m| m.borrow_mut().insert(ptr_of(&view), target));
view
}
Some(Builtin::Picker) => picker::realize_any(self, props, id),
Some(Builtin::TextArea) => textarea::realize_any(self, props, id),
Some(Builtin::TextField) => {
let Some(p) = props_of::<TextFieldProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let tf = new_text_field(mtm, id, false);
unsafe {
tf.setStringValue(&NSString::from_str(&p.text));
tf.setPlaceholderString(Some(&NSString::from_str(&p.placeholder)));
}
view_of(tf)
}
Some(Builtin::Divider) => {
let b = unsafe { NSBox::new(mtm) };
unsafe { b.setBoxType(NSBoxType::Separator) };
view_of(b)
}
Some(Builtin::Progress) => {
let Some(p) = props_of::<ProgressProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
if p.value.is_none() {
return crate::controls::spinner::make(self, p, id);
}
let pi = unsafe { NSProgressIndicator::new(mtm) };
unsafe {
match p.value {
Some(v) => {
pi.setStyle(NSProgressIndicatorStyle::Bar);
pi.setIndeterminate(false);
pi.setMinValue(0.0);
pi.setMaxValue(1.0);
pi.setDoubleValue(v);
}
None => {
pi.setStyle(NSProgressIndicatorStyle::Spinning);
pi.setIndeterminate(true);
pi.startAnimation(None);
}
}
}
view_of(pi)
}
Some(Builtin::Canvas) => {
let canvas = DayCanvas::new(mtm);
KEY_NODES.with(|t| t.insert(Retained::as_ptr(&canvas) as usize, id));
view_of(canvas)
}
Some(Builtin::Inspector) => {
let (visible, width, leading) = props
.downcast_ref::<InspectorProps>()
.map(|p| {
(
p.visible,
p.width,
p.edge == day_spec::props::PaneEdge::Leading,
)
})
.unwrap_or((false, 280.0, false));
let content_wrap = view_of(DayFlipped::new(mtm));
let panel_flipped = DayFlipped::new(mtm);
panel_flipped.set_pane_backdrop();
let panel_wrap = view_of(panel_flipped);
let split_vc = unsafe { objc2_app_kit::NSSplitViewController::new(mtm) };
let _ = unsafe { split_vc.view() };
let content_vc = unsafe { objc2_app_kit::NSViewController::new(mtm) };
let panel_vc = unsafe { objc2_app_kit::NSViewController::new(mtm) };
unsafe {
content_vc.setView(&content_wrap);
panel_vc.setView(&panel_wrap);
}
let content_item = unsafe {
objc2_app_kit::NSSplitViewItem::splitViewItemWithViewController(&content_vc)
};
let panel_item = unsafe {
if leading {
objc2_app_kit::NSSplitViewItem::splitViewItemWithViewController(&panel_vc)
} else {
objc2_app_kit::NSSplitViewItem::inspectorWithViewController(&panel_vc)
}
};
unsafe {
panel_item.setCanCollapse(false);
panel_item.setCollapsed(!visible);
panel_item.setMinimumThickness(width);
panel_item.setMaximumThickness(width);
panel_item.setAllowsFullHeightLayout(true);
if leading {
split_vc.addSplitViewItem(&panel_item);
split_vc.addSplitViewItem(&content_item);
} else {
split_vc.addSplitViewItem(&content_item);
split_vc.addSplitViewItem(&panel_item);
}
}
let split = unsafe { split_vc.splitView() };
unsafe {
split.setVertical(true);
split.setDividerStyle(objc2_app_kit::NSSplitViewDividerStyle::Thin);
split.setTranslatesAutoresizingMaskIntoConstraints(true);
}
let view = view_of(split);
INSPECTOR_STATE.with(|m| {
m.borrow_mut().insert(
ptr_of(&view),
InspectorState {
content_wrap,
panel_wrap,
_split_vc: split_vc,
panel_item,
},
)
});
view
}
Some(Builtin::InspectorPane) => {
let pane = view_of(DayNavPage::new(mtm, id));
NAV_PAGES.with(|set| set.borrow_mut().insert(ptr_of(&pane)));
let panel = props
.downcast_ref::<InspectorPaneProps>()
.map(|p| p.panel)
.unwrap_or(false);
INSPECTOR_PANES.with(|t| t.insert(ptr_of(&pane), panel));
pane
}
Some(Builtin::Split) => {
let (axis, fraction, min_pane) = props
.downcast_ref::<SplitProps>()
.map(|p| (p.axis, p.fraction, p.min_pane))
.unwrap_or((day_spec::props::SplitAxis::Horizontal, 0.5, 80.0));
let wrap = view_of(DayFlipped::new(mtm));
let split = unsafe { objc2_app_kit::NSSplitView::new(mtm) };
let first_wrap = view_of(DayFlipped::new(mtm));
let second_wrap = view_of(DayFlipped::new(mtm));
unsafe {
split.setVertical(axis == day_spec::props::SplitAxis::Horizontal);
split.setDividerStyle(objc2_app_kit::NSSplitViewDividerStyle::Thin);
split.addArrangedSubview(&first_wrap);
split.addArrangedSubview(&second_wrap);
split.setFrame(wrap.bounds());
split.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
wrap.addSubview(&split);
}
let delegate = DaySplitDelegate::new(mtm, id, min_pane, fraction);
split.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
SPLIT_STATE.with(|m| {
m.borrow_mut().insert(
ptr_of(&wrap),
SplitState {
split,
first_wrap,
second_wrap,
delegate,
},
)
});
wrap
}
Some(Builtin::SplitPane) => {
let pane = view_of(DayNavPage::new(mtm, id));
NAV_PAGES.with(|set| set.borrow_mut().insert(ptr_of(&pane)));
let second = props
.downcast_ref::<SplitPaneProps>()
.map(|p| p.second)
.unwrap_or(false);
SPLIT_PANES.with(|t| t.insert(ptr_of(&pane), second));
pane
}
Some(Builtin::Nav) => {
let presentation = props
.downcast_ref::<NavProps>()
.map(|p| p.presentation)
.unwrap_or(NavPresentation::Split);
let is_split = presentation.is_split();
let sidebar_wrap = view_of(DayFlipped::new(mtm));
let detail_wrap = view_of(DayFlipped::new(mtm));
let split_vc = unsafe { objc2_app_kit::NSSplitViewController::new(mtm) };
let _ = unsafe { split_vc.view() };
let sidebar_vc = unsafe { objc2_app_kit::NSViewController::new(mtm) };
let detail_vc = unsafe { objc2_app_kit::NSViewController::new(mtm) };
unsafe {
sidebar_vc.setView(&pane_under_title_bar(mtm, &sidebar_wrap));
detail_vc.setView(&pane_under_title_bar(mtm, &detail_wrap));
}
let sidebar_item = unsafe {
objc2_app_kit::NSSplitViewItem::sidebarWithViewController(&sidebar_vc)
};
let detail_item = unsafe {
objc2_app_kit::NSSplitViewItem::splitViewItemWithViewController(&detail_vc)
};
let list_width = props.downcast_ref::<NavProps>().and_then(|p| p.list_width);
let list_visible = props
.downcast_ref::<NavProps>()
.is_none_or(|p| p.list_visible);
let (list_wrap, list_item) = match list_width {
Some(w) => {
let wrap = view_of(DayFlipped::new(mtm));
let vc = unsafe { objc2_app_kit::NSViewController::new(mtm) };
unsafe { vc.setView(&pane_under_title_bar(mtm, &wrap)) };
let item = unsafe {
objc2_app_kit::NSSplitViewItem::contentListWithViewController(&vc)
};
unsafe {
item.setCanCollapse(true);
item.setMinimumThickness(NAV_LIST_MIN_W.min(w));
item.setMaximumThickness(NAV_LIST_MAX_W.max(w));
item.setCollapsed(!list_visible);
}
(Some(wrap), Some(item))
}
None => (None, None),
};
unsafe {
sidebar_item.setCanCollapse(is_split);
sidebar_item.setCollapsed(!is_split);
if is_split {
sidebar_item.setAllowsFullHeightLayout(true);
sidebar_item.setMinimumThickness(NAV_SIDEBAR_MIN_W);
sidebar_item.setMaximumThickness(NAV_SIDEBAR_MAX_W);
} else {
sidebar_item.setMinimumThickness(0.0);
sidebar_item.setMaximumThickness(0.0);
}
split_vc.addSplitViewItem(&sidebar_item);
if let Some(item) = list_item.as_ref() {
split_vc.addSplitViewItem(item);
}
split_vc.addSplitViewItem(&detail_item);
}
let split = unsafe { split_vc.splitView() };
unsafe {
let after = split.nextResponder();
split.setNextResponder(Some(&split_vc));
let own_view = split_vc.view();
let vc_resp: *const objc2_app_kit::NSResponder = {
let r: &objc2_app_kit::NSResponder = &split_vc;
r
};
let view_resp: *const objc2_app_kit::NSResponder = {
let r: &objc2_app_kit::NSResponder = &own_view;
r
};
let upstream = after.filter(|a| {
let p: *const objc2_app_kit::NSResponder = &**a;
p != vc_resp && p != view_resp
});
split_vc.setNextResponder(upstream.as_deref());
}
unsafe {
split.setVertical(true);
split.setDividerStyle(objc2_app_kit::NSSplitViewDividerStyle::Thin);
split.setTranslatesAutoresizingMaskIntoConstraints(true);
}
let root_title = props
.downcast_ref::<NavProps>()
.map(|p| p.title.clone())
.unwrap_or_default();
let header = if presentation == NavPresentation::Stack {
Some(build_nav_header(mtm, id, &detail_wrap, &root_title))
} else {
None
};
let tabs = (presentation == NavPresentation::Tabs).then(|| {
let tv = unsafe { objc2_app_kit::NSTabView::new(mtm) };
let b = detail_wrap.bounds();
let frame = NSRect::new(
NSPoint::new(NAV_TABVIEW_INSET, NAV_TABVIEW_INSET_TOP),
NSSize::new(
(b.size.width - 2.0 * NAV_TABVIEW_INSET).max(0.0),
(b.size.height - NAV_TABVIEW_INSET_TOP - NAV_TABVIEW_INSET).max(0.0),
),
);
unsafe {
tv.setTabViewType(
if props
.downcast_ref::<NavProps>()
.is_some_and(|p| p.documents.is_some())
{
objc2_app_kit::NSTabViewType::NoTabsNoBorder
} else {
objc2_app_kit::NSTabViewType::TopTabsBezelBorder
},
);
tv.setFrame(frame);
tv.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
detail_wrap.addSubview(&tv);
}
TabHost {
view: tv,
target: None,
}
});
let view = view_of(split);
NAV_STATE.with(|m| {
m.borrow_mut().insert(
ptr_of(&view),
NavState {
sidebar_wrap,
detail_wrap,
_split_vc: split_vc,
sidebar_item,
list_wrap,
list_item,
pages: Vec::new(),
list_page: None,
sidebar_page: None,
list_width,
list_visible,
list_positioned: false,
positioned: false,
presentation,
selected: 0,
tabs,
header,
root_title,
node: id,
title_strip: 0.0,
},
)
});
view
}
Some(Builtin::NavPage) => {
let page = view_of(DayNavPage::new(mtm, id));
NAV_PAGES.with(|set| set.borrow_mut().insert(ptr_of(&page)));
if let Some(p) = props.downcast_ref::<day_spec::props::NavPageProps>() {
PAGE_PANE.with(|t| t.insert(ptr_of(&page), p.pane));
}
page
}
Some(Builtin::Cover) => {
let page = DayNavPage::new(mtm, id);
let cover_props = props.downcast_ref::<CoverProps>();
page.ivars()
.sheet
.set(cover_props.is_some_and(|p| p.sheet || p.half));
page.ivars().half.set(cover_props.is_some_and(|p| p.half));
unsafe { page.setHidden(true) };
view_of(page)
}
Some(Builtin::NavMenu) => {
let Some(p) = props_of::<NavMenuProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let data = DayNavMenuData::new(
mtm,
id,
&p.items,
&p.icons,
&p.badges,
&p.badge_icons,
&p.badge_tints,
&p.sections,
&p.tints,
&p.menus,
);
*data.ivars().movable.borrow_mut() = p.movable.clone();
data.ivars().regroup.set(p.regroup);
*data.ivars().sorted.borrow_mut() = p.sorted.clone();
let outline: Retained<objc2_app_kit::NSOutlineView> = {
let o: Retained<DayNavOutlineView> =
unsafe { msg_send![DayNavOutlineView::alloc(mtm), init] };
NAV_OUTLINE_MENUS
.with(|t| t.insert(Retained::as_ptr(&o) as usize, data.clone()));
unsafe { msg_send![&*o, self] }
};
let col = unsafe {
objc2_app_kit::NSTableColumn::initWithIdentifier(
objc2_app_kit::NSTableColumn::alloc(mtm),
&NSString::from_str("item"),
)
};
unsafe {
outline.addTableColumn(&col);
outline.setOutlineTableColumn(Some(&col));
outline.setHeaderView(None);
outline.setStyle(objc2_app_kit::NSTableViewStyle::SourceList);
outline.setRowSizeStyle(objc2_app_kit::NSTableViewRowSizeStyle::Default);
outline.setFloatsGroupRows(true);
outline.setIndentationPerLevel(0.0);
outline.registerForDraggedTypes(
&objc2_foundation::NSArray::from_retained_slice(&[NSString::from_str(
DAY_NAV_PASTEBOARD_TYPE,
)]),
);
outline.setDraggingSourceOperationMask_forLocal(
objc2_app_kit::NSDragOperation::Move,
true,
);
outline.setDraggingSourceOperationMask_forLocal(
objc2_app_kit::NSDragOperation::None,
false,
);
outline.setDraggingDestinationFeedbackStyle(
objc2_app_kit::NSTableViewDraggingDestinationFeedbackStyle::Gap,
);
outline.setDataSource(Some(ProtocolObject::from_ref(&*data)));
outline.setDelegate(Some(ProtocolObject::from_ref(&*data)));
outline.reloadData();
}
let scroll = unsafe { NSScrollView::new(mtm) };
unsafe {
scroll.setDrawsBackground(false);
scroll.setHasVerticalScroller(true);
scroll.setScrollerStyle(objc2_app_kit::NSScrollerStyle::Overlay);
scroll.setHasHorizontalScroller(false);
outline.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable,
);
outline.setColumnAutoresizingStyle(
objc2_app_kit::NSTableViewColumnAutoresizingStyle::UniformColumnAutoresizingStyle,
);
scroll.setDocumentView(Some(&outline));
}
let view = view_of(scroll);
if let Some(row) = p.selected.and_then(|sel| data.row_of_item(sel)) {
data.ivars().suppress.set(true);
unsafe {
outline.selectRowIndexes_byExtendingSelection(
&objc2_foundation::NSIndexSet::indexSetWithIndex(row),
false,
)
};
data.ivars().suppress.set(false);
}
NAV_MENUS.with(|m| m.borrow_mut().insert(ptr_of(&view), (outline, data)));
view
}
Some(Builtin::List) => {
let Some(p) = props_of::<ListProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let table: Retained<DayListView> =
unsafe { msg_send![DayListView::alloc(mtm), init] };
let table = Retained::into_super(table);
let col = unsafe {
NSTableColumn::initWithIdentifier(
NSTableColumn::alloc(mtm),
&NSString::from_str("day.list.col"),
)
};
let data = DayListData::new(
mtm,
id,
p.selectable,
p.multi_select,
p.row_height == RowHeight::Automatic,
p.sections,
);
unsafe {
if p.multi_select {
table.setAllowsMultipleSelection(true);
}
table.addTableColumn(&col);
table.setHeaderView(None);
table.setStyle(objc2_app_kit::NSTableViewStyle::Plain);
table.setIntercellSpacing(NSSize::new(0.0, 0.0));
if p.separators == Some(true) {
table.setGridStyleMask(
objc2_app_kit::NSTableViewGridLineStyle::SolidHorizontalGridLineMask,
);
table.setGridColor(&objc2_app_kit::NSColor::separatorColor());
}
table.setColumnAutoresizingStyle(
objc2_app_kit::NSTableViewColumnAutoresizingStyle::UniformColumnAutoresizingStyle,
);
table.setFloatsGroupRows(p.sections);
match p.row_height {
RowHeight::Uniform(h) => table.setRowHeight(h),
RowHeight::Automatic => table.setRowHeight(44.0),
}
if !p.selectable {
table.setSelectionHighlightStyle(
objc2_app_kit::NSTableViewSelectionHighlightStyle::None,
);
}
table.setBackgroundColor(&objc2_app_kit::NSColor::clearColor());
table.setDataSource(Some(ProtocolObject::from_ref(&*data)));
table.setDelegate(Some(ProtocolObject::from_ref(&*data)));
table.setTarget(Some(&*data));
if p.activatable {
table.setDoubleAction(Some(sel!(doubleClickRow:)));
}
if p.reorderable {
table.registerForDraggedTypes(
&objc2_foundation::NSArray::from_retained_slice(&[NSString::from_str(
DAY_ROW_PASTEBOARD_TYPE,
)]),
);
table.setDraggingSourceOperationMask_forLocal(
objc2_app_kit::NSDragOperation::Move,
true,
);
table.setDraggingSourceOperationMask_forLocal(
objc2_app_kit::NSDragOperation::None,
false,
);
table.setDraggingDestinationFeedbackStyle(
objc2_app_kit::NSTableViewDraggingDestinationFeedbackStyle::Gap,
);
}
}
let scroll = unsafe { NSScrollView::new(mtm) };
unsafe {
scroll.setDrawsBackground(false);
scroll.setHasVerticalScroller(true);
scroll.setScrollerStyle(objc2_app_kit::NSScrollerStyle::Overlay);
scroll.setDocumentView(Some(&table));
let clip = scroll.contentView();
clip.setPostsBoundsChangedNotifications(true);
objc2_foundation::NSNotificationCenter::defaultCenter()
.addObserver_selector_name_object(
&data,
sel!(listViewportChanged:),
Some(objc2_app_kit::NSViewBoundsDidChangeNotification),
Some(&clip),
);
}
let view = view_of(scroll);
LIST_STATE.with(|m| m.borrow_mut().insert(ptr_of(&view), (table, data)));
view
}
Some(Builtin::Tree) => {
let Some(p) = props_of::<TreeProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let outline: Retained<objc2_app_kit::NSOutlineView> = {
let o: Retained<DayOutlineView> =
unsafe { msg_send![DayOutlineView::alloc(mtm), init] };
unsafe { msg_send![&*o, self] }
};
let col = unsafe {
NSTableColumn::initWithIdentifier(
NSTableColumn::alloc(mtm),
&NSString::from_str("day.tree.col"),
)
};
let data = DayTreeData::new(mtm, id, p.selectable);
unsafe {
if p.multi_select {
outline.setAllowsMultipleSelection(true);
}
outline.addTableColumn(&col);
outline.setOutlineTableColumn(Some(&col));
outline.setHeaderView(None);
if p.section_headers {
outline.setStyle(objc2_app_kit::NSTableViewStyle::SourceList);
outline.setFloatsGroupRows(true);
} else {
outline.setStyle(objc2_app_kit::NSTableViewStyle::FullWidth);
outline.setIntercellSpacing(NSSize::new(0.0, 0.0));
}
outline.setColumnAutoresizingStyle(
objc2_app_kit::NSTableViewColumnAutoresizingStyle::UniformColumnAutoresizingStyle,
);
outline.setAutoresizesOutlineColumn(true);
if let Some(indent) = p.indent {
outline.setIndentationPerLevel(indent);
}
match p.row_height {
RowHeight::Uniform(h) => outline.setRowHeight(h),
RowHeight::Automatic if !p.section_headers => outline.setRowHeight(24.0),
RowHeight::Automatic => {}
}
if !p.selectable {
outline.setSelectionHighlightStyle(
objc2_app_kit::NSTableViewSelectionHighlightStyle::None,
);
}
outline.setBackgroundColor(&objc2_app_kit::NSColor::clearColor());
outline.setDataSource(Some(ProtocolObject::from_ref(&*data)));
outline.setDelegate(Some(ProtocolObject::from_ref(&*data)));
if p.movable {
outline.registerForDraggedTypes(
&objc2_foundation::NSArray::from_retained_slice(&[NSString::from_str(
DAY_TREE_PASTEBOARD_TYPE,
)]),
);
outline.setDraggingSourceOperationMask_forLocal(
objc2_app_kit::NSDragOperation::Move,
true,
);
outline.setDraggingSourceOperationMask_forLocal(
objc2_app_kit::NSDragOperation::None,
false,
);
}
}
let scroll = unsafe { NSScrollView::new(mtm) };
unsafe {
scroll.setDrawsBackground(false);
scroll.setHasVerticalScroller(true);
scroll.setHasHorizontalScroller(false);
scroll.setScrollerStyle(objc2_app_kit::NSScrollerStyle::Overlay);
outline.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable,
);
scroll.setDocumentView(Some(&outline));
}
let view = view_of(scroll);
TREE_STATE.with(|m| m.borrow_mut().insert(ptr_of(&view), (outline, data)));
view
}
Some(Builtin::Image) => {
let Some(p) = props_of::<ImageProps>(kind, "appkit", props) else {
return placeholder_view(mtm, kind);
};
let iv: Retained<objc2_app_kit::NSImageView> =
if p.content_mode == ContentMode::Fill {
let view: Retained<FillImageView> =
unsafe { msg_send![FillImageView::alloc(mtm), init] };
Retained::from(
<FillImageView as AsRef<objc2_app_kit::NSImageView>>::as_ref(&view),
)
} else {
unsafe { objc2_app_kit::NSImageView::new(mtm) }
};
let scaling = match p.content_mode {
ContentMode::Stretch => objc2_app_kit::NSImageScaling::ScaleAxesIndependently,
_ => objc2_app_kit::NSImageScaling::ScaleProportionallyUpOrDown,
};
unsafe { iv.setImageScaling(scaling) };
if let Some(img) = appkit_image_for(&p.source) {
if p.tint.is_some() || p.template {
unsafe { img.setTemplate(true) };
}
unsafe { iv.setImage(Some(&img)) };
}
if let Some(t) = p.tint {
unsafe { iv.setContentTintColor(Some(&nscolor(t))) };
}
IMAGE_MODES.with(|t| {
t.insert(
ptr_of(&iv),
ImageMode {
template: p.template,
tinted: p.tint.is_some(),
},
)
});
view_of(iv)
}
Some(Builtin::ListItem) => {
let Some(p) = props_of::<day_spec::props::ListItemProps>(kind, "appkit", props)
else {
return placeholder_view(mtm, kind);
};
make_list_item(p, mtm)
}
Some(Builtin::ListCell) | None => {
if let Some(make) = self.registry.get(kind).map(|r| r.make) {
return make(self, props, id);
}
warn_missing_renderer(kind);
placeholder_view(mtm, kind)
}
}
}
fn update(&mut self, h: &Handle, kind: PieceKind, patch: &dyn Any, _anim: Option<&AnimSpec>) {
match kind {
kinds::LIST_ITEM => {
let Some(p) = patch.downcast_ref::<day_spec::props::ListItemPatch>() else {
return;
};
let props = LIST_ITEMS.with(|m| {
let mut m = m.borrow_mut();
let props = m.get_mut(&ptr_of(h))?;
use day_spec::props::ListItemPatch as P;
match p {
P::Title(t) => props.title = t.clone(),
P::Subtitle(t) => props.subtitle = t.clone(),
P::Detail(t) => props.detail = t.clone(),
P::Icon(i) => props.icon = i.clone(),
P::IconTint(c) => props.icon_tint = *c,
P::Checked(on) => props.checked = *on,
}
Some(props.clone())
});
if let Some(props) = props {
dress_list_item(h, &props, self.mtm());
}
}
kinds::IMAGE => {
if let Some(p) = patch.downcast_ref::<day_spec::props::ImagePatch>() {
match p {
day_spec::props::ImagePatch::Tint(c) => {
if let Ok(iv) = h.clone().downcast::<objc2_app_kit::NSImageView>() {
let key = ptr_of(&iv);
let template = IMAGE_MODES
.with(|t| {
t.with(key, |m| {
m.tinted = c.is_some();
m.renders_template()
})
})
.unwrap_or(c.is_some());
if let Some(img) = unsafe { iv.image() } {
unsafe { img.setTemplate(template) };
}
unsafe { iv.setContentTintColor(c.map(nscolor).as_deref()) };
}
}
day_spec::props::ImagePatch::Source(source) => {
if let Ok(iv) = h.clone().downcast::<objc2_app_kit::NSImageView>() {
if let Some(img) = appkit_image_for(source) {
let template = IMAGE_MODES
.with(|t| t.get(ptr_of(&iv)))
.is_some_and(|m| m.renders_template());
unsafe {
img.setTemplate(template);
iv.setImage(Some(&img));
}
}
}
}
}
}
}
kinds::CONTAINER => {
if let (Some(ContainerPatch::Background(c)), Ok(v)) = (
patch.downcast_ref::<ContainerPatch>(),
h.clone().downcast::<DayFlipped>(),
) {
v.set_surface(*c, 0.0, false);
}
}
kinds::LABEL => {
if let (Some(p), Ok(tf)) = (
patch.downcast_ref::<LabelPatch>(),
h.clone().downcast::<NSTextField>(),
) {
match p {
LabelPatch::Text(t) => {
unsafe { tf.setStringValue(&NSString::from_str(t)) };
if let Some(label) = h.downcast_ref::<DayLabel>() {
label.ivars().has_runs.set(false);
label.set_links(false);
}
}
LabelPatch::Color(c) => unsafe {
tf.setTextColor(c.map(nscolor).as_deref())
},
LabelPatch::Font(f) => unsafe { tf.setFont(Some(&nsfont(*f))) },
LabelPatch::Runs(text, runs) => {
let base = unsafe { tf.font() };
let s = match base.as_deref() {
Some(f) => {
attributed_label(text, f, None, Default::default(), runs)
}
None => attributed_label(
text,
&nsfont(day_spec::FontSpec::default()),
None,
Default::default(),
runs,
),
};
unsafe { tf.setAttributedStringValue(&s) };
set_paragraph_alignment(&tf, unsafe { tf.alignment() });
if let Some(label) = h.downcast_ref::<DayLabel>() {
label.ivars().has_runs.set(!runs.is_empty());
label.set_links(runs.iter().any(|r| r.link.is_some()));
}
}
}
}
}
kinds::BUTTON => {
if let (Some(p), Ok(btn)) = (
patch.downcast_ref::<ButtonPatch>(),
h.clone().downcast::<NSButton>(),
) {
match p {
ButtonPatch::Content(c) => {
apply_button_content(&btn, &c.title, c.icon.as_ref(), c.icon_only)
}
ButtonPatch::Title(t) => {
let style = BUTTON_STYLES
.with(|m| m.borrow().get(&ptr_of(&btn)).copied())
.unwrap_or_default();
set_button_title(&btn, t, style);
}
ButtonPatch::Enabled(e) => unsafe { btn.setEnabled(*e) },
ButtonPatch::Selected(on) => set_button_selected(&btn, *on),
ButtonPatch::Style(s) => apply_button_style(&btn, *s),
}
}
}
kinds::TOGGLE => {
if let (Some(p), Ok(sw)) = (
patch.downcast_ref::<TogglePatch>(),
h.clone().downcast::<NSSwitch>(),
) {
match p {
TogglePatch::On(on) => {
let want = if *on { 1 } else { 0 };
if unsafe { sw.state() } != want {
unsafe { sw.setState(want) };
}
}
TogglePatch::Enabled(e) => unsafe { sw.setEnabled(*e) },
}
}
}
kinds::SLIDER => {
if let (Some(p), Ok(sl)) = (
patch.downcast_ref::<SliderPatch>(),
h.clone().downcast::<NSSlider>(),
) {
match p {
SliderPatch::Value(v) => {
if (unsafe { sl.doubleValue() } - v).abs() > 0.001 {
unsafe { sl.setDoubleValue(*v) };
}
}
SliderPatch::Enabled(e) => unsafe { sl.setEnabled(*e) },
}
}
}
kinds::PROGRESS => {
if let Some(p @ ProgressPatch::Animating(_)) = patch.downcast_ref::<ProgressPatch>()
{
crate::controls::spinner::update(self, h, p);
return;
}
if let (Some(ProgressPatch::Value(v)), Ok(pi)) = (
patch.downcast_ref::<ProgressPatch>(),
h.clone().downcast::<NSProgressIndicator>(),
) {
unsafe {
match v {
Some(val) => {
if pi.isIndeterminate() {
pi.stopAnimation(None);
pi.setIndeterminate(false);
pi.setStyle(NSProgressIndicatorStyle::Bar);
pi.setMinValue(0.0);
pi.setMaxValue(1.0);
}
if (pi.doubleValue() - val).abs() > 0.0001 {
pi.setDoubleValue(*val);
}
}
None => {
pi.setIndeterminate(true);
pi.setStyle(NSProgressIndicatorStyle::Spinning);
pi.startAnimation(None);
}
}
}
}
}
kinds::NAV_MENU => {
if let Some(NavMenuPatch::Items {
items,
icons,
badges,
badge_icons,
badge_tints,
sections,
tints,
menus,
movable,
sorted,
selected,
rows_changed,
..
}) = patch.downcast_ref::<NavMenuPatch>()
{
NAV_MENUS.with(|m| {
let m = m.borrow();
let Some((outline, data)) = m.get(&ptr_of(h)) else {
return;
};
*data.ivars().movable.borrow_mut() = movable.clone();
*data.ivars().sorted.borrow_mut() = sorted.clone();
data.set_items(
items,
icons,
badges,
badge_icons,
badge_tints,
sections,
tints,
menus,
);
data.ivars().suppress.set(true);
if let Some(rows) = rows_changed {
let set = objc2_foundation::NSMutableIndexSet::new();
for row in rows.iter().filter_map(|i| data.row_of_item(*i)) {
unsafe { set.addIndex(row) };
}
unsafe {
outline.reloadDataForRowIndexes_columnIndexes(
&set,
&objc2_foundation::NSIndexSet::indexSetWithIndex(0),
)
};
data.ivars().suppress.set(false);
return;
}
unsafe {
outline.reloadData();
match selected.and_then(|i| data.row_of_item(i)) {
Some(row) => outline.selectRowIndexes_byExtendingSelection(
&objc2_foundation::NSIndexSet::indexSetWithIndex(row),
false,
),
None => outline.deselectAll(None),
}
}
data.ivars().suppress.set(false);
});
adopt_menu_for_tabs(self.mtm(), h);
} else if let Some(NavMenuPatch::IconProgress(progress)) =
patch.downcast_ref::<NavMenuPatch>()
{
NAV_MENUS.with(|menus| {
let menus = menus.borrow();
let Some((outline, data)) = menus.get(&ptr_of(h)) else {
return;
};
let previous = data.ivars().icon_progress.replace(progress.clone());
for (row, item) in data.ivars().rows.borrow().iter().enumerate() {
let Some(index) = item else {
continue;
};
let value = progress.iter().find(|(i, _)| i == index).map(|(_, v)| *v);
if previous.iter().find(|(i, _)| i == index).map(|(_, v)| *v) == value {
continue;
}
if let Some(view) = unsafe {
outline.viewAtColumn_row_makeIfNecessary(0, row as isize, false)
} && let Some(cell) =
view.downcast_ref::<objc2_app_kit::NSTableCellView>()
&& let Some(icon) = unsafe { cell.imageView() }
{
nav_progress::update(&icon, value);
}
}
});
} else if let Some(NavMenuPatch::Selected(sel)) =
patch.downcast_ref::<NavMenuPatch>()
{
NAV_MENUS.with(|m| {
let m = m.borrow();
let Some((outline, data)) = m.get(&ptr_of(h)) else {
return;
};
data.ivars().suppress.set(true);
unsafe {
match sel.and_then(|i| data.row_of_item(i)) {
Some(row) => outline.selectRowIndexes_byExtendingSelection(
&objc2_foundation::NSIndexSet::indexSetWithIndex(row),
false,
),
None => outline.deselectAll(None),
}
}
data.ivars().suppress.set(false);
});
}
}
kinds::INSPECTOR => {
if let Some(InspectorPatch::Visible(v)) = patch.downcast_ref::<InspectorPatch>() {
INSPECTOR_STATE.with(|m| {
if let Some(state) = m.borrow().get(&ptr_of(h)) {
state.panel_item.setCollapsed(!*v);
}
});
}
}
kinds::SPLIT => {
if let Some(p) = patch.downcast_ref::<SplitPatch>() {
SPLIT_STATE.with(|m| {
let m = m.borrow();
let Some(state) = m.get(&ptr_of(h)) else {
return;
};
let iv = state.delegate.ivars();
match p {
SplitPatch::Fraction(f) => {
iv.share.set(f.clamp(0.0, 1.0));
if iv.positioned.get() {
split_place(&state.split, iv);
}
}
SplitPatch::Axis(axis) => unsafe {
state
.split
.setVertical(*axis == day_spec::props::SplitAxis::Horizontal);
state.split.adjustSubviews();
if iv.positioned.get() {
split_place(&state.split, iv);
}
},
}
});
}
}
kinds::NAV => {
if let Some(NavPatch::Presentation(next)) = patch.downcast_ref::<NavPatch>() {
nav_present(self.mtm(), h, *next);
return;
}
if let Some(p) = patch.downcast_ref::<NavPatch>() {
NAV_STATE.with(|m| {
let mut m = m.borrow_mut();
let Some(state) = m.get_mut(&ptr_of(h)) else {
return;
};
match p {
NavPatch::Pushed { title, .. } => {
let last = state.pages.len().saturating_sub(1);
state.selected = last;
for (i, page) in state.pages.iter().enumerate() {
page.setHidden(i != last);
}
if let Some(hdr) = state.header.as_mut() {
hdr.titles.push(title.clone());
sync_nav_header(hdr, &state.detail_wrap, &state.pages);
}
}
NavPatch::Popped => {
let n = state.pages.len();
if let Some(top) = state.pages.last() {
top.setHidden(true);
}
if n >= 2 {
state.pages[n - 2].setHidden(false);
}
state.selected = n.saturating_sub(2);
if let Some(hdr) = state.header.as_mut() {
if hdr.titles.len() > 1 {
hdr.titles.pop();
}
let after: Vec<Retained<NSView>> = state
.pages
.iter()
.take(n.saturating_sub(1))
.cloned()
.collect();
sync_nav_header(hdr, &state.detail_wrap, &after);
}
}
NavPatch::Title(t) => {
if let Some(hdr) = state.header.as_mut() {
if let Some(last) = hdr.titles.last_mut() {
*last = t.clone();
}
unsafe {
hdr.title.setStringValue(&NSString::from_str(t));
}
}
}
NavPatch::GuardTop(_) => {}
NavPatch::Reorder(order) => {
let old = state.pages.clone();
state.pages =
order.iter().filter_map(|i| old.get(*i).cloned()).collect();
if let Some(tabs) = &state.tabs {
TAB_PICK_SUPPRESS.with(|s| s.set(true));
unsafe {
let items = tabs.view.tabViewItems();
for item in items.iter() {
tabs.view.removeTabViewItem(&item);
}
for i in order {
if let Some(item) = items.iter().nth(*i) {
tabs.view.addTabViewItem(&item);
}
}
}
TAB_PICK_SUPPRESS.with(|s| s.set(false));
}
}
NavPatch::Presentation(_) => {}
NavPatch::Select(i) => {
state.selected = *i;
match state.tabs.as_ref() {
Some(tabs) => {
let n = unsafe { tabs.view.numberOfTabViewItems() };
if (*i as isize) < n {
TAB_PICK_SUPPRESS.with(|s| s.set(true));
unsafe {
tabs.view.selectTabViewItemAtIndex(*i as isize)
};
TAB_PICK_SUPPRESS.with(|s| s.set(false));
}
}
None => {
for (n, page) in state.pages.iter().enumerate() {
page.setHidden(n != *i);
}
}
}
}
NavPatch::ListVisible(v) => {
state.list_visible = *v;
if let Some(item) = state.list_item.as_ref() {
item.setCollapsed(!*v);
let split = unsafe { state._split_vc.splitView() };
unsafe { split.layoutSubtreeIfNeeded() };
if let Some(w) = take_list_placement(state) {
unsafe {
split.setPosition_ofDividerAtIndex(
day_spec::NAV_SIDEBAR_WIDTH + w,
1,
);
}
}
if *v
&& let Some(wrap) = state.list_wrap.as_ref()
&& let Some(page) = state.list_page.as_ref()
{
unsafe {
wrap.layoutSubtreeIfNeeded();
page.setFrame(wrap.bounds());
}
}
}
}
NavPatch::ListInStack(_) => {}
}
});
}
}
kinds::COVER => {
if let (Some(p), Ok(page)) = (
patch.downcast_ref::<CoverPatch>(),
h.clone().downcast::<DayNavPage>(),
) {
let node = page.ivars().node;
match p {
CoverPatch::Present { background, .. } => {
if page.ivars().sheet.get() {
if let Some(parent) =
primary_content().and_then(|v| unsafe { v.window() })
{
let bounds = parent.frame().size;
let height = if page.ivars().half.get() {
(bounds.height * 0.5).clamp(160.0, 620.0)
} else {
(bounds.height - 80.0).clamp(300.0, 620.0)
};
let size = NSSize::new(
(bounds.width - 40.0).clamp(320.0, 640.0),
height,
);
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
NSWindow::alloc(MainThreadMarker::new().unwrap()),
NSRect::new(NSPoint::new(0.0, 0.0), size),
NSWindowStyleMask::Titled,
NSBackingStoreType::Buffered,
false,
)
};
unsafe {
window.setReleasedWhenClosed(false);
page.removeFromSuperview();
page.setHidden(false);
page.setFrame(NSRect::new(NSPoint::new(0.0, 0.0), size));
window.setContentView(Some(&page));
}
let key = ptr_of(h);
COVER_SHEETS.with(|t| t.insert(key, CoverSheet(window)));
dispatch2::DispatchQueue::main().exec_async(move || {
ffi_guard::contain((), || {
let window =
COVER_SHEETS.with(|t| t.with(key, |s| s.0.clone()));
if let (Some(window), Some(parent)) = (
window,
primary_content()
.and_then(|v| unsafe { v.window() }),
) {
unsafe {
parent.beginSheet_completionHandler(
&window, None,
);
}
}
});
});
emit(
node,
Event::FrameChanged(Size::new(size.width, size.height)),
);
}
return;
}
unsafe {
page.setWantsLayer(true);
if let Some(layer) = page.layer() {
let color = match background {
Some(bg) => nscolor(*bg),
None => NSColor::windowBackgroundColor(),
};
layer.setBackgroundColor(Some(&color.CGColor()));
}
}
if let Some(content) = primary_content() {
if let Some(window) = unsafe { content.window() } {
toolbar::cover_presented(ptr_of(h), &window);
}
let top = title_bar_inset(&content);
let full = content.frame().size;
page.ivars().top_inset.set(top);
unsafe {
page.removeFromSuperview();
content.addSubview(&page);
page.setFrame(NSRect::new(
NSPoint::new(0.0, -top),
NSSize::new(full.width, full.height),
));
page.setBoundsOrigin(NSPoint::new(0.0, -top));
page.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
page.setHidden(false);
}
emit(
node,
Event::FrameChanged(Size::new(
full.width,
(full.height - top).max(0.0),
)),
);
}
}
CoverPatch::DismissDisabled(_) => {}
CoverPatch::Dismiss => {
COVER_SHEETS.with(|t| drop(t.remove(ptr_of(h))));
toolbar::cover_dismissed(ptr_of(h));
unsafe {
page.setHidden(true);
page.removeFromSuperview();
}
emit(node, Event::CoverHidden);
}
}
}
}
kinds::PICKER => picker::update_any(self, h, patch),
kinds::TEXT_AREA => textarea::update_any(self, h, patch),
kinds::TEXT_FIELD => {
if let (Some(p), Ok(tf)) = (
patch.downcast_ref::<TextFieldPatch>(),
h.clone().downcast::<NSTextField>(),
) {
match p {
TextFieldPatch::Text { text, from_native } => {
if !*from_native && unsafe { tf.stringValue() }.to_string() != *text {
unsafe { tf.setStringValue(&NSString::from_str(text)) };
}
}
TextFieldPatch::Placeholder(t) => unsafe {
tf.setPlaceholderString(Some(&NSString::from_str(t)))
},
TextFieldPatch::Enabled(e) => unsafe { tf.setEnabled(*e) },
}
}
}
kinds::LIST => match patch.downcast_ref::<ListPatch>() {
Some(ListPatch::Splice(deltas)) => {
if let Some((table, data)) = list_entry(ptr_of(h)) {
use objc2_app_kit::NSTableViewAnimationOptions;
for d in deltas {
unsafe {
table.beginUpdates();
match d {
day_spec::props::RowDelta::Insert(i) => table
.insertRowsAtIndexes_withAnimation(
&objc2_foundation::NSIndexSet::indexSetWithIndex(*i),
NSTableViewAnimationOptions::SlideDown,
),
day_spec::props::RowDelta::Remove(i) => table
.removeRowsAtIndexes_withAnimation(
&objc2_foundation::NSIndexSet::indexSetWithIndex(*i),
NSTableViewAnimationOptions::SlideUp,
),
day_spec::props::RowDelta::Move(from, to) => {
table.moveRowAtIndex_toIndex(*from as isize, *to as isize)
}
}
table.endUpdates();
}
}
if let Some(source) = data.ivars().source.borrow().as_ref() {
let n = (source.len)();
*data.ivars().tokens.borrow_mut() =
(0..n).map(|i| (source.token_at)(i)).collect();
}
}
post_realize_visible_rows(ptr_of(h));
}
Some(ListPatch::Reload) => {
if let Some((table, data)) = list_entry(ptr_of(h)) {
let selected = unsafe { table.selectedRowIndexes() };
let tokens: Vec<u64> = data
.ivars()
.tokens
.borrow()
.iter()
.enumerate()
.filter(|(row, _)| selected.containsIndex(*row))
.map(|(_, token)| *token)
.collect();
data.ivars().suppress.set(true);
if let Some(moves) = data.permutation_moves(&table) {
unsafe {
table.beginUpdates();
for (from, to) in moves {
table.moveRowAtIndex_toIndex(from as isize, to as isize);
}
table.endUpdates();
}
} else {
unsafe { table.reloadData() };
}
let restored = objc2_foundation::NSMutableIndexSet::new();
for (row, token) in data.ivars().tokens.borrow().iter().enumerate() {
if tokens.contains(token) {
restored.addIndex(row);
}
}
unsafe { table.selectRowIndexes_byExtendingSelection(&restored, false) };
data.ivars().suppress.set(false);
}
post_realize_visible_rows(ptr_of(h));
}
Some(ListPatch::ScrollToRow(row)) => {
let (key, row) = (ptr_of(h), *row);
<AppKit as Platform>::post(Box::new(move || {
if let Some((table, _)) = list_entry(key) {
let rows = unsafe { table.numberOfRows() };
if rows > 0 {
unsafe { table.scrollRowToVisible((row as isize).min(rows - 1)) };
}
}
}));
post_realize_visible_rows(ptr_of(h));
}
Some(ListPatch::ScrollToEnd) => {
let key = ptr_of(h);
<AppKit as Platform>::post(Box::new(move || {
if let Some((table, _)) = list_entry(key) {
let rows = unsafe { table.numberOfRows() };
if rows > 0 {
unsafe { table.scrollRowToVisible(rows - 1) };
}
}
}));
post_realize_visible_rows(ptr_of(h));
}
Some(ListPatch::Selected(rows)) => {
if let Some((table, data)) = list_entry(ptr_of(h)) {
data.ivars().suppress.set(true);
unsafe {
if rows.is_empty() {
table.deselectAll(None);
} else {
let set = objc2_foundation::NSMutableIndexSet::new();
let count = table.numberOfRows().max(0) as usize;
for r in rows.iter().copied().filter(|r| *r < count) {
set.addIndex(r);
}
table.selectRowIndexes_byExtendingSelection(&set, false);
}
}
data.ivars().suppress.set(false);
}
}
Some(ListPatch::RowSizeInvalidated { cell, height }) => {
if let Some((table, data)) = list_entry(ptr_of(h)) {
data.row_resized(&table, *cell, *height);
}
}
Some(ListPatch::Editing(_)) | None => {}
},
kinds::TREE => match patch.downcast_ref::<TreePatch>() {
Some(TreePatch::Reload) => {
if let Some((outline, _)) = tree_entry(ptr_of(h)) {
unsafe { outline.reloadData() };
}
post_realize_visible_tree_rows(ptr_of(h));
}
Some(TreePatch::Expand(token, on)) => {
let (key, token, on) = (ptr_of(h), *token, *on);
<AppKit as Platform>::post(Box::new(move || {
if let Some((outline, data)) = tree_entry(key) {
let item = data.intern(token);
data.ivars().suppress.set(true);
unsafe {
if on {
outline.expandItem(Some(&item));
} else {
outline.collapseItem(Some(&item));
}
}
data.ivars().suppress.set(false);
}
day_core::pump_events();
}));
post_realize_visible_tree_rows(ptr_of(h));
}
Some(TreePatch::Selected(tokens)) => {
if let Some((outline, data)) = tree_entry(ptr_of(h)) {
data.ivars().suppress.set(true);
unsafe {
if tokens.is_empty() {
outline.deselectAll(None);
} else {
let set = objc2_foundation::NSMutableIndexSet::new();
for t in tokens {
let row = outline.rowForItem(Some(&data.intern(*t)));
if row >= 0 {
set.addIndex(row as usize);
}
}
outline.selectRowIndexes_byExtendingSelection(&set, false);
}
}
data.ivars().suppress.set(false);
}
}
Some(TreePatch::Reveal(token)) => {
let (key, token) = (ptr_of(h), *token);
<AppKit as Platform>::post(Box::new(move || {
if let Some((outline, data)) = tree_entry(key) {
let row = unsafe { outline.rowForItem(Some(&data.intern(token))) };
if row >= 0 {
unsafe { outline.scrollRowToVisible(row) };
}
}
}));
post_realize_visible_tree_rows(ptr_of(h));
}
None => {}
},
_ => {
if let Some(update) = self.registry.get(kind).map(|r| r.update) {
update(self, h, patch);
}
}
}
}
fn release_piece(&mut self, kind: day_spec::PieceKind, h: &Self::Handle) {
let f = self.registry.get(kind).and_then(|r| r.release);
if let Some(f) = f {
f(self, h);
}
}
fn release(&mut self, h: Handle) {
LIST_ITEMS.with(|m| m.borrow_mut().remove(&ptr_of(&h)));
self.secondary.retain(|w| {
if ptr_of(&w.content) == ptr_of(&h) {
w.window.close();
day_spec::sidetable::sweep(Retained::as_ptr(&w.window) as usize);
false
} else {
true
}
});
TARGETS.with(|m| {
m.borrow_mut().remove(&ptr_of(&h));
});
LIST_STATE.with(|m| {
m.borrow_mut().remove(&ptr_of(&h));
});
TREE_STATE.with(|m| {
m.borrow_mut().remove(&ptr_of(&h));
});
GESTURES.with(|m| {
m.borrow_mut().remove(&ptr_of(&h));
});
NAV_STATE.with(|m| {
if let Some(nav) = m.borrow_mut().remove(&ptr_of(&h)) {
unsafe {
nav._split_vc.setNextResponder(None);
let split = nav._split_vc.splitView();
if split.nextResponder().is_some_and(|r| {
core::ptr::eq::<objc2::runtime::AnyObject>(
r.as_ref(),
nav._split_vc.as_ref(),
)
}) {
split.setNextResponder(None);
}
}
}
});
NAV_PAGES.with(|set| {
set.borrow_mut().remove(&ptr_of(&h));
});
SPLIT_STATE.with(|m| {
if let Some(state) = m.borrow_mut().remove(&ptr_of(&h)) {
state.split.setDelegate(None);
}
});
INSPECTOR_STATE.with(|m| {
if let Some(insp) = m.borrow_mut().remove(&ptr_of(&h)) {
unsafe {
insp._split_vc.setNextResponder(None);
let split = insp._split_vc.splitView();
if split.nextResponder().is_some_and(|r| {
core::ptr::eq::<objc2::runtime::AnyObject>(
r.as_ref(),
insp._split_vc.as_ref(),
)
}) {
split.setNextResponder(None);
}
}
}
});
NAV_MENUS.with(|m| {
if let Some((outline, _)) = m.borrow_mut().remove(&ptr_of(&h)) {
day_spec::sidetable::sweep(Retained::as_ptr(&outline) as usize);
}
});
day_spec::sidetable::sweep(ptr_of(&h));
unsafe { h.removeFromSuperview() };
}
fn insert(&mut self, parent: &Handle, child: &Handle, _index: usize) {
if NAV_MENUS.with(|m| m.borrow().contains_key(&ptr_of(child))) {
unsafe { content_of(parent).addSubview(child) };
adopt_menu_for_tabs(self.mtm(), child);
return;
}
let page_pane = PAGE_PANE.with(|t| t.get(ptr_of(child)));
let is_sidebar_page = page_pane == Some(day_spec::props::Pane::Sidebar);
let handled = NAV_STATE.with(|m| {
let mut m = m.borrow_mut();
let Some(state) = m.get_mut(&ptr_of(parent)) else {
return false;
};
if page_pane == Some(day_spec::props::Pane::List)
&& let Some(wrap) = state.list_wrap.as_ref()
{
state.list_page = Some(child.clone());
unsafe {
child.setFrame(wrap.bounds());
child.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
wrap.addSubview(child);
}
return true;
}
if is_sidebar_page {
state.sidebar_page = Some(child.clone());
}
let to_pane = is_sidebar_page && state.presentation != NavPresentation::Stack;
if !to_pane && let Some(tabs) = state.tabs.as_ref() {
state.pages.push(child.clone());
let item = unsafe { objc2_app_kit::NSTabViewItem::new() };
unsafe {
child.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
item.setView(Some(child));
tabs.view.addTabViewItem(&item);
}
if let Some((titles, _)) = state.sidebar_page.as_deref().and_then(nav_menu_rows) {
sync_tab_labels(tabs, &titles, state.selected);
}
return true;
}
let (wrap, frame) = if to_pane {
(&state.sidebar_wrap, state.sidebar_wrap.bounds())
} else {
state.pages.push(child.clone());
let visible = state.header.as_ref().is_some_and(|h| !h.bar.isHidden());
(
&state.detail_wrap,
nav_page_frame(&state.detail_wrap, visible),
)
};
unsafe {
child.setFrame(frame);
child.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
wrap.addSubview(child);
}
true
});
if handled {
return;
}
let split_pane = SPLIT_STATE.with(|m| {
let m = m.borrow();
let Some(state) = m.get(&ptr_of(parent)) else {
return false;
};
let second = SPLIT_PANES.with(|t| t.get(ptr_of(child))).unwrap_or(false);
let wrap = if second {
&state.second_wrap
} else {
&state.first_wrap
};
unsafe {
child.setFrame(wrap.bounds());
child.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
wrap.addSubview(child);
}
true
});
if split_pane {
return;
}
let inspected = INSPECTOR_STATE.with(|m| {
let m = m.borrow();
let Some(state) = m.get(&ptr_of(parent)) else {
return false;
};
let panel = INSPECTOR_PANES
.with(|t| t.get(ptr_of(child)))
.unwrap_or(false);
let wrap = if panel {
&state.panel_wrap
} else {
&state.content_wrap
};
unsafe {
child.setFrame(wrap.bounds());
child.setAutoresizingMask(
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
);
wrap.addSubview(child);
}
true
});
if !inspected {
unsafe { content_of(parent).addSubview(child) };
}
}
fn remove(&mut self, parent: &Handle, child: &Handle) {
PAGE_PANE.with(|t| t.remove(ptr_of(child)));
let mtm = self.mtm();
NAV_STATE.with(|m| {
if let Some(state) = m.borrow_mut().get_mut(&ptr_of(parent)) {
if let Some(tabs) = state.tabs.as_ref() {
unsafe {
let n = tabs.view.numberOfTabViewItems();
for i in 0..n {
let item = tabs.view.tabViewItemAtIndex(i);
if item.view(mtm).is_some_and(|v| ptr_of(&v) == ptr_of(child)) {
tabs.view.removeTabViewItem(&item);
break;
}
}
}
}
state.pages.retain(|p| ptr_of(p) != ptr_of(child));
if state.sidebar_page.as_deref().map(ptr_of) == Some(ptr_of(child)) {
state.sidebar_page = None;
}
}
});
unsafe { child.removeFromSuperview() };
}
fn move_child(&mut self, parent: &Handle, child: &Handle, _to: usize) {
unsafe { content_of(parent).addSubview(child) };
}
fn measure(&mut self, h: &Handle, kind: PieceKind, p: Proposal) -> Size {
match kind {
kinds::LABEL => {
if let Some(tf) = h.downcast_ref::<NSTextField>() {
let w = p.width.unwrap_or(1.0e6);
let s = tf.sizeThatFits(NSSize::new(w, 1.0e6));
let mut height = s.height.ceil();
if tf.maximumNumberOfLines() == 1 {
thread_local! {
static METRICS: Retained<objc2_app_kit::NSLayoutManager> = objc2_app_kit::NSLayoutManager::new();
}
if let Some(font) = unsafe { tf.font() } {
height = height.min(
METRICS
.with(|metrics| metrics.defaultLineHeightForFont(&font))
.ceil(),
);
}
}
return Size::new(s.width.ceil().min(w), height);
}
Size::ZERO
}
kinds::BUTTON | kinds::TOGGLE => {
let s = unsafe { h.fittingSize() };
Size::new(s.width.ceil(), s.height.ceil())
}
kinds::SLIDER => {
let s = unsafe { h.fittingSize() };
Size::new(p.width.unwrap_or(180.0), s.height.max(21.0).ceil())
}
kinds::PICKER => picker::measure_any(self, h, p),
kinds::TEXT_AREA => textarea::measure_any(self, h, p),
kinds::TEXT_FIELD => {
let s = unsafe { h.fittingSize() };
Size::new(
p.width.unwrap_or(s.width.max(160.0)),
s.height.max(22.0).ceil(),
)
}
kinds::DIVIDER => Size::new(p.width.unwrap_or(0.0), 5.0),
kinds::LIST_ITEM => {
let s = h.fittingSize();
Size::new(p.width.unwrap_or(s.width.ceil()), s.height.ceil().max(28.0))
}
kinds::PROGRESS => {
let indeterminate = h
.clone()
.downcast::<NSProgressIndicator>()
.map(|pi| unsafe { pi.isIndeterminate() })
.unwrap_or(false);
if indeterminate {
let large = h
.downcast_ref::<NSProgressIndicator>()
.is_some_and(|pi| pi.controlSize() == objc2_app_kit::NSControlSize::Large);
let side = if large { 32.0 } else { 20.0 };
Size::new(side, side)
} else {
Size::new(p.width.unwrap_or(180.0), 20.0)
}
}
kinds::NAV_MENU => {
let rows = NAV_MENUS.with(|m| {
m.borrow()
.get(&ptr_of(h))
.map(|(_, d)| d.ivars().items.borrow().len())
.unwrap_or(0)
});
Size::new(
p.width.unwrap_or(220.0),
p.height.unwrap_or(rows as f64 * 32.0 + 12.0),
)
}
kinds::LIST | kinds::TREE => Size::new(p.width.unwrap_or(0.0), p.height.unwrap_or(0.0)),
_ => {
if let Some(measure) = self.registry.get(kind).and_then(|r| r.measure) {
measure(self, h, p)
} else {
let s = unsafe { h.fittingSize() };
Size::new(p.width.unwrap_or(s.width), p.height.unwrap_or(s.height))
}
}
}
}
fn set_opacity(&mut self, h: &Handle, opacity: f64, anim: Option<&AnimSpec>) {
unsafe {
h.setWantsLayer(true);
}
let v = h.clone();
with_appkit_anim(anim, move || unsafe { v.setAlphaValue(opacity) });
}
fn set_transform(&mut self, h: &Handle, t: Transform, _size: Size, anim: Option<&AnimSpec>) {
TRANSFORMS.with(|m| m.insert(ptr_of(h), t));
let base = BASE_FRAMES.with(|m| m.get(ptr_of(h)));
let v = h.clone();
with_appkit_anim(anim, move || {
if let Some(base) = base {
apply_geometry(&v, base, t);
}
});
}
fn set_selectable(&mut self, h: &Handle, selectable: bool) -> Option<Handle> {
if let Some(label) = h.downcast_ref::<DayLabel>() {
label.ivars().selectable.set(selectable);
label.set_links(label.ivars().has_links.get());
} else if let Some(tf) = h.downcast_ref::<NSTextField>() {
unsafe { tf.setSelectable(selectable) };
}
None
}
fn set_input_traits(&mut self, h: &Handle, traits: &InputTraits) -> Option<Handle> {
let node = text_field_node(h)?;
let old = h.downcast_ref::<NSTextField>()?;
let swapped = (h.downcast_ref::<DaySecureTextField>().is_some() != traits.secure)
.then(|| self.swap_text_field(h, old, node, traits.secure));
let tf = swapped.as_deref().unwrap_or(old);
unsafe {
tf.setEditable(!traits.read_only);
tf.setSelectable(true);
}
let content_type = match traits.purpose {
InputPurpose::Text | InputPurpose::Number | InputPurpose::Decimal => None,
InputPurpose::Name => text_content_type(c"NSTextContentTypeName"),
InputPurpose::Email => text_content_type(c"NSTextContentTypeEmailAddress"),
InputPurpose::Url => text_content_type(c"NSTextContentTypeURL"),
InputPurpose::Phone => text_content_type(c"NSTextContentTypeTelephoneNumber"),
InputPurpose::Username => text_content_type(c"NSTextContentTypeUsername"),
InputPurpose::Password => text_content_type(c"NSTextContentTypePassword"),
InputPurpose::NewPassword => text_content_type(c"NSTextContentTypeNewPassword"),
InputPurpose::OneTimeCode => text_content_type(c"NSTextContentTypeOneTimeCode"),
};
unsafe {
tf.setContentType(content_type);
tf.setAutomaticTextCompletionEnabled(traits.purpose == InputPurpose::Text);
}
swapped.map(view_of)
}
fn set_cursor(&mut self, h: &Handle, cursor: Cursor) {
let key = Retained::as_ptr(h) as usize;
if cursor == Cursor::Default {
if let Some((owner, area)) = self.cursors.remove(&key) {
unsafe { h.removeTrackingArea(&area) };
if owner.ivars().hiding.replace(false) {
NSCursor::unhide();
}
}
return;
}
let hidden = cursor == Cursor::None;
let shape = if hidden { None } else { ns_cursor(&cursor) };
let mtm = self.mtm;
let (owner, _) = self.cursors.entry(key).or_insert_with(|| {
let owner = DayCursorOwner::new(mtm);
let opts = NSTrackingAreaOptions::CursorUpdate
| NSTrackingAreaOptions::MouseEnteredAndExited
| NSTrackingAreaOptions::ActiveInKeyWindow
| NSTrackingAreaOptions::InVisibleRect;
let any: &objc2::runtime::AnyObject = &owner;
let area = unsafe {
NSTrackingArea::initWithRect_options_owner_userInfo(
NSTrackingArea::alloc(),
NSRect::ZERO,
opts,
Some(any),
None,
)
};
unsafe { h.addTrackingArea(&area) };
(owner, area)
});
let changed = {
let iv = owner.ivars();
let same = match (iv.cursor.borrow().as_ref(), shape.as_ref()) {
(Some(a), Some(b)) => Retained::as_ptr(a) == Retained::as_ptr(b),
(None, None) => true,
_ => false,
};
*iv.cursor.borrow_mut() = shape;
let was_hidden = iv.hidden.replace(hidden);
!same || was_hidden != hidden
};
if changed && let Some(w) = h.window() {
unsafe { w.invalidateCursorRectsForView(h) };
}
}
fn first_baseline(&mut self, h: &Handle, kind: PieceKind, size: Size) -> Option<f64> {
if !day_spec::kind_has_baseline(kind) {
return None;
}
let current = unsafe { h.frame() };
if (current.size.height - size.height).abs() > 0.5 {
unsafe { h.setFrameSize(NSSize::new(current.size.width.max(size.width), size.height)) };
}
let insets = unsafe { h.alignmentRectInsets() };
let reported = unsafe { h.firstBaselineOffsetFromTop() } + insets.top;
let font = h
.downcast_ref::<NSControl>()
.and_then(|c| unsafe { c.font() });
let offset = match font {
Some(_) if kind == kinds::TEXT_AREA => reported,
Some(f) => {
let (ascender, descender) = unsafe { (f.ascender(), f.descender()) };
let line = ascender - descender;
let align_h = (size.height - insets.top - insets.bottom).max(0.0);
insets.top + ((align_h - line) / 2.0).max(0.0) + ascender
}
None => reported,
};
(offset > 0.0 && offset < size.height).then_some(offset)
}
fn set_frame(&mut self, h: &Handle, frame: Rect, _anim: Option<&AnimSpec>) {
if NAV_PAGES.with(|set| set.borrow().contains(&ptr_of(h))) {
return;
}
let r = NSRect::new(
NSPoint::new(frame.origin.x, frame.origin.y),
NSSize::new(frame.size.width, frame.size.height),
);
if let Some(split) = h.downcast_ref::<objc2_app_kit::NSSplitView>() {
let strip = unsafe { split.superview() }
.and_then(|sup| {
let content = unsafe { sup.window() }?.contentView()?;
let mut ancestor = Some(sup.clone());
while let Some(view) = ancestor {
if std::ptr::eq(&*view, &*content) {
break;
}
if view.downcast_ref::<objc2_app_kit::NSSplitView>().is_some() {
return None; }
ancestor = unsafe { view.superview() };
}
let point = unsafe {
sup.convertPoint_toView(
NSPoint::new(frame.origin.x, frame.origin.y),
Some(&content),
)
};
(point.y.abs() <= 0.5).then(|| title_bar_inset(&content))
})
.unwrap_or(0.0);
let r = NSRect::new(
NSPoint::new(r.origin.x, r.origin.y - strip),
NSSize::new(r.size.width, r.size.height + strip),
);
let wraps = NAV_STATE.with(|m| {
m.borrow_mut().get_mut(&ptr_of(h)).and_then(|s| {
(s.title_strip != strip).then(|| {
s.title_strip = strip;
let mut w = vec![s.sidebar_wrap.clone(), s.detail_wrap.clone()];
w.extend(s.list_wrap.clone());
w
})
})
});
let first = NAV_STATE.with(|m| {
m.borrow_mut()
.get_mut(&ptr_of(h))
.map(|s| {
if !s.presentation.is_split() {
return (false, None);
}
let sidebar = !std::mem::replace(&mut s.positioned, true);
(sidebar, take_list_placement(s))
})
.unwrap_or((false, None))
});
unsafe {
split.setFrame(r);
split.layoutSubtreeIfNeeded();
}
for wrap in wraps.iter().flatten() {
place_below_strip(wrap, strip);
}
unsafe {
if first.0 {
split.setPosition_ofDividerAtIndex(day_spec::NAV_SIDEBAR_WIDTH, 0);
}
if let Some(w) = first.1 {
split.setPosition_ofDividerAtIndex(day_spec::NAV_SIDEBAR_WIDTH + w, 1);
}
}
} else {
BASE_FRAMES.with(|m| {
m.insert(
ptr_of(h),
[
frame.origin.x,
frame.origin.y,
frame.size.width,
frame.size.height,
],
)
});
match TRANSFORMS.with(|m| m.get(ptr_of(h))) {
Some(t) => apply_geometry(
h,
[
frame.origin.x,
frame.origin.y,
frame.size.width,
frame.size.height,
],
t,
),
None => unsafe { h.setFrame(r) },
}
}
}
fn set_scroll_content(&mut self, h: &Handle, content: Size) {
if let Some(sv) = h.downcast_ref::<NSScrollView>()
&& let Some(doc) = unsafe { sv.documentView() }
{
unsafe { doc.setFrameSize(NSSize::new(content.width, content.height)) };
}
}
fn list_row_metrics(&mut self, _h: &Handle) -> day_spec::RowMetrics {
day_spec::RowMetrics {
insets: day_spec::Insets {
top: 4.0,
leading: 8.0,
bottom: 4.0,
trailing: 8.0,
},
min_height: 28.0,
}
}
fn scroll_to(&mut self, h: &Handle, target: Rect, _animated: bool) {
if let Some(sv) = h.downcast_ref::<NSScrollView>()
&& let Some(doc) = unsafe { sv.documentView() }
{
unsafe {
doc.scrollRectToVisible(NSRect::new(
NSPoint::new(target.origin.x, target.origin.y),
NSSize::new(target.size.width, target.size.height),
))
};
}
}
fn focus(&mut self, h: &Handle, _node: NodeId, focused: bool) {
let Some(window) = h.window() else { return };
let list = list_entry(ptr_of(h));
let responder: &NSResponder = match &list {
Some((table, _)) => table,
None => h,
};
let responder_ptr = responder as *const NSResponder as usize;
if focused {
window.makeFirstResponder(Some(responder));
return;
}
let owns = window
.firstResponder()
.is_some_and(|fr| Retained::as_ptr(&fr) as usize == responder_ptr)
|| owns_first_responder(&window, h);
if owns {
window.makeFirstResponder(None);
}
}
fn set_focusable(&mut self, h: &Handle, node: NodeId, focusable: bool) {
if focusable {
FOCUSABLE_NODES.with(|t| t.insert(ptr_of(h), node));
} else {
FOCUSABLE_NODES.with(|t| t.remove(ptr_of(h)));
}
}
fn set_event_sink(&mut self, sink: EventSink) {
SINK.with(|s| *s.borrow_mut() = Some(Rc::from(sink)));
}
fn set_edit_state(&mut self, state: &day_spec::EditState) {
EDIT_STATE.with(|s| s.set(*state));
}
fn modifiers(&mut self) -> day_spec::Modifiers {
let f = NSEvent::modifierFlags_class();
day_spec::Modifiers {
shift: f.contains(NSEventModifierFlags::Shift),
primary: f.contains(NSEventModifierFlags::Command),
alt: f.contains(NSEventModifierFlags::Option),
}
}
fn set_undo_state(&mut self, state: &day_spec::UndoState) {
let front = undo_front(self.mtm);
*front.ivars().state.borrow_mut() = state.clone();
}
fn attach_list(&mut self, host: &Handle, source: ListSource) {
let key = ptr_of(host);
if let Some((table, data)) = list_entry(key) {
data.ivars().source.replace(Some(source));
unsafe { table.reloadData() };
}
post_realize_visible_rows(key);
}
fn attach_tree(&mut self, host: &Handle, source: TreeSource) {
let key = ptr_of(host);
if let Some((outline, data)) = tree_entry(key) {
data.ivars().source.replace(Some(source));
unsafe { outline.reloadData() };
}
<AppKit as Platform>::post(Box::new(move || {
if let Some((outline, _)) = tree_entry(key) {
unsafe { outline.layoutSubtreeIfNeeded() };
}
}));
}
fn adopt(&mut self, raw: RawHandle) -> Handle {
let ptr = raw as *mut NSView;
unsafe { Retained::retain(ptr) }.expect("adopt: null list cell handle")
}
fn edit_toolbar(&mut self, h: &Handle, ops: &[day_spec::ToolbarOp]) -> bool {
self.edit_toolbar(h, ops)
}
fn update_toolbar(&mut self, h: &Handle, patch: &day_spec::ToolbarPatch) {
self.patch_toolbar(h, patch);
}
fn set_app_menu(&mut self, items: &[day_spec::MenuItem]) {
let mtm = self.mtm;
if self.menu_escape_monitor.is_none() {
let block = block2::RcBlock::new(move |event: std::ptr::NonNull<NSEvent>| {
ffi_guard::contain(event.as_ptr(), || {
let event_ref = unsafe { event.as_ref() };
let app = NSApplication::sharedApplication(mtm);
if app.modalWindow().is_none()
&& app.keyWindow().is_some_and(|w| w.attachedSheet().is_none())
&& unsafe { event_ref.charactersIgnoringModifiers() }
.is_some_and(|s| s.to_string() == "\u{1b}")
&& app
.mainMenu()
.is_some_and(|menu| menu.performKeyEquivalent(event_ref))
{
std::ptr::null_mut()
} else {
event.as_ptr()
}
})
});
self.menu_escape_monitor = unsafe {
NSEvent::addLocalMonitorForEventsMatchingMask_handler(
objc2_app_kit::NSEventMask::KeyDown,
&block,
)
}
.map(MenuEscapeMonitor);
}
let app = NSApplication::sharedApplication(mtm);
let menubar = NSMenu::new(mtm);
let mut items = items.to_vec();
let about = extract_app_role(&mut items, day_spec::MenuRole::About);
let updates = extract_app_role(&mut items, day_spec::MenuRole::CheckForUpdates);
let prefs = extract_app_role(&mut items, day_spec::MenuRole::Preferences);
let quit = extract_app_role(&mut items, day_spec::MenuRole::Quit);
let services =
install_application_menu(mtm, &menubar, &self.app_name, about, updates, prefs, quit);
let items =
day_core::menu::standard_menu_bar_for(day_core::menu::MenuBarStyle::Macos, items);
let mut help_menu: Option<(String, Retained<NSMenu>)> = None;
for item in &items {
match item {
day_spec::MenuItem::Submenu { label, items, role } => {
let sub = build_ns_menu(mtm, label, items);
let it = NSMenuItem::new(mtm);
if *role == Some(day_spec::MenuBarRole::Help)
|| *label == day_localization::t(day_localization::key::day_help)
{
help_menu = Some((label.clone(), sub));
continue;
}
it.setTitle(&NSString::from_str(label));
it.setSubmenu(Some(&sub));
menubar.addItem(&it);
}
other => {
let sub = build_ns_menu(mtm, "", std::slice::from_ref(other));
let it = NSMenuItem::new(mtm);
it.setSubmenu(Some(&sub));
menubar.addItem(&it);
}
}
}
if !model_has_role(&items, day_spec::MenuRole::Minimize) {
install_windows_menu(mtm, &app, &menubar);
}
if let Some((label, help)) = help_menu {
let it = NSMenuItem::new(mtm);
it.setTitle(&NSString::from_str(&label));
it.setSubmenu(Some(&help));
menubar.addItem(&it);
unsafe { app.setHelpMenu(Some(&help)) };
}
app.setMainMenu(Some(&menubar));
unsafe { app.setServicesMenu(Some(&services)) };
}
fn set_context_menu(&mut self, h: &Handle, _node: NodeId, items: &[day_spec::MenuItem]) {
let Some(view) = h.downcast_ref::<NSView>() else {
return;
};
if items.is_empty() {
unsafe { view.setMenu(None) };
return;
}
let menu = build_ns_menu(self.mtm, "", items);
unsafe { view.setMenu(Some(&menu)) };
fn apply_to_subviews(v: &NSView, menu: &NSMenu) {
for sub in v.subviews() {
unsafe { sub.setMenu(Some(menu)) };
apply_to_subviews(&sub, menu);
}
}
apply_to_subviews(view, &menu);
}
fn set_drag_source(&mut self, h: &Handle, source: day_spec::transfer::Source) {
transfer::source(h, source);
}
fn set_drop_target(&mut self, h: &Handle, target: day_spec::transfer::Target) {
transfer::target(h, target);
}
fn set_context_menu_fn(&mut self, h: &Handle, _node: NodeId, f: day_spec::ContextMenuFn) {
CTX_MENU_FNS.with(|t| t.insert(ptr_of(h), f));
}
fn enable_gesture(&mut self, h: &Handle, node: NodeId, kind: day_spec::GestureKind) {
let key = ptr_of(h);
if kind == day_spec::GestureKind::Pan {
PAN_NODES.with(|t| t.insert(key, node));
install_container_pan_monitor();
return;
}
if kind == day_spec::GestureKind::Hover {
let first = HOVER_NODES.with(|t| t.get(key)).is_none();
HOVER_NODES.with(|t| t.insert(key, node));
if first {
install_tracking_area(h);
}
return;
}
let already = GESTURES.with(|m| {
m.borrow()
.get(&key)
.is_some_and(|v| v.iter().any(|t| t.ivars().kind == kind))
});
if already {
return;
}
let mtm = self.mtm;
let target = DayGesture::new(mtm, node, kind);
let recognizer: Retained<NSGestureRecognizer> = unsafe {
match kind {
day_spec::GestureKind::Drag => {
Retained::into_super(NSPanGestureRecognizer::initWithTarget_action(
NSPanGestureRecognizer::alloc(mtm),
Some(&target),
Some(sel!(fire:)),
))
}
day_spec::GestureKind::Pinch => Retained::into_super(
objc2_app_kit::NSMagnificationGestureRecognizer::initWithTarget_action(
objc2_app_kit::NSMagnificationGestureRecognizer::alloc(mtm),
Some(&target),
Some(sel!(fire:)),
),
),
_ => Retained::into_super(NSClickGestureRecognizer::initWithTarget_action(
NSClickGestureRecognizer::alloc(mtm),
Some(&target),
Some(sel!(fire:)),
)),
}
};
unsafe { h.addGestureRecognizer(&recognizer) };
GESTURES.with(|m| m.borrow_mut().entry(key).or_default().push(target));
}
fn set_a11y(&mut self, h: &Handle, a11y: &A11yProps) {
unsafe {
if let Some(id) = &a11y.identifier {
h.setAccessibilityIdentifier(Some(&NSString::from_str(id)));
}
if let Some(label) = &a11y.label {
h.setAccessibilityLabel(Some(&NSString::from_str(label)));
APP_LABELED.with(|t| t.insert(ptr_of(h), ()));
} else {
APP_LABELED.with(|t| t.remove(ptr_of(h)));
}
if let Some(hint) = &a11y.hint {
h.setAccessibilityHelp(Some(&NSString::from_str(hint)));
}
if let Some(value) = &a11y.value {
let ns = NSString::from_str(value);
h.setAccessibilityValue(Some(ns.as_ref() as &objc2::runtime::AnyObject));
}
if let Some(role) = ns_role(a11y.role) {
h.setAccessibilityRole(Some(role));
}
if a11y.role == day_spec::Role::Tab {
h.setAccessibilitySubrole(Some(objc2_app_kit::NSAccessibilityTabButtonSubrole));
}
if let Some(on) = a11y.selected {
h.setAccessibilitySelected(on);
}
if a11y.hidden {
h.setAccessibilityElement(false);
h.setAccessibilityChildren(Some(&NSArray::new()));
}
}
}
fn announce(&mut self, text: &str, urgent: bool) {
use objc2_app_kit::{
NSAccessibilityAnnouncementKey, NSAccessibilityAnnouncementRequestedNotification,
NSAccessibilityPostNotificationWithUserInfo, NSAccessibilityPriorityKey,
NSAccessibilityPriorityLevel,
};
let app = NSApplication::sharedApplication(self.mtm());
let window = primary_content().and_then(|content| content.window());
let element: &objc2::runtime::AnyObject = match &window {
Some(window) => window,
None => &app,
};
let priority = if urgent {
NSAccessibilityPriorityLevel::High
} else {
NSAccessibilityPriorityLevel::Medium
};
let announcement = NSString::from_str(text);
let priority = NSNumber::new_isize(priority.0);
let values: [&objc2::runtime::AnyObject; 2] = [&announcement, &priority];
unsafe {
let user_info = NSDictionary::from_slices::<NSString>(
&[NSAccessibilityAnnouncementKey, NSAccessibilityPriorityKey],
&values,
);
NSAccessibilityPostNotificationWithUserInfo(
element,
NSAccessibilityAnnouncementRequestedNotification,
Some(&user_info),
);
}
}
fn read_native(&self, h: &Handle) -> day_spec::NativeSnapshot {
unsafe {
let role = h
.accessibilityRole()
.map(|r| day_role_from_ns(&r.to_string()))
.unwrap_or(day_spec::Role::None);
let picker = picker::read_native(h);
day_spec::NativeSnapshot {
found: true,
role,
label: h.accessibilityLabel().map(|s| s.to_string()),
value: h
.accessibilityValue()
.and_then(|v| v.downcast_ref::<NSString>().map(|s| s.to_string())),
identifier: h
.accessibilityIdentifier()
.map(|s| s.to_string())
.filter(|s| !s.is_empty()),
text: match &picker {
Some((text, _)) => text.clone(),
None => native_text(h),
},
number: native_number(h),
checked: if let Some(sw) = h.downcast_ref::<NSSwitch>() {
match sw.state() {
s if s == NSControlStateValueOn => Some(true),
s if s == NSControlStateValueOff => Some(false),
_ => None,
}
} else if let Some(btn) = h.downcast_ref::<NSButton>() {
BUTTON_SELECTED
.with(|m| m.borrow().contains_key(&ptr_of(h)))
.then(|| btn.state() == NSControlStateValueOn)
} else {
None
},
enabled: match (&picker, h.downcast_ref::<NSControl>()) {
(Some((_, on)), _) => *on,
(None, Some(_)) if h.downcast_ref::<DayLabel>().is_some() => None,
(None, Some(c)) => Some(c.isEnabled()),
(None, None) => None,
},
visible: Some(h.window().is_some() && !h.isHiddenOrHasHiddenAncestor()),
frame: native_frame(h),
}
}
}
fn request_frame(
&mut self,
host: &Handle,
cb: day_spec::FrameCallback,
) -> day_spec::CancelFrame {
frame::request(host, cb)
}
fn replay(&mut self, h: &Handle, ops: &[DrawOp], _size: Size) {
OPS.with(|t| t.insert(ptr_of(h), ops.to_vec()));
unsafe { h.setNeedsDisplay(true) };
}
fn toggle_sidebar(&mut self, host: &Self::Handle) -> bool {
NAV_STATE.with(|m| {
let m = m.borrow();
let Some(st) = m.get(&ptr_of(host)) else {
return false;
};
if !matches!(
st.presentation,
NavPresentation::Split | NavPresentation::Rail
) {
return false;
}
let item = &st.sidebar_item;
let collapsed = unsafe { item.isCollapsed() };
unsafe { item.setCollapsed(!collapsed) };
true
})
}
fn snapshot_window(&mut self) -> Result<Vec<u8>, String> {
let content = self.content.as_ref().ok_or("no window content")?;
snapshot_view(content)
}
fn decode_image(&mut self, req: u64, id: day_spec::BitmapId, bytes: &[u8]) {
use objc2::AllocAnyThread as _;
let data = NSData::with_bytes(bytes);
let Some(rep) = (unsafe { objc2_app_kit::NSBitmapImageRep::imageRepWithData(&data) })
else {
emit(
WINDOW_NODE,
Event::ImageDecoded {
req,
result: Err(day_spec::ImageError::Decode),
},
);
return;
};
let (w, h) = unsafe { (rep.pixelsWide() as f64, rep.pixelsHigh() as f64) };
let info = day_spec::BitmapInfo {
pixels: Size::new(w, h),
scale: 1.0,
format: day_spec::ImageFormat::sniff(bytes),
has_alpha: unsafe { rep.hasAlpha() },
};
let image = unsafe {
objc2_app_kit::NSImage::initWithSize(objc2_app_kit::NSImage::alloc(), NSSize::new(w, h))
};
unsafe { image.addRepresentation(&rep) };
BITMAPS.with(|m| {
m.borrow_mut().insert(
id.0,
AppKitBitmap {
image,
rep: Some(rep),
info,
},
)
});
emit(
WINDOW_NODE,
Event::ImageDecoded {
req,
result: Ok(info),
},
);
}
fn image_info(&mut self, id: day_spec::BitmapId) -> Option<day_spec::BitmapInfo> {
BITMAPS.with(|m| m.borrow().get(&id.0).map(|b| b.info))
}
fn image_properties(&mut self, id: day_spec::BitmapId) -> Option<day_spec::ImageProperties> {
let rep = BITMAPS.with(|m| m.borrow().get(&id.0).and_then(|b| b.rep.clone()))?;
let mut out = day_spec::ImageProperties::default();
let size = unsafe { rep.size() };
if size.width > 0.0 {
let dpi = unsafe { rep.pixelsWide() } as f64 / size.width * 72.0;
if (dpi - 72.0).abs() > 0.5 {
out.dpi = Some(dpi);
}
}
if let Some(exif) = rep.valueForProperty(unsafe { objc2_app_kit::NSImageEXIFData }) {
let keys: Retained<NSArray<NSString>> = unsafe { msg_send![&*exif, allKeys] };
for key in keys.iter() {
let value: Option<Retained<objc2::runtime::AnyObject>> =
unsafe { msg_send![&*exif, objectForKey: &*key] };
let Some(value) = value else { continue };
let text: Retained<NSString> = unsafe { msg_send![&*value, description] };
let (k, v) = (key.to_string(), text.to_string());
match k.as_str() {
"Orientation" => out.orientation = v.parse().ok(),
"DateTimeOriginal" => out.created = Some(v.clone()),
_ => {}
}
out.extra.push((k, v));
}
}
Some(out)
}
fn encode_formats(&mut self) -> Vec<day_spec::ImageFormat> {
use day_spec::ImageFormat::{Bmp, Gif, Jpeg, Png, Tiff};
vec![Png, Jpeg, Tiff, Bmp, Gif]
}
fn encode_image(&mut self, req: u64, id: day_spec::BitmapId, spec: &day_spec::EncodeSpec) {
let result = encode_bitmap(id, spec);
emit(WINDOW_NODE, Event::ImageEncoded { req, result });
}
fn release_image(&mut self, id: day_spec::BitmapId) {
release_bitmap(id);
}
fn render_bitmap(
&mut self,
req: u64,
id: day_spec::BitmapId,
ops: &[DrawOp],
size: Size,
scale: f64,
) {
let result = render_bitmap_image(ops, size, scale).map(|bitmap| {
let info = bitmap.info;
BITMAPS.with(|m| m.borrow_mut().insert(id.0, bitmap));
info
});
emit(WINDOW_NODE, Event::BitmapRendered { req, result });
}
fn render_pdf(&mut self, req: u64, pages: &[day_spec::PdfPage], info: &day_spec::PdfInfo) {
let result = render_pdf_bytes(pages, info);
emit(WINDOW_NODE, Event::PdfRendered { req, result });
}
fn font_families(&mut self) -> Vec<day_spec::FontFamilyInfo> {
use day_spec::FontWeight as W;
let mtm =
objc2::MainThreadMarker::new().expect("day-toolkit-appkit runs on the main thread");
let manager = unsafe { objc2_app_kit::NSFontManager::sharedFontManager(mtm) };
let mut out = Vec::new();
for family in unsafe { manager.availableFontFamilies() }.iter() {
let name = family.to_string();
if name.starts_with('.') {
continue;
}
let mut faces = Vec::new();
if let Some(members) = unsafe { manager.availableMembersOfFontFamily(&family) } {
for member in members.iter() {
if member.count() < 4 {
continue;
}
let style = member
.objectAtIndex(1)
.downcast_ref::<NSString>()
.map(|s| s.to_string())
.unwrap_or_default();
let weight = member
.objectAtIndex(2)
.downcast_ref::<objc2_foundation::NSNumber>()
.map(|n| n.integerValue())
.unwrap_or(5);
let traits = member
.objectAtIndex(3)
.downcast_ref::<objc2_foundation::NSNumber>()
.map(|n| n.integerValue())
.unwrap_or(0);
let weight = match weight {
..=2 => W::UltraLight,
3 => W::Thin,
4 => W::Light,
5 => W::Regular,
6 => W::Medium,
7 | 8 => W::Semibold,
9 => W::Bold,
10 | 11 => W::Heavy,
_ => W::Black,
};
let italic = traits & 0x1 != 0; faces.push(day_spec::FontFace {
name: if style.is_empty() {
day_spec::FontFace::synthesized_name(weight, italic)
} else {
style
},
weight,
italic,
});
}
}
out.push(day_spec::FontFamilyInfo {
family: name,
faces,
});
}
out
}
fn measure_text(
&mut self,
text: &str,
size: f64,
font: &day_spec::CanvasFont,
) -> Option<day_spec::TextMetrics> {
let nsfont = canvas_nsfont(size, font);
let attrs = canvas_text_attrs(&nsfont, day_spec::Color::BLACK);
let ns = NSString::from_str(text);
let sz: NSSize = unsafe { msg_send![&ns, sizeWithAttributes: &*attrs] };
let unbounded = NSSize::new(f64::MAX, f64::MAX);
let ink: NSRect = unsafe {
msg_send![
&ns,
boundingRectWithSize: unbounded,
options: objc2_app_kit::NSStringDrawingOptions::UsesDeviceMetrics,
attributes: &*attrs,
]
};
Some(day_spec::TextMetrics::from_baseline(
sz.width,
nsfont.ascender(),
sz.height - nsfont.ascender(),
nsfont.capHeight(),
day_spec::Rect::new(
ink.origin.x,
-(ink.origin.y + ink.size.height),
ink.size.width,
ink.size.height,
),
))
}
fn snapshot_window_chrome(&mut self) -> Result<Vec<u8>, String> {
let content = self.content.as_ref().ok_or("no window content")?;
snapshot_view_chrome(content)
}
fn snapshot_window_of(&mut self, host: &Handle) -> Result<Vec<u8>, String> {
snapshot_view(host)
}
fn open_window(
&mut self,
id: NodeId,
options: &WindowOptions,
kind: day_spec::WindowKind,
) -> day_spec::WindowOpenReply<Handle> {
let prefs = kind.is_panel();
let (window, delegate, content) = self.make_window(options, prefs, Some(id));
if prefs {
window.center();
unsafe { window.setExcludedFromWindowsMenu(true) };
} else {
let from = match self.cascade {
Some(p) => p,
None => self
.window
.as_ref()
.map(|w| {
let f = w.frame();
let top_left = NSPoint::new(f.origin.x, f.origin.y + f.size.height);
unsafe { w.cascadeTopLeftFromPoint(top_left) }
})
.unwrap_or(NSPoint::new(0.0, 0.0)),
};
self.cascade = Some(unsafe { window.cascadeTopLeftFromPoint(from) });
}
window_chrome::place(&window, options.placement);
let _ = pin_below_title_bar(&window);
if !options.start_hidden {
window.makeKeyAndOrderFront(None);
}
mark_tree_needs_display(&content);
self.secondary.push(SecondaryWin {
window,
delegate,
content: content.clone(),
});
day_spec::WindowOpenReply::Open(content)
}
fn native_window_tabs(&self) -> bool {
true
}
fn group_windows(&mut self, hosts: &[Handle]) {
let windows: Vec<_> = hosts.iter().filter_map(|h| self.ns_window_for(h)).collect();
let Some(first) = windows.first() else {
return;
};
unsafe {
NSWindow::setAllowsAutomaticWindowTabbing(true, self.mtm);
}
for window in windows.iter().skip(1) {
unsafe {
first.addTabbedWindow_ordered(window, objc2_app_kit::NSWindowOrderingMode::Above);
}
}
if let Some(group) = first.tabGroup() {
if !group.isTabBarVisible() {
unsafe {
first.toggleTabBar(None);
}
}
for (i, window) in windows.iter().enumerate() {
group.insertWindow_atIndex(window, i as isize);
}
}
for window in &windows {
if let Some(size) = pin_below_title_bar(window) {
let node = window
.delegate()
.and_then(|d| d.downcast::<DayWinDelegate>().ok())
.and_then(|d| d.ivars().node)
.unwrap_or(WINDOW_NODE);
emit(node, Event::WindowResized(size));
}
}
}
fn window_tab_order(&self, host: &Handle) -> Vec<NodeId> {
let Some(window) = self.ns_window_for(host) else {
return Vec::new();
};
let windows = window.tabGroup().map(|g| g.windows());
let Some(windows) = windows else {
return Vec::new();
};
windows
.iter()
.filter_map(|window| {
self.secondary
.iter()
.find(|w| std::ptr::eq(&*w.window, &*window))
.and_then(|w| w.delegate.ivars().node)
})
.collect()
}
fn close_window(&mut self, host: &Handle) {
if let Some(w) = self
.secondary
.iter()
.find(|w| ptr_of(&w.content) == ptr_of(host))
{
w.window.close();
}
}
fn focus_window(&mut self, host: &Handle) {
if let Some(w) = self
.secondary
.iter()
.find(|w| ptr_of(&w.content) == ptr_of(host))
{
w.window.makeKeyAndOrderFront(None);
}
}
fn fit_window(&mut self, host: &Handle, size: Size) {
let Some(w) = self
.secondary
.iter()
.find(|w| ptr_of(&w.content) == ptr_of(host))
else {
return;
};
unsafe {
w.window
.setContentSize(NSSize::new(size.width, size.height));
let mask = w.window.styleMask();
w.window
.setStyleMask(mask & !objc2_app_kit::NSWindowStyleMask::Resizable);
w.window.center();
}
}
fn set_window_title(&mut self, host: &Handle, title: &str) {
if let Some(w) = self
.secondary
.iter()
.find(|w| ptr_of(&w.content) == ptr_of(host))
{
w.window.setTitle(&NSString::from_str(title));
return;
}
if self
.content
.as_ref()
.is_some_and(|c| ptr_of(c) == ptr_of(host))
&& let Some(w) = self.window.as_ref()
{
w.setTitle(&NSString::from_str(title));
}
}
fn apply_window(&mut self, host: &Handle, change: &day_spec::WindowChange) {
use day_spec::WindowChange as C;
let Some(window) = self.ns_window_for(host) else {
return;
};
match change {
C::State(state) => set_ns_window_state(&window, *state),
C::ContentProtected(on) => window.setSharingType(if *on {
objc2_app_kit::NSWindowSharingType::None
} else {
objc2_app_kit::NSWindowSharingType::ReadOnly
}),
other => window_chrome::apply_property(&window, other),
}
}
fn window_frame(&self, host: &Handle) -> Option<day_spec::Rect> {
self.ns_window_for(host)
.map(|w| window_chrome::frame_of(&w))
}
fn monitors(&self) -> Vec<day_spec::Monitor> {
window_chrome::monitors(self.mtm)
}
fn set_launcher_shortcuts(&mut self, _shortcuts: &[day_spec::LauncherShortcut]) {}
fn set_dock_menu(&mut self, items: &[day_spec::MenuItem]) {
dock::set_app_items(self.mtm, items);
}
fn set_drag_region(&mut self, h: &Handle, drag: bool) {
window_chrome::set_drag_region(h, drag);
}
fn set_status_items(&mut self, items: &[day_spec::StatusItemSpec]) {
status_item::set_items(self.mtm, items);
}
fn set_app_progress(&mut self, progress: day_spec::AppProgress) {
status_item::set_progress(self.mtm, progress);
}
fn set_dock_visible(&mut self, visible: bool) {
status_item::set_dock_visible(self.mtm, visible);
}
fn set_keep_running(&mut self, keep: bool) {
status_item::set_keep_running(keep);
}
fn quit_app(&mut self) {
let app = NSApplication::sharedApplication(self.mtm);
unsafe { app.terminate(None) };
}
fn present(&mut self, req: u64, spec: &present::PresentSpec) {
use present::PresentSpec;
let mtm = self.mtm;
let app = NSApplication::sharedApplication(mtm);
let key = app.keyWindow().filter(|k| {
let mut owner = k.clone();
while let Some(parent) = owner.sheetParent() {
owner = parent;
}
self.window
.as_deref()
.is_some_and(|w| std::ptr::eq(w, &*owner))
|| self
.secondary
.iter()
.any(|s| std::ptr::eq(&*s.window, &*owner))
});
let Some(mut window) = key.or_else(|| self.window.clone()) else {
emit(
WINDOW_NODE,
Event::PresentResult {
req,
result: present::PresentResult::Dismissed,
},
);
return;
};
while let Some(sheet) = window.attachedSheet() {
window = sheet;
}
match spec {
PresentSpec::OpenFile {
filters, multiple, ..
} => {
let panel = unsafe { objc2_app_kit::NSOpenPanel::openPanel(mtm) };
unsafe {
panel.setCanChooseFiles(true);
panel.setCanChooseDirectories(false);
panel.setAllowsMultipleSelection(*multiple);
panel.setMessage(Some(&NSString::from_str(spec.title())));
}
apply_allowed_file_types(&panel, filters);
let p = panel.clone();
let handler: block2::RcBlock<dyn Fn(isize)> =
block2::RcBlock::new(move |resp: isize| {
ffi_guard::contain((), || emit_open_panel_result(req, resp, &p));
});
unsafe { panel.beginSheetModalForWindow_completionHandler(&window, &handler) };
PRESENT_PANELS.with(|m| m.borrow_mut().insert(req, Retained::into_super(panel)));
return;
}
PresentSpec::OpenFolder { .. } => {
let panel = unsafe { objc2_app_kit::NSOpenPanel::openPanel(mtm) };
unsafe {
panel.setCanChooseFiles(false);
panel.setCanChooseDirectories(true);
panel.setCanCreateDirectories(true);
panel.setAllowsMultipleSelection(false);
panel.setMessage(Some(&NSString::from_str(spec.title())));
}
let p = panel.clone();
let handler: block2::RcBlock<dyn Fn(isize)> =
block2::RcBlock::new(move |resp: isize| {
ffi_guard::contain((), || emit_open_panel_result(req, resp, &p));
});
unsafe { panel.beginSheetModalForWindow_completionHandler(&window, &handler) };
PRESENT_PANELS.with(|m| m.borrow_mut().insert(req, Retained::into_super(panel)));
return;
}
PresentSpec::SaveFile { suggested_name, .. } => {
let panel = unsafe { objc2_app_kit::NSSavePanel::savePanel(mtm) };
unsafe {
panel.setMessage(Some(&NSString::from_str(spec.title())));
panel.setNameFieldStringValue(&NSString::from_str(suggested_name));
}
apply_allowed_file_types(&panel, spec.filters());
let p = panel.clone();
let handler: block2::RcBlock<dyn Fn(isize)> =
block2::RcBlock::new(move |resp: isize| {
ffi_guard::contain((), || emit_panel_result(req, resp, &p));
});
unsafe { panel.beginSheetModalForWindow_completionHandler(&window, &handler) };
PRESENT_PANELS.with(|m| m.borrow_mut().insert(req, panel));
return;
}
_ => {}
}
let alert = unsafe { objc2_app_kit::NSAlert::new(mtm) };
unsafe { alert.setMessageText(&NSString::from_str(spec.title())) };
if let Some(msg) = spec.message() {
unsafe { alert.setInformativeText(&NSString::from_str(msg)) };
}
let handler: block2::RcBlock<dyn Fn(isize)> = match spec {
PresentSpec::Dialog { buttons, .. } => {
if buttons
.iter()
.any(|b| b.role == present::ButtonRole::Destructive)
{
unsafe { alert.setAlertStyle(objc2_app_kit::NSAlertStyle::Warning) };
}
for b in buttons {
unsafe { alert.addButtonWithTitle(&NSString::from_str(&b.label)) };
}
block2::RcBlock::new(move |resp: isize| {
ffi_guard::contain((), || {
let idx = resp - 1000;
emit(
WINDOW_NODE,
Event::PresentResult {
req,
result: present::PresentResult::Button(idx as i64),
},
);
PRESENT_ALERTS.with(|m| {
m.borrow_mut().remove(&req);
});
})
})
}
PresentSpec::Prompt {
placeholder,
initial,
ok,
cancel,
..
} => {
let tf = unsafe { NSTextField::new(mtm) };
unsafe {
tf.setFrame(NSRect::new(
NSPoint::new(0.0, 0.0),
NSSize::new(260.0, 24.0),
));
tf.setEditable(true);
tf.setBezeled(true);
tf.setStringValue(&NSString::from_str(initial));
tf.setPlaceholderString(Some(&NSString::from_str(placeholder)));
alert.setAccessoryView(Some(&tf));
alert.addButtonWithTitle(&NSString::from_str(ok)); alert.addButtonWithTitle(&NSString::from_str(cancel)); }
block2::RcBlock::new(move |resp: isize| {
ffi_guard::contain((), || {
let result = if resp == 1000 {
present::PresentResult::Text(unsafe { tf.stringValue() }.to_string())
} else {
present::PresentResult::Dismissed
};
emit(WINDOW_NODE, Event::PresentResult { req, result });
PRESENT_ALERTS.with(|m| {
m.borrow_mut().remove(&req);
});
})
})
}
PresentSpec::OpenFile { .. }
| PresentSpec::OpenFolder { .. }
| PresentSpec::SaveFile { .. } => unreachable!(),
};
unsafe {
alert.beginSheetModalForWindow_completionHandler(&window, Some(&handler));
}
PRESENT_ALERTS.with(|m| m.borrow_mut().insert(req, alert));
}
fn dismiss(&mut self, req: u64) {
let alert = PRESENT_ALERTS.with(|m| m.borrow_mut().remove(&req));
if let Some(alert) = alert {
let sheet = alert.window();
if let Some(parent) = sheet.sheetParent() {
unsafe { parent.endSheet(&sheet) };
}
}
let panel = PRESENT_PANELS.with(|m| m.borrow_mut().remove(&req));
if let Some(panel) = panel
&& let Some(parent) = panel.sheetParent()
{
unsafe { parent.endSheet(&panel) };
}
}
fn application_handlers(
&mut self,
query: &day_spec::applications::HandlerQuery,
) -> Result<day_spec::applications::ApplicationHandlers, day_spec::applications::ApplicationError>
{
applications::handlers(query)
}
fn open_url_with_application(
&mut self,
url: &str,
application: &str,
completion: day_spec::applications::OpenApplicationCompletion,
) {
applications::open(url, application, completion);
}
fn share_support(&self) -> day_spec::Support {
day_spec::Support::Native
}
fn share_url(&mut self, url: &str, _title: &str) -> bool {
use objc2::{msg_send, runtime::AnyObject};
use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize, NSString, NSURL};
let Some(mtm) = objc2::MainThreadMarker::new() else {
return false;
};
let Some(url) = NSURL::URLWithString(&NSString::from_str(url)) else {
return false;
};
let app = objc2_app_kit::NSApplication::sharedApplication(mtm);
let Some(view) = app.keyWindow().and_then(|w| w.contentView()) else {
return false;
};
unsafe {
let items = NSArray::from_retained_slice(&[url]);
let picker: objc2::rc::Allocated<AnyObject> =
msg_send![objc2::class!(NSSharingServicePicker), alloc];
let picker: objc2::rc::Retained<AnyObject> = msg_send![picker, initWithItems: &*items];
let bounds = view.bounds();
let y = if view.isFlipped() {
30.0
} else {
bounds.size.height - 30.0
};
let rect = NSRect::new(
NSPoint::new(bounds.size.width - 30.0, y),
NSSize::new(1.0, 1.0),
);
let _: () = msg_send![&*picker, showRelativeToRect: rect, ofView: &*view, preferredEdge: 3usize];
self.sharing = Some(picker);
}
true
}
fn open_url(&mut self, url: &str) {
let nsurl = unsafe { objc2_foundation::NSURL::URLWithString(&NSString::from_str(url)) };
if let Some(nsurl) = nsurl {
unsafe { objc2_app_kit::NSWorkspace::sharedWorkspace().openURL(&nsurl) };
}
}
}
#[allow(deprecated)]
fn apply_allowed_file_types(
panel: &objc2_app_kit::NSSavePanel,
filters: &[day_spec::present::FileFilter],
) {
let exts: Vec<Retained<NSString>> = filters
.iter()
.flat_map(|f| f.extensions.iter())
.map(|e| NSString::from_str(e))
.collect();
if !exts.is_empty() {
let refs: Vec<&NSString> = exts.iter().map(|r| &**r).collect();
let arr = objc2_foundation::NSArray::from_slice(&refs);
unsafe { panel.setAllowedFileTypes(Some(&arr)) };
}
}
fn emit_open_panel_result(req: u64, resp: isize, panel: &objc2_app_kit::NSOpenPanel) {
let mut paths = Vec::new();
if resp == 1 {
for url in unsafe { panel.URLs() }.iter() {
if let Some(p) = unsafe { url.path() } {
paths.push(p.to_string());
}
}
}
let result = if paths.is_empty() {
present::PresentResult::Dismissed
} else {
present::PresentResult::Files(paths)
};
emit(WINDOW_NODE, Event::PresentResult { req, result });
PRESENT_PANELS.with(|m| {
m.borrow_mut().remove(&req);
});
}
fn emit_panel_result(req: u64, resp: isize, panel: &objc2_app_kit::NSSavePanel) {
let result = if resp == 1 {
unsafe { panel.URL() }
.and_then(|url| unsafe { url.path() })
.map(|p| present::PresentResult::Files(vec![p.to_string()]))
.unwrap_or(present::PresentResult::Dismissed)
} else {
present::PresentResult::Dismissed
};
emit(WINDOW_NODE, Event::PresentResult { req, result });
PRESENT_PANELS.with(|m| {
m.borrow_mut().remove(&req);
});
}
impl Platform for AppKit {
const TARGET: &'static str = "macos-appkit";
const TOOLKIT: &'static str = "appkit";
fn run(mut self, options: WindowOptions, ready: Box<dyn FnOnce(Self, Handle, Size)>) {
let mtm = self.mtm;
self.app_name = options
.app_name
.clone()
.unwrap_or_else(|| options.title.clone());
unsafe {
objc2_foundation::NSProcessInfo::processInfo()
.setProcessName(&NSString::from_str(&self.app_name));
let bundle = objc2_foundation::NSBundle::mainBundle();
let info: Option<Retained<objc2_foundation::NSDictionary>> =
msg_send![&bundle, infoDictionary];
if let Some(info) = info {
let key = NSString::from_str("CFBundleName");
let value = NSString::from_str(&self.app_name);
if info.respondsToSelector(objc2::sel!(setObject:forKey:)) {
let _: () = msg_send![&info, setObject: &*value, forKey: &*key];
}
}
}
if day_core::layout_direction() == day_spec::LayoutDirection::Rtl {
unsafe {
use objc2_foundation::{NSDictionary, NSNumber, NSUserDefaults};
let yes: objc2::rc::Retained<objc2::runtime::AnyObject> = NSNumber::new_bool(true)
.into_super()
.into_super()
.into_super();
let dict = NSDictionary::from_retained_objects(
&[
&*NSString::from_str("AppleTextDirection"),
&*NSString::from_str("NSForceRightToLeftWritingDirection"),
],
&[yes.clone(), yes],
);
NSUserDefaults::standardUserDefaults().registerDefaults(&dict);
}
}
let app = NSApplication::sharedApplication(mtm);
let menu_bar_app = std::env::var("DAY_MACOS_DOCK").is_ok_and(|v| v == "0")
|| unsafe {
let bundle = objc2_foundation::NSBundle::mainBundle();
let value: Option<Retained<objc2::runtime::AnyObject>> = msg_send![
&bundle,
objectForInfoDictionaryKey: &*NSString::from_str("LSUIElement")
];
value.is_some_and(|v| {
let on: bool = msg_send![&v, boolValue];
on
})
};
app.setActivationPolicy(if menu_bar_app {
NSApplicationActivationPolicy::Accessory
} else {
NSApplicationActivationPolicy::Regular
});
let document_delegate = documents::delegate(mtm);
app.setDelegate(Some(ProtocolObject::from_ref(&*document_delegate)));
if let Ok(theme) = std::env::var("DAY_THEME") {
let name = match theme.as_str() {
"dark" => Some(unsafe { objc2_app_kit::NSAppearanceNameDarkAqua }),
"light" => Some(unsafe { objc2_app_kit::NSAppearanceNameAqua }),
_ => None,
};
if let Some(name) = name {
let appearance = objc2_app_kit::NSAppearance::appearanceNamed(name);
unsafe { app.setAppearance(appearance.as_deref()) };
}
}
register_bundled_fonts();
let app_name = self.app_name.clone();
install_main_menu(mtm, &app, &app_name);
install_lifecycle_observers();
install_appearance_observer();
install_reduce_motion_observer();
let (window, delegate, content) = self.make_window(&options, false, None);
let appearance_name = match std::env::var("DAY_APPEARANCE").ok().as_deref() {
Some("light") => Some(unsafe { objc2_app_kit::NSAppearanceNameAqua }),
Some("dark") => Some(unsafe { objc2_app_kit::NSAppearanceNameDarkAqua }),
_ => None,
};
if let Some(name) = appearance_name
&& let Some(appearance) = unsafe { objc2_app_kit::NSAppearance::appearanceNamed(name) }
{
window.setAppearance(Some(&appearance));
}
std::mem::forget(delegate);
self.window = Some(window.clone());
self.content = Some(content.clone());
PRIMARY_CONTENT.with(|c| *c.borrow_mut() = Some(content.clone()));
let layout = pin_below_title_bar(&window)
.unwrap_or(Size::new(options.size.width, options.size.height));
ready(self, content, layout);
if day_core::windows::new_window_action_id() == 0
&& options.tabbing == day_spec::WindowTabbing::Automatic
{
unsafe { NSWindow::setAllowsAutomaticWindowTabbing(false, mtm) };
}
if !day_core::has_app_menu() {
install_main_menu(mtm, &app, &app_name);
}
let deterministic =
std::env::var_os("DAY_SCRIPT").is_some() || std::env::var_os("DAY_WINDOW").is_some();
let autosave = NSString::from_str("day.main");
if deterministic || !unsafe { window.setFrameUsingName(&autosave) } {
window.center();
window_chrome::place(&window, options.placement);
}
if let Some(screen) = capture_screen(mtm) {
let (visible, frame) = (screen.visibleFrame(), window.frame());
window.setFrameOrigin(NSPoint::new(
visible.origin.x + ((visible.size.width - frame.size.width) / 2.0).max(0.0),
visible.origin.y + ((visible.size.height - frame.size.height) / 2.0).max(0.0),
));
}
if !deterministic {
unsafe { window.setFrameAutosaveName(&autosave) };
}
if !options.start_hidden {
window.makeKeyAndOrderFront(None);
app.activate();
}
if let Some(content) = window.contentView() {
mark_tree_needs_display(&content);
}
if std::env::var_os("DAY_DUMP").is_some() {
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(2000));
Self::post(Box::new(|| {
if let Some(content) = primary_content() {
let desc: Retained<NSString> =
unsafe { msg_send![&*content, _subtreeDescription] };
log::warn!("{desc}");
}
}));
});
}
app.run();
}
fn post(f: Box<dyn FnOnce() + Send>) {
dispatch2::DispatchQueue::main().exec_async(move || ffi_guard::contain((), f));
}
fn post_delayed(ms: u32, f: Box<dyn FnOnce() + Send>) {
let when =
dispatch2::DispatchTime::try_from(std::time::Duration::from_millis(u64::from(ms)))
.unwrap_or(dispatch2::DispatchTime::FOREVER);
if let Err(e) = dispatch2::DispatchQueue::main().after(when, move || {
ffi_guard::contain((), f);
}) {
log::error!("day-toolkit-appkit: dispatch_after refused a timer: {e:?}");
}
}
fn locale_hints(&self) -> Vec<String> {
objc2_foundation::NSLocale::preferredLanguages()
.iter()
.map(|s| s.to_string())
.collect()
}
}
unsafe extern "C" {
fn CGWindowListCreateImage(
bounds: objc2_core_foundation::CGRect,
list_option: u32,
window_id: u32,
image_option: u32,
) -> *mut objc2_core_graphics::CGImage;
}
const CG_WINDOW_LIST_INCLUDING_WINDOW: u32 = 1 << 3;
const CG_WINDOW_IMAGE_IGNORE_FRAMING: u32 = 1 << 0;
fn png_of_rep(rep: &objc2_app_kit::NSBitmapImageRep) -> Result<Vec<u8>, String> {
let srgb = rep.bitmapImageRepByConvertingToColorSpace_renderingIntent(
&objc2_app_kit::NSColorSpace::sRGBColorSpace(),
objc2_app_kit::NSColorRenderingIntent::Default,
);
let rep = srgb.as_deref().unwrap_or(rep);
let data = unsafe {
rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new())
}
.ok_or("png encode failed")?;
Ok(data.to_vec())
}
fn snapshot_via_window_server(content: &NSView, chrome: bool) -> Result<Vec<u8>, String> {
let window = content.window().ok_or("view is not in a window")?;
if let Some(sheet) = window.attachedSheet().and_then(|s| s.contentView()) {
return snapshot_via_window_server(&sheet, chrome);
}
let number = window.windowNumber();
if number <= 0 {
return Err(format!("window {number} is not on screen"));
}
unsafe { content.layoutSubtreeIfNeeded() };
window.displayIfNeeded();
CATransaction::flush();
let null_rect = objc2_core_foundation::CGRect::new(
objc2_core_foundation::CGPoint::new(f64::INFINITY, f64::INFINITY),
objc2_core_foundation::CGSize::new(0.0, 0.0),
);
let shot = unsafe {
CGWindowListCreateImage(
null_rect,
CG_WINDOW_LIST_INCLUDING_WINDOW,
number as u32,
CG_WINDOW_IMAGE_IGNORE_FRAMING,
)
};
if shot.is_null() {
return Err("the window server has no image for this window".into());
}
let shot = unsafe {
objc2_core_foundation::CFRetained::from_raw(std::ptr::NonNull::new_unchecked(shot))
};
if chrome {
let rep = unsafe {
objc2_app_kit::NSBitmapImageRep::initWithCGImage(
objc2_app_kit::NSBitmapImageRep::alloc(),
&shot,
)
};
return png_of_rep(&rep);
}
let frame = window.frame();
if frame.size.width <= 0.0 || frame.size.height <= 0.0 {
return Err("zero-size window".into());
}
let scale = objc2_core_graphics::CGImage::width(Some(&shot)) as f64 / frame.size.width;
let in_window = unsafe { content.convertRect_toView(content.bounds(), None) };
let from_top = frame.size.height - (in_window.origin.y + in_window.size.height);
let crop = objc2_core_foundation::CGRect::new(
objc2_core_foundation::CGPoint::new(
(in_window.origin.x * scale).round(),
(from_top * scale).round(),
),
objc2_core_foundation::CGSize::new(
(in_window.size.width * scale).round(),
(in_window.size.height * scale).round(),
),
);
let cropped = objc2_core_graphics::CGImage::with_image_in_rect(Some(&shot), crop)
.ok_or("crop to the content view failed")?;
let rep = unsafe {
objc2_app_kit::NSBitmapImageRep::initWithCGImage(
objc2_app_kit::NSBitmapImageRep::alloc(),
&cropped,
)
};
png_of_rep(&rep)
}
fn apply_geometry(v: &NSView, base: [f64; 4], t: Transform) {
let [x, y, w, h] = base;
let (sw, sh) = (w * t.sx, h * t.sy);
let frame = NSRect::new(
NSPoint::new(x + (w - sw) / 2.0 + t.tx, y + (h - sh) / 2.0 + t.ty),
NSSize::new(sw, sh),
);
unsafe {
v.setFrameCenterRotation(0.0);
v.setFrame(frame);
v.setBoundsSize(NSSize::new(w, h));
if t.rotate_deg != 0.0 {
v.setFrameCenterRotation(t.rotate_deg);
}
}
}
fn snapshot_view(content: &NSView) -> Result<Vec<u8>, String> {
let modal = content
.window()
.and_then(|w| w.attachedSheet())
.and_then(|s| s.contentView());
let content = modal.as_deref().unwrap_or(content);
match snapshot_via_window_server(content, false) {
Ok(bytes) => Ok(bytes),
Err(_) => snapshot_view_cache(content),
}
}
fn snapshot_view_chrome(content: &NSView) -> Result<Vec<u8>, String> {
let modal = content
.window()
.and_then(|w| w.attachedSheet())
.and_then(|s| s.contentView());
let content = modal.as_deref().unwrap_or(content);
match snapshot_via_window_server(content, true) {
Ok(bytes) => Ok(bytes),
Err(_) => snapshot_view_cache(content),
}
}
fn snapshot_view_cache(content: &NSView) -> Result<Vec<u8>, String> {
let bounds = content.bounds();
let rep =
unsafe { content.bitmapImageRepForCachingDisplayInRect(bounds) }.ok_or("no bitmap rep")?;
let ctx =
NSGraphicsContext::graphicsContextWithBitmapImageRep(&rep).ok_or("no graphics context")?;
NSGraphicsContext::saveGraphicsState_class();
NSGraphicsContext::setCurrentContext(Some(&ctx));
content
.effectiveAppearance()
.performAsCurrentDrawingAppearance(&block2::StackBlock::new(|| unsafe {
NSColor::windowBackgroundColor().setFill();
objc2_app_kit::NSRectFill(bounds);
}));
NSGraphicsContext::restoreGraphicsState_class();
unsafe { content.cacheDisplayInRect_toBitmapImageRep(bounds, &rep) };
png_of_rep(&rep)
}
fn capture_screen(mtm: MainThreadMarker) -> Option<Retained<objc2_app_kit::NSScreen>> {
let wanted: u32 = std::env::var("DAY_WINDOW_SCREEN")
.ok()?
.trim()
.parse()
.ok()?;
let key = NSString::from_str("NSScreenNumber");
objc2_app_kit::NSScreen::screens(mtm)
.into_iter()
.find(|screen| {
let number: Option<Retained<objc2_foundation::NSNumber>> =
unsafe { msg_send![&*screen.deviceDescription(), objectForKey: &*key] };
number.is_some_and(|n| n.unsignedIntValue() == wanted)
})
}
fn mark_tree_needs_display(view: &NSView) {
view.setNeedsDisplay(true);
for sub in view.subviews() {
mark_tree_needs_display(&sub);
}
}
pub const fn lifecycle_supported(phase: day_spec::Lifecycle) -> bool {
phase.is_universal()
}
fn install_appearance_observer() {
use objc2_foundation::{NSDistributedNotificationCenter, NSNotification, NSString};
let center = unsafe { NSDistributedNotificationCenter::defaultCenter() };
let block = block2::RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
dispatch2::DispatchQueue::main()
.exec_async(|| ffi_guard::contain((), day_core::note_appearance_changed));
});
let name = NSString::from_str("AppleInterfaceThemeChangedNotification");
let token = unsafe {
center.addObserverForName_object_queue_usingBlock(Some(&name), None, None, &block)
};
std::mem::forget(token);
}
fn install_reduce_motion_observer() {
use objc2_foundation::NSNotification;
let center = objc2_app_kit::NSWorkspace::sharedWorkspace().notificationCenter();
let block = block2::RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
dispatch2::DispatchQueue::main()
.exec_async(|| ffi_guard::contain((), day_core::note_motion_changed));
});
let token = unsafe {
center.addObserverForName_object_queue_usingBlock(
Some(objc2_app_kit::NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification),
None,
None,
&block,
)
};
std::mem::forget(token);
}
fn install_lifecycle_observers() {
use objc2_foundation::{NSNotification, NSNotificationCenter, NSNotificationName};
let center = unsafe { NSNotificationCenter::defaultCenter() };
let observe = |name: &NSNotificationName, phase: day_spec::Lifecycle| {
let block = block2::RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
ffi_guard::contain((), || emit(day_spec::WINDOW_NODE, Event::Lifecycle(phase)));
});
let token = unsafe {
center.addObserverForName_object_queue_usingBlock(Some(name), None, None, &block)
};
std::mem::forget(token); };
unsafe {
observe(
NSApplicationDidBecomeActiveNotification,
day_spec::Lifecycle::DidBecomeActive,
);
observe(
NSApplicationWillResignActiveNotification,
day_spec::Lifecycle::WillResignActive,
);
observe(
NSApplicationWillTerminateNotification,
day_spec::Lifecycle::WillTerminate,
);
observe(
NSApplicationWillTerminateNotification,
day_spec::Lifecycle::DidExit,
);
}
}
day_localization_ui::locales!();
fn install_application_menu(
mtm: MainThreadMarker,
menubar: &NSMenu,
title: &str,
about: Option<day_spec::MenuItem>,
updates: Option<day_spec::MenuItem>,
prefs: Option<day_spec::MenuItem>,
quit: Option<day_spec::MenuItem>,
) -> Retained<NSMenu> {
let app = NSApplication::sharedApplication(mtm);
use day_spec::{MenuItem as M, MenuRole as R};
let standard = |role, label| M::Action {
id: None,
action: 0,
label,
shortcut: None,
enabled: true,
checked: None,
role: Some(role),
icon: None,
};
let mut about =
about.unwrap_or_else(|| standard(R::About, res::str::about_app(title).format()));
let mut quit = quit.unwrap_or_else(|| standard(R::Quit, res::str::quit_app(title).format()));
for (item, title) in [
(&mut about, res::str::about_app(title).format()),
(&mut quit, res::str::quit_app(title).format()),
] {
if let M::Action { label, .. } = item {
*label = title;
}
}
if let M::Action { action, .. } = &mut about
&& *action == 0
{
*action = day_core::windows::about_action_id();
}
let updates_id = day_core::windows::check_for_updates_action_id();
let updates = updates.or_else(|| {
(updates_id != 0).then(|| {
standard(
R::CheckForUpdates,
day_localization::t(day_localization::key::day_check_for_updates),
)
})
});
let mut head = vec![about];
if let Some(mut updates) = updates {
if let M::Action { action, .. } = &mut updates
&& *action == 0
{
*action = updates_id;
}
head.push(updates);
}
head.push(M::Separator);
let menu = build_ns_menu(mtm, title, &head);
if let Some(mut prefs) = prefs {
if let M::Action { label, .. } = &mut prefs {
*label = res::str::settings().format();
}
let section = build_ns_menu(mtm, title, &[prefs, M::Separator]);
for item in section.itemArray() {
section.removeItem(&item);
menu.addItem(&item);
}
}
let services = app.servicesMenu().unwrap_or_else(|| NSMenu::new(mtm));
if let Some(parent) = unsafe { services.supermenu() } {
let index = parent.indexOfItemWithSubmenu(Some(&services));
if index >= 0 {
parent.itemAtIndex(index).unwrap().setSubmenu(None);
}
}
let services_item = NSMenuItem::new(mtm);
services_item.setTitle(&NSString::from_str(&res::str::services().format()));
services_item.setSubmenu(Some(&services));
menu.addItem(&services_item);
menu.addItem(&NSMenuItem::separatorItem(mtm));
for (label, selector, shortcut) in [
(
res::str::hide_app(title).format(),
sel!(hide:),
Some(day_spec::Shortcut::primary("h")),
),
(
res::str::hide_others().format(),
sel!(hideOtherApplications:),
Some(day_spec::Shortcut::primary("h").alt()),
),
(
res::str::show_all().format(),
sel!(unhideAllApplications:),
None,
),
] {
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(&label),
Some(selector),
&NSString::from_str(shortcut.as_ref().map_or("", |s| s.key.as_str())),
)
};
if let Some(shortcut) = shortcut {
item.setKeyEquivalentModifierMask(ns_modifiers(&shortcut));
}
unsafe { item.setTarget(Some(&app)) };
menu.addItem(&item);
}
menu.addItem(&NSMenuItem::separatorItem(mtm));
let section = build_ns_menu(mtm, title, &[quit]);
let quit = section.itemAtIndex(0).unwrap();
section.removeItem(&quit);
menu.addItem(&quit);
let item = NSMenuItem::new(mtm);
item.setSubmenu(Some(&menu));
menubar.addItem(&item);
services
}
fn install_main_menu(mtm: MainThreadMarker, app: &NSApplication, title: &str) {
let menubar = NSMenu::new(mtm);
let prefs_id = day_core::windows::preferences_action_id();
let prefs = (prefs_id != 0).then(|| day_spec::MenuItem::Action {
id: None,
action: prefs_id,
label: res::str::settings().format(),
shortcut: None,
enabled: true,
checked: None,
role: Some(day_spec::MenuRole::Preferences),
icon: None,
});
let services = install_application_menu(mtm, &menubar, title, None, None, prefs, None);
let new_window_id = day_core::windows::new_window_action_id();
if new_window_id != 0 {
let file_item = NSMenuItem::new(mtm);
let file_menu = unsafe {
NSMenu::initWithTitle(
NSMenu::alloc(mtm),
&NSString::from_str(&day_localization::t(day_localization::key::day_file)),
)
};
let new_window = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(&day_localization::t(day_localization::key::day_new_window)),
Some(sel!(fire:)),
&NSString::from_str("n"),
)
};
let target = menu_target(mtm);
let tobj: &objc2::runtime::AnyObject = target.as_ref();
unsafe { new_window.setTarget(Some(tobj)) };
new_window.setTag(new_window_id as isize);
file_menu.addItem(&new_window);
file_menu.addItem(&NSMenuItem::separatorItem(mtm));
let close = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(&day_localization::t(day_localization::key::day_close)),
Some(sel!(performClose:)),
&NSString::from_str("w"),
)
};
file_menu.addItem(&close);
file_item.setSubmenu(Some(&file_menu));
menubar.addItem(&file_item);
}
let edit_item = NSMenuItem::new(mtm);
let edit_menu = unsafe {
NSMenu::initWithTitle(
NSMenu::alloc(mtm),
&NSString::from_str(&day_localization::t(day_localization::key::day_edit)),
)
};
let add = |key: &str, action: objc2::runtime::Sel, accel: &str| {
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(&day_localization::t(key)),
Some(action),
&NSString::from_str(accel),
)
};
edit_menu.addItem(&item);
};
add("day_undo", sel!(undo:), "z");
add("day_redo", sel!(redo:), "Z");
edit_menu.addItem(&NSMenuItem::separatorItem(mtm));
add("day_cut", sel!(cut:), "x");
add("day_copy", sel!(copy:), "c");
add("day_paste", sel!(paste:), "v");
add("day_select_all", sel!(selectAll:), "a");
edit_item.setSubmenu(Some(&edit_menu));
menubar.addItem(&edit_item);
install_windows_menu(mtm, app, &menubar);
app.setMainMenu(Some(&menubar));
unsafe { app.setServicesMenu(Some(&services)) };
}
fn install_windows_menu(mtm: MainThreadMarker, app: &NSApplication, menubar: &NSMenu) {
let menu = unsafe {
NSMenu::initWithTitle(
NSMenu::alloc(mtm),
&NSString::from_str(&day_localization::t(day_localization::key::day_window)),
)
};
let add = |key: &str, action: objc2::runtime::Sel, accel: &str| {
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(&day_localization::t(key)),
Some(action),
&NSString::from_str(accel),
)
};
menu.addItem(&item);
};
add("day_minimize", sel!(performMiniaturize:), "m");
add("day_zoom", sel!(performZoom:), "");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add("day_bring_all_front", sel!(arrangeInFront:), "");
let item = NSMenuItem::new(mtm);
item.setTitle(&NSString::from_str(&day_localization::t(
day_localization::key::day_window,
)));
item.setSubmenu(Some(&menu));
menubar.addItem(&item);
unsafe { app.setWindowsMenu(Some(&menu)) };
}
fn extract_app_role(
items: &mut Vec<day_spec::MenuItem>,
role: day_spec::MenuRole,
) -> Option<day_spec::MenuItem> {
use day_spec::MenuItem as M;
let mut found = None;
let mut remaining = Vec::new();
for mut item in items.drain(..) {
match &mut item {
M::Action { role: Some(r), .. } if *r == role => {
found.get_or_insert(item);
continue;
}
M::Submenu { items, .. } => {
let nested = extract_app_role(items, role);
if found.is_none() {
found = nested;
}
if items.is_empty() {
continue;
}
}
M::Separator
if remaining.is_empty() || matches!(remaining.last(), Some(M::Separator)) =>
{
continue;
}
_ => {}
}
remaining.push(item);
}
while matches!(remaining.last(), Some(M::Separator)) {
remaining.pop();
}
*items = remaining;
found
}
fn model_has_role(items: &[day_spec::MenuItem], role: day_spec::MenuRole) -> bool {
items.iter().any(|it| match it {
day_spec::MenuItem::Action { role: r, .. } => *r == Some(role),
day_spec::MenuItem::Submenu { items, .. } => model_has_role(items, role),
day_spec::MenuItem::Separator => false,
})
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayMenuTarget"]
#[ivars = ()]
struct DayMenuTarget;
unsafe impl NSObjectProtocol for DayMenuTarget {}
impl DayMenuTarget {
#[unsafe(method(fire:))]
fn fire(&self, sender: &NSMenuItem) {
ffi_guard::contain((), || {
let id = sender.tag() as u64;
if id != 0 {
emit(day_spec::WINDOW_NODE, Event::MenuAction(id));
}
})
}
}
);
pub(crate) fn pin_below_title_bar(window: &NSWindow) -> Option<Size> {
let content = window.contentView()?;
let full = content.frame().size;
let layout = unsafe { window.contentLayoutRect() }.size;
let top = (full.height - layout.height).max(0.0);
if window_chrome::is_overlay(window) {
unsafe { content.setBoundsOrigin(NSPoint::new(0.0, 0.0)) };
window_chrome::report_title_bar_inset(window, top);
return Some(Size::new(full.width, full.height));
}
unsafe { content.setBoundsOrigin(NSPoint::new(0.0, -top)) };
Some(Size::new(full.width, (full.height - top).max(0.0)))
}
fn native_text(h: &NSView) -> Option<String> {
if h.downcast_ref::<NSSecureTextField>().is_some() {
return None;
}
if let Some(tf) = h.downcast_ref::<NSTextField>() {
return Some(tf.stringValue().to_string());
}
if let Some(btn) = h.downcast_ref::<NSButton>() {
if unsafe { btn.imagePosition() } == objc2_app_kit::NSCellImagePosition::ImageOnly {
return None;
}
return Some(btn.title().to_string());
}
let sv = h.downcast_ref::<NSScrollView>()?;
let doc = sv.documentView()?;
let tv = doc.downcast_ref::<NSTextView>()?;
Some(tv.string().to_string())
}
fn native_number(h: &NSView) -> Option<f64> {
if let Some(sl) = h.downcast_ref::<NSSlider>() {
return Some(sl.doubleValue());
}
let pi = h.downcast_ref::<NSProgressIndicator>()?;
(!pi.isIndeterminate()).then(|| pi.doubleValue())
}
fn native_frame(h: &NSView) -> Option<Rect> {
let content = h.window()?.contentView()?;
let r = h.convertRect_toView(h.bounds(), Some(&content));
let y = if content.isFlipped() {
r.origin.y
} else {
let b = content.bounds();
b.origin.y + b.size.height - (r.origin.y + r.size.height)
};
Some(Rect::new(r.origin.x, y, r.size.width, r.size.height))
}
fn title_bar_inset(content: &NSView) -> f64 {
(-content.bounds().origin.y).max(0.0)
}
fn primary_content() -> Option<Retained<NSView>> {
PRIMARY_CONTENT.with(|c| c.borrow().clone())
}
fn menu_target(mtm: MainThreadMarker) -> Retained<DayMenuTarget> {
MENU_TARGET.with(|t| {
t.borrow_mut()
.get_or_insert_with(|| {
let this = DayMenuTarget::alloc(mtm).set_ivars(());
let obj: Retained<DayMenuTarget> = unsafe { msg_send![super(this), init] };
obj
})
.clone()
})
}
fn ns_modifiers(s: &day_spec::Shortcut) -> objc2_app_kit::NSEventModifierFlags {
use objc2_app_kit::NSEventModifierFlags as F;
let mut m = F::empty();
if s.primary {
m |= F::Command;
}
if s.shift {
m |= F::Shift;
}
if s.alt {
m |= F::Option;
}
if s.control {
m |= F::Control;
}
m
}
fn ns_key_equivalent(key: &str) -> String {
match key {
"Return" | "Enter" => "\r".into(),
"Tab" => "\t".into(),
"Delete" | "Backspace" => "\u{8}".into(),
"Escape" => "\u{1b}".into(),
"Space" => " ".into(),
k if k.chars().count() == 1 => k.to_lowercase(),
_ => String::new(), }
}
fn role_spec(
role: day_spec::MenuRole,
) -> (
&'static str,
Option<objc2::runtime::Sel>,
Option<day_spec::Shortcut>,
) {
use day_spec::MenuRole as R;
use day_spec::Shortcut as S;
match role {
R::Cut => ("Cut", Some(sel!(cut:)), Some(S::primary("x"))),
R::Copy => ("Copy", Some(sel!(copy:)), Some(S::primary("c"))),
R::Paste => ("Paste", Some(sel!(paste:)), Some(S::primary("v"))),
R::SelectAll => ("Select All", Some(sel!(selectAll:)), Some(S::primary("a"))),
R::Undo => ("Undo", Some(sel!(undo:)), Some(S::primary("z"))),
R::Redo => ("Redo", Some(sel!(redo:)), Some(S::primary("z").shift())),
R::Delete => ("Delete", Some(sel!(delete:)), None),
R::About => ("About", Some(sel!(orderFrontStandardAboutPanel:)), None),
R::Quit => ("Quit", Some(sel!(terminate:)), Some(S::primary("q"))),
R::Preferences => ("Settings…", None, Some(S::primary(","))),
R::Minimize => (
"Minimize",
Some(sel!(performMiniaturize:)),
Some(S::primary("m")),
),
R::CloseWindow => ("Close", Some(sel!(performClose:)), Some(S::primary("w"))),
R::Fullscreen => (
"Enter Full Screen",
Some(sel!(toggleFullScreen:)),
Some(S::primary("f").control()),
),
R::NewWindow => ("New Window", None, Some(S::primary("n"))),
R::CheckForUpdates => ("Check for Updates…", None, None),
}
}
pub(crate) fn build_ns_menu(
mtm: MainThreadMarker,
title: &str,
items: &[day_spec::MenuItem],
) -> Retained<NSMenu> {
use day_spec::MenuItem as MI;
let menu = unsafe { NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str(title)) };
let target = menu_target(mtm);
for item in items {
match item {
MI::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)),
MI::Submenu { label, items, .. } => {
let sub = build_ns_menu(mtm, label, items);
let it = NSMenuItem::new(mtm);
it.setTitle(&NSString::from_str(label));
it.setSubmenu(Some(&sub));
menu.addItem(&it);
}
MI::Action {
action,
label,
shortcut,
enabled,
checked,
role,
icon,
..
} => {
let (mut lbl, sel, mut sc) = match role {
Some(r) => {
let (dl, ds, dsc) = role_spec(*r);
(dl.to_string(), ds, dsc)
}
None => (String::new(), None, None),
};
if !label.is_empty() {
lbl = label.clone();
}
if shortcut.is_some() {
sc = shortcut.clone();
}
let responder_role = matches!(
role,
Some(
day_spec::MenuRole::Undo
| day_spec::MenuRole::Redo
| day_spec::MenuRole::Cut
| day_spec::MenuRole::Copy
| day_spec::MenuRole::Paste
| day_spec::MenuRole::SelectAll
)
);
let custom = *action != 0 && !responder_role;
let key = sc
.as_ref()
.map(|s| ns_key_equivalent(&s.key))
.unwrap_or_default();
let it = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(&lbl),
if custom { Some(sel!(fire:)) } else { sel },
&NSString::from_str(&key),
)
};
if let Some(s) = &sc {
it.setKeyEquivalentModifierMask(ns_modifiers(s));
it.setAllowsAutomaticKeyEquivalentLocalization(true);
it.setAllowsAutomaticKeyEquivalentMirroring(true);
}
if custom {
let tobj: &objc2::runtime::AnyObject = target.as_ref();
unsafe { it.setTarget(Some(tobj)) };
it.setTag(*action as isize);
}
it.setEnabled(*enabled);
if let Some(on) = checked {
it.setState(if *on {
NSControlStateValueOn
} else {
NSControlStateValueOff
});
}
if let Some(icon) = icon
&& let Some(img) = crate::toolbar::image_for(icon, &lbl, mtm)
{
img.setSize(NSSize::new(16.0, 16.0));
it.setImage(Some(&img));
}
menu.addItem(&it);
}
}
}
menu
}
fn release_bitmap(id: day_spec::BitmapId) {
let _ = BITMAPS.try_with(|m| m.borrow_mut().remove(&id.0));
}
#[cfg(test)]
mod bitmap_teardown_tests {
use super::*;
#[test]
fn releasing_after_bitmap_registry_teardown_does_not_abort() {
const CHILD: &str = "DAY_APPKIT_BITMAP_TEARDOWN_CHILD";
if std::env::var_os(CHILD).is_some() {
struct LateRelease;
impl Drop for LateRelease {
fn drop(&mut self) {
assert!(BITMAPS.try_with(|_| ()).is_err());
release_bitmap(day_spec::BitmapId(1));
}
}
std::thread_local! {
static LATE: RefCell<Option<LateRelease>> = const { RefCell::new(None) };
}
std::thread::spawn(|| {
LATE.with(|slot| *slot.borrow_mut() = Some(LateRelease));
BITMAPS.with(|_| ()); })
.join()
.unwrap();
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bitmap_teardown_tests::releasing_after_bitmap_registry_teardown_does_not_abort",
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
}