diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d939cac..27b6ab8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,12 @@ jobs: # Run tests. - run: nim c tests/test.nim - run: nim r tests/test_openurl.nim + - run: nim r tests/test_macos.nim + if: matrix.os == 'macos-latest' + - run: nim r tests/test_tempfiles.nim + if: matrix.os == 'macos-latest' + - run: nim r -d:useCpu tests/test_macos.nim + if: matrix.os == 'macos-latest' - run: nim r -d:useCpu tests/test_cpu_pixels.nim if: matrix.os == 'windows-latest' diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 822f540..ccf833d 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -328,13 +328,18 @@ proc url*(window: Window): string = warn "Url cannot be gotten on macOS windows" proc handleMouseMove(window: Window, location: NSPoint) = + ## Updates pixel coordinates and containment from unrounded view points. let - x = round(location.x) - y = round(window.inner.contentView.bounds.size.height - location.y) + bounds = window.inner.contentView.bounds + x = location.x + y = bounds.size.height - location.y window.state.mousePrevPos = window.state.mousePos - window.state.mousePos = (vec2(x, y) * window.contentScale).ivec2 - window.state.mouseInside = true + window.state.mousePos = + (vec2(round(x), round(y)) * window.contentScale).ivec2 + window.state.mouseInside = + x >= 0 and x < bounds.size.width and + y >= 0 and y < bounds.size.height # Prevent a jump in the mouse delta when focusing a window. if window.state.hasPrevMouse: @@ -955,9 +960,13 @@ proc resetCursorRects(self: ID, cmd: SEL): ID {.cdecl.} = window.state.cursor.hotspot.x.float, window.state.cursor.hotspot.y.float ) + defer: + image.ID.release() NSCursor.alloc().initWithImage(image, hotspot) self.NSView.addCursorRect(self.NSView.bounds, cursor) + if window.state.cursor.kind == CustomCursor: + cursor.ID.release() proc drawRect(self: ID, cmd: SEL, dirtyRect: NSRect): ID {.cdecl.} = when defined(useCpu): @@ -1153,13 +1162,13 @@ proc drainEvents() = # - https://github.com/andlabs/ui/blob/bc848f5c4078b999dbe6ef1cd90e16290a0d1c3a/delegateuitask_darwin.m#L46 if event.`type`() == NSEventTypeKeyDown: processKeyDown(event) - break + continue elif event.`type`() == NSEventTypeKeyUp: processKeyUp(event) - break + continue elif event.`type`() == NSEventTypeFlagsChanged: processFlagsChanged(event) - break + continue # Forward event for app to handle. NSApp.sendEvent(event) @@ -1193,10 +1202,12 @@ proc pollEvents*() = autoreleasepool: rescueActivation() - # Draw first (in case a message closes a window or similar) - for window in windows: - if window.onFrame != nil: - window.onFrame() + # Callbacks may close or create windows while this frame is being drawn. + let frameWindows = windows + for window in frameWindows: + let onFrame = window.onFrame + if not window.state.closed and onFrame != nil: + onFrame() # Clear all per-frame data for window in windows: @@ -1207,15 +1218,18 @@ proc pollEvents*() = pollHttp() proc centerWindow(window: Window) = - ## Calculate centered position for a window on the primary screen. - let - screenFrame = window.inner.screen.frame - screenWidth = screenFrame.size.width.int - screenHeight = screenFrame.size.height.int - # Calculate center position. - x = screenFrame.origin.x.int + (screenWidth - window.size.x) div 2 - y = screenFrame.origin.y.int + (screenHeight - window.size.y) div 2 - window.pos = ivec2(x.int32, y.int32) + ## Centers the native window frame using screen point coordinates. + autoreleasepool: + let + screenFrame = window.inner.screen.frame + windowFrame = window.inner.frame + origin = NSMakePoint( + screenFrame.origin.x + + (screenFrame.size.width - windowFrame.size.width) / 2, + screenFrame.origin.y + + (screenFrame.size.height - windowFrame.size.height) / 2 + ) + window.inner.setFrameOrigin(origin) proc makeContextCurrent*(window: Window) = when defined(useMetal4) or defined(useCpu): @@ -1232,19 +1246,28 @@ proc swapBuffers*(window: Window) = proc presentPixels*(window: Window, image: Image) = ## Presents a CPU-rendered Pixie image into the macOS window content view. when defined(useCpu): - if image == nil or image.width <= 0 or image.height <= 0: - return - let encodedPng = image.encodePng() - window.cpuImage = NSImage.alloc().initWithData(NSData.dataWithBytes( - encodedPng[0].unsafeAddr, - encodedPng.len - )) - window.inner.contentView.setNeedsDisplay(true) + if window.state.closed or image == nil or + image.width <= 0 or image.height <= 0: + return + autoreleasepool: + let + encodedPng = image.encodePng() + nativeImage = NSImage.alloc().initWithData(NSData.dataWithBytes( + encodedPng[0].unsafeAddr, + encodedPng.len + )) + if nativeImage.int == 0: + raise newException(WindyError, "Unable to create CPU frame image") + window.cpuImage.ID.release() + window.cpuImage = nativeImage + window.inner.contentView.setNeedsDisplay(true) else: discard proc close*(window: Window) = window.releaseMouse() + window.cpuImage.ID.release() + window.cpuImage = 0.NSImage window.onCloseRequest = nil window.onFrame = nil window.onMove = nil @@ -1260,6 +1283,13 @@ proc close*(window: Window) = if window.inner.int != 0: autoreleasepool: + window.inner.setDelegate(0.ID) + if window.trackingArea.int != 0: + window.inner.contentView.removeTrackingArea(window.trackingArea) + window.trackingArea.ID.release() + window.trackingArea = 0.NSTrackingArea + window.markedText.ID.release() + window.markedText = 0.NSString window.inner.close() let index = windows.indexForNSWindow(window.inner) @@ -1305,6 +1335,8 @@ proc newWindow*( let nativeView = WindyView.alloc().NSView.initWithFrame( result.inner.contentView.frame ) + defer: + nativeView.ID.release() result.inner.setDelegate(result.inner.ID) result.inner.setContentView(nativeView) discard result.inner.makeFirstResponder(nativeView) @@ -1329,11 +1361,15 @@ proc newWindow*( pixelFormat = NSOpenGLPixelFormat.alloc().initWithAttributes( pixelFormatAttribs[0].unsafeAddr ) + defer: + pixelFormat.ID.release() let openglView = WindyView.alloc().NSOpenGLView.initWithFrame( result.inner.contentView.frame, pixelFormat ) + defer: + openglView.ID.release() openglView.setWantsBestResolutionOpenGLSurface(true) openglView.openGLContext.makeCurrentContext() @@ -1365,11 +1401,11 @@ proc newWindow*( result.title = title result.size = size + result.style = style # Center window on screen by default (macOS standard behavior). result.centerWindow() - result.style = style result.visible = visible result.minimizedState = result.inner.isMiniaturized @@ -1481,6 +1517,8 @@ proc getClipboardImage*(): Image = let bitmap = NSBitmapImageRep.alloc().initWithData(data) if bitmap.int == 0: return + defer: + bitmap.ID.release() let pngData = bitmap.representationUsingType( NSBitmapImageFileTypePNG, @@ -1565,10 +1603,21 @@ proc setConfig*(appName: string, fileName: string, content: string) = proc openTempTextFile*(title, text: string) = ## Open a text file in the default text editor. - if not dirExists("tmp"): + try: createDir("tmp") - writeFile("tmp/" & title, text) - discard execShellCmd("open -a TextEdit tmp/" & title) + let path = "tmp" / title + writeFile(path, text) + let process = startProcess( + "open", + args = ["-a", "TextEdit", "--", path], + options = {poUsePath, poParentStreams} + ) + defer: + process.close() + if process.waitForExit() != 0: + raise newException(WindyError, "Unable to open temporary text file") + except IOError, OSError: + raise newException(WindyError, getCurrentExceptionMsg()) proc openUrl*(url: string) = ## Open a URL in the default web browser. diff --git a/tests/test_macos.nim b/tests/test_macos.nim new file mode 100644 index 0000000..06fb35c --- /dev/null +++ b/tests/test_macos.nim @@ -0,0 +1,194 @@ +when defined(macosx): + include ../src/windy/platforms/macos/platform + + objc: + proc retainCount(self: ID): uint + proc windowNumber(self: NSWindow): int + proc postEvent(self: NSApplication, x: NSEvent, atStart: bool) + proc keyEventWithType( + class: typedesc[NSEvent], + x: uint, + location: NSPoint, + modifierFlags: uint, + timestamp: float64, + windowNumber: int, + context: ID, + characters: NSString, + charactersIgnoringModifiers: NSString, + isARepeat: bool, + keyCode: uint16 + ): NSEvent + + when defined(useCpu): + proc testCpuImages() = + ## Checks image ownership after replacement and repeated close. + let + window = newWindow("CPU ownership", ivec2(32, 32), visible = false) + image = newImage(32, 32) + window.presentPixels(image) + let first = window.cpuImage.ID + first.retain() + defer: + first.release() + let firstCount = first.retainCount() + window.presentPixels(image) + doAssert first.retainCount() == firstCount - 1 + let last = window.cpuImage.ID + last.retain() + defer: + last.release() + let lastCount = last.retainCount() + window.close() + doAssert last.retainCount() == lastCount - 1 + doAssert window.cpuImage.int == 0 + window.close() + window.presentPixels(image) + doAssert window.cpuImage.int == 0 + + testCpuImages() + + proc testFrameClosures() = + ## Checks closing and creating windows during frame callbacks. + let + first = newWindow("First", ivec2(32, 32), visible = false) + second = newWindow("Second", ivec2(32, 32), visible = false) + third = newWindow("Third", ivec2(32, 32), visible = false) + var + child: Window + thirdFrames, childFrames: int + first.onFrame = proc() = + ## Closes the current and next windows and creates another window. + first.close() + second.close() + child = newWindow("Child", ivec2(32, 32), visible = false) + child.onFrame = proc() = + ## Counts frames for the newly created window. + inc childFrames + second.onFrame = proc() = + ## Rejects callbacks for a window closed earlier in this frame. + doAssert false, "Closed window received a frame" + third.onFrame = proc() = + ## Counts frames for the surviving window. + inc thirdFrames + pollEvents() + doAssert first.closed and second.closed + doAssert thirdFrames == 1 and childFrames == 0 + pollEvents() + doAssert thirdFrames == 2 and childFrames == 1 + third.close() + child.close() + + testFrameClosures() + + proc testWindowOwnership() = + ## Checks that closing releases the view and its tracking area. + let + window = newWindow("View ownership", ivec2(32, 32), visible = false) + view = window.inner.contentView.ID + view.retain() + defer: + view.release() + autoreleasepool: + discard updateTrackingAreas(view, s"updateTrackingAreas") + let tracking = window.trackingArea.ID + doAssert tracking.int != 0 + tracking.retain() + defer: + tracking.release() + window.close() + for i in 0 ..< 5: + drainEvents() + doAssert view.retainCount() == 1 + doAssert tracking.retainCount() == 1 + doAssert window.trackingArea.int == 0 + + testWindowOwnership() + + proc testKeyboardQueue() = + ## Checks that one poll delivers all queued key transitions in order. + let window = newWindow("Keyboard queue", ivec2(32, 32), visible = false) + defer: + window.close() + var presses, releases: seq[Button] + window.onButtonPress = proc(button: Button) = + ## Records press order. + presses.add(button) + window.onButtonRelease = proc(button: Button) = + ## Records release order. + releases.add(button) + autoreleasepool: + for keyCode in [0.uint16, 11, 8]: + for eventType in [10.uint, 11]: + let event = NSEvent.keyEventWithType( + eventType, + NSMakePoint(0, 0), + 0, + 0, + window.inner.windowNumber(), + 0.ID, + @"a", + @"a", + false, + keyCode + ) + NSApp.postEvent(event, false) + pollEvents() + doAssert presses == @[KeyA, KeyB, KeyC] + doAssert releases == presses + for button in presses: + doAssert window.buttonPressed[button] + doAssert window.buttonReleased[button] + doAssert not window.buttonDown[button] + + testKeyboardQueue() + + proc testWindowCenter() = + ## Checks that native frame centers agree on Retina and 1x screens. + for size in [ivec2(320, 200), ivec2(800, 600)]: + for style in [DecoratedResizable, Decorated, Undecorated]: + let window = newWindow( + "Centered window", + size, + style = style, + visible = false + ) + defer: + window.close() + let + frame = window.inner.frame + screen = window.inner.screen.frame + dx = frame.origin.x + frame.size.width / 2 - + (screen.origin.x + screen.size.width / 2) + dy = frame.origin.y + frame.size.height / 2 - + (screen.origin.y + screen.size.height / 2) + doAssert abs(dx) <= 1 and abs(dy) <= 1 + + testWindowCenter() + + proc testMouseInside() = + ## Checks containment for drags outside each edge and focus updates. + let + window = newWindow("Mouse bounds", ivec2(100, 100), visible = false) + bounds = window.inner.contentView.bounds + width = bounds.size.width + height = bounds.size.height + defer: + window.close() + for point in [ + NSMakePoint(-0.1, height / 2), + NSMakePoint(width, height / 2), + NSMakePoint(width / 2, height + 0.1), + NSMakePoint(width / 2, 0) + ]: + handleMouseMove(window, NSMakePoint(width / 2, height / 2)) + doAssert window.mouseInside + handleMouseMove(window, point) + doAssert not window.mouseInside + handleMouseMove(window, NSMakePoint(0, height)) + doAssert window.mouseInside + doAssert window.mousePos == ivec2(0, 0) + + testMouseInside() + echo "Windy macOS regression tests passed" +else: + echo "Windy macOS regression tests skipped" diff --git a/tests/test_tempfiles.nim b/tests/test_tempfiles.nim new file mode 100644 index 0000000..fabc8a8 --- /dev/null +++ b/tests/test_tempfiles.nim @@ -0,0 +1,49 @@ +when defined(macosx): + import + std/[json, os, tempfiles], + windy + + if getEnv("WINDY_TEMPFILE_RECORD") != "": + writeFile(getEnv("WINDY_TEMPFILE_RECORD"), $(%commandLineParams())) + quit(0) + + proc testTempFiles() = + ## Checks that filenames reach TextEdit without shell interpretation. + let + tempDir = createTempDir("windy-tempfiles-", "") + originalDir = getCurrentDir() + originalPath = getEnv("PATH") + recordPath = tempDir / "args.json" + opener = tempDir / "open" + titles = [ + "ordinary.txt", + "two words.txt", + "quotes'\".txt", + "$(touch windy-injected)", + "`touch windy-injected`", + "title; touch windy-injected", + "title\ntouch windy-injected", + "日本語.txt" + ] + defer: + setCurrentDir(originalDir) + putEnv("PATH", originalPath) + delEnv("WINDY_TEMPFILE_RECORD") + removeDir(tempDir) + copyFile(getAppFilename(), opener) + setFilePermissions(opener, {fpUserRead, fpUserWrite, fpUserExec}) + setCurrentDir(tempDir) + putEnv("PATH", tempDir & ":" & originalPath) + putEnv("WINDY_TEMPFILE_RECORD", recordPath) + for title in titles: + openTempTextFile(title, "Text contents.") + doAssert parseFile(recordPath) == %[ + "-a", "TextEdit", "--", "tmp" / title + ] + doAssert readFile("tmp" / title) == "Text contents." + doAssert not fileExists("windy-injected") + + testTempFiles() + echo "Windy temporary file regression test passed" +else: + echo "Windy temporary file regression test skipped"