対象読者と動作環境
NSEvent.modifierFlags のポーリングで修飾キーの押下状態を取りたい人、CAShapeLayer の多重シャドウ手法を「動く軌跡」に応用する実装に興味がある人を対象にしています。取り上げるのはメニューバー常駐アプリ「Neon Pointer」の v1.2.0 です。修飾キー(既定 ⌥ Option)を押している間だけカーソルの軌跡をネオンの線として画面に残す機能を実装しました。
- macOS 14 以上
- Swift 5.0
- Xcode 15 以上(ビルド時のみ)
- XcodeGen(
brew install xcodegen、ビルド時のみ)
リポジトリは Tanishi-z/NeonPointer です。なぜアクセシビリティ権限を増やさずにこの機能を作ったか(設計判断の話)は姉妹記事の Zenn 版で扱っています。この記事はコードと技術的な仕組みだけに絞ります。カーソル本体の多重シャドウ発光の基礎は v1.0.0 の実装記事(本記事末尾の関連記事)ですでに解説済みなので、そこは繰り返さず今回の軌跡機能との差分だけを書きます。
NSEvent.modifierFlags のポーリングで「押している間」を取る
軌跡を描くトリガーになる修飾キーは TrailModifier で選択肢を定義しています。
/// 軌跡描画を有効にする修飾キー。
enum TrailModifier: String, CaseIterable, Identifiable {
case option
case control
case shift
case command
case function
var id: String { rawValue }
var displayName: String {
switch self {
case .option: return "⌥"
case .control: return "⌃"
case .shift: return "⇧"
case .command: return "⌘"
case .function: return "fn"
}
}
var eventFlag: NSEvent.ModifierFlags {
switch self {
case .option: return .option
case .control: return .control
case .shift: return .shift
case .command: return .command
case .function: return .function
}
}
/// 他の修飾キーが同時に押されていても検出できるよう、包含判定にする。
func isActive(in flags: NSEvent.ModifierFlags) -> Bool {
flags.intersection(.deviceIndependentFlagsMask).contains(eventFlag)
}
}
isActive(in:) は flags == eventFlag のような完全一致にはしていません。NSEvent.modifierFlags にはデバイス依存のビット(キーコードや副修飾キーの区別など)が混ざっているため、まず .deviceIndependentFlagsMask との intersection でそれらを取り除きます。そのうえで contains(eventFlag) を使うことで、たとえば ⌥+⇧ を同時に押していても TrailModifier.option は「押されている」と判定できます。完全一致にすると、他のキーを同時に押した瞬間に軌跡が途切れてしまいます。
差分での早期returnをやめ、間引きを呼び出し側へ移す
カーソル座標のポーリングを担う CursorTracker は、v1.1.0 まで次のように「座標が変わらないフレームでは何もしない」実装でした。
@objc private func tick() {
let location = NSEvent.mouseLocation
guard location != lastLocation else { return }
lastLocation = location
onMove(location)
}
軌跡機能では、カーソルが静止していてもフェードの経過時間は進める必要があります。座標が変わらないフレームで早期returnしてしまうと、静止中に線がフェードアウトしていく処理が呼ばれません。そこで tick() からは間引きの判断を取り除き、毎フレーム座標と修飾キーの状態をそのまま呼び出し元へ渡す形に変えました。
/// 軌跡のフェード計算を進める必要があるため、座標が変わらないフレームでも毎回呼ぶ。
@objc private func tick() {
onTick(NSEvent.mouseLocation, NSEvent.modifierFlags)
}
間引きの責務は呼び出し側の OverlayController.handleTick へ移しました。
private func handleTick(location: CGPoint, flags: NSEvent.ModifierFlags) {
let shouldDraw = settings.isEnabled && settings.trailEnabled && settings.trailModifier.isActive(in: flags)
let locationChanged = location != lastCursorLocation
// カーソルが静止していて、描画中でも軌跡が残ってもいなければ何もすることがない。
guard shouldDraw || trail.isDrawing || !trail.strokes.isEmpty || locationChanged else {
return
}
let now = CACurrentMediaTime()
trail.setDrawing(shouldDraw, at: now)
if shouldDraw, locationChanged {
trail.append(location)
}
trail.prune(now: now, hold: settings.trailHoldDuration, fade: settings.trailFadeDuration)
lastCursorLocation = location
reposition()
}
guard の4つの条件はそれぞれ「この tick で何かする理由になり得るもの」です。shouldDraw は今まさに描画すべき状態かどうか、trail.isDrawing は直前まで描画中だったかどうか(描画→非描画の遷移をこのtickで処理する必要がある)、!trail.strokes.isEmpty はフェード中・保持中の線がまだ残っているかどうか(フェードの経過時間計算とpruneを進める必要がある)、locationChanged はカーソルが動いたかどうかです。この4つがすべて偽のとき、つまり描画もしておらず線も残っておらずカーソルも静止しているときだけ、reposition() まで含めて何もせず抜けます。
anchorTime / anchorAlpha で「途中から復活できるフェード」を作る
軌跡の状態管理は StrokeTrail という、AppKit のレイヤーに一切依存しない純粋なモデルに切り出しています。フェードの計算でまず素朴に思いつくのは「ストロークを描き終えた時刻からの経過時間」だけでアルファ値を決める方式です。ですがこの方式には問題があります。フェードアウトの途中で修飾キーを再度押して描画を再開したとき、その線をそのまま延長したいのに、経過時間ベースの計算では「今のアルファ」を知る手段がなく、アルファが1へ飛んでしまうか、あるいは古い経過時間を引きずって不自然な見た目になります。
TrailStroke はこれを避けるため、状態遷移が起きた時刻とその時点のアルファ値を都度基準として持ち直す設計にしています。
/// 修飾キー描画で生まれる 1 本の線。座標はすべてグローバル座標。
struct TrailStroke {
let id: Int
var points: [CGPoint]
var path: CGPath
var bounds: CGRect
/// フェードイン/フェードアウトの計算基準時刻。
var anchorTime: CFTimeInterval
/// `anchorTime` 時点でのアルファ値。復活時はここから 0.15 秒でフェードインする。
var anchorAlpha: Double
}
状態遷移を処理するのが setDrawing(_:at:) です。
/// 描画状態の遷移を処理する。
/// - 非描画 → 描画: 生存中の全ストロークを現在のアルファで固定し、そこから 0.15 秒フェードインさせる。新しい空ストロークを開始する。
/// - 描画 → 非描画: 全ストロークを現在のアルファで固定し、そこから hold/fade カウントを開始する。点が 2 個未満のストロークは即破棄する。
mutating func setDrawing(_ drawing: Bool, at now: CFTimeInterval) {
guard drawing != isDrawing else { return }
for index in strokes.indices {
strokes[index].anchorAlpha = Self.alpha(of: strokes[index], isDrawing: isDrawing, at: now, hold: 0, fade: 0)
strokes[index].anchorTime = now
}
isDrawing = drawing
if drawing {
strokes.append(
TrailStroke(
id: nextID,
points: [],
path: CGMutablePath(),
bounds: .null,
anchorTime: now,
anchorAlpha: 1
)
)
nextID += 1
if strokes.count > trailMaxStrokes {
strokes.removeFirst(strokes.count - trailMaxStrokes)
}
} else {
strokes.removeAll { $0.points.count < 2 }
}
}
遷移のたびに、その瞬間の見た目のアルファ値を Self.alpha(...) で計算し直して anchorAlpha に打ち直し、anchorTime を now にリセットします。こうすることで、以降のアルファ計算は常に「直近の遷移時点のアルファ」からの相対計算になり、途中の状態から自然に続きを描けます。
実際のアルファ計算は alpha(of:isDrawing:at:hold:fade:) です。
private static func alpha(of stroke: TrailStroke, isDrawing: Bool, at now: CFTimeInterval, hold: Double, fade: Double) -> Double {
if isDrawing {
let t = min(1, max(0, (now - stroke.anchorTime) / trailFadeInDuration))
return stroke.anchorAlpha + (1 - stroke.anchorAlpha) * t
}
let elapsed = now - stroke.anchorTime
if elapsed < hold { return stroke.anchorAlpha }
guard fade > 0 else { return 0 }
let fadeElapsed = elapsed - hold
return stroke.anchorAlpha * max(0, 1 - fadeElapsed / fade)
}
描画中(isDrawing == true)は anchorAlpha から 1 へ、trailFadeInDuration(0.15秒固定)かけて線形に近づけるだけです。非描画中は2段階で、まず hold 秒のあいだは anchorAlpha をそのまま維持し(設定項目でいう「保持時間」)、その後 fade 秒かけて anchorAlpha から0へ線形に減衰させます(「フェードアウト時間」)。フェード中に描画を再開したときは、setDrawing が hold: 0, fade: 0 を渡して anchorAlpha を計算し直すため、実際のフェード進捗にかかわらず必ず0にリセットされます。つまり見た目のアルファがどの段階にあっても、復活は常にゼロから0.15秒かけてフェードインし直す形になります(時間が短いため体感上はほとんど気づきません)。
線が完全に消えたかどうかの判定は prune が行います。
/// 完全に消えたストロークを取り除く。一度消えた線は二度と戻らない。
mutating func prune(now: CFTimeInterval, hold: Double, fade: Double) {
guard !isDrawing else { return }
let isDrawing = isDrawing
strokes.removeAll { Self.alpha(of: $0, isDrawing: isDrawing, at: now, hold: hold, fade: fade) <= trailInvisibleAlphaThreshold }
}
trailInvisibleAlphaThreshold は 0.004 です。アルファがこの値を下回ったストロークは配列から取り除かれ、二度と戻りません(prune は非描画中にしか呼ばないので、描画再開前に消えた線が復活することもありません)。ストローク数の上限は trailMaxStrokes = 200 で、これを超えると古いものから removeFirst で捨てます。点の追加も無条件ではなく、trailMinPointDistance = 1.5pt 未満の移動では新しい点として採用しません(append(_:) 内の距離判定)。カーソルが微小に揺れただけで点が増え続けるのを防いでいます。
点列を中点通過の2次ベジエで滑らかにする
append(_:) で点が追加されるたびに、パスは smoothedPath(through:) で再構築されます。
/// 点列を中点通過の 2 次ベジエで滑らかにつないだパスを作る。
private static func smoothedPath(through points: [CGPoint]) -> CGPath {
let path = CGMutablePath()
guard let first = points.first else { return path }
path.move(to: first)
switch points.count {
case 1:
break
case 2:
path.addLine(to: points[1])
default:
for i in 1..<(points.count - 1) {
let mid = CGPoint(
x: (points[i].x + points[i + 1].x) / 2,
y: (points[i].y + points[i + 1].y) / 2
)
path.addQuadCurve(to: mid, control: points[i])
}
path.addLine(to: points[points.count - 1])
}
return path
}
生の点列をそのまま addLine でつなぐと、CADisplayLink のフレームごとに拾った座標には微小なジッターが乗るため、線がガタついた折れ線になります。ここでは点 points[i] を制御点として使いながら、実際に通過させるアンカーは隣り合う2点の中点 mid にしています。つまり points[i] そのものは通らず、points[i-1] と points[i] の中点から points[i] と points[i+1] の中点まで、points[i] を制御点とする2次ベジエで結びます。連続する区間の終点と始点が同じ中点で一致するため、区間の継ぎ目で接線が滑らかにつながり、ジッターの角が丸められます。最後の点だけは中点を作れないので addLine で直接結んでいます。
CAShapeLayerの多重シャドウを軌跡用に流用する
半径違いの影を2枚重ねて発光させる「多重シャドウ」の基礎的な仕組みは、カーソル本体の実装記事で詳しく解説済みです。詳しくはこちらの記事を参照してください。ここでは軌跡側での違いだけを書きます。
カーソル本体は outerGlowLayer / glowLayer / coreLayer の3枚を単一の CAShapeLayer として持ち回っていますが、軌跡は同時に複数本存在しうるので、ストロークごとにレイヤー3枚組を管理する TrailStrokeLayers を新設しました。
/// 軌跡の 1 ストローク分のレイヤー群。カーソル本体と同じ「半径違いの影を重ねる」手法を使う。
private final class TrailStrokeLayers {
let group = CALayer()
let outerGlow = CAShapeLayer()
let glow = CAShapeLayer()
let core = CAShapeLayer()
init() {
group.masksToBounds = false
group.anchorPoint = .zero
for shapeLayer in [outerGlow, glow, core] {
shapeLayer.masksToBounds = false
shapeLayer.fillColor = nil
shapeLayer.lineCap = .round
shapeLayer.lineJoin = .round
shapeLayer.anchorPoint = .zero
group.addSublayer(shapeLayer)
}
}
}
updateTrail(_:) は items(現在生存しているストロークの id・パス・アルファ)を受け取り、id ごとに TrailStrokeLayers を生成・更新・破棄します。
for (shapeLayer, spread) in [(strokeLayers.outerGlow, 2.0), (strokeLayers.glow, 0.8)] {
shapeLayer.strokeColor = color.cgColor
shapeLayer.lineWidth = width
shapeLayer.shadowColor = color.cgColor
shapeLayer.shadowOpacity = 1
shapeLayer.shadowRadius = glowRadius * spread
shapeLayer.shadowOffset = .zero
// shadowPath を指定すると線形状のグローが崩れる。カーソル本体と同じ理由。
shapeLayer.shadowPath = nil
}
strokeLayers.core.strokeColor = coreColor.cgColor
strokeLayers.core.lineWidth = max(1, width * 0.4)
spread を2.0/0.8にする配分、shadowPath = nil にする理由はカーソル本体とまったく同じです。線形状の CAShapeLayer に shadowPath を明示すると塗りつぶし形状の影になり、線のグローが崩れるためです。芯の色を coreColor = color.lightened(by: 0.35) で明るくし、線幅を width * 0.4 に細くしている点もカーソル本体の coreLayer と同じ考え方です。線ごとに group.opacity = Float(item.alpha) を設定することで、StrokeTrail が計算したフェードのアルファ値をレイヤーの不透明度にそのまま反映しています。
グローバル座標のパスをマルチスクリーンに載せる
カーソル本体は neonLayer.position をスクリーンローカル座標(cursorLocation - screenOrigin)で動かす方式です。この仕組みは v1.1.0 のマルチディスプレイ対応記事で解説済みです。軌跡はこれとは非対称な設計にしています。
func update(cursorLocation: CGPoint, screenOrigin: CGPoint) {
CATransaction.begin()
CATransaction.setDisableActions(true)
neonLayer.position = CGPoint(
x: cursorLocation.x - screenOrigin.x,
y: cursorLocation.y - screenOrigin.y
)
// 軌跡のパスはグローバル座標のまま構築されているため、層ごと平行移動させる。
trailContainer.position = CGPoint(x: -screenOrigin.x, y: -screenOrigin.y)
CATransaction.commit()
}
StrokeTrail が保持する points と path はグローバル座標のまま構築されています(OverlayController.handleTick が NSEvent.mouseLocation のグローバル座標をそのまま trail.append(location) へ渡している)。それを乗せる trailContainer には position = (-screenOrigin.x, -screenOrigin.y) という平行移動だけを与えています。
1本の軌跡が複数のスクリーンにまたがることがあります。もしパスをスクリーンローカル座標で持つ設計にすると、スクリーンをまたぐたびにパス自体をスクリーンごとに再計算・再分割する必要が出ます。パスをグローバル座標のまま1つだけ持ち、各スクリーンの NeonCursorView にはコンテナの平行移動だけを与える設計にすることで、同じ CGPath をそのまま全スクリーンの trailContainer へ渡して共有できます。パスの再計算はスクリーンの数に関係なく1回で済みます。
カリング条件を軌跡ぶんまで広げる
OverlayController.reposition は、そのスクリーンに何も描くものがなければビューを隠して合成コストを避けます。v1.1.0まではカーソル本体の外接矩形(cursorRect)だけを見ていましたが、軌跡が加わったことで判定を広げました。
private func reposition() {
let configuration = NeonConfiguration(settings: settings)
let half = CGSize(
width: configuration.canvasSize.width / 2,
height: configuration.canvasSize.height / 2
)
let cursorRect = CGRect(
x: lastCursorLocation.x - half.width,
y: lastCursorLocation.y - half.height,
width: half.width * 2,
height: half.height * 2
)
let now = CACurrentMediaTime()
let renderItems = trail.renderItems(at: now, hold: settings.trailHoldDuration, fade: settings.trailFadeDuration)
let trailPadding = configuration.trailGlowRadius * 4 + configuration.trailWidth
let trailRect = trail.bounds(at: now, hold: settings.trailHoldDuration, fade: settings.trailFadeDuration)
.map { $0.insetBy(dx: -trailPadding, dy: -trailPadding) }
for overlay in overlays {
let cursorOnScreen = overlay.panel.frame.intersects(cursorRect)
let trailOnScreen = trailRect.map { overlay.panel.frame.intersects($0) } ?? false
// どちらの外接矩形もそのスクリーンに掛からないなら、合成コストを避けるため隠す。
overlay.view.isHidden = !cursorOnScreen && !trailOnScreen
overlay.view.setCursorVisible(cursorOnScreen)
overlay.view.update(
cursorLocation: lastCursorLocation,
screenOrigin: overlay.panel.frame.origin
)
overlay.view.updateTrail(renderItems)
}
}
trailPadding = trailGlowRadius * 4 + trailWidth は、パスの外接矩形(stroke.bounds、線そのものの幾何学的な範囲)に対して、グローの発光がどれだけはみ出すかを見積もった値です。shadowRadius は glowRadius * spread で最大 glowRadius * 2.0 まで広がるため、片側あたり glowRadius * 2 相当の余白があれば発光ははみ出しません。ただし insetBy(dx:dy:) の dx は片側あたりの量として扱われるため、trailPadding(glowRadius * 4 + trailWidth)をそのまま渡すと、片側ごとに必要な見積もりのさらに2倍、つまり両側合計では glowRadius * 8 相当のかなり余裕を持った矩形が確保されます。カリング判定を広めに取るだけなので実害はありませんが、狙って2倍にしているわけではなく計算上の余裕です。
isHidden は「カーソル本体・軌跡のどちらもそのスクリーンに掛かっていないビュー」を隠す判定にまとめていますが、setCursorVisible(cursorOnScreen) はビューの表示・非表示とは別に、カーソル本体(neonLayer)だけを個別に隠せるようにしたものです。軌跡はスクリーンA・Bにまたがって伸びているが、カーソル本体は今スクリーンAにしかない、というケースがありえます。この場合スクリーンBのビュー全体は isHidden = false(軌跡が掛かっているので表示は必要)にしつつ、カーソル本体だけは setCursorVisible(false) で隠さないと、実際にはそこにいないはずのスクリーンBにもカーソルの発光が二重に出てしまいます。
追加した設定項目
SettingsStore に追加したキーと既定値・範囲です。
| キー | 既定値 | 範囲 |
|---|---|---|
trailEnabled |
true |
— |
trailModifier |
.option(⌥) |
option / control / shift / command / function
|
trailHoldDuration |
2.0 秒 |
0.2...30 |
trailFadeDuration |
1.5 秒 |
0.1...10 |
trailWidth |
6 pt |
1...40 |
trailGlowRadius |
14 pt |
0...60 |
trailUsesCursorColor |
true |
— |
trailColor |
RGBAColor.neonCyan |
— |
trailUsesCursorColor が true のときは trailColor を無視してカーソル本体と同じ色(settings.rgba)を使います(NeonConfiguration.init(settings:) の trailColor = settings.trailUsesCursorColor ? settings.rgba : settings.trailRGBA を参照)。trailHoldDuration と trailFadeDuration は範囲外の値が渡されても clamped(to:) で範囲内に丸められます。
project.yml のバージョン表記
MARKETING_VERSION を 1.2.0 に上げています。
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.2.0"
CURRENT_PROJECT_VERSION: "1"
ビルド・配布の手順(XcodeGen での .xcodeproj 生成、make dmg での dmg 作成)自体は v1.1.0 から変わっていません。