From e4041ed839d50f1a1ca3bd0498ebdad2047afd90 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 10 Aug 2026 11:36:07 +0200 Subject: [PATCH 1/2] feat(pointer-icon): mirror native cursor visibility --- .../Mouse/Core/CursorVisibilityProvider.swift | 41 +++++++++++++++ .../PointerIcon/PointerIconVisualizer.swift | 15 +++++- .../PointerIconVisualizerWindow.swift | 50 ++++++++++++------- .../PointerIconVisualizerTests.swift | 41 +++++++++++++++ 4 files changed, 128 insertions(+), 19 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/CursorVisibilityProvider.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/CursorVisibilityProvider.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/CursorVisibilityProvider.swift new file mode 100644 index 0000000..2082d5c --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/CursorVisibilityProvider.swift @@ -0,0 +1,41 @@ +// +// CursorVisibilityProvider.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Darwin + +/// Reports whether the system cursor is currently visible on screen. +protocol CursorVisibilityProviding { + var isCursorVisible: Bool { get } +} + +struct SystemCursorVisibilityProvider: CursorVisibilityProviding { + var isCursorVisible: Bool { + guard let cursorIsVisible else { + return true + } + + return cursorIsVisible() != 0 + } + + private typealias CursorIsVisibleFunction = @convention(c) () -> Int32 + + // The runtime symbol still exists, but the current Swift SDK marks it unavailable. + private var cursorIsVisible: CursorIsVisibleFunction? { + Self.cursorIsVisibleFunction + } + + private static let cursorIsVisibleFunction: CursorIsVisibleFunction? = { + guard + let handle = dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_LAZY), + let symbol = dlsym(handle, "CGCursorIsVisible") + else { + return nil + } + return unsafeBitCast(symbol, to: CursorIsVisibleFunction.self) + }() +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift index f5771fa..f9e1432 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift @@ -12,6 +12,7 @@ import Combine @MainActor final class PointerIconVisualizer { private let settings: any PointerIconSettingsProtocol & ReactiveSettings + private let cursorVisibilityProvider: any CursorVisibilityProviding private var cancellables = Set() private var window: PointerIconVisualizerWindow? private var tracker: DisplayTracker? @@ -27,8 +28,12 @@ final class PointerIconVisualizer { self.window != nil } - init(settings: any PointerIconSettingsProtocol & ReactiveSettings = PointerIconSettings()) { + init( + settings: any PointerIconSettingsProtocol & ReactiveSettings = PointerIconSettings(), + cursorVisibilityProvider: any CursorVisibilityProviding = SystemCursorVisibilityProvider() + ) { self.settings = settings + self.cursorVisibilityProvider = cursorVisibilityProvider self.settings.changes .sink { [weak self] in Task { @MainActor in @@ -74,7 +79,12 @@ private extension PointerIconVisualizer { } func show() { - if self.window == nil { self.window = PointerIconVisualizerWindow.make(settings: self.settings) } + if self.window == nil { + self.window = PointerIconVisualizerWindow( + settings: self.settings, + cursorVisibilityProvider: self.cursorVisibilityProvider + ) + } self.window?.update(screenLocation: NSEvent.mouseLocation) self.window?.refreshVisibility() self.startTracking() @@ -89,6 +99,7 @@ private extension PointerIconVisualizer { guard self.tracker == nil else { return } self.tracker = DisplayTracker { [weak self] in self?.window?.update(screenLocation: NSEvent.mouseLocation) + self?.window?.refreshVisibility() } self.tracker?.start() } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift index d1d9ba2..aafacf5 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift @@ -11,10 +11,17 @@ import Combine final class PointerIconVisualizerWindow: NSWindow { private let settings: any PointerIconSettingsProtocol & ReactiveSettings + private let cursorVisibilityProvider: any CursorVisibilityProviding + private let pointerContentView: PointerIconContentView private var cancellables = Set() - init(settings: any PointerIconSettingsProtocol & ReactiveSettings) { + init( + settings: any PointerIconSettingsProtocol & ReactiveSettings, + cursorVisibilityProvider: any CursorVisibilityProviding + ) { self.settings = settings + self.cursorVisibilityProvider = cursorVisibilityProvider + self.pointerContentView = PointerIconContentView(settings: settings) super.init( contentRect: NSRect(origin: .zero, size: PointerIconContentView.windowSize(settings: settings)), styleMask: .borderless, @@ -28,6 +35,11 @@ final class PointerIconVisualizerWindow: NSWindow { self.ignoresMouseEvents = true self.collectionBehavior = .canJoinAllSpaces + self.pointerContentView.visibilityDidChange = { [weak self] _ in + self?.refreshVisibility() + } + self.contentView = self.pointerContentView + settings.changes .sink { [weak self] in Task { @MainActor in @@ -45,35 +57,39 @@ final class PointerIconVisualizerWindow: NSWindow { self.refreshVisibility() } - static func make(settings: any PointerIconSettingsProtocol & ReactiveSettings) -> PointerIconVisualizerWindow { - let window = PointerIconVisualizerWindow(settings: settings) - let contentView = PointerIconContentView(settings: settings) - contentView.visibilityDidChange = { [weak window] _ in - window?.refreshVisibility() - } - window.contentView = contentView - return window - } - func update(screenLocation: NSPoint) { - let size = frame.size + let size = self.frame.size let origin = self.settings.anchor.origin(relativeTo: screenLocation, windowSize: size, offset: self.settings.offset) self.setFrameOrigin(origin) } func update(mouseEvent: MouseEvent) { - (contentView as? PointerIconContentView)?.handle(mouseEvent: mouseEvent) + self.pointerContentView.handle(mouseEvent: mouseEvent) self.refreshVisibility() } func refreshVisibility() { - let isTransientlyVisible = (contentView as? PointerIconContentView)?.isTransientlyVisible ?? false - if self.settings.isEnabled, self.settings.alwaysVisible || isTransientlyVisible { - orderFrontRegardless() + if Self.shouldBeVisible( + isEnabled: self.settings.isEnabled, + alwaysVisible: self.settings.alwaysVisible, + isTransientlyVisible: self.pointerContentView.isTransientlyVisible, + isCursorVisible: self.cursorVisibilityProvider.isCursorVisible + ) { + self.orderFrontRegardless() } else { - orderOut(nil) + self.orderOut(nil) } } + + static func shouldBeVisible( + isEnabled: Bool, + alwaysVisible: Bool, + isTransientlyVisible: Bool, + isCursorVisible: Bool + ) -> Bool { + guard isEnabled else { return false } + return isTransientlyVisible || (alwaysVisible && isCursorVisible) + } } private extension PointerIconAnchor { diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift index 7b37f9b..ee45c77 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift @@ -131,6 +131,47 @@ final class PointerIconVisualizerTests: XCTestCase { XCTAssertTrue(view.isTransientlyVisible) } + func testIdleAlwaysVisibleIconFollowsNativeCursorVisibility() { + XCTAssertTrue( + PointerIconVisualizerWindow.shouldBeVisible( + isEnabled: true, + alwaysVisible: true, + isTransientlyVisible: false, + isCursorVisible: true + ) + ) + XCTAssertFalse( + PointerIconVisualizerWindow.shouldBeVisible( + isEnabled: true, + alwaysVisible: true, + isTransientlyVisible: false, + isCursorVisible: false + ) + ) + } + + func testTransientPointerActivityRemainsVisibleWhenNativeCursorIsHidden() { + XCTAssertTrue( + PointerIconVisualizerWindow.shouldBeVisible( + isEnabled: true, + alwaysVisible: true, + isTransientlyVisible: true, + isCursorVisible: false + ) + ) + } + + func testDisabledPointerIconRemainsHidden() { + XCTAssertFalse( + PointerIconVisualizerWindow.shouldBeVisible( + isEnabled: false, + alwaysVisible: true, + isTransientlyVisible: true, + isCursorVisible: true + ) + ) + } + private func makeMouseEvent(type: CGEventType, button: CGMouseButton = .left, buttonNumber: Int = 0) throws -> MouseEvent { guard let cgEvent = CGEvent( mouseEventSource: nil, From 149ada29730586175db87600de182191d01bfbc0 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 10 Aug 2026 13:11:41 +0200 Subject: [PATCH 2/2] refactor(pointer-icon): centralize presentation orchestration --- .../PointerIcon/PointerIconVisualizer.swift | 66 +++++++++++++++---- .../PointerIconVisualizerWindow.swift | 57 +++------------- .../PointerIconVisualizerTests.swift | 12 ++-- 3 files changed, 73 insertions(+), 62 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift index f9e1432..da1d3d5 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizer.swift @@ -13,6 +13,7 @@ import Combine final class PointerIconVisualizer { private let settings: any PointerIconSettingsProtocol & ReactiveSettings private let cursorVisibilityProvider: any CursorVisibilityProviding + private let pointerContentView: PointerIconContentView private var cancellables = Set() private var window: PointerIconVisualizerWindow? private var tracker: DisplayTracker? @@ -34,6 +35,10 @@ final class PointerIconVisualizer { ) { self.settings = settings self.cursorVisibilityProvider = cursorVisibilityProvider + self.pointerContentView = PointerIconContentView(settings: settings) + self.pointerContentView.visibilityDidChange = { [weak self] _ in + self?.syncPresentation() + } self.settings.changes .sink { [weak self] in Task { @MainActor in @@ -49,6 +54,21 @@ final class PointerIconVisualizer { } } +extension PointerIconVisualizer { + enum VisibilityPolicy { + static func shouldShow( + isEnabled: Bool, + isPresentationActive: Bool, + alwaysVisible: Bool, + isTransientlyVisible: Bool, + isCursorVisible: Bool + ) -> Bool { + guard isEnabled, isPresentationActive else { return false } + return isTransientlyVisible || (alwaysVisible && isCursorVisible) + } + } +} + // MARK: - Public API extension PointerIconVisualizer { var isEnabled: Bool { @@ -64,30 +84,28 @@ extension PointerIconVisualizer { extension PointerIconVisualizer: PointerVisualizer { func noteMouseEvent(_ mouseEvent: MouseEvent) { guard self.isEnabled else { return } - self.window?.update(mouseEvent: mouseEvent) + self.pointerContentView.handle(mouseEvent: mouseEvent) + self.syncPresentation() } } // MARK: - Private API private extension PointerIconVisualizer { func settingsDidChange() { - self.presentationStateDidChange() + self.syncPresentation() } func presentationStateDidChange() { - self.isEnabled && self.isPresentationActive ? self.show() : self.hide() + self.syncPresentation() } - func show() { + func showIfNeeded() { if self.window == nil { self.window = PointerIconVisualizerWindow( - settings: self.settings, - cursorVisibilityProvider: self.cursorVisibilityProvider + contentView: self.pointerContentView, + contentSize: PointerIconContentView.windowSize(settings: self.settings) ) } - self.window?.update(screenLocation: NSEvent.mouseLocation) - self.window?.refreshVisibility() - self.startTracking() } func hide() { @@ -95,11 +113,37 @@ private extension PointerIconVisualizer { self.destroyWindow() } + func syncPresentation() { + guard self.isEnabled && self.isPresentationActive else { + self.hide() + return + } + + self.showIfNeeded() + self.startTracking() + self.window?.updateContentSize(PointerIconContentView.windowSize(settings: self.settings)) + self.window?.update( + screenLocation: NSEvent.mouseLocation, + anchor: self.settings.anchor, + offset: self.settings.offset + ) + self.window?.setVisible(self.shouldShowWindow) + } + + var shouldShowWindow: Bool { + VisibilityPolicy.shouldShow( + isEnabled: self.isEnabled, + isPresentationActive: self.isPresentationActive, + alwaysVisible: self.settings.alwaysVisible, + isTransientlyVisible: self.pointerContentView.isTransientlyVisible, + isCursorVisible: self.cursorVisibilityProvider.isCursorVisible + ) + } + func startTracking() { guard self.tracker == nil else { return } self.tracker = DisplayTracker { [weak self] in - self?.window?.update(screenLocation: NSEvent.mouseLocation) - self?.window?.refreshVisibility() + self?.syncPresentation() } self.tracker?.start() } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift index aafacf5..8f617ab 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerWindow.swift @@ -7,23 +7,17 @@ // import AppKit -import Combine final class PointerIconVisualizerWindow: NSWindow { - private let settings: any PointerIconSettingsProtocol & ReactiveSettings - private let cursorVisibilityProvider: any CursorVisibilityProviding private let pointerContentView: PointerIconContentView - private var cancellables = Set() init( - settings: any PointerIconSettingsProtocol & ReactiveSettings, - cursorVisibilityProvider: any CursorVisibilityProviding + contentView: PointerIconContentView, + contentSize: NSSize ) { - self.settings = settings - self.cursorVisibilityProvider = cursorVisibilityProvider - self.pointerContentView = PointerIconContentView(settings: settings) + self.pointerContentView = contentView super.init( - contentRect: NSRect(origin: .zero, size: PointerIconContentView.windowSize(settings: settings)), + contentRect: NSRect(origin: .zero, size: contentSize), styleMask: .borderless, backing: .buffered, defer: false @@ -34,62 +28,31 @@ final class PointerIconVisualizerWindow: NSWindow { self.alphaValue = 1 self.ignoresMouseEvents = true self.collectionBehavior = .canJoinAllSpaces - - self.pointerContentView.visibilityDidChange = { [weak self] _ in - self?.refreshVisibility() - } self.contentView = self.pointerContentView - - settings.changes - .sink { [weak self] in - Task { @MainActor in - self?.settingsDidChange() - } - } - .store(in: &self.cancellables) } - private func settingsDidChange() { - let newSize = PointerIconContentView.windowSize(settings: self.settings) - self.setContentSize(newSize) + func updateContentSize(_ size: NSSize) { + self.setContentSize(size) self.contentView?.needsDisplay = true - self.update(screenLocation: NSEvent.mouseLocation) - self.refreshVisibility() } - func update(screenLocation: NSPoint) { + func update(screenLocation: NSPoint, anchor: PointerIconAnchor, offset: CGFloat) { let size = self.frame.size - let origin = self.settings.anchor.origin(relativeTo: screenLocation, windowSize: size, offset: self.settings.offset) + let origin = anchor.origin(relativeTo: screenLocation, windowSize: size, offset: offset) self.setFrameOrigin(origin) } func update(mouseEvent: MouseEvent) { self.pointerContentView.handle(mouseEvent: mouseEvent) - self.refreshVisibility() } - func refreshVisibility() { - if Self.shouldBeVisible( - isEnabled: self.settings.isEnabled, - alwaysVisible: self.settings.alwaysVisible, - isTransientlyVisible: self.pointerContentView.isTransientlyVisible, - isCursorVisible: self.cursorVisibilityProvider.isCursorVisible - ) { + func setVisible(_ isVisible: Bool) { + if isVisible { self.orderFrontRegardless() } else { self.orderOut(nil) } } - - static func shouldBeVisible( - isEnabled: Bool, - alwaysVisible: Bool, - isTransientlyVisible: Bool, - isCursorVisible: Bool - ) -> Bool { - guard isEnabled else { return false } - return isTransientlyVisible || (alwaysVisible && isCursorVisible) - } } private extension PointerIconAnchor { diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift index ee45c77..cd72db3 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Mouse/PointerIcon/PointerIconVisualizerTests.swift @@ -133,16 +133,18 @@ final class PointerIconVisualizerTests: XCTestCase { func testIdleAlwaysVisibleIconFollowsNativeCursorVisibility() { XCTAssertTrue( - PointerIconVisualizerWindow.shouldBeVisible( + PointerIconVisualizer.VisibilityPolicy.shouldShow( isEnabled: true, + isPresentationActive: true, alwaysVisible: true, isTransientlyVisible: false, isCursorVisible: true ) ) XCTAssertFalse( - PointerIconVisualizerWindow.shouldBeVisible( + PointerIconVisualizer.VisibilityPolicy.shouldShow( isEnabled: true, + isPresentationActive: true, alwaysVisible: true, isTransientlyVisible: false, isCursorVisible: false @@ -152,8 +154,9 @@ final class PointerIconVisualizerTests: XCTestCase { func testTransientPointerActivityRemainsVisibleWhenNativeCursorIsHidden() { XCTAssertTrue( - PointerIconVisualizerWindow.shouldBeVisible( + PointerIconVisualizer.VisibilityPolicy.shouldShow( isEnabled: true, + isPresentationActive: true, alwaysVisible: true, isTransientlyVisible: true, isCursorVisible: false @@ -163,8 +166,9 @@ final class PointerIconVisualizerTests: XCTestCase { func testDisabledPointerIconRemainsHidden() { XCTAssertFalse( - PointerIconVisualizerWindow.shouldBeVisible( + PointerIconVisualizer.VisibilityPolicy.shouldShow( isEnabled: false, + isPresentationActive: true, alwaysVisible: true, isTransientlyVisible: true, isCursorVisible: true