Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ waits on the UI thread (`test/threaded-verbs.js` covers each row):
| from a worker | verbs |
| --- | --- |
| unchanged, on the calling thread | surfaces and video surfaces, every `ctx*` (`ctxDrawSymbol` included), layouts and fonts, `symbolSize`, `pasteboardTypeForMIME`, `pasteboardTypeInfo`, `contentTypeFor`, `colorSpace`, `lastInputMs` |
| a command, answering nothing | `initApp`, `setActivationPolicy`, `setAppName`, `activateApp`, `showWindow`, `hideWindow`, `setWindowFrame`, `setWindowTitle`, `setWindowMinMax`, `setWindowIgnoresMouseEvents`, `invalidateWindowShadow`, `destroyWindow2`, `setCursor`, `setMainMenu`, `setDockMenu`, `setDockBadge`, `cancelUserAttention`, `setStatusItem`, `setStatusItemMenu`, `removeStatusItem`, `registerDropTypes`, `setDropResponse`, `pasteboardWriteText`, `pasteboardWrite`, `pasteboardClear`, `cancelPanel`, `cancelPopUpMenu`; the test posts `postMouseEvent`, `postKeyEvent`, `postAppleEvent`, `postAccessibilityDisplayChange` |
| a command, answering nothing | `initApp`, `setActivationPolicy`, `setAppName`, `activateApp`, `showWindow`, `hideWindow`, `setWindowFrame`, `setWindowTitle`, `setWindowMinMax`, `setWindowFullScreen`, `setWindowZoomed`, `setWindowMiniaturized`, `setWindowIgnoresMouseEvents`, `invalidateWindowShadow`, `destroyWindow2`, `setCursor`, `setMainMenu`, `setDockMenu`, `setDockBadge`, `cancelUserAttention`, `setStatusItem`, `setStatusItemMenu`, `removeStatusItem`, `registerDropTypes`, `setDropResponse`, `pasteboardWriteText`, `pasteboardWrite`, `pasteboardClear`, `cancelPanel`, `cancelPopUpMenu`; the test posts `postMouseEvent`, `postKeyEvent`, `postAppleEvent`, `postAccessibilityDisplayChange` |
| a handle at the call | `createWindow2`, followed by `window-created { handle, windowNumber }`; every event about the window, input included, carries `handle`, so `ev.handle === win` |
| | `windowRootLayer`, allocated with the window |
| | `createStatusItem`: its clicks carry the handle, and it is held until `removeStatusItem` |
Expand Down Expand Up @@ -188,6 +188,23 @@ handshake the edge moves first while the content catches up a frame or more behi
end is how a renderer knows to run the measured layout it deferred during the drag. The
published window state (`getWindowFrame`, `windowState`) carries `liveResize` too.

**Window states.** What the title bar's buttons do to a window can be asked for and is
reported back:

- `setWindowFullScreen(win, on)` takes a window into a full-screen Space of its own and back,
as the green button does. The transition is bracketed by
`window-fullscreen { fullscreen, phase: 'begin' | 'end' }`. The begin comes before the
animation, so a renderer can take its window chrome away while the window grows rather
than after it; a transition AppKit could not finish ends with the state it started from.
AppKit drops a toggle during a transition, so one asked for then is applied when it ends.
A transition is also a live resize, and comes inside `window-live-resize`'s two ends.
- `setWindowZoomed(win, on)` zooms to the standard frame and back (the green button with
Option held). AppKit sends nothing for a zoom but the resize, so read it back then.
- `setWindowMiniaturized(win, on)` minimizes to the Dock and back, reported as
`window-miniaturize { miniaturized }`.
- The published window state carries `fullscreen`, `zoomed` and `miniaturized`.
`fullscreen` says the target from a transition's begin; `zoomed` is false while full screen.

JS never waits on the UI thread, so the worst case is the deadline. A frame that misses it
shows the last frame at the new size, with the root layer's `backgroundColor` in the newly
exposed edge. The deadline is a strict dispatch timer (`DISPATCH_TIMER_STRICT`, zero
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"install": "node scripts/install.js",
"build": "node-gyp rebuild",
"demo": "node demo/demo.js",
"test": "node test/app-lifecycle.js && node test/animation.js && node test/layer-transform.js && node test/contents-gravity.js && node test/contents-center.js && node test/accessibility-display.js && node test/ignores-mouse-events.js && node test/popup-menu.js && node test/pasteboard-write.js && node test/surface-blit.js && node test/surface-faded.js && node test/image-smoothing.js && node test/surface-colorspace.js && node test/video-surface.js && node test/player.js && node test/round-rect-xy.js && node test/clip-rule.js && node test/radial-gradient.js && node test/control-bezels.js && node test/text-features.js && node test/font-from-data.js && node test/text-coverage.js && node test/text-typesetter.js && node test/text-line-height.js && node test/text-line-width.js && node test/text-justify.js && node test/text-scale.js && node test/symbols.js && node test/eventkit-permissions.js && node test/calendars.js && node test/calendar-writes.js && node test/screencolor.js && node test/notifications.js && node test/threaded.js && node test/threaded-verbs.js && node test/threaded-frames.js && node test/threaded-resize.js && node test/ending-worker.js && node test/live-resize.js && node test/activation-policy.js && node test/last-input.js"
"test": "node test/app-lifecycle.js && node test/animation.js && node test/layer-transform.js && node test/contents-gravity.js && node test/contents-center.js && node test/accessibility-display.js && node test/ignores-mouse-events.js && node test/popup-menu.js && node test/pasteboard-write.js && node test/surface-blit.js && node test/surface-faded.js && node test/image-smoothing.js && node test/surface-colorspace.js && node test/video-surface.js && node test/player.js && node test/round-rect-xy.js && node test/clip-rule.js && node test/radial-gradient.js && node test/control-bezels.js && node test/text-features.js && node test/font-from-data.js && node test/text-coverage.js && node test/text-typesetter.js && node test/text-line-height.js && node test/text-line-width.js && node test/text-justify.js && node test/text-scale.js && node test/symbols.js && node test/eventkit-permissions.js && node test/calendars.js && node test/calendar-writes.js && node test/screencolor.js && node test/notifications.js && node test/threaded.js && node test/threaded-verbs.js && node test/threaded-frames.js && node test/threaded-resize.js && node test/ending-worker.js && node test/live-resize.js && node test/window-states.js && node test/threaded-window-states.js && node test/activation-policy.js && node test/last-input.js"
},
"dependencies": {
"node-addon-api": "^8.3.0"
Expand Down
135 changes: 135 additions & 0 deletions src/backend.mm
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ void CALPumpDeliver(const CALEvent& ev) {
double x = 0, y = 0, width = 0, height = 0, scale = 1;
bool visible = false, occluded = false, key = false, ignoresMouseEvents = false;
bool liveResize = false; // inside a live resize (windowkit/appkit#63)
// what the window manager did with the window: full screen in a Space of
// its own, zoomed to its standard frame, minimized to the Dock
bool fullscreen = false, zoomed = false, miniaturized = false;
};

struct PubScreen {
Expand Down Expand Up @@ -273,6 +276,21 @@ static void PublishBadge() {
gPubBadge = [badge copy];
}

// Full screen as the window's delegate last said it, from the start of a
// transition (see windowWillEnterFullScreen:). NSWindowStyleMaskFullScreen
// says it only once AppKit has put the window in its Space, and a renderer
// told at the end hides its window chrome after the animation has shown it
// shrinking away; nil where nothing has been said, and the mask answers.
static char kFullScreenKey;
// set from a full-screen transition's begin to its end
static char kFullScreenTransitionKey;

static bool WindowFullScreen(NSWindow* win) {
NSNumber* said = objc_getAssociatedObject(win, &kFullScreenKey);
if (said) return said.boolValue;
return (win.styleMask & NSWindowStyleMaskFullScreen) != 0;
}

static bool WindowOnGlass(NSWindow* win) {
return (win.occlusionState & NSWindowOcclusionStateVisible) != 0;
}
Expand All @@ -292,6 +310,14 @@ static PubWindow WindowStateOf(NSWindow* win) {
s.key = win.isKeyWindow;
s.ignoresMouseEvents = win.ignoresMouseEvents;
s.liveResize = win.inLiveResize;
s.fullscreen = WindowFullScreen(win);
s.miniaturized = win.isMiniaturized;
// a full-screen window fills its screen, which isZoomed would call zoomed
// — through the exit animation too, where `fullscreen` already says false
// and the window is still the screen's size
bool transition = objc_getAssociatedObject(win, &kFullScreenTransitionKey) != nil;
s.zoomed = !s.fullscreen && !s.miniaturized && !transition &&
(win.styleMask & NSWindowStyleMaskResizable) && win.isZoomed;
return s;
}

Expand All @@ -308,6 +334,9 @@ static PubWindow WindowStateOf(NSWindow* win) {
r.Set("key", s.key);
r.Set("ignoresMouseEvents", s.ignoresMouseEvents);
r.Set("liveResize", s.liveResize);
r.Set("fullscreen", s.fullscreen);
r.Set("zoomed", s.zoomed);
r.Set("miniaturized", s.miniaturized);
return r;
}

Expand Down Expand Up @@ -683,6 +712,10 @@ static void BInstallAccessibilityObserver() {
@interface CALBackendDelegate : NSObject <NSWindowDelegate> {
@public
double handshakeMs_; // setResizeHandshake's budget; 0 is off
// setWindowFullScreen during a transition, which AppKit ignores a toggle
// in: -1 for none, else the state to toggle to once it ends
int wantFullScreen_;
bool inFullScreenTransition_;
}
@end

Expand Down Expand Up @@ -788,6 +821,64 @@ - (void)windowDidChangeOcclusionState:(NSNotification*)n {
ev.Bool("visible", WindowOnGlass(win));
CALEmit(std::move(ev));
}
// Full screen (windowkit/appkit#15's remainder): window-fullscreen
// { fullscreen, phase: 'begin' | 'end' }. The begin is AppKit's will-enter
// or will-exit, before the animation, so a renderer can take its chrome
// away while the window grows rather than after; the published state says
// the target from then on. A transition AppKit could not finish ends with
// the state it started from.
- (void)fullScreen:(NSWindow*)win is:(bool)on phase:(const char*)phase {
objc_setAssociatedObject(win, &kFullScreenKey, @(on), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
objc_setAssociatedObject(win, &kFullScreenTransitionKey,
inFullScreenTransition_ ? @YES : nil,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
PublishWindow(win);
CALEvent ev = WindowEvent(win, "window-fullscreen");
ev.Bool("fullscreen", on).Str("phase", phase);
CALEmit(std::move(ev));
}
- (void)endFullScreen:(NSWindow*)win is:(bool)on {
inFullScreenTransition_ = false;
[self fullScreen:win is:on phase:"end"];
int want = wantFullScreen_;
wantFullScreen_ = -1;
if (want >= 0 && (bool)want != on) [win toggleFullScreen:nil];
}
- (void)windowWillEnterFullScreen:(NSNotification*)n {
inFullScreenTransition_ = true;
[self fullScreen:n.object is:true phase:"begin"];
}
- (void)windowDidEnterFullScreen:(NSNotification*)n {
[self endFullScreen:n.object is:true];
}
- (void)windowDidFailToEnterFullScreen:(NSWindow*)win {
[self endFullScreen:win is:false];
}
- (void)windowWillExitFullScreen:(NSNotification*)n {
inFullScreenTransition_ = true;
[self fullScreen:n.object is:false phase:"begin"];
}
- (void)windowDidExitFullScreen:(NSNotification*)n {
[self endFullScreen:n.object is:false];
}
- (void)windowDidFailToExitFullScreen:(NSWindow*)win {
[self endFullScreen:win is:true];
}
// Minimized to the Dock and back: window-miniaturize { miniaturized }.
- (void)windowDidMiniaturize:(NSNotification*)n {
NSWindow* win = n.object;
PublishWindow(win);
CALEvent ev = WindowEvent(win, "window-miniaturize");
ev.Bool("miniaturized", true);
CALEmit(std::move(ev));
}
- (void)windowDidDeminiaturize:(NSNotification*)n {
NSWindow* win = n.object;
PublishWindow(win);
CALEvent ev = WindowEvent(win, "window-miniaturize");
ev.Bool("miniaturized", false);
CALEmit(std::move(ev));
}
- (void)windowDidChangeBackingProperties:(NSNotification*)n {
NSWindow* win = n.object;
PublishWindow(win);
Expand Down Expand Up @@ -996,6 +1087,7 @@ static WindowSpec ParseWindowSpec(Napi::Object o) {
}

CALBackendDelegate* delegate = [[CALBackendDelegate alloc] init];
delegate->wantFullScreen_ = -1;
win.delegate = delegate;
objc_setAssociatedObject(win, &kDelegateKey, delegate,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
Expand Down Expand Up @@ -1169,6 +1261,46 @@ static double BNumArg(const Napi::CallbackInfo& info, size_t i) {
});
}

// setWindowFullScreen(win, on) — into or out of a full-screen Space, as the
// title bar's green button takes it; window-fullscreen reports the
// transition. A no-op where the window already is, and one asked for during
// a transition is applied when it ends, since AppKit drops a toggle then.
static Napi::Value SetWindowFullScreen(const Napi::CallbackInfo& info) {
bool on = info.Length() > 1 && info[1].ToBoolean().Value();
OnWindow(info[0], ^(NSWindow* win) {
CALBackendDelegate* d = objc_getAssociatedObject(win, &kDelegateKey);
if (d && d->inFullScreenTransition_) {
d->wantFullScreen_ = on;
return;
}
if (WindowFullScreen(win) != on) [win toggleFullScreen:nil];
});
return info.Env().Undefined();
}

// setWindowZoomed(win, on) — to the window's standard frame and back, the
// green button with Option held. AppKit sends no notification for a zoom,
// only the resize, so the published `zoomed` is read back at each one.
static Napi::Value SetWindowZoomed(const Napi::CallbackInfo& info) {
bool on = info.Length() > 1 && info[1].ToBoolean().Value();
OnWindow(info[0], ^(NSWindow* win) {
if (WindowFullScreen(win) || win.isMiniaturized) return;
if ((bool)win.isZoomed != on) [win zoom:nil];
});
return info.Env().Undefined();
}

// setWindowMiniaturized(win, on) — to the Dock and back; window-miniaturize
// reports it.
static Napi::Value SetWindowMiniaturized(const Napi::CallbackInfo& info) {
bool on = info.Length() > 1 && info[1].ToBoolean().Value();
OnWindow(info[0], ^(NSWindow* win) {
if (on && !win.isMiniaturized) [win miniaturize:nil];
else if (!on && win.isMiniaturized) [win deminiaturize:nil];
});
return info.Env().Undefined();
}

static Napi::Value SetWindowMinMax(const Napi::CallbackInfo& info) {
Napi::Object o = info[1].As<Napi::Object>();
bool setMin = o.Has("minWidth") || o.Has("minHeight");
Expand Down Expand Up @@ -7988,6 +8120,9 @@ void InitBackend(Napi::Env env, Napi::Object exports) {
BFN("getWindowFrame", GetWindowFrame);
BFN("windowState", WindowStateFn);
BFN("setWindowMinMax", SetWindowMinMax);
BFN("setWindowFullScreen", SetWindowFullScreen);
BFN("setWindowZoomed", SetWindowZoomed);
BFN("setWindowMiniaturized", SetWindowMiniaturized);
BFN("setResizeHandshake", SetResizeHandshake);
BFN("destroyWindow2", DestroyWindow2);
BFN("invalidateWindowShadow", InvalidateWindowShadow);
Expand Down
86 changes: 86 additions & 0 deletions test/threaded-window-states.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use strict';
// window-states.js from a worker: with the main thread parked in runMain,
// setWindowFullScreen runs on the UI thread, the transition comes back as
// window-fullscreen events naming the handle, and the published copy the
// worker reads says the target from the begin. Its own file because a trip
// through a full-screen Space leaves the app between Spaces for a while,
// which the pop-up menus later in threaded-verbs.js do not survive. Exits 0
// when every expectation held.

const assert = require('assert');
const fs = require('fs');
const { Worker, isMainThread } = require('worker_threads');
const { native } = require('..');

const say = (s) => fs.writeSync(2, `threaded-window-states: ${s}\n`);

if (isMainThread) {
native.initApp();
new Worker(__filename).on('error', () => {});
const code = native.runMain();
if (code !== 0) say(`FAIL: runMain returned ${code}`);
process.exit(code === 0 ? 0 : 1);
} else {
run().then(
() => native.requestExit(0),
(e) => {
say(`FAIL ${e && e.stack ? e.stack : e}`);
native.requestExit(1);
},
);
}

async function run() {
const events = [];
let waiters = [];
native.connect((batch) => {
events.push(...batch);
waiters = waiters.filter((check) => !check());
});
const until = (pred, what, ms = 6000) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), ms);
const check = () => {
const hit = events.find(pred);
if (!hit) return false;
clearTimeout(timer);
resolve(hit);
return true;
};
if (!check()) waiters.push(check);
});

const win = native.createWindow2({ width: 300, height: 200, title: 'states from a worker', x: 120, y: 140 });
native.showWindow(win, true);
native.activateApp();
await until((ev) => ev.type === 'window-occlusion' && ev.handle === win, 'shown');
const before = native.getWindowFrame(win);
assert.deepStrictEqual(
[before.fullscreen, before.zoomed, before.miniaturized],
[false, false, false],
'the published window states',
);

native.setWindowMiniaturized(win, true);
await until((ev) => ev.type === 'window-miniaturize' && ev.miniaturized && ev.handle === win, 'miniaturized');
assert.strictEqual(native.getWindowFrame(win).miniaturized, true, 'published miniaturized');
native.setWindowMiniaturized(win, false);
await until((ev) => ev.type === 'window-miniaturize' && !ev.miniaturized && ev.handle === win, 'restored');
assert.strictEqual(native.getWindowFrame(win).miniaturized, false, 'published restored');

native.setWindowFullScreen(win, true);
const entering = await until(
(ev) => ev.type === 'window-fullscreen' && ev.phase === 'begin' && ev.handle === win,
'window-fullscreen begin',
);
assert.strictEqual(entering.fullscreen, true, 'the begin says where it is going');
assert.strictEqual(native.getWindowFrame(win).fullscreen, true, 'published from the begin');
await until((ev) => ev.type === 'window-fullscreen' && ev.phase === 'end' && ev.fullscreen, 'entered');

native.setWindowFullScreen(win, false);
await until((ev) => ev.type === 'window-fullscreen' && ev.phase === 'end' && !ev.fullscreen, 'exited');
assert.strictEqual(native.getWindowFrame(win).fullscreen, false, 'published after the exit');

native.destroyWindow2(win);
say('ok');
}
Loading
Loading