Automatically Changing Scroll Direction Based on USB Devices

Last time I wrote about programmatically toggling natural scrolling on macOS and building a menu bar app to let me do that quickly. It works very well, but there’s still a little bit of friction with having to click the menu bar icon—or, more accurately, forgetting to click it and then scrolling backwards and realizing you forgot. As I mentioned at the end of my previous post, one obvious way to extend it, now that the ability to programmatically set direction is in place, would be toggling it automatically based on what’s currently connected. This turned out to not be terribly complicated, but dealing with IOKit was somewhat annoying, so I thought I’d write it up.

Watching for USB Device Changes

Some cursory googling quickly led me to the IOKit documentation, which describes an IODeviceManager that sounds exactly like what I wanted. Unfortunately, the docs are rather lacking—there’s not a single example of how to actually set it up (and it’s not helped by the fact that it’s all old CoreFoundation style, nor that the Swift overlay is very poor).

But, with a combination of the docs and the open source ManyMouse project[1], I managed to get it working.

manager = IOHIDManagerCreate(kCFAllocatorDefault, 0, /* kIOHIDManagerOptionNone */)

First, you need to create a manager using IOHIDManagerCreate. It takes an allocator and the options to use. I’m using the literal 0 here because the constants for the options are not imported into Swift. You also need to retain the manager for as long as you want to observe changes, so I’m storing it here in an instance var on my app delegate.

After that, you tell the manager what sorts of devices you care about. You do this by creating a dictionary that matches against the properties of a device.

var dict = IOServiceMatching(kIOHIDDeviceKey)! as! [String: Any]
dict[kIOHIDDeviceUsagePageKey] = kHIDPage_GenericDesktop
dict[kIOHIDDeviceUsageKey] = kHIDUsage_GD_Mouse
IOHIDManagerSetDeviceMatching(manager, dict as CFDictionary)

The IOServiceMatching function takes the name of a service type, for which we pass the redundantly named HID device key. It returns a CFDictionary, which I convert into a Swift dictionary, so that I can set properties on it more easily than dealing with CFDictionarySetValue from Swift. The filter is further refined by limiting it to the USB Generic Desktop usage page[2], and specifically the mouse usage (the USB HID spec makes no differentiation between kinds of pointing devices, they’re all just mice), before setting the matching dictionary on the manager.

After that, we register callbacks for device addition and removal and add the manager to the run loop.

IOHIDManagerRegisterDeviceMatchingCallback(manager, hidDeviceAdded(context:result:sender:device:), nil)
IOHIDManagerRegisterDeviceRemovalCallback(manager, hidDeviceRemoved(context:result:sender:device:), nil)

IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.commonModes.rawValue)

One important thing to note about the callbacks is that, since this is all old CoreFoundation-style code and not Objective-C, they’re not blocks, they’re C function pointers. Specifically, IOHIDDeviceCallbacks. This means that, when providing one from Swift, the value can either be a global function or a closure that does not capture anything. In either case, this requirement is because a C function pointer needs to point to a specific point in our binary, which can’t encode additional information like captures or the method receiver. To help alleviate this, the callback registration functions take a third parameter, a void pointer to some context that will be provided to the function when it’s called. All of the contextual information I need in the callbacks can be stored on the app delegate, though, so I don’t bother with trying to make the void *context shenanigans play nicely with Swift.

The callback functions receive an IOHIDDevice, the functions for which appear to only be documented in the headers. The first thing I do in the callbacks is get the name and usage for the device:

func hidDeviceAdded(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
    guard let name = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String,
          let usage = IOHIDDeviceGetProperty(device, kIOHIDPrimaryUsageKey as CFString) as? UInt32 else {
        fatalError()
    }
}

The get property function returns an optional CFTypeRef so we have to try and cast it to the appropriate type.

The usage we need because, even though the manager’s filter is set to only allow Mouse devices through, the callback is still sometimes invoked with a device of type kHIDUsage_GD_SystemControl for reasons that I can’t discern. So, as a precaution, I silently ignore devices which don’t have the right usage:

func hidDeviceAdded(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
	// ...
    guard usage == kHIDUsage_GD_Mouse else { return }
}

Determining Device Types

The next task is determining whether the given device is a mouse or trackpad. Unfortunately, as I mentioned, the USB HID spec doesn’t differentiate between mice and trackpads, and I couldn’t find any IOHIDDevice properties that did either. So, we have to come up with our own heuristics based on the information we do have access. Here’s where it gets really dumb:

func deviceNameIsProbablyTrackpad(_ name: String) -> Bool {
    return name.lowercased().contains("trackpad")
}

Yep. I figure this should cover most (at least Apple ones, which do call themselves "trackpad"s). And what makes something a mouse? Well, if it’s not a trackpad. Yes, it’s a bit ridiculous, but it works well enough.

func hidDeviceAdded(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
    // ...
    let delegate = NSApp.delegate as! AppDelegate
    if deviceNameIsProbablyTrackpad(name) {
        delegate.trackpadCount += 1
    } else {
        delegate.mouseCount += 1
    }
}

We track the actual counts of trackpads and mice rather than just whether one is connected or not, because in the device removed callback, it saves have to re-enumerate all connected devices in case there were multiple of one type connected and only one was removed.

The device removed callback does pretty much the same thing, just subtracting instead of adding 1 to the respective counts.

Automatically Switching Scroll Direction

After the counts are updated, the delegate is notified that devices have changed and told to update the scroll direction. This is done by sending void to a PasstroughSubject on the app delegate. I use Combine rather than just calling a method on the app delegate because, when the IOHIDManager is initially added, it fires the added callback a whole bunch of times for every device that’s already connected. Additionally, when a USB hub is added/removed we get callbacks for all of the individual devices and I don’t want to flip the scroll direction a bunch of times. So, when the app delegate listens to the subject, it uses Combine’s debouncing to only fire every 0.1 seconds at most.

Actually changing the scroll direction is next, which requires figuring out what direction to change it to, based on the current device counts. This has a slight complication: laptops.

On a laptop, the trackpad is always connected, so if we were to switch to natural scrolling whenever a trackpad was connected, that would be useless. Similarly, for desktops, you can imagine a case where a mouse is always connected, so just switching to normal when a mouse is present would be similarly ineffectual in some cases. The solution? Doing both, and having a preference to let the user choose.

private func updateDirectionForAutoMode() {
    switch Preferences.autoMode {
	case .disabled:
        return

	case .normalWhenMousePresent:
        setDirection(mouseCount > 0 ? .normal : .natural)

	case .naturalWhenTrackpadPresent:
        setDirection(trackpadCount > 0 ? .natural : .normal)
    }
}

This way, on a laptop you can set it to “normal when mouse present” and on a desktop with an always-connected mouse you can set it to “natural when trackpad present”.

I’ve been using this updated version of ScrollSwitcher for about a week now, and it’s worked flawlessly. I haven’t had to open System Preferences or even click the menu bar icon. If you’re interested, the full source code for the app can be found here: https://git.shadowfacts.net/shadowfacts/ScrollSwitcher.


  1. Archived: https://archive.is/NvmMN ↩︎

  2. USB HID usage page and usage tables can be found here: https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf. ↩︎

Comments

Comments powered by ActivityPub. To respond to this post or to another comment, copy its URL into the search interface of your client for Mastodon, Pleroma, or other compatible software. Learn more.