Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
private var window: PointerIconVisualizerWindow?
private var tracker: DisplayTracker?
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -59,36 +84,66 @@ 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() {
self.stopTracking()
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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
//

import AppKit
import Combine

final class PointerIconVisualizerWindow: NSWindow {
private let settings: any PointerIconSettingsProtocol & ReactiveSettings
private var cancellables = Set<AnyCancellable>()
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
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading