🐭

Scroll-Reverserのようなアプリを自作してみる

に公開

はじめに

みなさんはMacを使っていますか? 私はもうかれこれMacBook暦10年を突破しました。MacBookは非常に使いやすいんですが、一つ大きな不満があります。それはマウスのスクロールの向き。標準の設定だとWindowsと逆なんですね。マウスホイールを下に動かしたらページはなぜか上にスクロールされ、逆にホイールを上に動かしたらページは下にスクロールされます。もう大混乱。Appleはこれをナチュラルスクローリングと呼んでいるらしく、OS X Lionから導入されたようです。公開当時もユーザーが混乱していたことがネットの記事から伺えます。もちろん設定でスクロールの向きを変えられるんですが、そうするとトラックパッドのスクロール方向まで巻き添えを喰らいます。スクロールの向きはマウスとトラックパッド個別に設定できるようにしてよ。。。と思いますが、現状Appleがそんな機能を提供する気配はありません。

そんな課題を解決するべく、素晴らしいサードパーティ製アプリが公開されています。たとえばScroll-Reverserなんてものがあります。なんとこのアプリでは、マウスとトラックパッドのスクロール方向を個別に変えられます。上下スクロールと左右スクロールも分けて設定できるので、個人に適したカスタマイズが可能です。私もこのアプリにかなりお世話になっていたのですが、どんな仕組みで動いているのだろうと気になりました。少し調べてみると、なんとこのアプリ、オープンソースでした。

https://github.com/pilotmoon/Scroll-Reverser

Scroll-Reverserの実装調査

少し説明を読んでみると、以下のような一文が。

The guts of the code is in MouseTap.m. Everything else is just user interface rigging.

どうやら、マウスのスクロール方向を変えるロジックはこの MouseTap.m というファイルに書かれていそうです。ソースコードを読んでいくと、スクロール方向を変化させていそうな部分を発見しました。

    // active tap, for modifying scroll events
    // this one requires user privacy permissions
    self.activeTapPort=(CFMachPortRef)CGEventTapCreate(kCGSessionEventTap,
                                           kCGTailAppendEventTap,
                                           kCGEventTapOptionDefault,
                                           NSEventMaskScrollWheel,
                                           _callback,
                                           (__bridge void *)(self));

CGEventTapCreate という関数は、ユーザーのマウスやキーボードからの入力を低レベルで監視・改変する機能を提供しているそうです。

要は以下の処理を実装すればScroll-Reverserと同じことが実現できそうです。

  • ユーザーのスクロール入力を監視する。
  • スクロールに使われたデバイスがマウスかトラックパッドかを判定する。
  • 上記の判定の結果がマウスなら、スクロール方向を反転させる。

実装

そうとわかれば、あとは実装してみるだけです。本家のScroll-ReverserはObjective-Cで書かれていますが、今回はSwiftを使います。ここは適当にChatGPTと相談しながら作りました。その結果、AppDelegate.swiftに以下の処理を書けばよいことがわかりました。

//
//  AppDelegate.swift
//  ScrollReverser
//

import SwiftUI
import AppKit

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusItem: NSStatusItem!
    
    var eventTap: CFMachPort?
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        // メニューバーにボタンを追加する
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        
        if let button = statusItem.button {
            button.image = NSImage(systemSymbolName: "arrow.up.arrow.down", accessibilityDescription: "Scroll Reverser")
        }
        
        let menu = NSMenu()
        menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
        statusItem.menu = menu
        
        // ここからがスクロール方向を反転させるための処理
        
        // スクロールホイールイベントを監視するためのマスクを作成する
        let mask = CGEventMask(1 << CGEventType.scrollWheel.rawValue)
        
        // ユーザー入力を監視する
        eventTap = CGEvent.tapCreate(
            tap: .cgSessionEventTap,
            place: .headInsertEventTap,
            options: .defaultTap,
            eventsOfInterest: mask,
            callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
                
                // スクロールでなければスキップする
                guard type == .scrollWheel else {
                    return Unmanaged.passUnretained(event)
                }
                
                // トラックパッド判定
                let isTrackpad = event.getIntegerValueField(.scrollWheelEventIsContinuous) != 0
                
                // トラックパッドならスキップする
                if isTrackpad {
                    return Unmanaged.passUnretained(event)
                }
                
                // スクロール方向を反転させる
                let deltaY = event.getDoubleValueField(.scrollWheelEventDeltaAxis1)
                let deltaX = event.getDoubleValueField(.scrollWheelEventDeltaAxis2)
                
                event.setDoubleValueField(.scrollWheelEventDeltaAxis1, value: -deltaY)
                event.setDoubleValueField(.scrollWheelEventDeltaAxis2, value: -deltaX)
                
                return Unmanaged.passUnretained(event)
            },
            userInfo: nil,
        )
        
        guard let tap = eventTap else {
            NSLog("Warning: Failed to create scroll event tap. Make sure Accessibility permission is granted.")
            return
        }
        
        let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
        CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
        CGEvent.tapEnable(tap: tap, enable: true)
    }
    
    @objc func quit() {
        NSApp.terminate(nil)
    }
}

ちなみにアプリのエントリポイントである ScrollReverserApp.swift では、以下のように AppDelegate の処理を利用する作りにしました。

//
//  ScrollReverserApp.swift
//  ScrollReverser
//

import SwiftUI

@main
struct ScrollReverserApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        Settings {
            EmptyView()
        }
    }
}

動作確認

実際にアプリを起動してうまく動くのか確認しました。キャプチャでは伝わりにくいですが、ちゃんとマウスだけスクロールの向きを変えることができました。

おわりに

今回はScroll-ReverserのようなアプリをSwiftで実装してみました。意外と短いコードで簡単に実装できたのではないでしょうか? 本家のソースコードはもう少し複雑な処理をしていますが、おそらくOSのバージョンやマウスが違っていても、動作が安定するように工夫をしているんだと思われます。

ユーザーの入力を監視して便利な機能を提供するアプリは他にもありますが、実装の方針は基本的に同じだと思います。みなさんも何か便利なユーティリティアプリ開発にチャレンジしてください!

Discussion