use super::*;
use objc2_app_kit::{NSMenuDelegate, NSStatusBar, NSStatusItem};
struct Shown {
spec: day_spec::StatusItemSpec,
item: Retained<NSStatusItem>,
menu: Retained<NSMenu>,
_target: Retained<StatusTarget>,
}
thread_local! {
static SHOWN: RefCell<Vec<Shown>> = const { RefCell::new(Vec::new()) };
static KEEP_RUNNING: Cell<bool> = const { Cell::new(false) };
static PROGRESS: RefCell<Option<Retained<objc2_app_kit::NSProgressIndicator>>> =
const { RefCell::new(None) };
}
pub(super) fn keeps_running() -> bool {
KEEP_RUNNING.with(|k| k.get())
}
pub(super) fn set_keep_running(keep: bool) {
KEEP_RUNNING.with(|k| k.set(keep));
}
struct TargetIvars {
id: RefCell<String>,
tracking: Cell<u64>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "DayStatusTarget"]
#[ivars = TargetIvars]
struct StatusTarget;
unsafe impl NSObjectProtocol for StatusTarget {}
unsafe impl NSMenuDelegate for StatusTarget {
#[unsafe(method(menuWillOpen:))]
fn menu_will_open(&self, _menu: &NSMenu) {
ffi_guard::contain((), || {
let token = day_core::status::begin_menu_tracking(&self.ivars().id.borrow());
self.ivars().tracking.set(token);
})
}
#[unsafe(method(menuDidClose:))]
fn menu_did_close(&self, _menu: &NSMenu) {
ffi_guard::contain((), || {
let id = self.ivars().id.borrow().clone();
let token = self.ivars().tracking.replace(0);
AppKit::post(Box::new(move || day_core::status::end_menu_tracking(&id, token)));
})
}
}
impl StatusTarget {
#[unsafe(method(clicked:))]
fn clicked(&self, _sender: Option<&objc2::runtime::AnyObject>) {
ffi_guard::contain((), || {
let id = self.ivars().id.borrow().clone();
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
let right = app.currentEvent().is_some_and(|e| {
(unsafe { e.r#type() }) == objc2_app_kit::NSEventType::RightMouseUp
|| unsafe { e.modifierFlags() }
.contains(objc2_app_kit::NSEventModifierFlags::Control)
});
let found = SHOWN.with(|s| {
s.borrow()
.iter()
.find(|x| x.spec.id == id)
.map(|x| (x.spec.activate, x.item.clone(), x.menu.clone()))
});
let Some((activate, item, menu)) = found else {
return;
};
if right && activate != 0 {
unsafe {
item.setMenu(Some(&menu));
if let Some(button) = item.button(mtm) {
button.performClick(None);
}
item.setMenu(None);
}
} else if activate != 0 {
day_core::dispatch_menu_action(activate);
}
})
}
}
);
fn new_target(mtm: MainThreadMarker, id: &str) -> Retained<StatusTarget> {
let this = StatusTarget::alloc(mtm).set_ivars(TargetIvars {
id: RefCell::new(id.to_owned()),
tracking: Cell::new(0),
});
unsafe { msg_send![super(this), init] }
}
fn raster_image(raster: &day_spec::StatusImage) -> Option<Retained<objc2_app_kit::NSImage>> {
use objc2::AllocAnyThread as _;
use objc2_app_kit::{NSBitmapFormat, NSBitmapImageRep, NSImage};
let rep = unsafe {
NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bitmapFormat_bytesPerRow_bitsPerPixel(
NSBitmapImageRep::alloc(), std::ptr::null_mut(), raster.width() as isize,
raster.height() as isize, 8, 4, true, false,
objc2_app_kit::NSCalibratedRGBColorSpace, NSBitmapFormat::AlphaNonpremultiplied,
raster.width() as isize * 4, 32,
)
}?;
let data = rep.bitmapData();
if data.is_null() {
return None;
}
unsafe { std::ptr::copy_nonoverlapping(raster.pixels().as_ptr(), data, raster.pixels().len()) };
let rep = rep
.bitmapImageRepByRetaggingWithColorSpace(&objc2_app_kit::NSColorSpace::sRGBColorSpace())?;
let size = raster.size();
let fit = (18.0 / size.height).min(1.0);
let size = NSSize::new(size.width * fit, size.height * fit);
unsafe { rep.setSize(size) };
let image = NSImage::initWithSize(NSImage::alloc(), size);
unsafe { image.addRepresentation(&rep) };
Some(image)
}
fn configure(mtm: MainThreadMarker, shown: &mut Shown, spec: &day_spec::StatusItemSpec) {
let Some(button) = (unsafe { shown.item.button(mtm) }) else {
return;
};
if shown.spec.raster != spec.raster
|| shown.spec.icon != spec.icon
|| shown.spec.template != spec.template
|| unsafe { button.image() }.is_none()
{
let image = spec.raster.as_ref().and_then(raster_image).or_else(|| {
let image = spec
.icon
.as_ref()
.and_then(|icon| crate::toolbar::image_for(icon, &spec.tooltip, mtm));
if let Some(image) = &image {
unsafe { image.setSize(NSSize::new(18.0, 18.0)) };
}
image
});
if let Some(image) = &image {
unsafe { image.setTemplate(spec.template) };
}
unsafe { button.setImage(image.as_deref()) };
}
unsafe {
button.setImagePosition(objc2_app_kit::NSCellImagePosition::ImageLeft);
}
button.setTitle(&NSString::from_str(&spec.title));
let tip = (!spec.tooltip.is_empty()).then(|| NSString::from_str(&spec.tooltip));
button.setToolTip(tip.as_deref());
button.setAccessibilityLabel(tip.as_deref());
shown.menu = build_ns_menu(mtm, "", &spec.menu);
shown
.menu
.setDelegate(Some(ProtocolObject::from_ref(&*shown._target)));
unsafe {
shown
.item
.setMenu((spec.activate == 0).then_some(&*shown.menu));
}
shown.spec = spec.clone();
}
pub(super) fn set_items(mtm: MainThreadMarker, items: &[day_spec::StatusItemSpec]) {
SHOWN.with(|s| {
let mut shown = s.borrow_mut();
let bar = unsafe { NSStatusBar::systemStatusBar() };
shown.retain(|x| {
let keep = items.iter().any(|i| i.id == x.spec.id);
if !keep {
unsafe { bar.removeStatusItem(&x.item) };
}
keep
});
for spec in items {
if let Some(x) = shown.iter_mut().find(|x| x.spec.id == spec.id) {
if x.spec != *spec {
configure(mtm, x, spec);
}
continue;
}
let item =
unsafe { bar.statusItemWithLength(objc2_app_kit::NSVariableStatusItemLength) };
unsafe { item.setAutosaveName(Some(&NSString::from_str(&spec.id))) };
let target = new_target(mtm, &spec.id);
if let Some(button) = unsafe { item.button(mtm) } {
unsafe {
button.setTarget(Some(&target));
button.setAction(Some(sel!(clicked:)));
button.sendActionOn(
objc2_app_kit::NSEventMask::LeftMouseUp
| objc2_app_kit::NSEventMask::RightMouseUp,
);
}
}
let mut x = Shown {
spec: spec.clone(),
item,
menu: NSMenu::new(mtm),
_target: target,
};
configure(mtm, &mut x, spec);
shown.push(x);
}
});
}
pub(super) fn set_progress(mtm: MainThreadMarker, progress: day_spec::AppProgress) {
use day_spec::AppProgress as P;
let app = NSApplication::sharedApplication(mtm);
let tile = app.dockTile();
let value = match progress {
P::None => {
unsafe { tile.setContentView(None) };
PROGRESS.with(|p| *p.borrow_mut() = None);
tile.display();
return;
}
P::Indeterminate => None,
P::Value(v) | P::Paused(v) | P::Error(v) => Some(v.clamp(0.0, 1.0)),
};
let bar = PROGRESS.with(|p| p.borrow().clone()).unwrap_or_else(|| {
let size = tile.size();
let icon_view = objc2_app_kit::NSImageView::new(mtm);
icon_view.setFrame(NSRect::new(NSPoint::new(0.0, 0.0), size));
unsafe { icon_view.setImage(app.applicationIconImage().as_deref()) };
let bar = objc2_app_kit::NSProgressIndicator::new(mtm);
unsafe {
bar.setStyle(objc2_app_kit::NSProgressIndicatorStyle::Bar);
bar.setMinValue(0.0);
bar.setMaxValue(1.0);
}
bar.setFrame(NSRect::new(
NSPoint::new(size.width * 0.1, size.height * 0.06),
NSSize::new(size.width * 0.8, size.height * 0.14),
));
icon_view.addSubview(&bar);
unsafe { tile.setContentView(Some(&icon_view)) };
PROGRESS.with(|p| *p.borrow_mut() = Some(bar.clone()));
bar
});
unsafe {
bar.setIndeterminate(value.is_none());
bar.setDoubleValue(value.unwrap_or(0.0));
}
tile.display();
}
pub(super) fn set_dock_visible(mtm: MainThreadMarker, visible: bool) {
let app = NSApplication::sharedApplication(mtm);
let policy = if visible {
objc2_app_kit::NSApplicationActivationPolicy::Regular
} else {
objc2_app_kit::NSApplicationActivationPolicy::Accessory
};
app.setActivationPolicy(policy);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_raster_keeps_wide_retina_size_and_straight_alpha_pixels() {
objc2::rc::autoreleasepool(|_| {
let mut pixels = vec![0; 120 * 36 * 4];
pixels[..4].copy_from_slice(&[255, 0, 0, 128]);
let source = day_spec::StatusImage::rgba(120, 36, 2.0, pixels.clone()).unwrap();
let image = raster_image(&source).unwrap();
assert_eq!(unsafe { image.size() }, NSSize::new(60.0, 18.0));
let reps = unsafe { image.representations() };
let rep = reps
.objectAtIndex(0)
.downcast::<objc2_app_kit::NSBitmapImageRep>()
.unwrap();
assert_eq!(unsafe { rep.pixelsWide() }, 120);
assert_eq!(unsafe { rep.pixelsHigh() }, 36);
assert_eq!(
unsafe { std::slice::from_raw_parts(rep.bitmapData(), pixels.len()) },
pixels
);
let taller = day_spec::StatusImage::rgba(120, 72, 2.0, vec![0; 120 * 72 * 4]).unwrap();
assert_eq!(
unsafe { raster_image(&taller).unwrap().size() },
NSSize::new(30.0, 18.0)
);
});
}
}