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..f91cd08
--- /dev/null
+++ b/Sources/CodexNotesProbe/MainWindowResizeCursorController.swift
@@ -0,0 +1,400 @@
+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 {
+ 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 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 {
+ cursor(resolvingFrameResizeCursorWith: MainWindowFrameResizeCursorRuntime.cursor)
+ }
+
+ func cursor(
+ resolvingFrameResizeCursorWith resolve: (UInt) -> NSCursor?
+ ) -> NSCursor {
+ if let nativeCursor = resolve(frameResizePosition) {
+ return nativeCursor
+ }
+
+ // 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
+ 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..6a954f5
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/MainWindowResizeCursorControllerTests.swift
@@ -0,0 +1,398 @@
+import AppKit
+import ObjectiveC.runtime
+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 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 {
+ 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()
+ 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(