const TEST_NAME: &str = "appkit_session";
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|arg| arg == "--list") {
if !args.iter().any(|arg| arg == "--ignored") {
println!("{TEST_NAME}: test");
}
return;
}
#[cfg(not(target_os = "macos"))]
{
println!(
"crcbl appkit session: skipped — AppKit is a macOS window system and \
this is not macOS"
);
}
#[cfg(target_os = "macos")]
{
macos::run();
}
}
#[cfg(target_os = "macos")]
mod macos {
use std::time::{Duration, Instant};
use crcbl_core::log::Filter;
use crcbl_shell::{
ButtonState, ClipboardContent, ClipboardOffer, CursorIcon, DisplayMode, KeyCode, Keysym,
LogicalPoint, LogicalRect, LogicalSize, MimeType, MonitorId, PanelAnchor, PanelDesc,
PanelKeyboard, PanelLayer, PhysicalPoint, PhysicalRect, PhysicalSize, PointerButton,
PointerMode, PopupDesc, PopupInput, PopupPlacement, ReceivedMime, ScrollDelta, Shell,
ShellBackend, ShellCaps, ShellError, ShellEvent, SizeConstraints, TextPurpose, WindowDesc,
WindowId, WindowRole, open_backend, session_support,
};
const TITLE: &str = "crcbl appkit session";
const VK_A: u16 = 0x00;
const VK_UP_ARROW: u16 = 0x7E;
const NUDGE: (i64, i64) = (40, -30);
const DEADLINE: Duration = Duration::from_secs(10);
const REQUESTED: LogicalSize = LogicalSize::new(640.0, 480.0);
const POINTER_SLACK: f64 = 3.0;
pub fn run() {
install_logger();
println!("crcbl appkit session: opening the AppKit backend on the main thread");
let mut shell = match open_backend(ShellBackend::AppKit) {
Ok(shell) => shell,
Err(error) => panic!(
"crcbl appkit session: the AppKit backend would not open: {error}\n\
If this is the first run, the finding is that a GitHub macOS \
runner does not give a process a WindowServer session."
),
};
assert_eq!(shell.backend(), ShellBackend::AppKit);
println!(
"crcbl appkit session: opened, caps {:?}, {} monitor(s)",
shell.caps(),
shell.monitors().len()
);
assert!(
!shell.monitors().is_empty(),
"a session with a window server has at least one screen"
);
for (name, outcome) in [
(
"every cursor shape",
session_support::every_cursor_shape_exists(),
),
(
"the dragged-type registration",
session_support::dragged_types_register_on_a_view(),
),
(
"the general pasteboard",
session_support::pasteboard_round_trip(),
),
] {
match outcome {
Ok(()) => println!("crcbl appkit session: {name} answered"),
Err(detail) => panic!("crcbl appkit session: {name} — {detail}"),
}
}
let window = shell
.create_window(&WindowDesc {
title: TITLE,
app_id: "sh.kryptic.crcbl.appkit-session",
size: REQUESTED,
constraints: SizeConstraints::min(LogicalSize::new(320.0, 180.0)),
mode: DisplayMode::Windowed,
resizable: true,
visible: true,
accept_drops: true,
role: WindowRole::Toplevel,
transparent: false,
})
.expect("create_window");
assert_eq!(
shell.window_state(window).expect("state").size(),
None,
"a window has no size until the window system says so"
);
let size = wait_for(&mut shell, "the first Resized", |shell| {
shell
.window_state(window)
.ok()
.and_then(|state| state.size())
});
println!("crcbl appkit session: configured at {size:?}");
assert!(
size.width > 0 && size.height > 0,
"a configured window has an extent a swapchain could be built at: {size:?}"
);
shell.surface_target(window).expect("surface_target");
let activation = take_activation(&mut shell);
let facts = appkit_agrees(size);
input(&mut shell, window, size);
text_input(&mut shell, window);
match &activation {
Ok(active) => {
println!(
"crcbl appkit session: this process is frontmost and its window has the \
keyboard, so input can be injected: {active:?}"
);
warp_round_trip(&mut shell, window, size);
injected_input(&mut shell, window, size);
}
Err(refused) => injection_skipped(refused),
}
clipboard(&mut shell, window);
clipboard_peer(&mut shell, window);
waker(&mut shell);
resize(&mut shell, window, facts.backing_scale);
let before_borderless = appkit_says("before going borderless").frame;
let borderless = flip(
&mut shell,
window,
DisplayMode::Borderless { monitor: None },
);
println!("crcbl appkit session: borderless at {borderless:?}");
let covering = appkit_says("borderless");
let windowed = flip(&mut shell, window, DisplayMode::Windowed);
println!("crcbl appkit session: windowed again at {windowed:?}");
let restored = appkit_says("windowed again");
println!(
"crcbl appkit session: the mode round trip — before {before_borderless:?}, \
borderless {:?}, restored {:?}; first responder {} -> {} -> {}",
covering.frame,
restored.frame,
facts.first_responder,
covering.first_responder,
restored.first_responder
);
assert!(
!covering.titled,
"a borderless window has no title bar; the style mask is {:#x}",
covering.style_mask
);
assert_eq!(
covering.first_responder, "CrcblView",
"a borderless window still routes key events to the view: `sendEvent:` delivers to \
the first responder, so the window being its own is every keystroke going nowhere. \
{covering:?}"
);
let screen = covering
.screen_frame
.expect("a visible window is on a screen");
assert_eq!(
covering.frame, screen,
"borderless covers the screen AppKit says it is on, exactly.\n\
The window was at {before_borderless:?} before the flip, and everything AppKit says \
about it while borderless is {covering:?}.\n\
The mechanism was `setPresentationOptions:`, which returns every window of the \
application to its **creation** frame — so `apply_mode` now applies the \
presentation options before the style mask and the frame, and the frame is the \
last geometry it sets. A failure here means that ordering has been undone or \
something new below the arm repositions the window; the `apply_mode:` readings in \
this test's stderr say which statement, because each of them prints the frame \
after one step."
);
assert_eq!(
borderless,
PhysicalSize::new(
(screen[2] * covering.backing_scale).round() as u32,
(screen[3] * covering.backing_scale).round() as u32,
),
"and the extent the seam reports is that screen in backing pixels, at a scale of \
{}",
covering.backing_scale
);
assert!(
restored.titled,
"a windowed window has its title bar back; the style mask is {:#x}",
restored.style_mask
);
assert_eq!(
restored.frame, before_borderless,
"the way back restores the whole placement, origin included, and not merely the size"
);
assert_eq!(
restored.first_responder, "CrcblView",
"and the view still has the keyboard after a full mode round trip: {restored:?}"
);
hidden_window_stays_hidden(&mut shell);
a_window_born_borderless_round_trips(&mut shell);
a_borderless_window_names_its_monitor(&mut shell);
panels(&mut shell);
popups(&mut shell);
shell.destroy_window(window).expect("destroy_window");
assert!(
shell.window_state(window).is_err(),
"a destroyed window is a stale handle"
);
println!("crcbl appkit session: a window opened, changed mode twice and closed");
}
fn input(shell: &mut Box<dyn Shell>, window: WindowId, size: PhysicalSize) {
let caps = shell.caps();
assert!(
caps.has_mouselook(),
"a locked pointer and a delta beside it are both implemented: {caps:?}"
);
assert!(caps.contains(ShellCaps::POINTER_WARP), "{caps:?}");
assert!(caps.contains(ShellCaps::TEXT_COMPOSE), "{caps:?}");
assert!(
caps.contains(ShellCaps::CURSOR_SHAPE),
"NSCursor applies every shape: {caps:?}"
);
assert!(
caps.contains(ShellCaps::TEXT_IME),
"fields switch the input method, the pre-edit is sent and the candidate window \
follows the caret: {caps:?}"
);
assert!(
!caps.contains(ShellCaps::POINTER_CONFINE),
"macOS has no confine API and this backend must not claim one: {caps:?}"
);
let refused = shell
.set_pointer_mode(window, PointerMode::Confined)
.expect_err("confinement is not implementable on this platform");
assert!(
refused.to_string().contains("confine"),
"the refusal has to name the mode, or nobody can act on it: {refused}"
);
shell
.warp_pointer(
window,
PhysicalPoint::new(
f64::from(size.width) + 200.0,
f64::from(size.height) + 200.0,
),
)
.expect("a warp outside the window is clamped, not refused");
shell.pump(&mut |_event| {});
shell
.set_pointer_mode(window, PointerMode::Locked)
.expect("pointer lock");
assert_eq!(
shell.window_state(window).expect("state").pointer_mode,
PointerMode::Locked
);
shell
.set_pointer_mode(window, PointerMode::Free)
.expect("back to free");
assert_eq!(
shell.window_state(window).expect("state").pointer_mode,
PointerMode::Free,
"the freeze is desktop-wide, so leaving it set would follow the runner out \
of this process"
);
for cursor in [
Some(CursorIcon::Crosshair),
Some(CursorIcon::Text),
None,
Some(CursorIcon::Default),
] {
shell.set_cursor(window, cursor).expect("set_cursor");
}
println!("crcbl appkit session: pointer modes, cursors and the warp all answered");
}
fn text_input(shell: &mut Box<dyn Shell>, window: WindowId) {
let facts = |step: &str| {
session_support::text_input_facts(TITLE)
.unwrap_or_else(|detail| panic!("crcbl appkit session: {step}: {detail}"))
};
let mark = |text: &str, cursor: usize| {
session_support::mark_text(TITLE, text, cursor).unwrap_or_else(|detail| {
panic!("crcbl appkit session: marking {text:?}: {detail}")
});
};
let unmark = || {
session_support::unmark_text(TITLE)
.unwrap_or_else(|detail| panic!("crcbl appkit session: unmarking: {detail}"));
};
let pumped = |shell: &mut Box<dyn Shell>| {
let mut seen = Vec::new();
shell.pump(&mut |event| seen.push(event));
seen
};
let fresh = facts("a new window");
assert!(
fresh.roman_only,
"a window starts with no field, so its input context is held to the keyboard \
layouts and no input method composes in it: {fresh:?}"
);
mark("\u{b4}", 1);
assert!(facts("an accent marked").has_marked_text);
let seen = pumped(shell);
assert!(
preedits(&seen).is_empty(),
"with no field, marked text is the dead key's and no pre-edit is reported, as on \
Win32 and Wayland: {:?}",
preedits(&seen)
);
unmark();
assert!(!facts("the accent unmarked").has_marked_text);
assert!(
preedits(&pumped(shell)).is_empty(),
"nothing was shown, so nothing ends"
);
shell
.set_text_input(window, Some(TextPurpose::Normal))
.expect("set_text_input");
let open = facts("an ordinary field");
assert!(
open.allowed_input_sources.is_empty() && !open.roman_only,
"an ordinary field allows every input source, which is nil: {open:?}"
);
mark("か𠀋な", 3);
assert_eq!(
preedits(&pumped(shell)),
[("か𠀋な".to_owned(), Some(7))],
"the marked text is the pre-edit, and its cursor is a byte offset on a char boundary"
);
unmark();
assert_eq!(
preedits(&pumped(shell)),
[(String::new(), None)],
"unmarkText ends the pre-edit with an empty one"
);
mark("かな", 2);
pumped(shell);
session_support::commit_text(TITLE, "仮名")
.unwrap_or_else(|detail| panic!("crcbl appkit session: committing: {detail}"));
let committed = pumped(shell);
assert_eq!(
preedits(&committed),
[(String::new(), None)],
"{:?}",
names(&committed)
);
assert!(
committed.iter().any(
|event| matches!(event, ShellEvent::TextCommit { text, .. } if text == "仮名")
),
"the conversion is committed: {:?}",
names(&committed)
);
assert!(!facts("after the commit").has_marked_text);
mark("かな", 2);
pumped(shell);
shell.set_text_input(window, None).expect("set_text_input");
assert_eq!(
preedits(&pumped(shell)),
[(String::new(), None)],
"a field that goes away ends its composition"
);
let closed = facts("the field cleared");
assert!(closed.roman_only && !closed.has_marked_text, "{closed:?}");
let window_facts = appkit_says("before placing the caret");
let [x, y, ..] = window_facts.frame;
let height = window_facts.content_points[1];
let scale = window_facts.backing_scale;
let unplaced = facts("no caret area").first_rect;
assert!(
near(unplaced, [x, y, 0.0, 0.0]),
"with no area the candidate window goes to the window's own origin, {:?}, and the \
view said {unplaced:?}",
[x, y]
);
let area = PhysicalRect::new(40, 60, 200, 24);
shell
.set_text_input_area(window, Some(area))
.expect("set_text_input_area");
let placed = facts("a caret area").first_rect;
let want = [
x + 40.0 / scale,
y + height - (60.0 + 24.0) / scale,
200.0 / scale,
24.0 / scale,
];
assert!(
near(placed, want),
"firstRectForCharacterRange: answers the caret area in screen points: asked for \
{area:?} in a {height}-point view at {scale}x on a window at {:?}, wanted {want:?} \
and the view said {placed:?}",
[x, y]
);
shell
.set_text_input_area(window, None)
.expect("set_text_input_area");
assert!(
near(facts("the caret area handed back").first_rect, unplaced),
"None hands placement back"
);
if fresh.secure_input {
println!(
"crcbl appkit session: secure input was already held when this ran, by \
something that is not this shell, so whether this shell takes and gives it \
back cannot be read off the session and is not asserted"
);
} else {
shell
.set_text_input(window, Some(TextPurpose::Password))
.expect("set_text_input");
let focused = shell.window_state(window).expect("state").focused;
let guarded = facts("a password field");
assert!(
guarded.roman_only,
"a password keeps input methods out: {guarded:?}"
);
assert_eq!(
guarded.secure_input, focused,
"secure input is held exactly while the window with the password field has the \
keyboard (focused: {focused}): {guarded:?}"
);
shell.set_text_input(window, None).expect("set_text_input");
assert!(
!facts("the password field cleared").secure_input,
"clearing the field gives secure input back, or no other process on the machine \
can read the keyboard"
);
println!(
"crcbl appkit session: a password field {} secure input with the window {}",
if focused { "held" } else { "did not hold" },
if focused { "key" } else { "not key" }
);
}
println!("crcbl appkit session: fields, the pre-edit and the caret area all answered");
}
fn preedits(events: &[ShellEvent]) -> Vec<(String, Option<usize>)> {
events
.iter()
.filter_map(|event| match event {
ShellEvent::TextPreedit { text, cursor, .. } => Some((text.clone(), *cursor)),
_ => None,
})
.collect()
}
fn near(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6)
}
fn warp_round_trip(shell: &mut Box<dyn Shell>, window: WindowId, size: PhysicalSize) {
let target =
PhysicalPoint::new(f64::from(size.width) * 0.75, f64::from(size.height) * 0.50);
shell.warp_pointer(window, target).expect("warp_pointer");
let landed = wait_for_pointer(shell, quartz::cursor(), target);
println!("crcbl appkit session: warped to {target:?} and landed at {landed:?}");
}
const RING_AFTER: Duration = Duration::from_millis(50);
const QUIET: Duration = Duration::from_millis(100);
fn settle(shell: &mut Box<dyn Shell>) {
let deadline = Instant::now() + DEADLINE;
while Instant::now() < deadline {
shell.pump(&mut |_| {});
let start = Instant::now();
shell.wait_events(Some(QUIET));
if start.elapsed() >= QUIET {
return;
}
}
println!("crcbl appkit session: the queue never went quiet; the waker checks run anyway");
}
fn waker(shell: &mut Box<dyn Shell>) {
let waker = shell.waker().expect("the AppKit backend has a waker");
settle(shell);
let ringer = waker.clone();
let thread = std::thread::spawn(move || {
std::thread::sleep(RING_AFTER);
ringer.wake();
});
let start = Instant::now();
shell.wait_events(Some(DEADLINE));
let slept = start.elapsed();
thread.join().expect("the ringing thread");
assert!(
slept < DEADLINE / 2,
"a ring from another thread did not end the wait: it slept {slept:?}"
);
settle(shell);
waker.wake();
shell.pump(&mut |_| {});
let start = Instant::now();
shell.wait_events(Some(DEADLINE));
let slept = start.elapsed();
assert!(
slept < DEADLINE / 2,
"a ring the pump took did not end the next wait: it slept {slept:?}"
);
println!(
"crcbl appkit session: the waker ended a wait from another thread and through a pump"
);
}
fn clipboard(shell: &mut Box<dyn Shell>, window: WindowId) {
const TEXT: &str = "crcbl M3 — clipboard 🎮";
const RON: &str = "(kind:\"node\",id:7)";
let caps = shell.caps();
assert!(caps.contains(ShellCaps::CLIPBOARD), "{caps:?}");
assert!(caps.contains(ShellCaps::DRAG_DROP), "{caps:?}");
assert!(
shell.clipboard_readable(window),
"any process may read the general pasteboard at any time, so this backend has \
nothing to gate on and must say so"
);
shell
.clipboard_offer(
window,
&[ClipboardOffer::text(TEXT), ClipboardOffer::ron(RON)],
)
.expect("clipboard_offer");
let (mime, content) = paste(shell, window, MimeType::TextUtf8);
assert_eq!(
content.text(),
Some(TEXT),
"the general pasteboard answered {content:?} for the text that was just written"
);
assert!(
mime.matches(MimeType::TextUtf8),
"a pasteboard type is a UTI and not a mime, so the answer must name the format \
that was asked for: {mime}"
);
let (_, content) = paste(shell, window, MimeType::CrcblRon);
assert_eq!(
content.bytes(),
Some(RON.as_bytes()),
"the engine's own format has to be byte-exact or a pasted scene node will not \
parse: {content:?}"
);
let (_, content) = paste(shell, window, MimeType::UriList);
assert_eq!(
content,
ClipboardContent::Empty,
"a type nobody wrote is an empty answer, not a failed read"
);
shell
.clipboard_offer(window, &[])
.expect("an empty offer clears the pasteboard");
let (_, content) = paste(shell, window, MimeType::TextUtf8);
assert_eq!(
content,
ClipboardContent::Empty,
"clearContents left {content:?} behind"
);
println!("crcbl appkit session: both formats round-tripped through the system pasteboard");
}
fn install_logger() {
let filter = Filter::parse(
"info,crcbl_shell::appkit::window=debug,crcbl_shell::appkit::shell=debug",
);
match crcbl_core::log::try_init_logging(filter) {
Ok(()) => println!(
"crcbl appkit session: logging installed — backend warnings and the window \
module's placement trail follow on stderr"
),
Err(error) => println!(
"crcbl appkit session: a logger was already installed, so the backend's own \
account of where it put the window may be filtered out: {error}"
),
}
}
fn appkit_says(step: &str) -> session_support::WindowFacts {
session_support::window_facts(TITLE).unwrap_or_else(|detail| {
panic!(
"crcbl appkit session: {step}: {detail}\n\
What the application says: {:?}",
session_support::activation(TITLE)
)
})
}
fn take_activation(
shell: &mut Box<dyn Shell>,
) -> Result<session_support::Activation, session_support::Activation> {
let started = Instant::now();
let mut announced = false;
loop {
let asked = frontmost::ask();
if !announced {
announced = true;
println!(
"crcbl appkit session: asked the session for activation; \
-[NSRunningApplication activateWithOptions:] answered {asked}"
);
}
shell.pump(&mut |_event: ShellEvent| {});
let state = session_support::activation(TITLE).unwrap_or_else(|detail| {
panic!(
"crcbl appkit session: the shell is open and there is no NSApplication \
behind it: {detail}"
)
});
if state.app_active && state.is_key {
return Ok(state);
}
if started.elapsed() >= DEADLINE {
return Err(state);
}
shell.wait_events(Some(Duration::from_millis(20)));
}
}
fn injection_skipped(refused: &session_support::Activation) {
let mechanism = if refused.app_active {
"the application is active but the keyboard is on another window"
} else {
"the application never became active"
};
let whose = if refused.can_become_key {
"can_become_key is true, so the CrcblWindow canBecomeKeyWindow override is installed \
and answering: this is the session refusing to activate an unbundled binary, and no \
backend can fix it"
} else {
"can_become_key is FALSE on our own window, which is a defect of this backend and not \
the runner's refusal: the canBecomeKeyWindow override is not on the class the window \
turned out to be, and a borderless window would silently stop receiving keystrokes"
};
println!(
"crcbl appkit session: ============================================================\n\
crcbl appkit session: INJECTED INPUT WAS NOT EXERCISED — {mechanism}, so no window \
of this process holds it.\n\
crcbl appkit session: CGEventPost puts an event in the *session's* stream and the \
session gives it to whatever is frontmost, so posting now would type this test's \
keystrokes into some other process on the runner rather than into our window.\n\
crcbl appkit session: What AppKit says: {refused:?}\n\
crcbl appkit session: {whose}. The harness asked for activation itself, once per \
turn for {DEADLINE:?}, through -[NSRunningApplication activateWithOptions:] and \
-[NSApp activateIgnoringOtherApps:], and was refused.\n\
crcbl appkit session: SKIPPED: the injected key and its release, the TextCommit \
behind interpretKeyEvents:, the arrow key, the window-server pointer motion, the \
click, the wheel notch, and the warp round trip — which is skipped because \
reading a warp back needs a posted mouse-moved, a warp generating no event of \
its own. Nothing else: the capability set, the confine refusal, the pointer \
modes, the cursors, the clipboard, pbcopy/pbpaste, the resize, both mode flips \
and every AppKit readback all ran and asserted.\n\
crcbl appkit session: STILL UNVERIFIED ON CI: interpretKeyEvents: and the whole \
injected-input path. docs/backlog.md carries it as a gap, on the same terms as \
the F11 pass that needs a renderer.\n\
crcbl appkit session: ============================================================"
);
}
fn appkit_agrees(size: PhysicalSize) -> session_support::WindowFacts {
let facts = appkit_says("the window AppKit knows about");
assert!(
facts.accepts_mouse_moved,
"-[NSWindow acceptsMouseMovedEvents] is what makes pointer motion exist at all; \
without it a game's camera only moves while a button is held: {facts:?}"
);
assert_eq!(
facts.first_responder, "CrcblView",
"the content view has the first responder, so `sendEvent:` routes key events to it \
rather than to the window: {facts:?}"
);
assert_eq!(
facts.content_view, "CrcblView",
"and the content view is this backend's own class: {facts:?}"
);
assert_eq!(
facts.dragged_types,
vec!["public.file-url".to_owned()],
"a window created with accept_drops: true is registered for exactly the type this \
backend reads; AppKit sends no dragging message to a view registered for nothing: \
{facts:?}"
);
assert!(facts.titled, "a windowed window is titled: {facts:?}");
assert_eq!(
(
(facts.content_points[0] * facts.backing_scale).round() as u32,
(facts.content_points[1] * facts.backing_scale).round() as u32,
),
(size.width, size.height),
"the seam reported {size:?}; AppKit says the content view is {:?} points at a \
backing scale of {}",
facts.content_points,
facts.backing_scale
);
println!(
"crcbl appkit session: AppKit agrees — first responder {}, mouse-moved {}, dragged \
types {:?}, scale {}, application active {}, window key {}",
facts.first_responder,
facts.accepts_mouse_moved,
facts.dragged_types,
facts.backing_scale,
facts.app_active,
facts.is_key
);
facts
}
fn resize(shell: &mut Box<dyn Shell>, window: WindowId, scale: f64) {
const POINTS: (f64, f64) = (512.0, 384.0);
session_support::resize_window(TITLE, POINTS.0, POINTS.1).expect("resize_window");
let expected = PhysicalSize::new(
(POINTS.0 * scale).round() as u32,
(POINTS.1 * scale).round() as u32,
);
let got = wait_for(shell, "the resize AppKit was asked for", |shell| {
shell
.window_state(window)
.ok()
.and_then(|state| state.size())
.filter(|size| *size == expected)
});
let after = appkit_says("after the resize");
assert_eq!(
after.content_points,
[POINTS.0, POINTS.1],
"-[NSWindow setContentSize:] took: {after:?}"
);
println!("crcbl appkit session: resized to {got:?} backing pixels");
}
const INJECTION_HINT: &str = "\nNo injected event arriving at all, with the window key and \
this process frontmost, is the signature of TCC refusing CGEventPost: macOS 10.14+ \
gates synthetic keyboard events behind the Accessibility right, and a CI runner has \
nobody to grant one. Whether that gate applies to events posted back to the posting \
process is what this run answers. If it does, the fallback is \
`-[NSApplication postEvent:atStart:]`, which needs no permission and still goes through \
nextEventMatchingMask:, sendEvent:, the first responder and interpretKeyEvents: — \
everything but the window server's own leg.";
fn injected_input(shell: &mut Box<dyn Shell>, window: WindowId, size: PhysicalSize) {
let before = session_support::key_window()
.unwrap_or_else(|detail| panic!("crcbl appkit session: before injecting: {detail}"));
assert_eq!(
before.title, TITLE,
"input follows the key window, so nothing may be posted while somebody else has it: \
{before:?}"
);
quartz::tap_key(VK_A);
let typed = collect_until(shell, "the injected 'a' and its release", |seen| {
seen.iter()
.filter(|event| matches!(event, ShellEvent::Key { .. }))
.count()
>= 2
});
let typed_keys = keys(&typed);
assert_eq!(
typed_keys[0].0,
u32::from(VK_A),
"the scan code is the `kVK_*` the event carried: {:?}",
names(&typed)
);
assert_eq!(
typed_keys[0].1,
Some(KeyCode::KeyA),
"the physical position"
);
assert_eq!(typed_keys[0].3, ButtonState::Pressed);
assert!(!typed_keys[0].4, "the first press is not a repeat");
assert_eq!(
typed_keys[0].2,
Keysym::from_char('a'),
"the layout's symbol, lowercased so a rebind menu reads the same as it does on Linux"
);
assert_eq!(typed_keys[1].3, ButtonState::Released);
let committed: String = typed
.iter()
.filter_map(|event| match event {
ShellEvent::TextCommit { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(
committed,
"a",
"**the first text this backend has ever committed from a real keystroke.** It needs \
`sendEvent:` to route the event to the first responder, `keyDown:` to hand it to \
`interpretKeyEvents:`, and the view's `inputContext` to be non-nil — which it is \
only because CrcblView conforms to NSTextInputClient. The events that did arrive \
were {:?}",
names(&typed)
);
quartz::tap_key(VK_UP_ARROW);
let arrow = collect_until(shell, "the injected arrow key", |seen| {
seen.iter()
.any(|event| matches!(event, ShellEvent::Key { .. }))
});
let arrow_keys = keys(&arrow);
assert_eq!(
arrow_keys[0].1,
Some(KeyCode::ArrowUp),
"{:?}",
names(&arrow)
);
assert_eq!(arrow_keys[0].0, u32::from(VK_UP_ARROW));
assert!(
!names(&arrow).contains(&"TextCommit"),
"an arrow key moves a cursor; it is not a character in a text field, and a backend \
reading `-[NSEvent characters]` instead of asking the input method would commit one \
here: {:?}",
names(&arrow)
);
let target =
PhysicalPoint::new(f64::from(size.width) * 0.25, f64::from(size.height) * 0.25);
shell.warp_pointer(window, target).expect("warp_pointer");
let parked = wait_for_pointer(shell, quartz::cursor(), target);
let from = quartz::cursor();
quartz::move_mouse_by(from, NUDGE);
let reach = (NUDGE.0.abs() as f64 / 2.0, NUDGE.1.abs() as f64 / 2.0);
let injected = |event: &ShellEvent| match event {
ShellEvent::PointerMotion {
abs: Some(at),
raw_delta: Some(delta),
..
} if (at.x - parked.x).abs() >= reach.0 && (at.y - parked.y).abs() >= reach.1 => {
Some((*at, *delta))
}
_ => None,
};
let moved = collect_until(shell, "the injected motion", |seen| {
seen.iter().any(|event| injected(event).is_some())
});
let (at, delta) = moved
.iter()
.rev()
.find_map(injected)
.expect("just waited for it");
assert!(
at.x > parked.x && at.y < parked.y,
"the cursor was moved right and up the screen by {NUDGE:?} from {parked:?} and the \
window reported {at:?}; the desktop is {:?} and the events were {:?}",
quartz::cursor(),
names(&moved)
);
assert!(
delta.0 > 0.0 && delta.1 < 0.0,
"the raw delta beside it is Quartz's own, unflipped: this move set \
kCGMouseEventDeltaX/Y to {NUDGE:?} and the seam reported {delta:?}. (0, 0) means \
the posted event carried no delta at all — CGEventCreateMouseEvent computes none, \
which is what the run before this one found. A *positive* second component with \
the position above correct is the delta being reflected into the position's Y-up \
convention, which is an inverted camera. A negative *first* component is deltaX \
and deltaY being read from each other's field."
);
let asked = NUDGE.0 as f64 / NUDGE.1 as f64;
let got = delta.0 / delta.1;
assert!(
(got - asked).abs() <= asked.abs() * 0.05,
"the delta's two axes are out of proportion with each other: {NUDGE:?} was posted \
and {delta:?} came back, a ratio of {got} where {asked} was asked for. The signs \
are right, so this is one axis being scaled and the other not"
);
let at = quartz::cursor();
quartz::click(at.x, at.y);
let clicked = collect_until(shell, "the injected click", |seen| {
seen.iter()
.filter(|event| matches!(event, ShellEvent::Button { .. }))
.count()
>= 2
});
let buttons: Vec<_> = clicked
.iter()
.filter_map(|event| match event {
ShellEvent::Button { button, state, .. } => Some((*button, *state)),
_ => None,
})
.collect();
assert_eq!(
buttons,
vec![
(PointerButton::Left, ButtonState::Pressed),
(PointerButton::Left, ButtonState::Released),
],
"one click, two events: {:?}",
names(&clicked)
);
quartz::scroll_lines(1);
let scrolled = collect_until(shell, "the injected scroll", |seen| {
seen.iter()
.any(|event| matches!(event, ShellEvent::Wheel { .. }))
});
let wheel = scrolled
.iter()
.find_map(|event| match event {
ShellEvent::Wheel { delta, .. } => Some(*delta),
_ => None,
})
.expect("just waited for it");
match wheel {
ScrollDelta::Lines { x, y } => {
assert_eq!(x, 0.0, "a vertical notch has no horizontal component");
assert!(
y != 0.0,
"a notch of one line is not zero lines, and a zero here is far more likely \
to be this harness than the backend: it means the amount never reached \
CGEventCreateScrollWheelEvent. `wheel1` is that function's last **named** \
parameter and only `wheel2`/`wheel3` are variadic, so a declaration that \
starts its `...` one parameter early passes the amount on the stack while \
the callee reads register w3 — which is exactly how this assertion first \
went red. Check the declaration before suspecting `appkit::events`."
);
}
other => panic!(
"a line-unit scroll wheel event is Lines and not {other:?}; a backend reading \
hasPreciseScrollingDeltas backwards would report a trackpad's pixels here: {:?}",
names(&scrolled)
),
}
println!(
"crcbl appkit session: the window server's own keyboard, text, pointer, click and \
scroll all arrived"
);
}
fn keys(events: &[ShellEvent]) -> Vec<(u32, Option<KeyCode>, Keysym, ButtonState, bool)> {
let keys: Vec<_> = events
.iter()
.filter_map(|event| match event {
ShellEvent::Key {
scancode,
key_code,
keysym,
state,
repeat,
..
} => Some((scancode.0, *key_code, *keysym, *state, *repeat)),
_ => None,
})
.collect();
assert!(!keys.is_empty(), "no key events in {:?}", names(events));
keys
}
fn names(events: &[ShellEvent]) -> Vec<&'static str> {
events.iter().map(ShellEvent::name).collect()
}
fn collect_until(
shell: &mut Box<dyn Shell>,
what: &str,
mut enough: impl FnMut(&[ShellEvent]) -> bool,
) -> Vec<ShellEvent> {
let started = Instant::now();
let mut seen = Vec::new();
loop {
shell.pump(&mut |event| seen.push(event));
if enough(&seen) {
return seen;
}
assert!(
started.elapsed() < DEADLINE,
"crcbl appkit session: waited {DEADLINE:?} for {what} and it never came; the \
events that did arrive were {:?}.{INJECTION_HINT}",
names(&seen)
);
shell.wait_events(Some(Duration::from_millis(20)));
}
}
fn clipboard_peer(shell: &mut Box<dyn Shell>, window: WindowId) {
const OURS: &str = "crcbl M4 — written by the shell 🎮";
const THEIRS: &str = "crcbl M4 — written by pbcopy 🎮";
shell
.clipboard_offer(window, &[ClipboardOffer::text(OURS)])
.expect("clipboard_offer");
assert_eq!(
pbpaste().trim_end_matches('\n'),
OURS,
"another process read the general pasteboard and did not find what this shell put \
there, so the bytes never reached the pasteboard server"
);
pbcopy(THEIRS);
let (_, content) = paste(shell, window, MimeType::TextUtf8);
assert_eq!(
content.text(),
Some(THEIRS),
"the shell read {content:?} rather than what pbcopy had just written, so it is \
answering out of its own cache rather than from the pasteboard server"
);
shell
.clipboard_offer(window, &[])
.expect("an empty offer clears the pasteboard");
println!("crcbl appkit session: pbcopy and pbpaste both agree with the seam");
}
fn pbpaste() -> String {
let output = crcbl_spawn::command("/usr/bin/pbpaste")
.output()
.expect("pbpaste is part of a stock macOS");
assert!(
output.status.success(),
"pbpaste failed with {:?}: {:?}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn pbcopy(text: &str) {
use std::io::Write as _;
let mut child = crcbl_spawn::command("/usr/bin/pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("pbcopy is part of a stock macOS");
child
.stdin
.take()
.expect("stdin was piped")
.write_all(text.as_bytes())
.expect("pbcopy reads its stdin");
let status = child.wait().expect("pbcopy exits");
assert!(status.success(), "pbcopy failed with {status:?}");
}
fn paste(
shell: &mut Box<dyn Shell>,
window: WindowId,
mime: MimeType,
) -> (ReceivedMime, ClipboardContent) {
let request = shell
.clipboard_request(window, mime)
.expect("clipboard_request");
let started = Instant::now();
let mut answers = Vec::new();
let mut seen = Vec::new();
let collect = |event: ShellEvent, answers: &mut Vec<_>, seen: &mut Vec<_>| {
seen.push(event.name());
if let ShellEvent::ClipboardData {
window: asked,
request: id,
mime,
content,
} = event
{
assert_eq!(asked, window, "an answer named a window nobody asked about");
assert_eq!(id, request, "an answer named a request nobody issued");
answers.push((mime, content));
}
};
loop {
shell.pump(&mut |event| collect(event, &mut answers, &mut seen));
if !answers.is_empty() {
break;
}
assert!(
started.elapsed() < DEADLINE,
"crcbl appkit session: waited {DEADLINE:?} for the answer to {request} \
({mime}) and nothing arrived; the events that did were {seen:?}"
);
shell.wait_events(Some(Duration::from_millis(20)));
}
shell.pump(&mut |event| collect(event, &mut answers, &mut seen));
assert_eq!(
answers.len(),
1,
"{request} was answered {} times, and obligation 4 says exactly once: {answers:?}",
answers.len()
);
answers.remove(0)
}
fn wait_for_pointer(
shell: &mut Box<dyn Shell>,
post_at: quartz::Point,
want: PhysicalPoint,
) -> PhysicalPoint {
let started = Instant::now();
let mut seen = Vec::new();
let mut reported: Vec<PhysicalPoint> = Vec::new();
loop {
quartz::move_mouse(post_at.x, post_at.y);
let mut landed = None;
shell.pump(&mut |event: ShellEvent| {
seen.push(event.name());
match event {
ShellEvent::PointerFocus {
entered: true,
position: Some(at),
..
}
| ShellEvent::PointerMotion { abs: Some(at), .. } => {
reported.push(at);
if (at.x - want.x).abs() <= POINTER_SLACK
&& (at.y - want.y).abs() <= POINTER_SLACK
{
landed = Some(at);
}
}
_ => {}
}
});
if let Some(at) = landed {
return at;
}
assert!(
started.elapsed() < DEADLINE,
"crcbl appkit session: waited {DEADLINE:?} for the pointer to be reported at \
{want:?}. A kCGEventMouseMoved was posted at {post_at:?} on every turn of \
that wait; the positions that came back were {reported:?} and the events \
that arrived were {seen:?}. What AppKit says about this window: {:?}.\n\
Read the reported positions first, because they separate the mechanisms:\n\
* **Positions arrived, none of them {want:?}.** The conversion is wrong \
rather than absent. A Y that is the window's height minus the one asked \
for is a missing flip; an X that matches while the Y does not is the \
desktop reflection rather than the window one; both wrong by the window's \
origin is the screen's space reaching the seam unconverted.\n\
* **No positions at all, and nothing was posted.** \
CGWarpMouseCursorPosition generates no event, so a wait that relies on the \
warp alone sees nothing — that is what this function was rewritten to stop \
doing. If the posted move is also arriving nowhere, CGEventPost is being \
refused, which is TCC and which the injected keyboard above would have \
failed on first.\n\
* **No PointerFocus and no PointerMotion, with the post landing.** The \
NSTrackingArea is not delivering; it is registered NSTrackingActiveAlways \
and NSTrackingInVisibleRect, so it should fire even for an inactive \
application.\n\
* **A PointerFocus with no PointerMotion.** acceptsMouseMovedEvents is off, \
or the window is not key — read is_key in the facts above rather than \
guessing, because both are printed there.",
session_support::activation(TITLE)
);
shell.wait_events(Some(Duration::from_millis(20)));
}
}
fn flip(shell: &mut Box<dyn Shell>, window: WindowId, mode: DisplayMode) -> PhysicalSize {
shell.set_mode(window, mode).expect("set_mode");
let what = if matches!(mode, DisplayMode::Windowed) {
"windowed"
} else {
"borderless"
};
wait_for(shell, what, |shell| {
let state = shell.window_state(window).ok()?;
state
.mode_request_honoured()
.then(|| state.size())
.flatten()
})
}
fn hidden_window_stays_hidden(shell: &mut Box<dyn Shell>) {
const HIDDEN_TITLE: &str = "crcbl appkit session — hidden mode flip";
let hidden = shell
.create_window(&WindowDesc {
title: HIDDEN_TITLE,
app_id: "sh.kryptic.crcbl.appkit-session",
size: REQUESTED,
constraints: SizeConstraints::min(LogicalSize::new(320.0, 180.0)),
mode: DisplayMode::Windowed,
resizable: true,
visible: false,
accept_drops: false,
role: WindowRole::Toplevel,
transparent: false,
})
.expect("create_window");
assert!(
!shell.window_state(hidden).expect("state").visible,
"a window created visible: false is not on screen"
);
let _ = flip(shell, hidden, DisplayMode::Borderless { monitor: None });
assert!(
!shell.window_state(hidden).expect("state").visible,
"set_mode(Borderless) must not show a hidden window: apply_mode used to order it \
front unconditionally, so window_state().visible reported true for a window nobody \
showed"
);
let facts = session_support::window_facts(HIDDEN_TITLE)
.expect("the hidden window is still this process's to read");
assert!(
!facts.is_key,
"a hidden window takes no key focus across a mode change: {facts:?}"
);
shell.destroy_window(hidden).expect("destroy_window");
println!(
"crcbl appkit session: a window hidden at creation stayed hidden across a mode flip"
);
}
fn a_window_born_borderless_round_trips(shell: &mut Box<dyn Shell>) {
const BORN_TITLE: &str = "crcbl appkit session — born borderless";
let born = shell
.create_window(&WindowDesc {
title: BORN_TITLE,
app_id: "sh.kryptic.crcbl.appkit-session",
size: REQUESTED,
constraints: SizeConstraints::min(LogicalSize::new(320.0, 180.0)),
mode: DisplayMode::Borderless { monitor: None },
resizable: true,
visible: false,
accept_drops: false,
role: WindowRole::Toplevel,
transparent: false,
})
.expect("create_window");
assert!(
!shell.window_state(born).expect("state").visible,
"a window created visible: false is not on screen, whichever style it is born in"
);
let covering = wait_for(
shell,
"the born-borderless window's first Resized",
|shell| shell.window_state(born).ok().and_then(|state| state.size()),
);
assert!(
covering.width > 0 && covering.height > 0,
"a configured window has an extent a swapchain could be built at: {covering:?}"
);
let born_facts = session_support::window_facts(BORN_TITLE)
.expect("the born-borderless window is this process's to read");
let screen = born_facts
.screen_frame
.expect("a window created against a screen is on one");
assert!(
!born_facts.titled,
"a window created borderless has no title bar; the style mask is {:#x}",
born_facts.style_mask
);
assert_eq!(
born_facts.frame, screen,
"a window created borderless covers the screen it was created against, exactly — \
not the centred windowed rectangle. frame {:?}, screen {screen:?}",
born_facts.frame
);
let windowed = flip(shell, born, DisplayMode::Windowed);
let restored = session_support::window_facts(BORN_TITLE)
.expect("the born-borderless window is this process's to read");
assert!(
restored.titled,
"leaving borderless builds a titled mask; the style mask is {:#x}",
restored.style_mask
);
assert_eq!(
restored.content_points,
[REQUESTED.width, REQUESTED.height],
"the way back uses the size the window asked for, which it has never had: frame {:?}",
restored.frame
);
assert!(
windowed.width < covering.width && windowed.height < covering.height,
"the restored window is smaller than the screen it was covering: {windowed:?} \
against {covering:?}"
);
let _ = flip(shell, born, DisplayMode::Borderless { monitor: None });
assert!(
!shell.window_state(born).expect("state").visible,
"neither flip may show a window created hidden"
);
let again = session_support::window_facts(BORN_TITLE)
.expect("the born-borderless window is this process's to read");
assert!(
!again.titled,
"the second borderless flip drops the title bar again; the style mask is {:#x}",
again.style_mask
);
assert_eq!(
again.frame, screen,
"and covers the screen again: frame {:?}, screen {screen:?}",
again.frame
);
shell.destroy_window(born).expect("destroy_window");
println!(
"crcbl appkit session: a window born borderless covered its screen, restored the \
requested placement and went back"
);
}
fn a_borderless_window_names_its_monitor(shell: &mut Box<dyn Shell>) {
const NAMED_TITLE: &str = "crcbl appkit session — borderless on a monitor";
assert!(
!shell.monitors().is_empty(),
"a Mac with a window server has at least one screen to be borderless on"
);
let target = shell.monitors()[0].id;
let absent = MonitorId(u32::MAX);
let refused = shell.create_window(&WindowDesc {
title: NAMED_TITLE,
app_id: "sh.kryptic.crcbl.appkit-session",
size: REQUESTED,
constraints: SizeConstraints::min(LogicalSize::new(320.0, 180.0)),
mode: DisplayMode::Borderless {
monitor: Some(absent),
},
resizable: true,
visible: false,
accept_drops: false,
role: WindowRole::Toplevel,
transparent: false,
});
assert!(
matches!(refused, Err(ShellError::NoSuchMonitor(id)) if id == absent.0),
"a borderless request naming a display that is gone must fail rather than quietly \
land on the primary: {refused:?}"
);
let named = shell
.create_window(&WindowDesc {
title: NAMED_TITLE,
app_id: "sh.kryptic.crcbl.appkit-session",
size: REQUESTED,
constraints: SizeConstraints::min(LogicalSize::new(320.0, 180.0)),
mode: DisplayMode::Borderless {
monitor: Some(target),
},
resizable: true,
visible: false,
accept_drops: false,
role: WindowRole::Toplevel,
transparent: false,
})
.expect("create_window on an attached monitor");
assert!(
!shell.window_state(named).expect("state").visible,
"a window created visible: false is not on screen, whichever monitor it names"
);
let _covering = wait_for(shell, "the named-monitor window's first Resized", |shell| {
shell
.window_state(named)
.ok()
.and_then(|state| state.size())
});
let facts = session_support::window_facts(NAMED_TITLE)
.expect("the named-monitor window is this process's to read");
assert!(
!facts.titled,
"a window created borderless has no title bar; the style mask is {:#x}",
facts.style_mask
);
let screen = facts
.screen_frame
.expect("a window created against a screen is on one");
assert_eq!(
facts.frame, screen,
"a borderless window covers the monitor it named, exactly: frame {:?}, screen \
{screen:?}",
facts.frame
);
assert_eq!(
shell.window_state(named).expect("state").requested_mode,
DisplayMode::Borderless {
monitor: Some(target),
},
"the seam echoes the monitor the caller named"
);
shell.destroy_window(named).expect("destroy_window");
println!(
"crcbl appkit session: a borderless window named its monitor, and a monitor that is \
not attached was refused"
);
}
fn panels(shell: &mut Box<dyn Shell>) {
const BAR: &str = "crcbl appkit session — bar";
const LAUNCHER: &str = "crcbl appkit session — launcher";
const NONACTIVATING: usize = 1 << 7;
const STATUS_LEVEL: isize = 25;
const POP_UP_MENU_LEVEL: isize = 101;
const EVERY_SPACE: usize = (1 << 0) | (1 << 4) | (1 << 6);
const FULL_SCREEN_AUXILIARY: usize = 1 << 8;
const HEIGHT: f64 = 24.0;
let caps = shell.caps();
assert!(caps.contains(ShellCaps::PANEL | ShellCaps::TRANSPARENT));
assert!(
!caps.contains(ShellCaps::PANEL_RESERVE),
"macOS reserves space for nothing but the menu bar and the Dock"
);
let primary = shell
.monitors()
.iter()
.find(|monitor| monitor.is_primary)
.map(|monitor| monitor.id)
.expect("a primary monitor");
let bar = shell
.create_window(&WindowDesc {
title: BAR,
size: LogicalSize::new(0.0, HEIGHT),
visible: false,
transparent: true,
role: WindowRole::Panel(PanelDesc {
monitor: Some(primary),
anchor: PanelAnchor::TOP | PanelAnchor::LEFT | PanelAnchor::RIGHT,
..PanelDesc::default()
}),
..WindowDesc::default()
})
.expect("a panel");
wait_for(shell, "the bar's first configure", |shell| {
shell.window_state(bar).ok()?.size()
});
let facts = session_support::window_facts(BAR).expect("the bar is readable");
let visible = session_support::menu_bar_screen_visible_frame().expect("a screen");
assert_eq!(facts.style_mask & NONACTIVATING, NONACTIVATING, "{facts:?}");
assert!(!facts.titled, "a panel has no title bar: {facts:?}");
assert_eq!(facts.level, STATUS_LEVEL, "above the menu bar: {facts:?}");
assert_eq!(
facts.collection_behavior & EVERY_SPACE,
EVERY_SPACE,
"{facts:?}"
);
assert_eq!(
facts.collection_behavior & FULL_SCREEN_AUXILIARY,
0,
"{facts:?}"
);
assert!(
!facts.hides_on_deactivate,
"a bar that hid whenever its application was not active: {facts:?}"
);
assert!(!facts.opaque, "a transparent window: {facts:?}");
assert!(
shell.surface_target(bar).expect("a live bar").transparent(),
"the surface target says the bar is transparent"
);
assert!(!facts.can_become_key, "a bar takes no keyboard: {facts:?}");
assert_eq!(
[
facts.frame[0],
facts.frame[1] + facts.frame[3],
facts.frame[2],
facts.frame[3]
],
[visible[0], visible[1] + visible[3], visible[2], HEIGHT],
"across the top of the visible frame, at its own height: {facts:?}"
);
const TALLER: f64 = 40.0;
let reloaded = PanelDesc {
monitor: Some(primary),
layer: PanelLayer::Overlay,
anchor: PanelAnchor::TOP | PanelAnchor::LEFT | PanelAnchor::RIGHT,
keyboard: PanelKeyboard::OnDemand,
..PanelDesc::default()
};
shell
.set_panel(bar, LogicalSize::new(0.0, TALLER), &reloaded)
.expect("set_panel");
wait_for(shell, "the bar's resize to its new height", |shell| {
let state = shell.window_state(bar).ok()?;
(state.size()?.height
== LogicalSize::new(0.0, TALLER)
.to_physical(state.scale_factor()?)
.height)
.then_some(())
});
let facts = session_support::window_facts(BAR).expect("the bar is readable");
assert_eq!(
[facts.frame[1] + facts.frame[3], facts.frame[3]],
[visible[1] + visible[3], TALLER],
"still against the top, at its new height: {facts:?}"
);
assert_eq!(facts.level, POP_UP_MENU_LEVEL, "an overlay now: {facts:?}");
assert!(
facts.can_become_key,
"an on-demand bar can take the keyboard: {facts:?}"
);
let renamed = PanelDesc {
namespace: "renamed",
..reloaded
};
assert!(
matches!(
shell.set_panel(bar, LogicalSize::new(0.0, TALLER), &renamed),
Err(ShellError::InvalidDescriptor(_))
),
"a panel's namespace is fixed"
);
shell.destroy_window(bar).expect("destroy the bar");
let launcher = shell
.create_window(&WindowDesc {
title: LAUNCHER,
size: LogicalSize::new(400.0, 300.0),
visible: false,
role: WindowRole::Panel(PanelDesc {
layer: PanelLayer::Overlay,
keyboard: PanelKeyboard::Exclusive,
..PanelDesc::default()
}),
..WindowDesc::default()
})
.expect("a panel");
wait_for(shell, "the launcher's first configure", |shell| {
shell.window_state(launcher).ok()?.size()
});
let facts = session_support::window_facts(LAUNCHER).expect("the launcher is readable");
assert_eq!(facts.level, POP_UP_MENU_LEVEL, "{facts:?}");
assert_eq!(
facts.collection_behavior & FULL_SCREEN_AUXILIARY,
FULL_SCREEN_AUXILIARY,
"an overlay may join a full-screen window's space: {facts:?}"
);
assert!(
facts.can_become_key,
"a launcher takes the keyboard: {facts:?}"
);
assert!(facts.opaque, "not asked to be transparent: {facts:?}");
assert!(
!shell
.surface_target(launcher)
.expect("a live launcher")
.transparent(),
"nor does its surface target say so"
);
assert_eq!(facts.content_points, [400.0, 300.0], "{facts:?}");
shell
.destroy_window(launcher)
.expect("destroy the launcher");
println!("crcbl appkit session: a bar and a launcher are panels at their levels");
}
fn popups(shell: &mut Box<dyn Shell>) {
const OWNER: &str = "crcbl appkit session — popup owner";
let owner = shell
.create_window(&WindowDesc {
title: OWNER,
size: LogicalSize::new(500.0, 400.0),
visible: false,
..WindowDesc::default()
})
.expect("a window");
wait_for(shell, "the owner's first configure", |shell| {
shell.window_state(owner).ok()?.size()
});
let owner_facts = session_support::window_facts(OWNER).expect("the owner is readable");
let made = [
("crcbl appkit session — tooltip", PopupInput::Passthrough),
("crcbl appkit session — hover card", PopupInput::Pointer),
("crcbl appkit session — menu", PopupInput::Grab),
]
.map(|(title, input)| {
let popup = shell
.create_window(&WindowDesc {
title,
size: LogicalSize::new(200.0, 150.0),
visible: false,
role: WindowRole::Popup(PopupDesc {
parent: owner,
anchor: LogicalRect::new(
LogicalPoint::new(10.0, 10.0),
LogicalSize::new(100.0, 20.0),
),
placement: PopupPlacement::Below,
input,
}),
..WindowDesc::default()
})
.expect("a popup");
(popup, title, input)
});
let content_top = owner_facts.frame[1] + owner_facts.content_points[1];
for (_, title, input) in made {
let facts = session_support::window_facts(title).expect("the popup is readable");
assert_eq!(
facts.frame,
[
owner_facts.frame[0] + 10.0,
content_top - 30.0 - 150.0,
200.0,
150.0
],
"below the button, left edges aligned: {facts:?}"
);
assert!(!facts.titled, "{facts:?}");
assert_eq!(
facts.ignores_mouse_events,
input == PopupInput::Passthrough,
"{input:?}: {facts:?}"
);
assert_eq!(
facts.can_become_key,
input == PopupInput::Grab,
"{input:?}: {facts:?}"
);
}
let (card, card_title, _) = made[1];
shell
.reposition_popup(
card,
LogicalRect::new(LogicalPoint::new(60.0, 10.0), LogicalSize::new(100.0, 20.0)),
LogicalSize::new(240.0, 90.0),
)
.expect("reposition the hover card");
wait_for(shell, "the hover card's resize", |shell| {
(shell.window_state(card).ok()?.size()?
== LogicalSize::new(240.0, 90.0)
.to_physical(shell.window_state(card).ok()?.scale_factor()?))
.then_some(())
});
let facts = session_support::window_facts(card_title).expect("the card is readable");
assert_eq!(
facts.frame,
[
owner_facts.frame[0] + 60.0,
content_top - 30.0 - 90.0,
240.0,
90.0
],
"below the moved button, at its new size: {facts:?}"
);
let mut destroyed = Vec::new();
shell.pump(&mut |_| {});
shell.destroy_window(owner).expect("destroy the owner");
shell.pump(&mut |event| {
if let ShellEvent::WindowDestroyed { window } = event {
destroyed.push(window);
}
});
assert_eq!(
destroyed.last(),
Some(&owner),
"the owner last: {destroyed:?}"
);
assert_eq!(destroyed.len(), made.len() + 1, "{destroyed:?}");
for (popup, _, _) in made {
assert!(destroyed.contains(&popup), "{destroyed:?}");
}
println!("crcbl appkit session: three popups opened below their button and went first");
}
fn wait_for<T>(
shell: &mut Box<dyn Shell>,
what: &str,
mut ready: impl FnMut(&mut Box<dyn Shell>) -> Option<T>,
) -> T {
let started = Instant::now();
let mut seen = Vec::new();
loop {
shell.pump(&mut |event: ShellEvent| seen.push(event.name()));
if let Some(value) = ready(shell) {
return value;
}
assert!(
started.elapsed() < DEADLINE,
"crcbl appkit session: waited {DEADLINE:?} for {what} and it never came; \
the events that did arrive were {seen:?}"
);
shell.wait_events(Some(Duration::from_millis(20)));
}
}
mod frontmost {
use core::ffi::{CStr, c_char, c_void};
type Id = *mut c_void;
type Sel = *const c_void;
type ObjcBool = i8;
const ACTIVATE_ALL_WINDOWS: usize = 1 << 0;
const ACTIVATE_IGNORING_OTHER_APPS: usize = 1 << 1;
#[link(name = "objc")]
unsafe extern "C" {
fn objc_getClass(name: *const c_char) -> Id;
fn sel_registerName(name: *const c_char) -> Sel;
fn objc_msgSend();
fn objc_autoreleasePoolPush() -> *mut c_void;
fn objc_autoreleasePoolPop(pool: *mut c_void);
}
struct Pool(*mut c_void);
impl Pool {
fn push() -> Self {
Self(unsafe { objc_autoreleasePoolPush() })
}
}
impl Drop for Pool {
fn drop(&mut self) {
unsafe { objc_autoreleasePoolPop(self.0) };
}
}
unsafe fn msg_send<F: Copy>() -> F {
assert!(
size_of::<F>() == size_of::<*const c_void>(),
"msg_send's type parameter must be a function pointer"
);
unsafe { core::mem::transmute_copy(&(objc_msgSend as *const c_void)) }
}
fn sel(name: &CStr) -> Sel {
unsafe { sel_registerName(name.as_ptr()) }
}
unsafe fn responds_to(receiver: Id, selector: Sel) -> bool {
let send: unsafe extern "C" fn(Id, Sel, Sel) -> ObjcBool = unsafe { msg_send() };
unsafe { send(receiver, sel(c"respondsToSelector:"), selector) != 0 }
}
#[must_use]
pub fn ask() -> bool {
let _pool = Pool::push();
unsafe {
let running = objc_getClass(c"NSRunningApplication".as_ptr());
let application = objc_getClass(c"NSApplication".as_ptr());
if running.is_null() || application.is_null() {
return false;
}
let class_method: unsafe extern "C" fn(Id, Sel) -> Id = msg_send();
let app = class_method(application, sel(c"sharedApplication"));
let ignoring = sel(c"activateIgnoringOtherApps:");
if !app.is_null() && responds_to(app, ignoring) {
let send: unsafe extern "C" fn(Id, Sel, ObjcBool) = msg_send();
send(app, ignoring, 1);
}
let current = class_method(running, sel(c"currentApplication"));
let with_options = sel(c"activateWithOptions:");
if current.is_null() || !responds_to(current, with_options) {
return false;
}
let activate: unsafe extern "C" fn(Id, Sel, usize) -> ObjcBool = msg_send();
activate(
current,
with_options,
ACTIVATE_ALL_WINDOWS | ACTIVATE_IGNORING_OTHER_APPS,
) != 0
}
}
}
mod quartz {
use core::ffi::c_void;
type EventRef = *mut c_void;
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
const HID_TAP: u32 = 0;
const LEFT_DOWN: u32 = 1;
const LEFT_UP: u32 = 2;
const MOUSE_MOVED: u32 = 5;
const BUTTON_LEFT: u32 = 0;
const MOUSE_CLICK_STATE: u32 = 1;
const MOUSE_DELTA_X: u32 = 4;
const MOUSE_DELTA_Y: u32 = 5;
const UNIT_LINE: u32 = 1;
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGEventCreate(source: *mut c_void) -> EventRef;
fn CGEventGetLocation(event: EventRef) -> Point;
fn CGEventCreateKeyboardEvent(
source: *mut c_void,
keycode: u16,
down: bool,
) -> EventRef;
fn CGEventCreateMouseEvent(
source: *mut c_void,
kind: u32,
at: Point,
button: u32,
) -> EventRef;
fn CGEventCreateScrollWheelEvent(
source: *mut c_void,
units: u32,
wheels: u32,
wheel1: i32,
...
) -> EventRef;
fn CGEventPost(tap: u32, event: EventRef);
fn CGEventSetIntegerValueField(event: EventRef, field: u32, value: i64);
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFRelease(object: *mut c_void);
}
#[must_use]
pub fn cursor() -> Point {
unsafe {
let event = CGEventCreate(core::ptr::null_mut());
if event.is_null() {
return Point::default();
}
let at = CGEventGetLocation(event);
CFRelease(event);
at
}
}
pub fn tap_key(keycode: u16) {
key(keycode, true);
key(keycode, false);
}
fn key(keycode: u16, down: bool) {
unsafe {
let event = CGEventCreateKeyboardEvent(core::ptr::null_mut(), keycode, down);
if event.is_null() {
return;
}
CGEventPost(HID_TAP, event);
CFRelease(event);
}
}
pub fn move_mouse(x: f64, y: f64) {
mouse(MOUSE_MOVED, Point { x, y }, &[]);
}
pub fn move_mouse_by(from: Point, delta: (i64, i64)) {
let at = Point {
x: from.x + delta.0 as f64,
y: from.y + delta.1 as f64,
};
mouse(
MOUSE_MOVED,
at,
&[(MOUSE_DELTA_X, delta.0), (MOUSE_DELTA_Y, delta.1)],
);
}
pub fn click(x: f64, y: f64) {
let at = Point { x, y };
let single = &[(MOUSE_CLICK_STATE, 1)];
mouse(LEFT_DOWN, at, single);
mouse(LEFT_UP, at, single);
}
fn mouse(kind: u32, at: Point, fields: &[(u32, i64)]) {
unsafe {
let event = CGEventCreateMouseEvent(core::ptr::null_mut(), kind, at, BUTTON_LEFT);
if event.is_null() {
return;
}
for (field, value) in fields {
CGEventSetIntegerValueField(event, *field, *value);
}
CGEventPost(HID_TAP, event);
CFRelease(event);
}
}
pub fn scroll_lines(lines: i32) {
unsafe {
let event =
CGEventCreateScrollWheelEvent(core::ptr::null_mut(), UNIT_LINE, 1, lines);
if event.is_null() {
return;
}
CGEventPost(HID_TAP, event);
CFRelease(event);
}
}
}
}