From 55ff17e1438fc7a754a1ba3a80fe87cb40e7fe93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:35:21 +0800 Subject: [PATCH 1/2] Prepare CodexNotes 1.4.71 --- AppBundle/Info.plist | 4 +- Sources/CodexNotesProbe/ContentView.swift | 11 + .../MainWindowResizeCursorController.swift | 359 ++++++++++++++++++ .../PlainMarkdownTextView.swift | 14 +- ...ainWindowResizeCursorControllerTests.swift | 356 +++++++++++++++++ .../PlainMarkdownTextViewTests.swift | 62 +++ .../WindowConfiguratorLifecycleTests.swift | 31 ++ 7 files changed, 827 insertions(+), 10 deletions(-) create mode 100644 Sources/CodexNotesProbe/MainWindowResizeCursorController.swift create mode 100644 Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift diff --git a/AppBundle/Info.plist b/AppBundle/Info.plist index a3b2962..a02de10 100644 --- a/AppBundle/Info.plist +++ b/AppBundle/Info.plist @@ -24,9 +24,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.70 + 1.4.71 CFBundleVersion - 90 + 91 LSUIElement LSMinimumSystemVersion diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift index dbb8ade..aad12de 100644 --- a/Sources/CodexNotesProbe/ContentView.swift +++ b/Sources/CodexNotesProbe/ContentView.swift @@ -1964,6 +1964,7 @@ struct WindowConfigurator: NSViewRepresentable { private var codexActivationTimeoutWorkItem: DispatchWorkItem? private let codexAvailabilityMonitor: CodexApplicationAvailabilityObserving private let closeButtonHoverHintController = CloseButtonHoverHintController() + private let resizeCursorController = MainWindowResizeCursorController() private var appliedLanguageRevision: String? private var isInvalidated = false @@ -1984,6 +1985,7 @@ struct WindowConfigurator: NSViewRepresentable { languageRevision: languageRevision ) refreshCloseButtonHoverHint(for: window) + refreshResizeCursorTracking(for: window) return } if self.window != nil { @@ -2044,6 +2046,7 @@ struct WindowConfigurator: NSViewRepresentable { installFramePersistenceObservers(for: window) appliedLanguageRevision = languageRevision refreshCloseButtonHoverHint(for: window) + refreshResizeCursorTracking(for: window) updateVisibility(frontmostApplication: NSWorkspace.shared.frontmostApplication) } @@ -2055,6 +2058,7 @@ struct WindowConfigurator: NSViewRepresentable { func detach() { closeButtonHoverHintController.detach() + resizeCursorController.detach() cancelCodexActivationTimeout() codexAvailabilityMonitor.stop() if let activationObserver { @@ -2109,6 +2113,11 @@ struct WindowConfigurator: NSViewRepresentable { closeButtonHoverHintController.attach(to: closeButton, in: window) } + func refreshResizeCursorTracking(for window: NSWindow) { + guard !isInvalidated, self.window === window else { return } + resizeCursorController.attach(to: window) + } + func refreshLocalizationIfNeeded( for window: NSWindow, languageRevision: String @@ -2203,6 +2212,7 @@ struct WindowConfigurator: NSViewRepresentable { private func orderOutMainWindow(_ window: NSWindow) { closeButtonHoverHintController.cancelAndDismiss() + resizeCursorController.cancelAndRestore() window.orderOut(nil) } @@ -2417,6 +2427,7 @@ struct WindowConfigurator: NSViewRepresentable { languageRevision: languageRevision ) context.coordinator.refreshCloseButtonHoverHint(for: window) + context.coordinator.refreshResizeCursorTracking(for: window) } } diff --git a/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift b/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift new file mode 100644 index 0000000..70be766 --- /dev/null +++ b/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift @@ -0,0 +1,359 @@ +import AppKit + +/// The part of a resizable window frame currently under the pointer. +enum MainWindowResizeCursorRegion: CaseIterable, Equatable { + case top + case topRight + case right + case bottomRight + case bottom + case bottomLeft + case left + case topLeft + + static let edgeThickness: CGFloat = 6 + static let cornerReach: CGFloat = 14 + + /// Returns an edge only inside the thin native resize band. Corners use + /// an L-shaped extension along their two adjacent edges, matching the + /// larger target users expect without claiming interior content pixels. + static func region( + at point: NSPoint, + in bounds: NSRect, + edgeThickness: CGFloat = edgeThickness, + cornerReach: CGFloat = cornerReach + ) -> Self? { + guard bounds.width > 0, + bounds.height > 0, + point.x >= bounds.minX, + point.x <= bounds.maxX, + point.y >= bounds.minY, + point.y <= bounds.maxY else { + return nil + } + + let edgeThickness = max(0, edgeThickness) + let horizontalCornerReach = min( + max(edgeThickness, cornerReach), + bounds.width / 2 + ) + let verticalCornerReach = min( + max(edgeThickness, cornerReach), + bounds.height / 2 + ) + let distanceFromLeft = point.x - bounds.minX + let distanceFromRight = bounds.maxX - point.x + let distanceFromBottom = point.y - bounds.minY + let distanceFromTop = bounds.maxY - point.y + let nearLeft = distanceFromLeft <= edgeThickness + let nearRight = distanceFromRight <= edgeThickness + let nearBottom = distanceFromBottom <= edgeThickness + let nearTop = distanceFromTop <= edgeThickness + + if (nearTop && distanceFromLeft <= horizontalCornerReach) + || (nearLeft && distanceFromTop <= verticalCornerReach) { + return .topLeft + } + if (nearTop && distanceFromRight <= horizontalCornerReach) + || (nearRight && distanceFromTop <= verticalCornerReach) { + return .topRight + } + if (nearBottom && distanceFromRight <= horizontalCornerReach) + || (nearRight && distanceFromBottom <= verticalCornerReach) { + return .bottomRight + } + if (nearBottom && distanceFromLeft <= horizontalCornerReach) + || (nearLeft && distanceFromBottom <= verticalCornerReach) { + return .bottomLeft + } + if nearTop { return .top } + if nearRight { return .right } + if nearBottom { return .bottom } + if nearLeft { return .left } + return nil + } + + var cursor: NSCursor { + if #available(macOS 15.0, *) { + let position: NSCursor.FrameResizePosition = switch self { + case .top: .top + case .topRight: .topRight + case .right: .right + case .bottomRight: .bottomRight + case .bottom: .bottom + case .bottomLeft: .bottomLeft + case .left: .left + case .topLeft: .topLeft + } + return NSCursor.frameResize(position: position, directions: .all) + } + + // macOS 14 has no public diagonal frame-resize cursor. Keep the + // frame resizable and provide an axis cue rather than a custom image. + switch self { + case .top, .bottom: + return .resizeUpDown + case .left, .right, .topLeft, .topRight, .bottomLeft, .bottomRight: + return .resizeLeftRight + } + } +} + +@MainActor +protocol MainWindowResizeCursorPresenting: AnyObject { + func beginPresenting(_ region: MainWindowResizeCursorRegion) + func updatePresentedRegion(_ region: MainWindowResizeCursorRegion) + func endPresenting() +} + +@MainActor +private final class AppKitMainWindowResizeCursorPresenter: + MainWindowResizeCursorPresenting +{ + private var previousCursor: NSCursor? + private var lastPresentedCursor: NSCursor? + private var isPresenting = false + + func beginPresenting(_ region: MainWindowResizeCursorRegion) { + if !isPresenting { + let currentCursor = NSCursor.current + previousCursor = Self.isFrameResizeCursor(currentCursor) + ? .arrow + : currentCursor + isPresenting = true + } + present(region.cursor) + } + + func updatePresentedRegion(_ region: MainWindowResizeCursorRegion) { + guard isPresenting else { + beginPresenting(region) + return + } + // Reassert on every mouse-moved event. Cursor rects belonging to a + // descendant view may also be active while the pointer hugs the edge. + present(region.cursor) + } + + func endPresenting() { + guard isPresenting else { return } + // AppKit may already have selected an I-beam, checkbox hand, or title + // bar cursor for the same event. Restore our snapshot only while the + // cursor is still the exact frame cursor this presenter last set. + if NSCursor.current === lastPresentedCursor { + (previousCursor ?? .arrow).set() + } + previousCursor = nil + lastPresentedCursor = nil + isPresenting = false + } + + private func present(_ cursor: NSCursor) { + lastPresentedCursor = cursor + cursor.set() + } + + private static func isFrameResizeCursor(_ cursor: NSCursor) -> Bool { + MainWindowResizeCursorRegion.allCases.contains { + let candidate = $0.cursor + return candidate === cursor + || ( + candidate.hotSpot == cursor.hotSpot + && candidate.image.tiffRepresentation + == cursor.image.tiffRepresentation + ) + } + } +} + +/// Restores native resize feedback for the accessory app's visible, non-key +/// floating window. The tracking area observes only; AppKit still owns all +/// hit testing and the actual resize drag. +@MainActor +final class MainWindowResizeCursorController: NSResponder { + private weak var window: NSWindow? + private weak var frameView: NSView? + private var trackingArea: NSTrackingArea? + private var windowVisibilityObservation: NSKeyValueObservation? + private var originalAcceptsMouseMovedEvents: Bool? + private let presenter: any MainWindowResizeCursorPresenting + private(set) var currentRegion: MainWindowResizeCursorRegion? + + override convenience init() { + self.init(presenter: AppKitMainWindowResizeCursorPresenter()) + } + + init(presenter: any MainWindowResizeCursorPresenting) { + self.presenter = presenter + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + MainActor.assumeIsolated { + cancelAndRestore() + windowVisibilityObservation?.invalidate() + if let frameView, let trackingArea { + frameView.removeTrackingArea(trackingArea) + } + if let window, let originalAcceptsMouseMovedEvents { + window.acceptsMouseMovedEvents = originalAcceptsMouseMovedEvents + } + } + } + + /// Repeated attachment to the same standard frame keeps exactly one + /// tracking area. NSThemeFrame is intentionally used because contentView + /// does not include the title bar or the top resize edge. + func attach(to window: NSWindow) { + guard let frameView = window.contentView?.superview else { + detach() + return + } + + if self.window === window, self.frameView === frameView { + ensureTrackingAreaIsInstalled(on: frameView) + window.acceptsMouseMovedEvents = true + return + } + + detach() + self.window = window + self.frameView = frameView + originalAcceptsMouseMovedEvents = window.acceptsMouseMovedEvents + window.acceptsMouseMovedEvents = true + installTrackingArea(on: frameView) + windowVisibilityObservation = window.observe( + \.isVisible, + options: [.new] + ) { [weak self] _, change in + guard change.newValue == false else { return } + MainActor.assumeIsolated { + self?.cancelAndRestore() + } + } + } + + /// Removes the cursor immediately when the window is hidden while the + /// pointer is still in its former frame. + func cancelAndRestore() { + guard currentRegion != nil else { return } + currentRegion = nil + presenter.endPresenting() + } + + func detach() { + cancelAndRestore() + windowVisibilityObservation?.invalidate() + windowVisibilityObservation = nil + if let frameView, let trackingArea { + frameView.removeTrackingArea(trackingArea) + } + trackingArea = nil + if let window, let originalAcceptsMouseMovedEvents { + window.acceptsMouseMovedEvents = originalAcceptsMouseMovedEvents + } + originalAcceptsMouseMovedEvents = nil + frameView = nil + window = nil + } + + override func mouseEntered(with event: NSEvent) { + guard event.trackingArea === trackingArea else { return } + pointerMoved(using: event) + } + + override func mouseMoved(with event: NSEvent) { + // NSEvent.trackingArea is only valid for enter, exit, and cursor-update + // events. AppKit dispatches this callback to the tracking area's owner, + // so attachment identity is validated through our retained area/view. + guard trackingArea != nil else { return } + pointerMoved(using: event) + } + + override func mouseExited(with event: NSEvent) { + pointerExited(from: event.trackingArea) + } + + /// A semantic entry point makes the geometry and cursor lifecycle + /// deterministic in unit tests without moving the user's real pointer. + func pointerMoved(to point: NSPoint) { + guard let window, + let frameView, + frameView.window === window, + window.isVisible, + !window.isMiniaturized, + window.styleMask.contains(.resizable), + !window.styleMask.contains(.fullScreen) else { + cancelAndRestore() + return + } + updatePresentedRegion( + MainWindowResizeCursorRegion.region(at: point, in: frameView.bounds) + ) + } + + func pointerExited(from eventTrackingArea: NSTrackingArea?) { + guard eventTrackingArea === trackingArea else { return } + cancelAndRestore() + } + + private func pointerMoved(using event: NSEvent) { + guard let window, + event.window === window, + let frameView, + frameView.window === window else { return } + pointerMoved(to: frameView.convert(event.locationInWindow, from: nil)) + } + + private func updatePresentedRegion( + _ newRegion: MainWindowResizeCursorRegion? + ) { + switch (currentRegion, newRegion) { + case (nil, let newRegion?): + currentRegion = newRegion + presenter.beginPresenting(newRegion) + case (.some, let newRegion?): + self.currentRegion = newRegion + // Reassert even if the region did not change; descendant cursor + // rects must not win a race at the inside edge of the frame. + presenter.updatePresentedRegion(newRegion) + case (.some, nil): + currentRegion = nil + presenter.endPresenting() + case (nil, nil): + break + } + } + + private func ensureTrackingAreaIsInstalled(on frameView: NSView) { + if let trackingArea, frameView.trackingAreas.contains(trackingArea) { + return + } + installTrackingArea(on: frameView) + } + + private func installTrackingArea(on frameView: NSView) { + if let trackingArea { + self.frameView?.removeTrackingArea(trackingArea) + } + let trackingArea = NSTrackingArea( + rect: .zero, + options: [ + .mouseEnteredAndExited, + .mouseMoved, + .activeAlways, + .inVisibleRect, + .enabledDuringMouseDrag, + ], + owner: self, + userInfo: nil + ) + frameView.addTrackingArea(trackingArea) + self.trackingArea = trackingArea + } +} diff --git a/Sources/CodexNotesProbe/PlainMarkdownTextView.swift b/Sources/CodexNotesProbe/PlainMarkdownTextView.swift index 68b0fd0..04baad9 100644 --- a/Sources/CodexNotesProbe/PlainMarkdownTextView.swift +++ b/Sources/CodexNotesProbe/PlainMarkdownTextView.swift @@ -5282,11 +5282,6 @@ final class CheckboxTextView: NSTextView, NSTextStorageDelegate { hoveredMarkerLocation = newLocation needsDisplay = true } - if newLocation != nil { - NSCursor.pointingHand.set() - } else { - NSCursor.iBeam.set() - } } override func mouseExited(with event: NSEvent) { @@ -5294,16 +5289,19 @@ final class CheckboxTextView: NSTextView, NSTextStorageDelegate { hoveredMarkerLocation = nil needsDisplay = true } - NSCursor.iBeam.set() } override func resetCursorRects() { super.resetCursorRects() - for geometry in checkboxGeometries() { - addCursorRect(geometry.hitRect, cursor: .pointingHand) + for rect in checkboxCursorRects() { + addCursorRect(rect, cursor: .pointingHand) } } + func checkboxCursorRects() -> [NSRect] { + checkboxGeometries().map(\.hitRect) + } + private func apply( _ plan: MarkdownTextEditPlan, selectionOverride: NSRange? = nil diff --git a/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift b/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift new file mode 100644 index 0000000..60239fe --- /dev/null +++ b/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift @@ -0,0 +1,356 @@ +import AppKit +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class MainWindowResizeCursorControllerTests: XCTestCase { + func testClassifiesAllFourEdgesFourCornersAndInterior() { + let bounds = NSRect(x: 10, y: 20, width: 100, height: 80) + let cases: [(NSPoint, MainWindowResizeCursorRegion?)] = [ + (NSPoint(x: 60, y: 100), .top), + (NSPoint(x: 110, y: 100), .topRight), + (NSPoint(x: 110, y: 60), .right), + (NSPoint(x: 110, y: 20), .bottomRight), + (NSPoint(x: 60, y: 20), .bottom), + (NSPoint(x: 10, y: 20), .bottomLeft), + (NSPoint(x: 10, y: 60), .left), + (NSPoint(x: 10, y: 100), .topLeft), + (NSPoint(x: 60, y: 60), nil), + ] + + for (point, expectedRegion) in cases { + XCTAssertEqual( + MainWindowResizeCursorRegion.region(at: point, in: bounds), + expectedRegion, + "point=\(point)" + ) + } + } + + func testCornersUseLShapedReachAndDoNotClaimInteriorSquare() { + let bounds = NSRect(x: 0, y: 0, width: 100, height: 80) + + XCTAssertEqual( + MainWindowResizeCursorRegion.region(at: NSPoint(x: 10, y: 79), in: bounds), + .topLeft + ) + XCTAssertEqual( + MainWindowResizeCursorRegion.region(at: NSPoint(x: 1, y: 70), in: bounds), + .topLeft + ) + XCTAssertNil( + MainWindowResizeCursorRegion.region(at: NSPoint(x: 10, y: 70), in: bounds) + ) + XCTAssertEqual( + MainWindowResizeCursorRegion.region(at: NSPoint(x: 15, y: 79), in: bounds), + .top + ) + } + + func testPointsOutsideFrameNeverProduceResizeRegion() { + let bounds = NSRect(x: 0, y: 0, width: 100, height: 80) + let outsidePoints = [ + NSPoint(x: -0.01, y: 40), + NSPoint(x: 100.01, y: 40), + NSPoint(x: 50, y: -0.01), + NSPoint(x: 50, y: 80.01), + ] + + for point in outsidePoints { + XCTAssertNil( + MainWindowResizeCursorRegion.region(at: point, in: bounds), + "point=\(point)" + ) + } + } + + func testAllRegionsMapToTheirNativeFrameResizeCursor() throws { + guard #available(macOS 15.0, *) else { + XCTAssertCursorEqual( + MainWindowResizeCursorRegion.top.cursor, + .resizeUpDown + ) + XCTAssertCursorEqual( + MainWindowResizeCursorRegion.left.cursor, + .resizeLeftRight + ) + return + } + let expected: [(MainWindowResizeCursorRegion, NSCursor.FrameResizePosition)] = [ + (.top, .top), + (.topRight, .topRight), + (.right, .right), + (.bottomRight, .bottomRight), + (.bottom, .bottom), + (.bottomLeft, .bottomLeft), + (.left, .left), + (.topLeft, .topLeft), + ] + + for (region, position) in expected { + XCTAssertCursorEqual( + region.cursor, + NSCursor.frameResize(position: position, directions: .all), + "region=\(region)" + ) + } + } + + func testAttachUsesOneAlwaysActiveFullFrameTrackingAreaWithoutChangingHitTesting() throws { + let context = try makeVisibleWindow() + let presenter = ResizeCursorPresenterSpy() + let controller = MainWindowResizeCursorController(presenter: presenter) + defer { + controller.detach() + context.window.orderOut(nil) + } + let originalStyleMask = context.window.styleMask + let originalContentView = context.window.contentView + let originalSubviewIDs = context.frameView.subviews.map(ObjectIdentifier.init) + let probePoint = context.frameView.convert( + NSPoint(x: 20, y: 20), + from: context.window.contentView + ) + let originalHitView = context.frameView.hitTest(probePoint) + XCTAssertFalse(context.window.acceptsMouseMovedEvents) + + controller.attach(to: context.window) + controller.attach(to: context.window) + + let ownedAreas = context.frameView.trackingAreas.filter { + $0.owner === controller + } + let area = try XCTUnwrap(ownedAreas.first) + XCTAssertEqual(ownedAreas.count, 1) + XCTAssertTrue(area.options.contains(.mouseEnteredAndExited)) + XCTAssertTrue(area.options.contains(.mouseMoved)) + XCTAssertTrue(area.options.contains(.activeAlways)) + XCTAssertTrue(area.options.contains(.inVisibleRect)) + XCTAssertTrue(area.options.contains(.enabledDuringMouseDrag)) + XCTAssertFalse(area.options.contains(.activeInKeyWindow)) + XCTAssertFalse(area.options.contains(.cursorUpdate)) + XCTAssertTrue(context.window.acceptsMouseMovedEvents) + XCTAssertEqual(context.window.styleMask, originalStyleMask) + XCTAssertTrue(context.window.contentView === originalContentView) + XCTAssertEqual( + context.frameView.subviews.map(ObjectIdentifier.init), + originalSubviewIDs + ) + XCTAssertTrue(context.frameView.hitTest(probePoint) === originalHitView) + + controller.detach() + + XCTAssertFalse(context.frameView.trackingAreas.contains { $0.owner === controller }) + XCTAssertFalse(context.window.acceptsMouseMovedEvents) + } + + func testVisibleNonKeyWindowPresentsEightRegionsAndRestoresOnceInInterior() throws { + let context = try makeVisibleWindow() + let presenter = ResizeCursorPresenterSpy() + let controller = MainWindowResizeCursorController(presenter: presenter) + defer { + controller.detach() + context.window.orderOut(nil) + } + controller.attach(to: context.window) + XCTAssertFalse(context.window.isKeyWindow) + let bounds = context.frameView.bounds + + let regionPoints: [(MainWindowResizeCursorRegion, NSPoint)] = [ + (.top, NSPoint(x: bounds.midX, y: bounds.maxY)), + (.topRight, NSPoint(x: bounds.maxX, y: bounds.maxY)), + (.right, NSPoint(x: bounds.maxX, y: bounds.midY)), + (.bottomRight, NSPoint(x: bounds.maxX, y: bounds.minY)), + (.bottom, NSPoint(x: bounds.midX, y: bounds.minY)), + (.bottomLeft, NSPoint(x: bounds.minX, y: bounds.minY)), + (.left, NSPoint(x: bounds.minX, y: bounds.midY)), + (.topLeft, NSPoint(x: bounds.minX, y: bounds.maxY)), + ] + + controller.pointerMoved(to: regionPoints[0].1) + for (_, point) in regionPoints { + controller.pointerMoved(to: point) + } + controller.pointerMoved(to: NSPoint(x: bounds.midX, y: bounds.midY)) + + XCTAssertEqual(presenter.begunRegions, [.top]) + XCTAssertEqual( + presenter.updatedRegions, + regionPoints.map(\.0) + ) + XCTAssertEqual(presenter.endCount, 1) + XCTAssertNil(controller.currentRegion) + XCTAssertFalse(context.window.isKeyWindow) + } + + func testSameEdgeReassertsCursorWithoutStartingAnotherPresentation() throws { + let context = try makeVisibleWindow() + let presenter = ResizeCursorPresenterSpy() + let controller = MainWindowResizeCursorController(presenter: presenter) + defer { + controller.detach() + context.window.orderOut(nil) + } + controller.attach(to: context.window) + let point = NSPoint( + x: context.frameView.bounds.maxX, + y: context.frameView.bounds.midY + ) + + controller.pointerMoved(to: point) + controller.pointerMoved(to: point) + controller.pointerMoved(to: point) + + XCTAssertEqual(presenter.begunRegions, [.right]) + XCTAssertEqual(presenter.updatedRegions, [.right, .right]) + XCTAssertEqual(presenter.endCount, 0) + } + + func testMouseMovedEventUsesAttachedFrameWithoutReadingTrackingArea() throws { + let context = try makeVisibleWindow() + let presenter = ResizeCursorPresenterSpy() + let controller = MainWindowResizeCursorController(presenter: presenter) + defer { + controller.detach() + context.window.orderOut(nil) + } + controller.attach(to: context.window) + let framePoint = NSPoint( + x: context.frameView.bounds.maxX, + y: context.frameView.bounds.midY + ) + let event = try XCTUnwrap( + NSEvent.mouseEvent( + with: .mouseMoved, + location: context.frameView.convert(framePoint, to: nil), + modifierFlags: [], + timestamp: 0, + windowNumber: context.window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 0, + pressure: 0 + ) + ) + + controller.mouseMoved(with: event) + + XCTAssertEqual(presenter.begunRegions, [.right]) + XCTAssertEqual(controller.currentRegion, .right) + } + + func testHiddenOrNonResizableWindowDoesNotLeaveResizeCursorPresented() throws { + let context = try makeVisibleWindow() + let presenter = ResizeCursorPresenterSpy() + let controller = MainWindowResizeCursorController(presenter: presenter) + defer { + controller.detach() + context.window.orderOut(nil) + } + controller.attach(to: context.window) + let edgePoint = NSPoint( + x: context.frameView.bounds.maxX, + y: context.frameView.bounds.midY + ) + + controller.pointerMoved(to: edgePoint) + context.window.styleMask.remove(.resizable) + controller.pointerMoved(to: edgePoint) + context.window.styleMask.insert(.resizable) + context.window.orderOut(nil) + controller.pointerMoved(to: edgePoint) + + XCTAssertEqual(presenter.begunRegions, [.right]) + XCTAssertEqual(presenter.endCount, 1) + XCTAssertNil(controller.currentRegion) + } + + func testReattachingToAnotherWindowRemovesOldAreaAndIgnoresStaleExit() throws { + let oldContext = try makeVisibleWindow(x: 100) + let newContext = try makeVisibleWindow(x: 620) + let presenter = ResizeCursorPresenterSpy() + let controller = MainWindowResizeCursorController(presenter: presenter) + defer { + controller.detach() + oldContext.window.orderOut(nil) + newContext.window.orderOut(nil) + } + controller.attach(to: oldContext.window) + let staleArea = try XCTUnwrap( + oldContext.frameView.trackingAreas.first { $0.owner === controller } + ) + + controller.attach(to: newContext.window) + controller.pointerMoved( + to: NSPoint( + x: newContext.frameView.bounds.maxX, + y: newContext.frameView.bounds.midY + ) + ) + controller.pointerExited(from: staleArea) + + XCTAssertFalse(oldContext.frameView.trackingAreas.contains { $0.owner === controller }) + XCTAssertEqual( + newContext.frameView.trackingAreas.filter { $0.owner === controller }.count, + 1 + ) + XCTAssertEqual(presenter.begunRegions, [.right]) + XCTAssertEqual(presenter.endCount, 0) + XCTAssertEqual(controller.currentRegion, .right) + } + + private func makeVisibleWindow( + x: CGFloat = 100 + ) throws -> (window: NSWindow, frameView: NSView) { + let window = NonKeyResizeTestWindow( + contentRect: NSRect(x: x, y: 100, width: 500, height: 680), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + window.orderFrontRegardless() + XCTAssertTrue(window.isVisible) + return (window, try XCTUnwrap(window.contentView?.superview)) + } + + private func XCTAssertCursorEqual( + _ actual: NSCursor, + _ expected: NSCursor, + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(actual.hotSpot, expected.hotSpot, message, file: file, line: line) + XCTAssertEqual( + actual.image.tiffRepresentation, + expected.image.tiffRepresentation, + message, + file: file, + line: line + ) + } +} + +private final class NonKeyResizeTestWindow: NSWindow { + override var isKeyWindow: Bool { false } +} + +@MainActor +private final class ResizeCursorPresenterSpy: MainWindowResizeCursorPresenting { + private(set) var begunRegions: [MainWindowResizeCursorRegion] = [] + private(set) var updatedRegions: [MainWindowResizeCursorRegion] = [] + private(set) var endCount = 0 + + func beginPresenting(_ region: MainWindowResizeCursorRegion) { + begunRegions.append(region) + } + + func updatePresentedRegion(_ region: MainWindowResizeCursorRegion) { + updatedRegions.append(region) + } + + func endPresenting() { + endCount += 1 + } +} diff --git a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift index de12d96..5a2716b 100644 --- a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift +++ b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift @@ -3429,6 +3429,49 @@ final class PlainMarkdownTextViewTests: XCTestCase { } } + func testCheckboxHoverAndExitDoNotOverrideAWindowFrameCursor() throws { + let todoHarness = makeHarness(text: "- [ ] 待办", textWidth: 420) + let todoTextView = todoHarness.textView + todoTextView.layoutManager?.ensureLayout( + for: try XCTUnwrap(todoTextView.textContainer) + ) + let checkboxRect = try XCTUnwrap(todoTextView.checkboxCursorRects().first) + XCTAssertEqual(todoTextView.checkboxCursorRects().count, 1) + + let ordinaryHarness = makeHarness(text: "普通正文", textWidth: 420) + XCTAssertTrue(ordinaryHarness.textView.checkboxCursorRects().isEmpty) + + let previousCursor = NSCursor.current + defer { previousCursor.set() } + let sentinelCursor = NSCursor.crosshair + + sentinelCursor.set() + todoTextView.mouseMoved( + with: try mouseMovedEvent( + at: NSPoint(x: checkboxRect.midX, y: checkboxRect.midY), + in: todoHarness + ) + ) + XCTAssertTrue(NSCursor.current === sentinelCursor) + + sentinelCursor.set() + ordinaryHarness.textView.mouseMoved( + with: try mouseMovedEvent( + at: NSPoint(x: 200, y: 40), + in: ordinaryHarness + ) + ) + XCTAssertTrue(NSCursor.current === sentinelCursor) + + ordinaryHarness.textView.mouseExited( + with: try mouseMovedEvent( + at: NSPoint(x: 200, y: 40), + in: ordinaryHarness + ) + ) + XCTAssertTrue(NSCursor.current === sentinelCursor) + } + func testFinalLineEOFAndTrailingNewlineKeepNativeSelectionAndCaretGeometry() throws { let finalLineText = "上一行\n前选区后" let finalSelection = (finalLineText as NSString).range(of: "选区") @@ -5279,6 +5322,25 @@ final class PlainMarkdownTextViewTests: XCTestCase { ) } + private func mouseMovedEvent( + at point: NSPoint, + in harness: EditorHarness + ) throws -> NSEvent { + try XCTUnwrap( + NSEvent.mouseEvent( + with: .mouseMoved, + location: harness.textView.convert(point, to: nil), + modifierFlags: [], + timestamp: 0, + windowNumber: harness.window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 0, + pressure: 0 + ) + ) + } + private func clickTextView( at point: NSPoint, in harness: EditorHarness diff --git a/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift b/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift index 1e7f5b7..9f44187 100644 --- a/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift +++ b/Tests/CodexNotesProbeTests/WindowConfiguratorLifecycleTests.swift @@ -4,6 +4,37 @@ import XCTest @MainActor final class WindowConfiguratorLifecycleTests: XCTestCase { + func testMainCoordinatorOwnsExactlyOneFullFrameResizeTracker() throws { + let availability = FakeCodexApplicationAvailabilityMonitor( + isCodexAvailable: false + ) + let coordinator = WindowConfigurator.Coordinator( + codexAvailabilityMonitor: availability + ) + let window = makeMainWindow() + let frameView = try XCTUnwrap(window.contentView?.superview) + defer { window.orderOut(nil) } + + coordinator.attach(to: window, languageRevision: "test") + coordinator.attach(to: window, languageRevision: "test") + + XCTAssertEqual( + frameView.trackingAreas.filter { + $0.owner is MainWindowResizeCursorController + }.count, + 1 + ) + XCTAssertTrue(window.styleMask.contains(.resizable)) + + coordinator.detach() + + XCTAssertFalse( + frameView.trackingAreas.contains { + $0.owner is MainWindowResizeCursorController + } + ) + } + func testSettingsVisibilityNotificationDoesNotHideVisibleMainWindow() async { await preservingStableFrameDefaults { let availability = FakeCodexApplicationAvailabilityMonitor( From f4fa0aa3e7cb5c792006c62668151ac73271b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:47:15 +0800 Subject: [PATCH 2/2] Support Xcode 15.4 cursor builds --- .../MainWindowResizeCursorController.swift | 69 +++++++++++--- ...ainWindowResizeCursorControllerTests.swift | 90 ++++++++++++++----- 2 files changed, 121 insertions(+), 38 deletions(-) diff --git a/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift b/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift index 70be766..f91cd08 100644 --- a/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift +++ b/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift @@ -1,4 +1,36 @@ import AppKit +import ObjectiveC.runtime + +private enum MainWindowFrameResizeCursorRuntime { + typealias Factory = @convention(c) ( + AnyClass, + Selector, + UInt, + UInt + ) -> Unmanaged + + private static let selector = NSSelectorFromString( + "frameResizeCursorFromPosition:inDirections:" + ) + private static let factory: Factory? = { + guard let method = class_getClassMethod(NSCursor.self, selector) else { + return nil + } + return unsafeBitCast( + method_getImplementation(method), + to: Factory.self + ) + }() + + static func cursor(position: UInt) -> NSCursor? { + factory?( + NSCursor.self, + selector, + position, + 3 // inward | outward + ).takeUnretainedValue() + } +} /// The part of a resizable window frame currently under the pointer. enum MainWindowResizeCursorRegion: CaseIterable, Equatable { @@ -73,23 +105,32 @@ enum MainWindowResizeCursorRegion: CaseIterable, Equatable { return nil } + var frameResizePosition: UInt { + switch self { + case .top: 1 + case .left: 2 + case .bottom: 4 + case .right: 8 + case .topLeft: 3 + case .topRight: 9 + case .bottomLeft: 6 + case .bottomRight: 12 + } + } + var cursor: NSCursor { - if #available(macOS 15.0, *) { - let position: NSCursor.FrameResizePosition = switch self { - case .top: .top - case .topRight: .topRight - case .right: .right - case .bottomRight: .bottomRight - case .bottom: .bottom - case .bottomLeft: .bottomLeft - case .left: .left - case .topLeft: .topLeft - } - return NSCursor.frameResize(position: position, directions: .all) + cursor(resolvingFrameResizeCursorWith: MainWindowFrameResizeCursorRuntime.cursor) + } + + func cursor( + resolvingFrameResizeCursorWith resolve: (UInt) -> NSCursor? + ) -> NSCursor { + if let nativeCursor = resolve(frameResizePosition) { + return nativeCursor } - // macOS 14 has no public diagonal frame-resize cursor. Keep the - // frame resizable and provide an axis cue rather than a custom image. + // macOS 14 has no public frame-resize cursor factory. Keep the frame + // resizable and provide an axis cue rather than a custom image. switch self { case .top, .bottom: return .resizeUpDown diff --git a/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift b/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift index 60239fe..6a954f5 100644 --- a/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift +++ b/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift @@ -1,4 +1,5 @@ import AppKit +import ObjectiveC.runtime import XCTest @testable import CodexNotesProbe @@ -64,38 +65,79 @@ final class MainWindowResizeCursorControllerTests: XCTestCase { } } - func testAllRegionsMapToTheirNativeFrameResizeCursor() throws { - guard #available(macOS 15.0, *) else { - XCTAssertCursorEqual( - MainWindowResizeCursorRegion.top.cursor, - .resizeUpDown - ) - XCTAssertCursorEqual( - MainWindowResizeCursorRegion.left.cursor, - .resizeLeftRight - ) - return - } - let expected: [(MainWindowResizeCursorRegion, NSCursor.FrameResizePosition)] = [ - (.top, .top), - (.topRight, .topRight), - (.right, .right), - (.bottomRight, .bottomRight), - (.bottom, .bottom), - (.bottomLeft, .bottomLeft), - (.left, .left), - (.topLeft, .topLeft), + func testAllRegionsMapToTheirNativeFrameResizePosition() { + let expected: [(MainWindowResizeCursorRegion, UInt)] = [ + (.top, 1), + (.topRight, 9), + (.right, 8), + (.bottomRight, 12), + (.bottom, 4), + (.bottomLeft, 6), + (.left, 2), + (.topLeft, 3), ] for (region, position) in expected { - XCTAssertCursorEqual( - region.cursor, - NSCursor.frameResize(position: position, directions: .all), + var requestedPosition: UInt? + let cursor = region.cursor { requested in + requestedPosition = requested + return .crosshair + } + + XCTAssertEqual( + region.frameResizePosition, + position, "region=\(region)" ) + XCTAssertEqual(requestedPosition, position, "region=\(region)") + XCTAssertTrue(cursor === NSCursor.crosshair, "region=\(region)") } } + func testAllRegionsUseAxisFallbackWhenNativeFactoryIsUnavailable() { + for region in MainWindowResizeCursorRegion.allCases { + let expected: NSCursor = switch region { + case .top, .bottom: + .resizeUpDown + case .left, .right, .topLeft, .topRight, .bottomLeft, .bottomRight: + .resizeLeftRight + } + let cursor = region.cursor { _ in nil } + + XCTAssertCursorEqual(cursor, expected, "region=\(region)") + } + } + + func testProductionRuntimeUsesNativeFrameResizeCursorWhenAvailable() throws { + let selector = NSSelectorFromString( + "frameResizeCursorFromPosition:inDirections:" + ) + guard let method = class_getClassMethod(NSCursor.self, selector) else { + throw XCTSkip("Native frame-resize cursors require macOS 15 or later") + } + typealias Factory = @convention(c) ( + AnyClass, + Selector, + UInt, + UInt + ) -> Unmanaged + let factory = unsafeBitCast( + method_getImplementation(method), + to: Factory.self + ) + let expected = factory( + NSCursor.self, + selector, + 3, // top-left + 3 // inward | outward + ).takeUnretainedValue() + + XCTAssertCursorEqual( + MainWindowResizeCursorRegion.topLeft.cursor, + expected + ) + } + func testAttachUsesOneAlwaysActiveFullFrameTrackingAreaWithoutChangingHitTesting() throws { let context = try makeVisibleWindow() let presenter = ResizeCursorPresenterSpy()