use std::panic::{AssertUnwindSafe, catch_unwind};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
#[cfg(feature = "frust-api")]
use objc2_app_kit::NSView;
use objc2_app_kit::{
NSControl, NSControlStateValue, NSControlStateValueOn, NSDatePicker, NSSegmentedControl,
NSSwitch,
};
use crate::controls::date_picker::foundation::civil_date;
use crate::events::{
EVENT_KIND_CLICK, EVENT_KIND_DATE, EVENT_KIND_SELECTION, EVENT_KIND_TOGGLED,
EVENT_KIND_VALUE_CHANGED, pack_bool, pack_date, pack_index, pack_value_changed,
};
#[cfg(doc)]
use crate::events::{EVENT_KIND_DRAG_END, EVENT_KIND_DRAG_START};
use crate::registry::SlotId;
use crate::runtime::{self, NativeEvent};
#[derive(Clone, Copy)]
pub(crate) struct TargetIvars {
slot: SlotId,
kind: i32,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = TargetIvars]
pub(crate) struct FrustNativeControlTarget;
unsafe impl NSObjectProtocol for FrustNativeControlTarget {}
impl FrustNativeControlTarget {
#[unsafe(method(frustAction:))]
fn frust_action(&self, sender: Option<&NSControl>) {
self.dispatch(sender);
}
}
);
impl FrustNativeControlTarget {
fn new(mtm: MainThreadMarker, slot: SlotId, kind: i32) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(TargetIvars { slot, kind });
unsafe { msg_send![super(this), init] }
}
pub(crate) fn attach(
mtm: MainThreadMarker,
control: &NSControl,
slot: SlotId,
kind: i32,
) -> Retained<Self> {
let this = Self::new(mtm, slot, kind);
let target: &AnyObject = &this;
unsafe {
control.setTarget(Some(target));
control.setAction(Some(sel!(frustAction:)));
}
this
}
pub(crate) fn detach(&self, control: &NSControl) {
unsafe {
control.setTarget(None);
control.setAction(None);
}
}
#[cfg(feature = "frust-api")]
pub(crate) fn attach_view(
mtm: MainThreadMarker,
view: &NSView,
slot: SlotId,
kind: i32,
) -> Result<(Retained<Self>, Retained<NSControl>), String> {
use objc2::Message as _;
let any: &AnyObject = view;
let control = any.downcast_ref::<NSControl>().ok_or_else(|| {
"macOS attach_listener: the view is not an NSControl, so it has no target/action \
pair to attach to"
.to_string()
})?;
if kind == EVENT_KIND_TOGGLED && any.downcast_ref::<NSSwitch>().is_none() {
return Err(
"macOS attach_listener: TOGGLED reads an NSSwitch's state, and this control is \
no NSSwitch"
.into(),
);
}
let target = Self::attach(mtm, control, slot, kind);
Ok((target, control.retain()))
}
#[cfg(feature = "frust-api")]
pub(crate) fn detach_if_current(&self, control: &NSControl) {
let current = control.target();
let ours: &AnyObject = self;
if current
.as_deref()
.is_some_and(|target| std::ptr::eq(target, ours))
{
self.detach(control);
}
}
fn dispatch(&self, sender: Option<&NSControl>) {
let TargetIvars { slot, kind } = *self.ivars();
let outcome = catch_unwind(AssertUnwindSafe(|| {
let Some(detail) = detail_for(kind, sender) else {
log::warn!(
"frust-native-widgets: macOS action for slot {slot} carries kind {kind}, which \
this arm does not encode (or whose sender is not the control that kind reads) \
— event dropped"
);
return Some(());
};
let event = NativeEvent { kind, detail };
runtime::with_runtime(|runtime| {
runtime.on_event(slot, event);
})
}));
match outcome {
Ok(Some(())) => {}
Ok(None) => {
log::debug!("frust-native-widgets: event for slot {slot} dropped (re-entrant)");
}
Err(_) => {
log::warn!(
"frust-native-widgets: panic caught in frustAction: for slot {slot} — event \
dropped"
);
}
}
}
}
fn detail_for(kind: i32, sender: Option<&NSControl>) -> Option<i64> {
match kind {
EVENT_KIND_CLICK => Some(0),
EVENT_KIND_TOGGLED => {
let any: &AnyObject = sender?;
let switch = any.downcast_ref::<NSSwitch>()?;
Some(pack_bool(is_on(switch.state())))
}
EVENT_KIND_VALUE_CHANGED => Some(pack_value_changed(
platform_value(sender?.doubleValue()),
true,
)),
EVENT_KIND_SELECTION => {
let any: &AnyObject = sender?;
let segmented = any.downcast_ref::<NSSegmentedControl>()?;
Some(pack_index(segmented.selectedSegment()))
}
EVENT_KIND_DATE => {
let any: &AnyObject = sender?;
let picker = any.downcast_ref::<NSDatePicker>()?;
Some(pack_date(civil_date(&picker.dateValue())?))
}
_ => None,
}
}
fn is_on(state: NSControlStateValue) -> bool {
state == NSControlStateValueOn
}
fn platform_value(double_value: f64) -> i32 {
double_value.round() as i32
}
#[cfg(test)]
mod tests {
use objc2_app_kit::{NSControlStateValueMixed, NSControlStateValueOff};
use super::*;
use crate::events::{
EVENT_KIND_DRAG_END, EVENT_KIND_DRAG_START, decode_click, unpack_bool, unpack_value_changed,
};
#[test]
fn a_click_packs_no_detail_and_decodes_through_the_shared_decoder() {
let detail = detail_for(EVENT_KIND_CLICK, None);
assert_eq!(detail, Some(0));
assert_eq!(
decode_click(NativeEvent {
kind: EVENT_KIND_CLICK,
detail: 0,
}),
Some(crate::events::EventPayload::Click)
);
}
#[test]
fn the_drag_edges_are_refused_never_synthesized() {
assert_eq!(detail_for(EVENT_KIND_DRAG_START, None), None);
assert_eq!(detail_for(EVENT_KIND_DRAG_END, None), None);
}
#[test]
fn a_payload_kind_with_no_sender_is_refused_not_zeroed() {
assert_eq!(detail_for(EVENT_KIND_TOGGLED, None), None);
assert_eq!(detail_for(EVENT_KIND_VALUE_CHANGED, None), None);
assert_eq!(detail_for(EVENT_KIND_SELECTION, None), None);
assert_eq!(detail_for(EVENT_KIND_DATE, None), None);
}
#[test]
fn only_the_on_state_is_on() {
assert!(unpack_bool(pack_bool(is_on(NSControlStateValueOn))));
assert!(!is_on(NSControlStateValueOff));
assert!(!is_on(NSControlStateValueMixed));
}
#[test]
fn a_slider_value_rounds_to_the_nearest_platform_space_integer() {
assert_eq!(platform_value(0.0), 0);
assert_eq!(platform_value(41.49), 41);
assert_eq!(platform_value(41.5), 42);
assert_eq!(platform_value(99.9), 100);
assert_eq!(platform_value(f64::NAN), 0, "NaN never wraps");
let packed = pack_value_changed(platform_value(7.6), true);
assert_eq!(unpack_value_changed(packed), (8, true));
}
}