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..da1d3d5 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,8 @@ import Combine @MainActor 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? @@ -27,8 +29,16 @@ 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.pointerContentView = PointerIconContentView(settings: settings) + self.pointerContentView.visibilityDidChange = { [weak self] _ in + self?.syncPresentation() + } self.settings.changes .sink { [weak self] in Task { @MainActor in @@ -44,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 { @@ -59,25 +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() { - if self.window == nil { self.window = PointerIconVisualizerWindow.make(settings: self.settings) } - self.window?.update(screenLocation: NSEvent.mouseLocation) - self.window?.refreshVisibility() - self.startTracking() + func showIfNeeded() { + if self.window == nil { + self.window = PointerIconVisualizerWindow( + contentView: self.pointerContentView, + contentSize: PointerIconContentView.windowSize(settings: self.settings) + ) + } } func hide() { @@ -85,10 +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?.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 d1d9ba2..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,16 +7,17 @@ // import AppKit -import Combine final class PointerIconVisualizerWindow: NSWindow { - private let settings: any PointerIconSettingsProtocol & ReactiveSettings - private var cancellables = Set() + private let pointerContentView: PointerIconContentView - init(settings: any PointerIconSettingsProtocol & ReactiveSettings) { - self.settings = settings + init( + contentView: PointerIconContentView, + contentSize: NSSize + ) { + 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 @@ -27,51 +28,29 @@ final class PointerIconVisualizerWindow: NSWindow { self.alphaValue = 1 self.ignoresMouseEvents = true self.collectionBehavior = .canJoinAllSpaces - - settings.changes - .sink { [weak self] in - Task { @MainActor in - self?.settingsDidChange() - } - } - .store(in: &self.cancellables) + self.contentView = self.pointerContentView } - 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() - } - - 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 origin = self.settings.anchor.origin(relativeTo: screenLocation, windowSize: size, offset: self.settings.offset) + func update(screenLocation: NSPoint, anchor: PointerIconAnchor, offset: CGFloat) { + let size = self.frame.size + let origin = anchor.origin(relativeTo: screenLocation, windowSize: size, offset: offset) self.setFrameOrigin(origin) } func update(mouseEvent: MouseEvent) { - (contentView as? PointerIconContentView)?.handle(mouseEvent: mouseEvent) - self.refreshVisibility() + self.pointerContentView.handle(mouseEvent: mouseEvent) } - func refreshVisibility() { - let isTransientlyVisible = (contentView as? PointerIconContentView)?.isTransientlyVisible ?? false - if self.settings.isEnabled, self.settings.alwaysVisible || isTransientlyVisible { - orderFrontRegardless() + func setVisible(_ isVisible: Bool) { + if isVisible { + self.orderFrontRegardless() } else { - orderOut(nil) + self.orderOut(nil) } } } 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..cd72db3 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,51 @@ final class PointerIconVisualizerTests: XCTestCase { XCTAssertTrue(view.isTransientlyVisible) } + func testIdleAlwaysVisibleIconFollowsNativeCursorVisibility() { + XCTAssertTrue( + PointerIconVisualizer.VisibilityPolicy.shouldShow( + isEnabled: true, + isPresentationActive: true, + alwaysVisible: true, + isTransientlyVisible: false, + isCursorVisible: true + ) + ) + XCTAssertFalse( + PointerIconVisualizer.VisibilityPolicy.shouldShow( + isEnabled: true, + isPresentationActive: true, + alwaysVisible: true, + isTransientlyVisible: false, + isCursorVisible: false + ) + ) + } + + func testTransientPointerActivityRemainsVisibleWhenNativeCursorIsHidden() { + XCTAssertTrue( + PointerIconVisualizer.VisibilityPolicy.shouldShow( + isEnabled: true, + isPresentationActive: true, + alwaysVisible: true, + isTransientlyVisible: true, + isCursorVisible: false + ) + ) + } + + func testDisabledPointerIconRemainsHidden() { + XCTAssertFalse( + PointerIconVisualizer.VisibilityPolicy.shouldShow( + isEnabled: false, + isPresentationActive: true, + 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,