diff --git a/README.md b/README.md index c62dfca..8029731 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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 diff --git a/package.json b/package.json index e987144..2f7cba8 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/backend.mm b/src/backend.mm index da6fa89..10dcec2 100644 --- a/src/backend.mm +++ b/src/backend.mm @@ -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 { @@ -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; } @@ -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; } @@ -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; } @@ -683,6 +712,10 @@ static void BInstallAccessibilityObserver() { @interface CALBackendDelegate : NSObject { @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 @@ -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); @@ -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); @@ -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(); bool setMin = o.Has("minWidth") || o.Has("minHeight"); @@ -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); diff --git a/test/threaded-window-states.js b/test/threaded-window-states.js new file mode 100644 index 0000000..66fb754 --- /dev/null +++ b/test/threaded-window-states.js @@ -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'); +} diff --git a/test/window-states.js b/test/window-states.js new file mode 100644 index 0000000..49cbbe8 --- /dev/null +++ b/test/window-states.js @@ -0,0 +1,108 @@ +'use strict'; +// Full screen, zoom and the Dock (windowkit/appkit#15's remainder), in pump +// mode: setWindowFullScreen takes a window into a Space of its own and back, +// bracketed by window-fullscreen { fullscreen, phase: 'begin' | 'end' }, and +// the published state says the target from the begin on — the moment a +// renderer hides its chrome. A second ask during the transition is applied +// when it ends. setWindowZoomed and setWindowMiniaturized are read back the +// same way. Exits 0 when every expectation held. + +const { native } = require('..'); + +const fail = (msg, ...rest) => { + console.error('window-states:', msg, ...rest); + process.exit(1); +}; + +native.initApp(); +const events = []; +native.setBackendEventCallback((ev) => events.push(ev)); +const win = native.createWindow2({ width: 320, height: 220, title: 'window-states', x: 140, y: 160 }); +native.showWindow(win, true); +native.activateApp(); + +const pumpUntil = (pred, what, ms = 6000) => { + for (const deadline = Date.now() + ms; Date.now() < deadline; ) { + native.pump2(); + const hit = events.find(pred); + if (hit) return hit; + } + fail(`timed out waiting for ${what}`, events.map((ev) => ev.type)); +}; +const settle = (ms) => { + for (const deadline = Date.now() + ms; Date.now() < deadline; ) native.pump2(); +}; +settle(300); + +const before = native.getWindowFrame(win); +for (const k of ['fullscreen', 'zoomed', 'miniaturized']) { + if (before[k] !== false) fail(`${k} before anything`, before); +} + +// --- full screen, and an exit asked for mid-animation --------------------- +events.length = 0; +let beginRead = null; +native.setBackendEventCallback((ev) => { + events.push(ev); + if (ev.type === 'window-fullscreen' && ev.phase === 'begin' && ev.fullscreen) + beginRead = native.getWindowFrame(win).fullscreen; +}); +native.setWindowFullScreen(win, true); +pumpUntil((ev) => ev.type === 'window-fullscreen' && ev.phase === 'begin', 'the enter begin'); +if (beginRead !== true) fail('the published state did not say full screen at the begin', beginRead); +native.setWindowFullScreen(win, true); // a no-op: already on its way +pumpUntil((ev) => ev.type === 'window-fullscreen' && ev.phase === 'end' && ev.fullscreen, 'the enter end'); +const full = native.getWindowFrame(win); +if (!full.fullscreen || full.zoomed) fail('after entering', full); +if (full.width <= before.width) fail('the window did not grow', before, full); + +events.length = 0; +const exitReads = []; +native.setBackendEventCallback((ev) => { + events.push(ev); + const f = native.getWindowFrame(win); + if (!f.fullscreen) exitReads.push(f.zoomed); +}); +native.setWindowFullScreen(win, false); +pumpUntil((ev) => ev.type === 'window-fullscreen' && ev.phase === 'begin', 'the exit begin'); +native.setWindowFullScreen(win, true); // during the transition, which AppKit would drop +pumpUntil((ev) => ev.type === 'window-fullscreen' && ev.phase === 'end' && ev.fullscreen, 'the re-entry'); +if (exitReads.some((z) => z)) fail('zoomed said on the way out of full screen', exitReads); +let fs = events.filter((ev) => ev.type === 'window-fullscreen'); +if (fs.map((ev) => `${ev.phase}:${ev.fullscreen}`).join() !== 'begin:false,end:false,begin:true,end:true') + fail('the ask made during the exit was not applied at its end', fs); +if (fs.some((ev) => ev.windowNumber !== native.windowNumber(win))) fail('for another window', fs); + +// an ask that the transition already satisfies starts nothing +events.length = 0; +native.setWindowFullScreen(win, false); +pumpUntil((ev) => ev.type === 'window-fullscreen' && ev.phase === 'begin', 'the second exit begin'); +native.setWindowFullScreen(win, true); +native.setWindowFullScreen(win, false); +pumpUntil((ev) => ev.type === 'window-fullscreen' && ev.phase === 'end', 'the second exit end'); +settle(1500); +fs = events.filter((ev) => ev.type === 'window-fullscreen'); +if (fs.map((ev) => `${ev.phase}:${ev.fullscreen}`).join() !== 'begin:false,end:false') + fail('expected one exit and nothing after the last ask', fs); +if (native.getWindowFrame(win).fullscreen) fail('still full screen'); + +// --- zoom ------------------------------------------------------------------- +native.setWindowZoomed(win, true); +settle(800); +if (!native.getWindowFrame(win).zoomed) fail('not zoomed', native.getWindowFrame(win)); +native.setWindowZoomed(win, false); +settle(800); +if (native.getWindowFrame(win).zoomed) fail('still zoomed', native.getWindowFrame(win)); + +// --- the Dock --------------------------------------------------------------- +events.length = 0; +native.setWindowMiniaturized(win, true); +pumpUntil((ev) => ev.type === 'window-miniaturize' && ev.miniaturized, 'miniaturize'); +if (!native.getWindowFrame(win).miniaturized) fail('published state not miniaturized'); +native.setWindowMiniaturized(win, false); +pumpUntil((ev) => ev.type === 'window-miniaturize' && !ev.miniaturized, 'deminiaturize'); +if (native.getWindowFrame(win).miniaturized) fail('published state still miniaturized'); + +native.destroyWindow2(win); +console.log('window-states OK: full screen in and out, a mid-transition ask, zoom, the Dock'); +process.exit(0);