use std::cell::{Cell, RefCell};
use std::ffi::c_void;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, Instant};
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use core_graphics::sys::CGContextRef;
use objc2::encode::{Encoding, RefEncode};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2::{define_class, msg_send, sel, ClassType, DefinedClass, MainThreadOnly, Message};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate,
NSApplicationTerminateReply, NSBackingStoreType, NSDragOperation, NSDraggingInfo, NSEvent,
NSEventMask, NSGraphicsContext, NSPasteboardTypeFileURL, NSView,
NSViewFrameDidChangeNotification, NSWindow, NSWindowDelegate,
NSWindowDidChangeBackingPropertiesNotification, NSWindowStyleMask, NSWindowTitleVisibility,
};
use objc2_foundation::{
MainThreadMarker, NSArray, NSDate, NSDefaultRunLoopMode, NSNotification, NSNotificationCenter,
NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSTimer, NSURL,
};
use super::backend::MacBackend;
use super::events::{
ease_out_cubic, fold_wheel_momentum_remaining, ns_files_dropped, ns_key_to_uievent,
ns_mouse_down, ns_mouse_moved, ns_mouse_up, route_wheel_event, wheel_momentum_quantized_target,
WheelRouting, WHEEL_MOMENTUM_DURATION, WHEEL_MOMENTUM_TICK,
};
use super::text::make_font;
use super::tray::decode_ns_image;
use crate::backend::Backend;
use crate::event::Viewport;
use crate::primitives::image::ImageSource;
use crate::runner::{AppLogic, Reaction};
pub use crate::runner::StepOutcome;
use crate::runtime::{self, ReactionSink, ResizeDebouncer, RESIZE_SETTLE};
pub(crate) use crate::runtime::EventOutcome;
use crate::transition::Transition;
use crate::{ButtonMask, Point, ScrollDelta, UiEvent};
#[repr(C)]
struct OpaqueCGContext {
_unused: [u8; 0],
}
unsafe impl RefEncode for OpaqueCGContext {
const ENCODING_REF: Encoding = Encoding::Pointer(&Encoding::Struct("CGContext", &[]));
}
extern "C" {
fn CGContextSetRGBFillColor(
c: CGContextRef,
red: core_graphics::base::CGFloat,
green: core_graphics::base::CGFloat,
blue: core_graphics::base::CGFloat,
alpha: core_graphics::base::CGFloat,
);
fn CGContextFillRect(c: CGContextRef, rect: CGRect);
}
pub(crate) unsafe fn fill_frame_background(
cg_ref: CGContextRef,
rect: CGRect,
bg: crate::types::Color,
) {
let (r, g, b, a) = super::cg::color_to_cg(bg);
CGContextSetRGBFillColor(cg_ref, r, g, b, a);
CGContextFillRect(cg_ref, rect);
}
type PaintFn = Box<dyn Fn(Viewport, CGContextRef) + 'static>;
type HandleFn = Rc<dyn Fn(UiEvent) -> Reaction + 'static>;
type TickFn = Box<dyn Fn() -> Reaction + 'static>;
pub(crate) fn dispatch_event<A: AppLogic>(
event: UiEvent,
backend: &mut MacBackend,
app: &mut A,
caret_visible: &Rc<Cell<bool>>,
caret_pause: &Rc<Cell<std::time::Instant>>,
) -> EventOutcome {
if matches!(event, UiEvent::KeyPressed { .. } | UiEvent::CharTyped(_)) {
caret_visible.set(true);
caret_pause.set(std::time::Instant::now() + std::time::Duration::from_millis(500));
}
if let UiEvent::MouseDown { .. } = &event {
let dispatched = {
let stack_rc = backend.modal_stack_handle();
let drag_rc = backend.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
crate::dispatch::route_pointer(
&stack,
&mut drag,
backend.text_selection_state_mut(),
event,
)
};
let mut outcome = EventOutcome::Continue;
for ev in dispatched {
let step = runtime::preprocess_event(ev, backend, app);
if matches!(step, EventOutcome::Exit) {
return EventOutcome::Exit;
}
outcome = outcome.merge(step);
}
return outcome;
}
if let UiEvent::MouseMoved { .. } = &event {
let events = {
let stack_rc = backend.modal_stack_handle();
let drag_rc = backend.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
crate::dispatch::route_pointer(
&stack,
&mut drag,
backend.text_selection_state_mut(),
event,
)
};
let mut outcome = EventOutcome::Continue;
for ev in events {
let step = if matches!(ev, UiEvent::MouseMoved { .. }) {
app.handle(ev, backend).into()
} else {
dispatch_event(ev, backend, app, caret_visible, caret_pause)
};
if matches!(step, EventOutcome::Exit) {
return EventOutcome::Exit;
}
outcome = outcome.merge(step);
}
return outcome;
}
if let UiEvent::MouseUp { .. } = &event {
let ev = {
let stack_rc = backend.modal_stack_handle();
let drag_rc = backend.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
crate::dispatch::route_pointer(
&stack,
&mut drag,
backend.text_selection_state_mut(),
event,
)
.into_iter()
.next()
.expect("dispatch_mouse_up always returns exactly one MouseUp")
};
return app.handle(ev, backend).into();
}
runtime::preprocess_event(event, backend, app)
}
pub(crate) fn render_frame<A: AppLogic>(
backend: &mut MacBackend,
app: &A,
viewport: Viewport,
ctx: CGContextRef,
) {
backend.begin_frame(viewport);
let focus_ring_rect =
runtime::focused_stop_rect(backend, app, <A as AppLogic>::AreaId::default());
backend.enter_frame_scope(ctx, |b| {
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
app.render(&mut *b, <A as AppLogic>::AreaId::default());
}));
if let Err(payload) = rendered {
crate::desktop::report_caught_panic_once(
"macos::run::render_frame (app.render)",
payload.as_ref(),
);
return;
}
if let Some(rect) = focus_ring_rect {
b.draw_focus_ring(rect);
}
b.apply_selection_highlight();
});
backend.end_frame();
}
#[derive(Default)]
pub(crate) struct WindowCloseFlag {
closed: Cell<bool>,
}
impl WindowCloseFlag {
fn note_closed(&self) {
self.closed.set(true);
}
fn outcome(&self) -> StepOutcome {
if self.closed.get() {
StepOutcome::Exited
} else {
StepOutcome::Continue
}
}
}
struct WheelMomentum {
transition: Transition,
total_dx: f32,
total_dy: f32,
delivered_dx: f32,
delivered_dy: f32,
x: f32,
y: f32,
}
pub(crate) struct QuadraViewIvars {
last_viewport: Cell<Viewport>,
backend: Rc<RefCell<MacBackend>>,
paint: PaintFn,
handle: HandleFn,
tick: TickFn,
resize_debouncer: RefCell<ResizeDebouncer>,
resize_timer: RefCell<Option<Retained<NSTimer>>>,
exit_gate: Rc<ExitGate>,
window_closed: Rc<WindowCloseFlag>,
wheel_momentum: RefCell<Option<WheelMomentum>>,
momentum_timer: RefCell<Option<Retained<NSTimer>>>,
}
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "QuadraUiView"]
#[ivars = QuadraViewIvars]
pub(crate) struct QuadraView;
impl QuadraView {
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty: NSRect) {
let bounds = self.bounds();
let scale = self
.window()
.map(|w| w.backingScaleFactor())
.unwrap_or(1.0);
let viewport = Viewport::new(
bounds.size.width as f32,
bounds.size.height as f32,
scale as f32,
);
self.ivars().last_viewport.set(viewport);
let Some(gctx) = NSGraphicsContext::currentContext() else {
return;
};
let cg_opaque: *mut OpaqueCGContext = unsafe { msg_send![&*gctx, CGContext] };
if cg_opaque.is_null() {
return;
}
let cg_ref: CGContextRef = cg_opaque.cast();
let rect = CGRect::new(
&CGPoint::new(bounds.origin.x, bounds.origin.y),
&CGSize::new(bounds.size.width, bounds.size.height),
);
let bg = self.ivars().backend.borrow().current_theme().background;
unsafe {
fill_frame_background(cg_ref, rect, bg);
}
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.ivars().paint)(viewport, cg_ref);
})) {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraView::draw_rect (paint)",
payload.as_ref(),
);
}
}
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method(acceptsFirstResponder))]
fn accepts_first_responder(&self) -> bool {
true
}
#[unsafe(method(mouseDown:))]
fn objc_mouse_down(&self, event: &NSEvent) {
let retained: Retained<NSEvent> = event.retain();
self.ivars().backend.borrow_mut().stash_window_press(retained);
let (x, y, flags) = self.locate(event);
let button = event.buttonNumber() as i64;
self.dispatch(ns_mouse_down(button, x, y, flags));
}
#[unsafe(method(rightMouseDown:))]
fn objc_right_mouse_down(&self, event: &NSEvent) {
let (x, y, flags) = self.locate(event);
let button = event.buttonNumber() as i64;
self.dispatch(ns_mouse_down(button, x, y, flags));
}
#[unsafe(method(otherMouseDown:))]
fn objc_other_mouse_down(&self, event: &NSEvent) {
let (x, y, flags) = self.locate(event);
let button = event.buttonNumber() as i64;
self.dispatch(ns_mouse_down(button, x, y, flags));
}
#[unsafe(method(mouseUp:))]
fn objc_mouse_up(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
let button = event.buttonNumber() as i64;
self.dispatch(ns_mouse_up(button, x, y));
}
#[unsafe(method(rightMouseUp:))]
fn objc_right_mouse_up(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
let button = event.buttonNumber() as i64;
self.dispatch(ns_mouse_up(button, x, y));
}
#[unsafe(method(otherMouseUp:))]
fn objc_other_mouse_up(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
let button = event.buttonNumber() as i64;
self.dispatch(ns_mouse_up(button, x, y));
}
#[unsafe(method(mouseMoved:))]
fn objc_mouse_moved(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
self.dispatch(ns_mouse_moved(x, y, ButtonMask::default()));
}
#[unsafe(method(mouseDragged:))]
fn objc_mouse_dragged(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
self.dispatch(ns_mouse_moved(
x,
y,
ButtonMask {
left: true,
..Default::default()
},
));
}
#[unsafe(method(rightMouseDragged:))]
fn objc_right_mouse_dragged(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
self.dispatch(ns_mouse_moved(
x,
y,
ButtonMask {
right: true,
..Default::default()
},
));
}
#[unsafe(method(otherMouseDragged:))]
fn objc_other_mouse_dragged(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
self.dispatch(ns_mouse_moved(
x,
y,
ButtonMask {
middle: true,
..Default::default()
},
));
}
#[unsafe(method(scrollWheel:))]
fn objc_scroll_wheel(&self, event: &NSEvent) {
let (x, y, _flags) = self.locate(event);
let dx = event.scrollingDeltaX();
let dy = event.scrollingDeltaY();
let precise = event.hasPreciseScrollingDeltas();
match route_wheel_event(precise, dx, dy, x, y) {
WheelRouting::Immediate(ev) => {
self.cancel_wheel_momentum();
self.dispatch(ev);
}
WheelRouting::Glide { dx, dy, x, y } => {
self.start_or_extend_wheel_momentum(dx, dy, x, y);
}
}
}
#[unsafe(method(keyDown:))]
fn objc_key_down(&self, event: &NSEvent) {
let flags = event.modifierFlags().0;
let key_code = event.keyCode();
let repeat = event.isARepeat();
let chars_ns = event.characters();
let chars_str = chars_ns.as_ref().map(|s| s.to_string());
if let Some(ev) = ns_key_to_uievent(chars_str.as_deref(), key_code, flags, repeat) {
self.dispatch(ev);
}
}
#[unsafe(method(viewFrameDidChange:))]
fn view_frame_did_change(&self, _note: &NSNotification) {
let bounds = self.bounds();
let scale = self
.window()
.map(|w| w.backingScaleFactor())
.unwrap_or(1.0);
let viewport = Viewport::new(
bounds.size.width as f32,
bounds.size.height as f32,
scale as f32,
);
if self.ivars().last_viewport.get() == viewport {
return;
}
self.ivars().last_viewport.set(viewport);
self.ivars().resize_debouncer.borrow_mut().note(viewport);
if let Some(old) = self.ivars().resize_timer.borrow_mut().take() {
old.invalidate();
}
let self_obj: &AnyObject = unsafe { &*(self as *const Self as *const AnyObject) };
let timer = unsafe {
NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats(
RESIZE_SETTLE.as_secs_f64(),
self_obj,
sel!(resizeDebounceFired:),
None,
false,
)
};
self.ivars().resize_timer.borrow_mut().replace(timer);
}
#[unsafe(method(resizeDebounceFired:))]
fn resize_debounce_fired(&self, _timer: &NSTimer) {
self.ivars().resize_timer.borrow_mut().take();
if let Some(viewport) = self.ivars().resize_debouncer.borrow_mut().take() {
self.dispatch(UiEvent::WindowResized { viewport });
}
}
#[unsafe(method(wheelMomentumTick:))]
fn wheel_momentum_tick(&self, _timer: &NSTimer) {
let now = Instant::now();
let (tick_dx, tick_dy, x, y, finished) = {
let mut momentum = self.ivars().wheel_momentum.borrow_mut();
let Some(state) = momentum.as_mut() else {
return;
};
let eased = ease_out_cubic(state.transition.progress_at(now));
let target_dx = wheel_momentum_quantized_target(state.total_dx, eased);
let target_dy = wheel_momentum_quantized_target(state.total_dy, eased);
let tick_dx = target_dx - state.delivered_dx;
let tick_dy = target_dy - state.delivered_dy;
let (x, y) = (state.x, state.y);
let finished = state.transition.is_finished_at(now);
state.delivered_dx = target_dx;
state.delivered_dy = target_dy;
(tick_dx, tick_dy, x, y, finished)
};
if tick_dx != 0.0 || tick_dy != 0.0 {
self.dispatch(UiEvent::Scroll {
widget: None,
delta: ScrollDelta::new(tick_dx, tick_dy),
position: Point::new(x, y),
});
}
if finished {
self.cancel_wheel_momentum();
}
}
#[unsafe(method(idlePollTick:))]
fn idle_poll_tick(&self, _timer: &NSTimer) {
self.dispatch_tick();
}
#[unsafe(method(windowDidChangeBackingProperties:))]
fn window_did_change_backing_properties(&self, _note: &NSNotification) {
let scale = self
.window()
.map(|w| w.backingScaleFactor())
.unwrap_or(1.0);
self.dispatch(UiEvent::DpiChanged(scale as f32));
}
#[unsafe(method(draggingEntered:))]
fn dragging_entered(
&self,
sender: &ProtocolObject<dyn NSDraggingInfo>,
) -> NSDragOperation {
self.drag_operation(sender)
}
#[unsafe(method(draggingUpdated:))]
fn dragging_updated(
&self,
sender: &ProtocolObject<dyn NSDraggingInfo>,
) -> NSDragOperation {
self.drag_operation(sender)
}
#[unsafe(method(performDragOperation:))]
fn perform_drag_operation(&self, sender: &ProtocolObject<dyn NSDraggingInfo>) -> bool {
let paths = self.dropped_paths_guarded(sender);
if paths.is_empty() {
false
} else {
let loc = sender.draggingLocation();
let view_pt = self.convertPoint_fromView(loc, None);
self.dispatch(ns_files_dropped(paths, view_pt.x, view_pt.y));
true
}
}
}
unsafe impl NSObjectProtocol for QuadraView {}
unsafe impl NSWindowDelegate for QuadraView {
#[unsafe(method(windowShouldClose:))]
fn window_should_close(&self, _sender: &NSWindow) -> bool {
self.evaluate_window_should_close()
}
#[unsafe(method(windowWillClose:))]
fn window_will_close(&self, _note: &NSNotification) {
self.ivars().window_closed.note_closed();
}
}
);
impl QuadraView {
fn new(
mtm: MainThreadMarker,
backend: Rc<RefCell<MacBackend>>,
paint: PaintFn,
handle: HandleFn,
tick: TickFn,
exit_gate: Rc<ExitGate>,
window_closed: Rc<WindowCloseFlag>,
) -> Retained<Self> {
let this = mtm.alloc::<Self>();
let this = this.set_ivars(QuadraViewIvars {
last_viewport: Cell::new(Viewport::default()),
backend,
paint,
handle,
tick,
resize_debouncer: RefCell::new(ResizeDebouncer::new()),
resize_timer: RefCell::new(None),
exit_gate,
window_closed,
wheel_momentum: RefCell::new(None),
momentum_timer: RefCell::new(None),
});
unsafe { msg_send![super(this), init] }
}
fn locate(&self, event: &NSEvent) -> (f64, f64, usize) {
let win_pt = event.locationInWindow();
let view_pt = self.convertPoint_fromView(win_pt, None);
let flags = event.modifierFlags().0;
(view_pt.x, view_pt.y, flags)
}
fn drag_operation(&self, sender: &ProtocolObject<dyn NSDraggingInfo>) -> NSDragOperation {
if self.dropped_paths_guarded(sender).is_empty() {
NSDragOperation::None
} else {
NSDragOperation::Copy
}
}
fn dropped_paths_guarded(&self, sender: &ProtocolObject<dyn NSDraggingInfo>) -> Vec<PathBuf> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.dropped_paths(sender)))
{
Ok(paths) => paths,
Err(payload) => {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraView::dropped_paths",
payload.as_ref(),
);
Vec::new()
}
}
}
fn dropped_paths(&self, sender: &ProtocolObject<dyn NSDraggingInfo>) -> Vec<PathBuf> {
let pasteboard = sender.draggingPasteboard();
let Some(items) = pasteboard.pasteboardItems() else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
let file_url_type = unsafe { NSPasteboardTypeFileURL };
let url_string = item.stringForType(file_url_type)?;
let url = NSURL::URLWithString(&url_string)?;
let path = url.path()?;
Some(PathBuf::from(path.to_string()))
})
.collect()
}
fn dispatch(&self, ev: UiEvent) {
let reaction = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.ivars().handle)(ev)
})) {
Ok(reaction) => reaction,
Err(payload) => {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraView::dispatch (app.handle)",
payload.as_ref(),
);
Reaction::Continue
}
};
self.apply_reaction(reaction);
}
fn cancel_wheel_momentum(&self) {
self.ivars().wheel_momentum.borrow_mut().take();
if let Some(timer) = self.ivars().momentum_timer.borrow_mut().take() {
timer.invalidate();
}
}
fn start_or_extend_wheel_momentum(&self, dx: f32, dy: f32, x: f32, y: f32) {
let now = Instant::now();
let (total_dx, total_dy) = {
let momentum = self.ivars().wheel_momentum.borrow();
match momentum.as_ref() {
Some(state) => (
fold_wheel_momentum_remaining(state.total_dx, state.delivered_dx, dx),
fold_wheel_momentum_remaining(state.total_dy, state.delivered_dy, dy),
),
None => (dx, dy),
}
};
self.ivars()
.wheel_momentum
.borrow_mut()
.replace(WheelMomentum {
transition: Transition::starting_at(now, WHEEL_MOMENTUM_DURATION),
total_dx,
total_dy,
delivered_dx: 0.0,
delivered_dy: 0.0,
x,
y,
});
if self.ivars().momentum_timer.borrow().is_some() {
return;
}
let self_obj: &AnyObject = unsafe { &*(self as *const Self as *const AnyObject) };
let timer = unsafe {
NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats(
WHEEL_MOMENTUM_TICK.as_secs_f64(),
self_obj,
sel!(wheelMomentumTick:),
None,
true,
)
};
self.ivars().momentum_timer.borrow_mut().replace(timer);
}
fn evaluate_window_should_close(&self) -> bool {
let reaction = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.ivars().handle)(UiEvent::WindowClose)
})) {
Ok(reaction) => reaction,
Err(payload) => {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraView::evaluate_window_should_close (app.handle)",
payload.as_ref(),
);
return false;
}
};
if window_should_close_for_reaction(reaction) {
self.ivars().exit_gate.note_exit_granted();
true
} else {
self.apply_reaction(reaction);
false
}
}
fn dispatch_tick(&self) {
let reaction = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.ivars().tick)()
})) {
Ok(reaction) => reaction,
Err(payload) => {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraView::dispatch_tick (app.tick)",
payload.as_ref(),
);
Reaction::Continue
}
};
self.apply_reaction(reaction);
}
fn apply_reaction(&self, reaction: Reaction) {
runtime::apply_outcome(reaction, self);
}
}
impl ReactionSink for QuadraView {
fn request_redraw(&self) {
self.setNeedsDisplay(true)
}
fn request_exit(&self) {
self.ivars().exit_gate.note_exit_granted();
let mtm = MainThreadMarker::from(self);
let app = NSApplication::sharedApplication(mtm);
app.terminate(None);
}
fn request_frame_in(&self, delay: std::time::Duration) {
self.ivars().backend.borrow().request_frame_in(delay);
}
}
pub(crate) struct QuadraAppDelegateIvars {
handle: HandleFn,
exit_gate: Rc<ExitGate>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "QuadraUiAppDelegate"]
#[ivars = QuadraAppDelegateIvars]
pub(crate) struct QuadraAppDelegate;
unsafe impl NSObjectProtocol for QuadraAppDelegate {}
unsafe impl NSApplicationDelegate for QuadraAppDelegate {
#[unsafe(method(applicationShouldTerminateAfterLastWindowClosed:))]
fn should_terminate_after_last_window(&self, _sender: &NSApplication) -> bool {
true
}
#[unsafe(method(applicationShouldTerminate:))]
fn should_terminate(&self, _sender: &NSApplication) -> NSApplicationTerminateReply {
self.evaluate_should_terminate()
}
#[unsafe(method(application:openURLs:))]
fn application_open_urls(&self, _application: &NSApplication, urls: &NSArray<NSURL>) {
self.dispatch_open_requested(ns_urls_to_open_requested(urls));
}
#[unsafe(method(application:openFiles:))]
fn application_open_files(&self, _sender: &NSApplication, filenames: &NSArray<NSString>) {
self.dispatch_open_requested(ns_filenames_to_open_requested(filenames));
}
}
);
impl QuadraAppDelegate {
fn new(mtm: MainThreadMarker, handle: HandleFn, exit_gate: Rc<ExitGate>) -> Retained<Self> {
let this = mtm.alloc::<Self>();
let this = this.set_ivars(QuadraAppDelegateIvars { handle, exit_gate });
unsafe { msg_send![super(this), init] }
}
fn evaluate_should_terminate(&self) -> NSApplicationTerminateReply {
self.ivars().exit_gate.terminate_reply(|| {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.ivars().handle)(UiEvent::WindowClose)
})) {
Ok(reaction) => reaction,
Err(payload) => {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraAppDelegate::evaluate_should_terminate (app.handle)",
payload.as_ref(),
);
Reaction::Continue
}
}
})
}
fn dispatch_open_requested(&self, event: UiEvent) {
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.ivars().handle)(event)
})) {
crate::desktop::report_caught_panic_once(
"macos::run::QuadraAppDelegate::dispatch_open_requested (app.handle)",
payload.as_ref(),
);
}
}
}
fn ns_urls_to_open_requested(urls: &NSArray<NSURL>) -> UiEvent {
let mut open_urls = Vec::new();
let mut files = Vec::new();
for url in urls.iter() {
if url.isFileURL() {
if let Some(path) = url.path() {
files.push(PathBuf::from(path.to_string()));
}
} else if let Some(absolute) = url.absoluteString() {
open_urls.push(absolute.to_string());
}
}
UiEvent::OpenRequested {
urls: open_urls,
files,
}
}
fn ns_filenames_to_open_requested(filenames: &NSArray<NSString>) -> UiEvent {
UiEvent::OpenRequested {
urls: Vec::new(),
files: filenames
.iter()
.map(|name| PathBuf::from(name.to_string()))
.collect(),
}
}
fn window_should_close_for_reaction(reaction: Reaction) -> bool {
matches!(reaction, Reaction::Exit)
}
fn terminate_reply_for_reaction(reaction: Reaction) -> NSApplicationTerminateReply {
if window_should_close_for_reaction(reaction) {
NSApplicationTerminateReply::TerminateNow
} else {
NSApplicationTerminateReply::TerminateCancel
}
}
#[derive(Debug, Default)]
pub(crate) struct ExitGate {
exit_granted: Cell<bool>,
}
impl ExitGate {
fn note_exit_granted(&self) {
self.exit_granted.set(true);
}
fn exit_already_granted(&self) -> bool {
self.exit_granted.get()
}
fn terminate_reply(
&self,
dispatch_window_close: impl FnOnce() -> Reaction,
) -> NSApplicationTerminateReply {
if self.exit_already_granted() {
return NSApplicationTerminateReply::TerminateNow;
}
terminate_reply_for_reaction(dispatch_window_close())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunConfig {
pub title: String,
pub client_side_titlebar: bool,
pub app_icon: Option<ImageSource>,
}
impl RunConfig {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
client_side_titlebar: false,
app_icon: None,
}
}
pub fn with_client_side_titlebar(mut self, enabled: bool) -> Self {
self.client_side_titlebar = enabled;
self
}
pub fn with_app_icon(mut self, icon: ImageSource) -> Self {
self.app_icon = Some(icon);
self
}
}
impl Default for RunConfig {
fn default() -> Self {
Self {
title: "quadraui (macos)".to_string(),
client_side_titlebar: false,
app_icon: None,
}
}
}
fn window_title(config: &RunConfig) -> String {
config.title.clone()
}
fn window_style_mask(config: &RunConfig) -> NSWindowStyleMask {
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Resizable
| NSWindowStyleMask::Miniaturizable;
if config.client_side_titlebar {
style | NSWindowStyleMask::FullSizeContentView
} else {
style
}
}
pub fn run<A: AppLogic + 'static>(app: A) -> std::process::ExitCode {
run_with(app, RunConfig::default())
}
pub fn run_with<A: AppLogic + 'static>(app: A, config: RunConfig) -> std::process::ExitCode {
let mtm =
MainThreadMarker::new().expect("quadraui::macos::run must be called from the main thread");
let boot = bootstrap(app, config, mtm);
boot.ns_app.run();
std::process::ExitCode::SUCCESS
}
struct MacBootstrap {
ns_app: Retained<NSApplication>,
_window: Retained<NSWindow>,
_view: Retained<QuadraView>,
_delegate: Retained<QuadraAppDelegate>,
_idle_poll_timer: Retained<NSTimer>,
_blink_target: Retained<super::caret_blink::QuadraBlinkTarget>,
_blink_timer: Retained<NSTimer>,
window_closed: Rc<WindowCloseFlag>,
}
fn bootstrap<A: AppLogic + 'static>(
app: A,
config: RunConfig,
mtm: MainThreadMarker,
) -> MacBootstrap {
let app = Rc::new(RefCell::new(app));
let backend = Rc::new(RefCell::new(MacBackend::new()));
if let Some(font) = make_font("Menlo", 14.0) {
backend.borrow_mut().set_current_font(font);
}
{
let mut backend_mut = backend.borrow_mut();
let mut app_mut = app.borrow_mut();
app_mut.setup(&mut *backend_mut);
}
let paint: PaintFn = {
let app = app.clone();
let backend = backend.clone();
Box::new(move |viewport: Viewport, cg_ref: CGContextRef| {
let pending: Vec<UiEvent> = backend.borrow_mut().poll_events();
for ev in pending {
let _ = app.borrow_mut().handle(ev, &mut *backend.borrow_mut());
}
let mut backend_mut = backend.borrow_mut();
let app_ref = app.borrow();
render_frame(&mut backend_mut, &*app_ref, viewport, cg_ref);
})
};
let caret_visible = backend.borrow().caret_visible_handle();
let caret_pause = backend.borrow().caret_blink_pause_handle();
let handle: HandleFn = {
let app = app.clone();
let backend = backend.clone();
let caret_visible = caret_visible.clone();
let caret_pause = caret_pause.clone();
Rc::new(move |ev: UiEvent| -> Reaction {
let mut backend_mut = backend.borrow_mut();
let mut app_mut = app.borrow_mut();
match dispatch_event(
ev,
&mut backend_mut,
&mut *app_mut,
&caret_visible,
&caret_pause,
) {
EventOutcome::Continue => Reaction::Continue,
EventOutcome::Redraw => Reaction::Redraw,
EventOutcome::RedrawAfter(d) => Reaction::RedrawAfter(d),
EventOutcome::Exit => Reaction::Exit,
}
})
};
let tick: TickFn = {
let app = app.clone();
let backend = backend.clone();
Box::new(move || -> Reaction {
let mut backend_mut = backend.borrow_mut();
let mut app_mut = app.borrow_mut();
app_mut.tick(&mut *backend_mut)
})
};
let ns_app = NSApplication::sharedApplication(mtm);
let _ = ns_app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
if let Some(source) = &config.app_icon {
if let Some(image) = decode_ns_image(source) {
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
}
}
let exit_gate = Rc::new(ExitGate::default());
let delegate = QuadraAppDelegate::new(mtm, handle.clone(), exit_gate.clone());
let delegate_proto = ProtocolObject::from_ref(&*delegate);
ns_app.setDelegate(Some(delegate_proto));
let content_rect = NSRect::new(NSPoint::new(120.0, 120.0), NSSize::new(800.0, 600.0));
let style = window_style_mask(&config);
let window: Retained<NSWindow> = unsafe {
msg_send![
mtm.alloc::<NSWindow>(),
initWithContentRect: content_rect,
styleMask: style,
backing: NSBackingStoreType::Buffered,
defer: false,
]
};
window.setTitle(&NSString::from_str(&window_title(&config)));
if config.client_side_titlebar {
window.setTitlebarAppearsTransparent(true);
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
}
backend.borrow_mut().set_window(window.clone());
let window_closed = Rc::new(WindowCloseFlag::default());
let view = QuadraView::new(
mtm,
backend.clone(),
paint,
handle,
tick,
exit_gate,
window_closed.clone(),
);
window.setContentView(Some(&view));
window.setAcceptsMouseMovedEvents(true);
window.makeFirstResponder(Some(view.as_super()));
window.setDelegate(Some(ProtocolObject::from_ref(&*view)));
window.makeKeyAndOrderFront(None);
let file_url_type = unsafe { NSPasteboardTypeFileURL };
let dragged_types: Retained<NSArray<NSString>> = NSArray::from_slice(&[file_url_type]);
view.registerForDraggedTypes(&dragged_types);
let view_for_wake = view.clone();
backend
.borrow()
.set_wake_callback(Rc::new(move || view_for_wake.request_redraw()), mtm);
let view_for_tick = view.clone();
backend
.borrow()
.set_tick_callback(Rc::new(move || view_for_tick.dispatch_tick()), mtm);
view.setPostsFrameChangedNotifications(true);
let view_obj: &AnyObject = unsafe { &*(&*view as *const QuadraView as *const AnyObject) };
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
view_obj,
sel!(viewFrameDidChange:),
Some(NSViewFrameDidChangeNotification),
Some(view_obj),
);
}
let _idle_poll_timer: Retained<NSTimer> = unsafe {
NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats(
crate::runtime::IDLE_POLL_CEILING.as_secs_f64(),
view_obj,
sel!(idlePollTick:),
None,
true,
)
};
let window_obj: &AnyObject = unsafe { &*(&*window as *const NSWindow as *const AnyObject) };
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
view_obj,
sel!(windowDidChangeBackingProperties:),
Some(NSWindowDidChangeBackingPropertiesNotification),
Some(window_obj),
);
}
let (_blink_target, _blink_timer) =
super::caret_blink::install_blink_timer(mtm, caret_visible, caret_pause);
if ns_app.respondsToSelector(sel!(activate)) {
ns_app.activate();
} else {
#[allow(deprecated)]
ns_app.activateIgnoringOtherApps(true);
}
MacBootstrap {
ns_app,
_window: window,
_view: view,
_delegate: delegate,
_idle_poll_timer,
_blink_target,
_blink_timer,
window_closed,
}
}
#[allow(dead_code)]
fn _unused_imports(_p: *mut c_void) {}
pub struct MacRunner {
_boot: MacBootstrap,
}
impl MacRunner {
pub fn new<A: AppLogic + 'static>(app: A) -> Self {
Self::new_with(app, RunConfig::default())
}
pub fn new_with<A: AppLogic + 'static>(app: A, config: RunConfig) -> Self {
let mtm = MainThreadMarker::new()
.expect("quadraui::macos::run::MacRunner must be built on the main thread");
let boot = bootstrap(app, config, mtm);
if !boot.ns_app.isRunning() {
boot.ns_app.finishLaunching();
}
Self { _boot: boot }
}
fn window_open(&self) -> bool {
self.outcome() == StepOutcome::Continue
}
fn outcome(&self) -> StepOutcome {
self._boot.window_closed.outcome()
}
fn run_one(&mut self, expiration: &NSDate) -> StepOutcome {
if !self.window_open() {
return StepOutcome::Exited;
}
let event = unsafe {
self._boot
.ns_app
.nextEventMatchingMask_untilDate_inMode_dequeue(
NSEventMask::Any,
Some(expiration),
NSDefaultRunLoopMode,
true,
)
};
if let Some(event) = event {
self._boot.ns_app.sendEvent(&event);
}
self._boot.ns_app.updateWindows();
self._boot._window.displayIfNeeded();
self.outcome()
}
pub fn step(&mut self) -> StepOutcome {
let expiration = NSDate::distantPast();
self.run_one(&expiration)
}
pub fn pump(&mut self, timeout: Duration) -> StepOutcome {
let expiration = NSDate::dateWithTimeIntervalSinceNow(timeout.as_secs_f64());
self.run_one(&expiration)
}
}
#[cfg(test)]
mod window_close_flag_tests {
use super::*;
#[test]
fn a_fresh_flag_reports_continue() {
assert_eq!(WindowCloseFlag::default().outcome(), StepOutcome::Continue);
}
#[test]
fn an_untouched_flag_keeps_reporting_continue() {
let flag = WindowCloseFlag::default();
for _ in 0..5 {
assert_eq!(flag.outcome(), StepOutcome::Continue);
}
}
#[test]
fn note_closed_latches_exited() {
let flag = WindowCloseFlag::default();
flag.note_closed();
assert_eq!(flag.outcome(), StepOutcome::Exited);
}
#[test]
fn the_latch_is_one_way() {
let flag = WindowCloseFlag::default();
flag.note_closed();
flag.note_closed();
assert_eq!(flag.outcome(), StepOutcome::Exited);
}
#[test]
fn clones_share_one_latch() {
let view_side = Rc::new(WindowCloseFlag::default());
let runner_side = view_side.clone();
assert_eq!(runner_side.outcome(), StepOutcome::Continue);
view_side.note_closed();
assert_eq!(runner_side.outcome(), StepOutcome::Exited);
}
}
#[cfg(test)]
mod step_pump_tests {
use super::*;
use std::sync::Arc;
type WakerSlot = Rc<RefCell<Option<Arc<dyn Fn(crate::UserPayload) + Send + Sync>>>>;
#[derive(Default, Clone)]
struct Recorder {
render_calls: Rc<Cell<usize>>,
tick_calls: Rc<Cell<usize>>,
events: Rc<RefCell<Vec<UiEvent>>>,
waker: WakerSlot,
}
struct StepTestApp {
recorder: Recorder,
redraw_after: Option<Duration>,
}
impl AppLogic for StepTestApp {
type AreaId = ();
fn setup(&mut self, backend: &mut dyn Backend) {
*self.recorder.waker.borrow_mut() = Some(backend.waker());
if let Some(delay) = self.redraw_after {
backend.request_frame_in(delay);
}
}
fn render(&self, _backend: &mut dyn Backend, _area: ()) {
self.recorder
.render_calls
.set(self.recorder.render_calls.get() + 1);
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.recorder.events.borrow_mut().push(event);
Reaction::Continue
}
fn tick(&mut self, _backend: &mut dyn Backend) -> Reaction {
self.recorder
.tick_calls
.set(self.recorder.tick_calls.get() + 1);
Reaction::Continue
}
}
#[test]
fn step_observes_a_rendered_frame() {
let Some(_mtm) = MainThreadMarker::new() else {
return;
};
let recorder = Recorder::default();
let app = StepTestApp {
recorder: recorder.clone(),
redraw_after: None,
};
let mut runner = MacRunner::new(app);
let outcome = runner.step();
assert_eq!(outcome, StepOutcome::Continue);
assert!(
recorder.render_calls.get() >= 1,
"step() must force a synchronous repaint of the freshly opened window"
);
}
#[test]
fn waker_delivers_a_user_event_without_busy_polling() {
let Some(_mtm) = MainThreadMarker::new() else {
return;
};
let recorder = Recorder::default();
let app = StepTestApp {
recorder: recorder.clone(),
redraw_after: None,
};
let mut runner = MacRunner::new(app);
let waker = recorder
.waker
.borrow_mut()
.take()
.expect("setup() must capture a waker before MacRunner::new returns");
let handle = std::thread::spawn(move || {
waker(crate::UserPayload::new(7_i32));
});
handle.join().expect("background thread must not panic");
let outcome = runner.pump(Duration::from_secs(2));
assert_eq!(outcome, StepOutcome::Continue);
let events = recorder.events.borrow();
assert_eq!(
events.len(),
1,
"expected exactly the one waker-delivered event"
);
match &events[0] {
UiEvent::User(payload) => {
assert_eq!(payload.downcast_ref::<i32>(), Some(&7));
}
other => panic!("expected UiEvent::User, got {other:?}"),
}
}
#[test]
fn redraw_after_schedules_exactly_one_wake_at_the_deadline() {
let Some(_mtm) = MainThreadMarker::new() else {
return;
};
let recorder = Recorder::default();
let app = StepTestApp {
recorder: recorder.clone(),
redraw_after: Some(Duration::from_millis(30)),
};
let mut runner = MacRunner::new(app);
pump_until(&mut runner, Duration::from_millis(80));
assert_eq!(
recorder.tick_calls.get(),
1,
"the scheduled wake must fire exactly once by the deadline"
);
pump_until(&mut runner, Duration::from_millis(80));
assert_eq!(
recorder.tick_calls.get(),
1,
"a RedrawAfter wake must not re-fire without a fresh request_frame_in call"
);
}
#[test]
fn a_hidden_or_minimized_window_still_reports_continue() {
let Some(_mtm) = MainThreadMarker::new() else {
return;
};
let recorder = Recorder::default();
let app = StepTestApp {
recorder: recorder.clone(),
redraw_after: None,
};
let mut runner = MacRunner::new(app);
let window = runner._boot._window.clone();
window.orderOut(None);
assert!(
!window.isVisible(),
"orderOut: must take the window offscreen"
);
assert_eq!(
runner.step(),
StepOutcome::Continue,
"a hidden window has not been closed"
);
window.makeKeyAndOrderFront(None);
window.miniaturize(None);
assert_eq!(
runner.step(),
StepOutcome::Continue,
"a minimized window has not been closed"
);
window.deminiaturize(None);
assert_eq!(runner.step(), StepOutcome::Continue);
}
fn pump_until(runner: &mut MacRunner, window: Duration) {
let deadline = std::time::Instant::now() + window;
loop {
let Some(left) = deadline.checked_duration_since(std::time::Instant::now()) else {
return;
};
if left.is_zero() || runner.pump(left) == StepOutcome::Exited {
return;
}
}
}
}
#[cfg(test)]
mod run_config_tests {
use super::*;
#[test]
fn new_sets_the_title() {
let config = RunConfig::new("kubeui");
assert_eq!(config.title, "kubeui");
}
#[test]
fn new_accepts_owned_and_borrowed_strings() {
assert_eq!(RunConfig::new("borrowed").title, "borrowed");
assert_eq!(RunConfig::new(String::from("owned")).title, "owned");
}
#[test]
fn new_defaults_client_side_titlebar_to_false() {
assert!(!RunConfig::new("kubeui").client_side_titlebar);
}
#[test]
fn with_client_side_titlebar_sets_the_flag() {
let config = RunConfig::new("kubeui").with_client_side_titlebar(true);
assert!(config.client_side_titlebar);
}
#[test]
fn new_and_default_set_no_app_icon() {
assert_eq!(RunConfig::new("kubeui").app_icon, None);
assert_eq!(RunConfig::default().app_icon, None);
}
#[test]
fn with_app_icon_sets_the_source() {
let config = RunConfig::new("kubeui").with_app_icon(ImageSource::Bytes(vec![0xde, 0xad]));
assert_eq!(config.app_icon, Some(ImageSource::Bytes(vec![0xde, 0xad])));
}
#[test]
fn default_matches_runs_previously_hardcoded_title() {
assert_eq!(RunConfig::default().title, "quadraui (macos)");
}
#[test]
fn window_title_comes_from_config_not_a_literal() {
let config = RunConfig::new("vimcode");
assert_eq!(window_title(&config), "vimcode");
assert_ne!(
window_title(&config),
"quadraui (macos)",
"a custom RunConfig title must not fall back to the generic default"
);
}
#[test]
fn style_mask_unset_matches_pre_947_hardcoded_mask() {
let config = RunConfig::new("kubeui");
let expected = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Resizable
| NSWindowStyleMask::Miniaturizable;
assert_eq!(window_style_mask(&config), expected);
assert!(!window_style_mask(&config).contains(NSWindowStyleMask::FullSizeContentView));
}
#[test]
fn style_mask_set_adds_full_size_content_view_without_losing_the_rest() {
let config = RunConfig::new("kubeui").with_client_side_titlebar(true);
let mask = window_style_mask(&config);
assert!(mask.contains(NSWindowStyleMask::FullSizeContentView));
assert!(mask.contains(NSWindowStyleMask::Titled));
assert!(mask.contains(NSWindowStyleMask::Closable));
assert!(mask.contains(NSWindowStyleMask::Resizable));
assert!(mask.contains(NSWindowStyleMask::Miniaturizable));
}
}
#[cfg(test)]
mod background_fill_tests {
use super::*;
use crate::macos::headless::BitmapSurface;
use crate::types::Color;
#[test]
fn fill_frame_background_paints_the_given_background_not_a_fixed_dark_literal() {
const W: u32 = 120;
const H: u32 = 80;
let light_bg = Color::rgb(236, 236, 236);
let surface = BitmapSurface::new(W, H);
let rect = CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(W as f64, H as f64));
unsafe {
fill_frame_background(surface.context_ptr(), rect, light_bg);
}
for (x, y, label) in [
(10, 10, "traffic-light inset"),
(5, H - 3, "breadcrumb/status-bar gap"),
(W - 5, H / 2, "scrollbar gutter"),
] {
let (r, g, b, _a) = surface.pixel(x, y);
assert_eq!(
(r, g, b),
(light_bg.r, light_bg.g, light_bg.b),
"{label} pixel ({x}, {y}) must show the theme background {light_bg:?}, \
not a hard-coded dark literal: got ({r}, {g}, {b})"
);
}
}
}
#[cfg(test)]
mod window_close_tests {
use super::*;
#[test]
fn only_exit_permits_the_window_to_close() {
assert!(window_should_close_for_reaction(Reaction::Exit));
assert!(!window_should_close_for_reaction(Reaction::Continue));
assert!(!window_should_close_for_reaction(Reaction::Redraw));
assert!(!window_should_close_for_reaction(Reaction::RedrawAfter(
std::time::Duration::from_millis(50)
)));
}
#[test]
fn only_exit_answers_terminate_now() {
assert_eq!(
terminate_reply_for_reaction(Reaction::Exit),
NSApplicationTerminateReply::TerminateNow
);
assert_eq!(
terminate_reply_for_reaction(Reaction::Continue),
NSApplicationTerminateReply::TerminateCancel
);
assert_eq!(
terminate_reply_for_reaction(Reaction::Redraw),
NSApplicationTerminateReply::TerminateCancel
);
assert_eq!(
terminate_reply_for_reaction(Reaction::RedrawAfter(std::time::Duration::from_millis(
50
))),
NSApplicationTerminateReply::TerminateCancel
);
}
struct RecordingApp {
events: Vec<UiEvent>,
reaction: Reaction,
}
impl RecordingApp {
fn new(reaction: Reaction) -> Self {
Self {
events: Vec::new(),
reaction,
}
}
}
impl AppLogic for RecordingApp {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.events.push(event);
self.reaction
}
}
fn dispatch(ev: UiEvent, backend: &mut MacBackend, app: &mut RecordingApp) -> EventOutcome {
let caret_visible = backend.caret_visible_handle();
let caret_pause = backend.caret_blink_pause_handle();
dispatch_event(ev, backend, app, &caret_visible, &caret_pause)
}
#[test]
fn window_close_reaches_the_app_unrewritten() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::new(Reaction::Continue);
let _ = dispatch(UiEvent::WindowClose, &mut backend, &mut app);
assert_eq!(
app.events,
vec![UiEvent::WindowClose],
"WindowClose must reach app.handle unchanged, matching gtk::run::dispatch_event/ \
win::run::dispatch_event"
);
}
#[test]
fn unhandled_window_close_does_not_exit() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::new(Reaction::Continue);
let outcome = dispatch(UiEvent::WindowClose, &mut backend, &mut app);
assert_eq!(outcome, EventOutcome::Continue);
}
#[test]
fn exit_reaction_survives_the_round_trip() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::new(Reaction::Exit);
let outcome = dispatch(UiEvent::WindowClose, &mut backend, &mut app);
assert_eq!(outcome, EventOutcome::Exit);
}
struct DispatchCounter {
calls: Cell<usize>,
reaction: Reaction,
}
impl DispatchCounter {
fn new(reaction: Reaction) -> Self {
Self {
calls: Cell::new(0),
reaction,
}
}
fn dispatch(&self) -> Reaction {
self.calls.set(self.calls.get() + 1);
self.reaction
}
}
#[test]
fn resolved_window_close_tells_terminate_to_skip_a_second_dispatch() {
let gate = ExitGate::default();
assert!(
!gate.exit_already_granted(),
"a fresh app lifetime must not start with the gate already marked"
);
if window_should_close_for_reaction(Reaction::Exit) {
gate.note_exit_granted();
}
let counter = DispatchCounter::new(Reaction::Continue);
assert_eq!(
gate.terminate_reply(|| counter.dispatch()),
NSApplicationTerminateReply::TerminateNow,
"the close was already granted — the follow-up must not veto it"
);
assert_eq!(
counter.calls.get(),
0,
"applicationShouldTerminate: must not re-dispatch WindowClose once \
windowShouldClose: already resolved this close to Exit"
);
}
#[test]
fn app_driven_exit_does_not_resurface_as_a_synthetic_window_close() {
struct ExitTestSink {
gate: Rc<ExitGate>,
}
impl ReactionSink for ExitTestSink {
fn request_redraw(&self) {}
fn request_exit(&self) {
self.gate.note_exit_granted();
}
fn request_frame_in(&self, _delay: std::time::Duration) {}
}
let gate = Rc::new(ExitGate::default());
let sink = ExitTestSink { gate: gate.clone() };
runtime::apply_outcome(Reaction::Exit, &sink);
let counter = DispatchCounter::new(Reaction::Continue);
assert_eq!(
gate.terminate_reply(|| counter.dispatch()),
NSApplicationTerminateReply::TerminateNow,
"an app that already returned Reaction::Exit must actually quit, not \
be vetoed by a synthetic WindowClose its catch-all answers Continue to"
);
assert_eq!(
counter.calls.get(),
0,
"no synthetic WindowClose may be dispatched for an event the user never \
triggered — an app that *does* handle WindowClose would otherwise run \
its unsaved-changes guard a second, spurious time"
);
}
#[test]
fn vetoed_window_close_leaves_terminate_dispatching_fresh() {
let gate = ExitGate::default();
for reaction in [
Reaction::Continue,
Reaction::Redraw,
Reaction::RedrawAfter(std::time::Duration::from_millis(50)),
] {
if window_should_close_for_reaction(reaction) {
gate.note_exit_granted();
}
}
assert!(
!gate.exit_already_granted(),
"a vetoed windowShouldClose: must not suppress a later, \
independent applicationShouldTerminate: dispatch"
);
let counter = DispatchCounter::new(Reaction::Continue);
assert_eq!(
gate.terminate_reply(|| counter.dispatch()),
NSApplicationTerminateReply::TerminateCancel
);
assert_eq!(counter.calls.get(), 1, "the app must be asked afresh");
}
#[test]
fn cmd_q_with_no_prior_grant_dispatches_fresh() {
let gate = ExitGate::default();
let counter = DispatchCounter::new(Reaction::Exit);
assert_eq!(
gate.terminate_reply(|| counter.dispatch()),
NSApplicationTerminateReply::TerminateNow
);
assert_eq!(
counter.calls.get(),
1,
"Cmd-Q must reach the app's WindowClose handler — that's the hole #951 exists to close"
);
}
#[test]
fn a_vetoed_cmd_q_does_not_grant_a_later_one() {
let gate = ExitGate::default();
let first = DispatchCounter::new(Reaction::Continue);
assert_eq!(
gate.terminate_reply(|| first.dispatch()),
NSApplicationTerminateReply::TerminateCancel
);
let second = DispatchCounter::new(Reaction::Continue);
assert_eq!(
gate.terminate_reply(|| second.dispatch()),
NSApplicationTerminateReply::TerminateCancel
);
assert_eq!(
second.calls.get(),
1,
"each fresh Cmd-Q gets its own dispatch — a veto never marks the gate"
);
}
}
#[cfg(test)]
mod text_selection_dispatch_tests {
use super::*;
use crate::dispatch::TextRegion;
use crate::event::Point;
use crate::types::WidgetId;
use crate::{ButtonMask, Key, Modifiers, MouseButton, UiEvent};
#[derive(Default)]
struct RecordingApp {
events: Vec<UiEvent>,
}
impl AppLogic for RecordingApp {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.events.push(event);
Reaction::Continue
}
}
fn region(id: &str, x: f32, y: f32, w: f32, h: f32, lines: &[&str]) -> TextRegion {
TextRegion {
id: WidgetId::new(id),
bounds: crate::event::Rect::new(x, y, w, h),
lines: lines.iter().map(|s| s.to_string()).collect(),
}
}
fn ctrl_char(c: char) -> UiEvent {
UiEvent::KeyPressed {
key: Key::Char(c),
modifiers: Modifiers {
ctrl: true,
shift: false,
alt: false,
cmd: false,
},
repeat: false,
}
}
fn dispatch(ev: UiEvent, backend: &mut MacBackend, app: &mut RecordingApp) -> EventOutcome {
let caret_visible = backend.caret_visible_handle();
let caret_pause = backend.caret_blink_pause_handle();
dispatch_event(ev, backend, app, &caret_visible, &caret_pause)
}
#[test]
fn drag_select_then_ctrl_c_copies_the_selection() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
backend.register_text_region(region("body", 0.0, 0.0, 88.0, 16.0, &["hello world"]));
let outcome = dispatch(
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(0.0, 4.0),
modifiers: Modifiers::default(),
},
&mut backend,
&mut app,
);
assert!(matches!(
outcome,
EventOutcome::Continue | EventOutcome::Redraw
));
let outcome = dispatch(
UiEvent::MouseMoved {
position: Point::new(88.0, 4.0),
buttons: ButtonMask {
left: true,
..ButtonMask::default()
},
},
&mut backend,
&mut app,
);
assert!(
matches!(outcome, EventOutcome::Redraw),
"a TextSelectionChanged event must force a redraw"
);
assert!(
backend.active_text_selection().is_some(),
"dragging across a registered TextRegion must produce an active selection"
);
let _ = dispatch(
UiEvent::MouseUp {
widget: None,
button: MouseButton::Left,
position: Point::new(88.0, 4.0),
},
&mut backend,
&mut app,
);
assert!(
backend.active_text_selection().is_some(),
"MouseUp must not clear the finalised selection"
);
let outcome = dispatch(ctrl_char('c'), &mut backend, &mut app);
assert!(matches!(
outcome,
EventOutcome::Redraw | EventOutcome::Continue
));
assert!(
backend.active_text_selection().is_none(),
"Ctrl-C must clear the selection after copying it"
);
assert!(
app.events
.iter()
.any(|e| matches!(e, UiEvent::TextCopied(text) if text == "hello world")),
"Ctrl-C over a full-region selection must deliver TextCopied(\"hello world\"); \
got {:?}",
app.events
);
}
#[test]
fn ctrl_c_without_a_selection_falls_through_to_the_app() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
let ev = ctrl_char('c');
let _ = dispatch(ev.clone(), &mut backend, &mut app);
assert_eq!(
app.events,
vec![ev],
"Ctrl-C with no active selection must fall through to app.handle unchanged, \
matching gtk::run::dispatch_event/win::run::dispatch_event"
);
}
#[test]
fn ctrl_a_selects_the_sole_registered_region() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
backend.register_text_region(region("body", 0.0, 0.0, 40.0, 16.0, &["hi"]));
let outcome = dispatch(ctrl_char('a'), &mut backend, &mut app);
assert!(matches!(outcome, EventOutcome::Redraw));
assert!(
backend.active_text_selection().is_some(),
"Ctrl-A must select the sole registered TextRegion"
);
assert!(
app.events.is_empty(),
"a recognised Ctrl-A must not also forward the raw KeyPressed to the app"
);
}
#[test]
fn ctrl_a_with_no_regions_falls_through_to_the_app() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
let ev = ctrl_char('a');
let _ = dispatch(ev.clone(), &mut backend, &mut app);
assert_eq!(
app.events,
vec![ev],
"Ctrl-A with no registered TextRegion must fall through to app.handle unchanged"
);
}
#[test]
fn mouse_down_clears_the_displayed_selection() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
backend.set_active_text_selection(
WidgetId::new("r"),
Point::new(0.0, 0.0),
Point::new(10.0, 0.0),
);
assert!(backend.active_text_selection().is_some());
let ev = UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(0.0, 0.0),
modifiers: Modifiers::default(),
};
let _ = dispatch(ev, &mut backend, &mut app);
assert!(
backend.active_text_selection().is_none(),
"a MouseDown reaching dispatch_event must clear the displayed selection"
);
}
#[test]
fn text_selection_changed_updates_active_selection_and_forces_redraw() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
backend.register_text_region(region("body", 0.0, 0.0, 40.0, 16.0, &["hi there"]));
let ev = UiEvent::TextSelectionChanged {
region: WidgetId::new("body"),
anchor: Point::new(0.0, 0.0),
focus: Point::new(40.0, 0.0),
};
let outcome = dispatch(ev, &mut backend, &mut app);
assert!(matches!(outcome, EventOutcome::Redraw));
assert!(backend.active_text_selection().is_some());
}
#[test]
fn middle_click_falls_through_to_the_app_no_primary_selection_on_macos() {
let mut backend = MacBackend::new();
let mut app = RecordingApp::default();
let ev = UiEvent::MouseDown {
widget: None,
button: MouseButton::Middle,
position: Point::new(5.0, 5.0),
modifiers: Modifiers::default(),
};
let _ = dispatch(ev, &mut backend, &mut app);
assert!(
app.events.iter().any(|e| matches!(
e,
UiEvent::MouseDown {
button: MouseButton::Middle,
..
}
)),
"middle-click must fall through to app.handle as an ordinary MouseDown when \
there is no PRIMARY selection to paste, got {:?}",
app.events
);
}
}