0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

常駐アプリでクリック透過のネオンカーソルを実装する

0
Last updated at Posted at 2026-09-01

対象読者と動作環境

SwiftUI で macOS の常駐ユーティリティを作りたい人、NSPanel でのオーバーレイ表示や CAShapeLayer での描画に興味がある人を対象にしています。取り上げるのはメニューバー常駐アプリ「Neon Pointer」です。マウスカーソルの周りにネオン風の半透明シェイプ(円・リング・十字・四角)を重ねて表示します。

  • macOS 14 以上
  • Swift 5.0
  • Xcode 15 以上(ビルド時のみ)
  • XcodeGen(brew install xcodegen、ビルド時のみ)

リポジトリは Tanishi-z/NeonPointer です。設計判断(なぜアクセシビリティ権限を避けたか、なぜ邪魔をしない設計にしたか)は姉妹記事の Zenn 版で扱っています。この記事はコードと技術的な仕組みだけに絞ります。マルチディスプレイ対応やログイン時自動起動など v1.1.0 での変更は別記事(本記事末尾の関連記事)にまとめています。

クリック透過・全スペース常駐の NSPanel

オーバーレイは NSPanel のサブクラス OverlayPanel で実装しています。

final class OverlayPanel: NSPanel {
    init(contentView: NSView) {
        super.init(
            contentRect: CGRect(origin: .zero, size: contentView.frame.size),
            styleMask: [.borderless, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )

        isOpaque = false
        backgroundColor = .clear
        hasShadow = false
        ignoresMouseEvents = true
        isMovable = false
        isMovableByWindowBackground = false
        hidesOnDeactivate = false
        isReleasedWhenClosed = false
        isFloatingPanel = true
        level = .screenSaver
        collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
        animationBehavior = .none
        self.contentView = contentView
    }

    override var canBecomeKey: Bool { false }
    override var canBecomeMain: Bool { false }
    override var acceptsFirstResponder: Bool { false }
}

各プロパティが何を防いでいるかをまとめます。

プロパティ 防いでいること
isOpaque = false / backgroundColor = .clear パネルの背景が不透明な矩形として描画されるのを防ぐ。ネオンの形以外を透明にする
hasShadow = false AppKit がパネルの矩形に対してつけるウィンドウシャドウを防ぐ。四角い影がネオンの周りに出るのを防ぐ
ignoresMouseEvents = true パネルがマウスイベントを奪うのを防ぐ。クリックやドラッグは背後のウィンドウにそのまま通る
level = .screenSaver 通常ウィンドウの背後に隠れるのを防ぐ。常に最前面に出す
collectionBehavior の .canJoinAllSpaces スペース(デスクトップ)を切り替えるとオーバーレイが消えるのを防ぐ
同 .stationary Mission Control 操作でパネルが動いてしまうのを防ぐ
同 .fullScreenAuxiliary フルスクリーンアプリの上にオーバーレイが出ないのを防ぐ
同 .ignoresCycle `Cmd+`` のウィンドウ切り替えにパネルが混ざるのを防ぐ
animationBehavior = .none パネルを表示するたびに AppKit がフェードなどの既定アニメーションを付けるのを防ぐ。カーソル追従の遅延要因になる
canBecomeKey / canBecomeMain / acceptsFirstResponder を false パネルがキーボードフォーカスやアプリのメインウィンドウを奪うのを防ぐ

styleMask に .nonactivatingPanel を指定しているのもポイントです。これがないと orderFrontRegardless() でパネルを出したときにアプリがアクティブ化され、フォーカスしていた別アプリの入力先が奪われます。

CADisplayLink でのカーソル追従

カーソル座標のポーリングは CursorTracker が担当します。

/// ディスプレイのリフレッシュに合わせてカーソル座標をポーリングする。
/// `NSEvent.mouseLocation` を読むだけなのでアクセシビリティ権限は不要。
@MainActor
final class CursorTracker {
    private weak var hostView: NSView?
    private var displayLink: CADisplayLink?
    private var lastLocation: CGPoint?
    private let onMove: (CGPoint) -> Void

    init(hostView: NSView, onMove: @escaping (CGPoint) -> Void) {
        self.hostView = hostView
        self.onMove = onMove
    }

    var isRunning: Bool { displayLink != nil }

    func start() {
        guard displayLink == nil, let hostView else { return }
        let link = hostView.displayLink(target: self, selector: #selector(tick))
        link.add(to: .main, forMode: .common)
        displayLink = link
        lastLocation = nil
        tick()
    }

    func stop() {
        displayLink?.invalidate()
        displayLink = nil
    }

    @objc private func tick() {
        let location = NSEvent.mouseLocation
        guard location != lastLocation else { return }
        lastLocation = location
        onMove(location)
    }

    deinit {
        displayLink?.invalidate()
    }
}

hostView.displayLink(target:selector:) は macOS 14 で追加された NSView の API です。旧来の CVDisplayLink を直接扱う方法に代わるもので、NSView / NSWindow / NSScreen から CADisplayLink を取得できます。ビューが乗っているディスプレイのリフレッシュレートに自動で追従し、ビューが画面に出ていないときは自動的に一時停止します。.add(to: .main, forMode: .common) は、メニュー操作やドラッグ中に発生する RunLoop のモード変更(.eventTracking など)でも配信を止めないための指定です。

毎フレーム読んでいるのは NSEvent.mouseLocation だけです。これはグローバルなカーソル座標をイベント経由ではなくその場で問い合わせる API で、アクセシビリティ権限(AXIsProcessTrusted)や CGEventTap を必要としません。他アプリのキー入力やクリックの中身を監視するわけではなく、座標を読むだけなので権限ダイアログが出ません。

tick() では lastLocation と比較して同座標なら onMove を呼ばずに抜けます。カーソルが静止している間は、後述するオーバーレイ側の再描画・レイヤー移動処理を一切呼ばないための間引きです。座標計算とレイヤー移動は無駄な負荷になるので、動いていないフレームでは何もしません。

CAShapeLayer の多重シャドウでネオンを作る

発光の描画は NeonCursorView が担当します。outerGlowLayer / glowLayer / coreLayer の3枚の CAShapeLayer を、カーソル位置に追従するコンテナ層 neonLayer の下にまとめて重ねます(neonLayer の追従の仕組みは v1.1.0 のマルチディスプレイ対応記事で扱います。この記事では発光の描画そのものに絞ります)。

private func redraw() {
    CATransaction.begin()
    CATransaction.setDisableActions(true)
    defer { CATransaction.commit() }

    let canvasSize = configuration.canvasSize
    neonLayer.bounds = CGRect(origin: .zero, size: canvasSize)

    let side = configuration.size
    let rect = CGRect(
        x: (canvasSize.width - side) / 2,
        y: (canvasSize.height - side) / 2,
        width: side,
        height: side
    )
    let strokeWidth = configuration.strokeWidth
    let path = configuration.shape.path(in: rect, strokeWidth: strokeWidth)
    let color = configuration.color

    neonLayer.opacity = Float(configuration.opacity)
    shapeLayers.forEach { $0.frame = CGRect(origin: .zero, size: canvasSize) }

    // 半径違いの影を重ねて、単層では出しきれないネオン管の滲みを作る。
    for (glow, spread) in [(outerGlowLayer, 2.0), (glowLayer, 0.8)] {
        glow.path = path
        glow.shadowColor = color.cgColor
        glow.shadowOpacity = 1
        glow.shadowRadius = configuration.glowRadius * spread
        glow.shadowOffset = .zero
        // shadowPath を指定すると常に塗りつぶし形状の影になり、線形状のグローが崩れる。
        glow.shadowPath = nil
    }

    let coreColor = color.lightened(by: 0.35)

    if configuration.shape.isStroked {
        for glow in [outerGlowLayer, glowLayer] {
            glow.fillColor = nil
            glow.strokeColor = color.cgColor
            glow.lineWidth = strokeWidth
        }

        coreLayer.path = path
        coreLayer.fillColor = nil
        coreLayer.strokeColor = coreColor.cgColor
        coreLayer.lineWidth = max(1, strokeWidth * 0.4)
    } else {
        for glow in [outerGlowLayer, glowLayer] {
            glow.fillColor = color.cgColor
            glow.strokeColor = nil
            glow.lineWidth = 0
        }

        let coreInset = side * 0.22
        let corePath = configuration.shape.path(
            in: rect.insetBy(dx: coreInset, dy: coreInset),
            strokeWidth: 0
        )
        coreLayer.path = corePath
        coreLayer.fillColor = coreColor.nsColor.withAlphaComponent(0.85).cgColor
        coreLayer.strokeColor = nil
        coreLayer.lineWidth = 0
    }
}

outerGlowLayer は spread 2.0、glowLayer は 0.8 で shadowRadius(configuration.glowRadius * spread)を変えて重ねています。半径の大きい影1枚だけだと発光の中心が薄くぼやけて輪郭が消えます。半径の違う影を2枚重ねることで、外側の広いにじみと内側の濃い光を両立させています。coreLayer はネオン管の芯にあたる、色を白側に寄せた明るいレイヤーです。

glow.shadowPath = nil にしているのは実装中に実際にハマった箇所です。コメントに残したとおり、shadowPath を指定すると CAShapeLayer はその影を常に塗りつぶし形状として描画します。リングや十字のような線形状(isStroked が true のシェイプ)に shadowPath を渡すと、線の内側まで塗りつぶされた影になり、線のグローが崩れます。shadowPath を nil のままにすると、影は path と fillColor / strokeColor の実際の見た目(線なら線の形)に沿って生成されます。パフォーマンス上は shadowPath を明示したほうが有利ですが、ここでは正しい見た目を優先して外しています。

isStroked(円・四角は false、リング・十字は true)で分岐しているのは塗りつぶし系と線形状系で fillColor / strokeColor の使い方が逆になるからです。塗りつぶし系はグロー層を fillColor で塗り、芯は一回り小さいパスを塗りつぶします。線形状系はグロー層を strokeColor で線描きし、芯は同じパスをより細い線幅(strokeWidth * 0.4)で線描きします。

芯の色は color.lightened(by: 0.35) で作っています。ネオン管は中心に近いほど白く飽和して見えるので、設定した本来の色をそのまま芯にも使うと発光の階調がつきません。RGB各成分を白側に35%寄せることで、外側のグローは設定色、芯は白みがかった明るい色という2段階の発光を作っています。

CATransaction.setDisableActions(true) で暗黙アニメーションを止める

redraw() の冒頭は次のようになっています。

CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }

CALayer はプロパティを変更すると、既定で暗黙アニメーション(implicit animation)がかかります。path や shadowRadius を直接書き換えても、変更前の値から変更後の値へ数百ミリ秒かけてアニメーションしてしまい、カーソルが動くたびに形状がふわっと遅れて追従します。CATransaction.setDisableActions(true) で囲んだブロック内の変更は、このアニメーションなしで即座に反映されます。カーソル追従アプリではこの即時性が必須なので、redraw() の全体をこのトランザクションでくるんでいます。

設定変更を即時反映させる仕組み

設定画面の変更をオーバーレイへ即座に伝えるために、SettingsStore に専用の Publisher を用意しています。

/// 値が確定した後に発火する。`objectWillChange` は変更前に発火するうえ、
/// スケジューラを挟むとイベントトラッキング中に配送が止まるため別に用意する。
let didChange = PassthroughSubject<Void, Never>()

ObservableObject の @Published は変更を検知すると自動的に objectWillChange を発火しますが、これをそのまま使わなかった理由がコメントのとおり2つあります。1つ目は objectWillChange が値が変わる前に発火することです。購読側で最新値を読もうとすると、まだ古い値しか手に入りません。2つ目は Combine の既定スケジューラがイベントの配送に RunLoop のデフォルトモードを使うことです。設定画面のスライダーをドラッグしている間(NSEvent のトラッキングループが動いている間)は .default モードのイベント配送が止まるため、objectWillChange を購読していると値がリアルタイムで反映されず、ドラッグを離した瞬間にまとめて反映されるような挙動になります。didChange は各プロパティの didSet が呼ぶ persist(_:forKey:) の中で send() する専用の PassthroughSubject で、この2つの問題を避けています。

受け取り側の OverlayController.applySettings() も、遅延レイアウトに任せず即時反映させています。マルチディスプレイ対応後はスクリーンごとのビューを持つので、全 overlay に対してこの反映処理をループします(マルチディスプレイ対応そのものは v1.1.0 の別記事で扱います)。

for overlay in overlays {
    overlay.view.configuration = configuration
    // イベントトラッキング中でも即時に反映させるため、遅延レイアウトに任せない。
    overlay.view.needsLayout = true
    overlay.view.layoutSubtreeIfNeeded()
}

needsLayout = true だけを立てると、実際のレイアウト処理は次の描画サイクルまで遅延します。通常のイベントループでは問題になりませんが、スライダーをドラッグしている間はトラッキングループがメインの描画サイクルを止めるため、layoutSubtreeIfNeeded() を呼ばないとドラッグ中にオーバーレイの見た目が更新されません。layoutSubtreeIfNeeded() を明示的に呼ぶことで、その場でレイアウトを確定させています。

XcodeGen + Makefile でのビルドと dmg 作成

このプロジェクトは .xcodeproj を直接リポジトリに含めず、project.yml から XcodeGen で生成します。

name: NeonPointer

options:
  bundleIdPrefix: com.local
  deploymentTarget:
    macOS: "14.0"
  createIntermediateGroups: true
  generateEmptyDirectories: true

settings:
  base:
    SWIFT_VERSION: "5.0"
    MARKETING_VERSION: "1.1.0"
    ...

.xcodeproj はバイナリに近い XML で差分レビューがしづらいため、YAML の project.yml を正として xcodegen generate のたびに再生成します。LSUIElement: true を Info.plist のプロパティに含めているため、Dock アイコンやアプリスイッチャーに出ないメニューバー常駐アプリとしてビルドされます。配布は GitHub Releases の dmg を主導線にしていますが、ここで説明する手順は自分でソースからビルドする場合のものです。

ビルドの流れは scripts/build.sh にまとまっています。

echo "==> Xcode プロジェクトを生成"
xcodegen generate

echo "==> Release ビルド"
xcodebuild \
  -project "$APP_NAME.xcodeproj" \
  -scheme "$APP_NAME" \
  -configuration Release \
  -derivedDataPath "$BUILD_DIR" \
  CODE_SIGNING_ALLOWED=NO \
  build

xcodegen generate → xcodebuild の Release ビルド → dist/ への配置 → codesign という順に進みます。CODE_SIGNING_ALLOWED=NO でビルド時の自動署名を止め、codesign --force --deep --sign "$SIGN_IDENTITY" で署名を明示的に後付けします。SIGN_IDENTITY を環境変数で渡さない場合は -(ad-hoc 署名)になります。

配布用の dmg 作成は scripts/make-dmg.sh が担当します。

# hdiutil create -srcfolder は一時ボリュームのマウントを伴い環境によっては失敗するため、
# マウント不要な makehybrid + convert を使う。
echo "==> ディスクイメージ作成"
rm -f "$RAW_DMG" "$DMG"
hdiutil makehybrid -hfs -hfs-volume-name "$VOL_NAME" -o "$RAW_DMG" "$STAGING" -quiet

echo "==> 圧縮"
hdiutil convert "$RAW_DMG" -format UDZO -o "$DMG" -quiet

dmg 作成の定番は hdiutil create -srcfolder ですが、これは内部で一時的なディスクイメージをマウントする処理を伴います。CI やサンドボックスされた環境ではこのマウントに失敗することがあります。hdiutil makehybrid はマウントせずにフォルダから直接 HFS+ イメージを生成でき、その後 hdiutil convert で圧縮形式(UDZO)に変換します。マウントの手順そのものが不要なので、マウントが失敗しうる環境でも安定して dmg を作れます。

NeonPointer の関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?