MNU gains user-defined keyboard shortcuts

MNU is a tool I use every time I work on my computer. It’s a menu bar utility that allows me to trigger command line scripts and tools with a couple of mouse clicks. Despite its utility to me, I haven’t given it any love for some time, so I recently remedied that with a spring clean of the code. I also added a new feature: custom keyboard shortcuts.

You know how macOS application menu items may have a keyboard shortcut. Instead of, say, clicking on the File menu and selecting Save, you hit CommandS. Menu items with “key equivalents”, as they’re called in Xcode and the AppKit SDK, display the key and any modifier keys it expects right alongside the menu item’s name.

MNU’s menu items now have this functionality too. I’ve added shortcuts to some of MNU’s pre-packaged menu items, like switching between macOS’ light and dark UI modes, but you can also add keyboard shortcuts to items you’ve added yourself. To add one, click on the action button at the bottom of MNU’s menu to open the Configure MNU panel, and edit an entry.

Click on the text field at the bottom of the panel and type in a letter. Optionally, select one or more modifier keys. Click Update or, for a new item, Add, and you’re done. Finally, click Apply to update MNU. Open up MNU to see the item’s new shortcut listed.

If you’ve used MNU before you may notice that the Configure MNU panel has had a bit of a polish. Choosing whether an item appears on the menu or is hidden is now a matter of flipping a switch. And the edit and delete icons have been replaced with more intuitive ones. There’s a quid pro quo, however: MNU has had to drop support for macOS versions below 10.15.

Working with NSTextField and modifier keys

For macOS programmers, there are some interesting elements to the update, in particular the code to handle the NSTextField into which you enter a menu item’s key equivalent. Trapping the object’s copy, cut, paste, select all and undo commands is not straightforward, and works differently from trapping other Command-key combinations — which is different again from how Shift- and Option-key combos are trapped.

Let’s say you want to set a menu item hot key to CommandOptionX. You could type X into the text field and select Command and Option from the adjacent NSSegmentedControl. That works, but typing the combination is better. Supporting this behaviour is not straightforward. Windows with one or more NSTextFields maintain a single “field editor” object to handle editing duties for all the fields. If you need non-standard behaviour, you you need to instantiate a custom field editor, and bind it to the target NSTextField via the window’s NSWindowDelegate.

Implement the delegate method windowWillReturnFieldEditor() and check which NSTextField is being passed in. If it’s the one requiring the custom field editor, return the latter. Otherwise return nil for the default, generic field editor to be used.

The field ediitor is an instance of NSText — or of NSTextView, which is an NSText sub-class. Here you need to override multiple event-driven functions: keyDown() can be used to catch regular key presses and those with the Shift and/or Option keys held down. These modifiers change entered text, so there’s a logic to these modifiers being handled here.

override func keyDown(with event: NSEvent) {

    // This traps Shift and Option modifiers, plus unmodified key presses
    let _ = processEvent(event)
}

To trap Command and Control, override the method performKeyEquivalent(). Like keyDown(), this method receives the triggering event, but it returns a Boolean: true if it implemented the pressed key equivalent, or false if it didn’t. These two methods’ code is close enough to refactor them into a single call — hence the call to processEvent() above and below — but that’s not the end of it.

override func performKeyEquivalent(with event: NSEvent) -> Bool {

    // This traps Command and Control modifiers
    return processEvent(event)
}

The common code selects the NSSegmentedControl segments representing each pressed modifier key, and adds the underlying alphanumeric key pressed to the text field. I use the event’s charactersIgnoringModifiers property to get the key as it appears on the keyboard. This is useful when the modifier key is Option or Shift as we want to record the key pressed, not the glyph it’s set to generate.

private func processEvent(_ event: NSEvent) -> Bool {

    var modKeyUsed: Bool = false

    // Extract the modifier key held (if one was
    let bitfield = event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue

    if let keyTextField: AddUserItemKeyTextField = self.keyTextField {
        if let segment: NSSegmentedControl = keyTextField.segment {
            // Deselect all segments first
            segment.selectedSegment = -1

            if bitfield & commandKey != 0 {
                segment.selectSegment(withTag: MNU_CONSTANTS.MOD_KEY_CMD)
                modKeyUsed = true
            }

            if bitfield & shiftKey != 0 {
                segment.selectSegment(withTag: MNU_CONSTANTS.MOD_KEY_SHIFT)
                modKeyUsed = true
            }

            if bitfield & optKey != 0 {
                segment.selectSegment(withTag: MNU_CONSTANTS.MOD_KEY_OPT)
                modKeyUsed = true
            }

            if bitfield & ctrlKey != 0 {
                segment.selectSegment(withTag: MNU_CONSTANTS.MOD_KEY_CTRL)
                modKeyUsed = true
            }
        }

        // Drop the pressed key into the linked NSTextField, ensuring we
        // only drop the first character from multi-character strings
        let theKeys: String = event.charactersIgnoringModifiers ?? ""
        if theKeys.count > 1 {
            let index: String.Index = String.Index(utf16Offset: 1, in: theKeys)
            keyTextField.stringValue = String(theKeys[index...]).uppercased()
        } else {
            keyTextField.stringValue = theKeys.uppercased()
        }
    }

    return modKeyUsed
}

So we’ve handled Option and Shift, and Command and Control. But there’s more. Editing commands — cut, copy, paste, select all — don’t count as key equivalents, but are routed through specific delegate functions named for each action. Again, in MNU there’s a lot of commonality in each to unify into a single function called by each action function: it selects the NSSegmentedControl segments representing each modifier key, and drops the appropriate text into the text field. Here’s an example:

override func copy(_ sender: Any?) {

    processStandard(MNU_CONSTANTS.EDIT_CMD_COPY)
}

And the handler function:

private func processStandard(_ code: Int) {

    // Handle the Field Editor's standard text editing key equivalents.
    // These are not trapped by `performKeyEquivalent()`.
    let rawKeys: [String] = ["C", "X", "V", "A", "Z"]

    if let keyTextField: AddUserItemKeyTextField = self.keyTextField {
        if let segment: NSSegmentedControl = keyTextField.segment {
            // Deselect all segments...
            segment.selectedSegment = -1

            // ...then select the segment representing the pressed modifier
            segment.selectedSegment = MNU_CONSTANTS.MOD_KEY_CMD
        }

        // Set the text field's string
        keyTextField.stringValue = rawKeys
    }
}

Finally, there’s Command-Z for undo. This triggers the active NSTextField’s own NSUndoManager instance. Grab this and use its registerUndo() method to bind the undo action to a field editor function you provide. For MNU, that function, undo(), just calls the same handler that deals with the edit actions described above. Here’s how we patch in the custom undo function:

self.keyEquivalentText.undoManager?.registerUndo(
    withTarget: self.keyFieldEditor!,
    selector: #selector(AddUserItemKeyFieldEditor.undo), 
    object: nil)

This is a line that’s called from within windowWillReturnFieldEditor().

You can view the field editor code, and the ViewController code that binds it to the host window and configures a NSTextField sub-class, over on GitHub.

MNU 1.7.0 is available now. You can download it from my website, or install it via Homebrew and my Tap, smittytone/homebrew-smittytone.