Skip to content

Instantly share code, notes, and snippets.

@rdev
Last active October 9, 2022 00:43
Show Gist options
  • Select an option

  • Save rdev/627a254417687a90c493528639465943 to your computer and use it in GitHub Desktop.

Select an option

Save rdev/627a254417687a90c493528639465943 to your computer and use it in GitHub Desktop.
SwiftUI modifier to handle customizable keyboard shortcuts anywhere in the app
import SwiftUI
class KeyboardHostingView<Content: View>: NSHostingView<Content> {
var actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void]
var monitors: [UInt16: Any?] = [:]
var isPassthrough: Bool
init(actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void], isPassthrough: Bool, content: () -> Content) {
self.actions = actions
self.isPassthrough = isPassthrough
super.init(rootView: content())
setupListeners()
}
@MainActor required init(rootView: Content) {
fatalError("init(rootView:) has not been implemented")
}
// swiftlint:disable:next attributes
@MainActor @objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
teardownListeners()
}
func setupListeners() {
for (keycode, action) in actions {
monitors[keycode] = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event -> NSEvent? in
if event.keyCode == keycode {
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
action(modifiers)
return self.isPassthrough ? event : nil
}
return event
}
}
}
func teardownListeners() {
for (keycode, monitor) in monitors {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
monitors[keycode] = nil
}
}
}
}
struct KeyboardView<Content: View>: NSViewRepresentable {
var active: Binding<Bool>
var actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void]
var isPassthrough: Bool
var content: () -> Content
func makeNSView(context _: Context) -> NSView {
let view = KeyboardHostingView(actions: actions, isPassthrough: isPassthrough, content: content)
return view
}
func updateNSView(_: NSView, context _: Context) {}
}
struct Keyboard<Content: View>: View {
var active: Binding<Bool>
var actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void]
var content: () -> Content
var isPassthrough = false
var body: some View {
KeyboardView(active: active, actions: actions, isPassthrough: isPassthrough, content: content)
}
}
// By default events will be prevented from reaching the responder chain after being handled
// Sometimes that's not desirable, hence this modifier
extension Keyboard {
func passthrough() -> Keyboard {
var view = self
view.isPassthrough = true
return view
}
}
// Convenience
extension UInt16 {
// Layout-independent Keys
// eg.These key codes are always the same key on all layouts.
static let returnKey: UInt16 = 0x24
static let enter: UInt16 = 0x4C
static let tab: UInt16 = 0x30
static let space: UInt16 = 0x31
static let delete: UInt16 = 0x33
static let escape: UInt16 = 0x35
static let command: UInt16 = 0x37
static let shift: UInt16 = 0x38
static let capsLock: UInt16 = 0x39
static let option: UInt16 = 0x3A
static let control: UInt16 = 0x3B
static let rightShift: UInt16 = 0x3C
static let rightOption: UInt16 = 0x3D
static let rightControl: UInt16 = 0x3E
static let leftArrow: UInt16 = 0x7B
static let rightArrow: UInt16 = 0x7C
static let downArrow: UInt16 = 0x7D
static let upArrow: UInt16 = 0x7E
static let volumeUp: UInt16 = 0x48
static let volumeDown: UInt16 = 0x49
static let mute: UInt16 = 0x4A
static let help: UInt16 = 0x72
static let home: UInt16 = 0x73
static let pageUp: UInt16 = 0x74
static let forwardDelete: UInt16 = 0x75
static let end: UInt16 = 0x77
static let pageDown: UInt16 = 0x79
static let function: UInt16 = 0x3F
static let f1: UInt16 = 0x7A
static let f2: UInt16 = 0x78
static let f4: UInt16 = 0x76
static let f5: UInt16 = 0x60
static let f6: UInt16 = 0x61
static let f7: UInt16 = 0x62
static let f3: UInt16 = 0x63
static let f8: UInt16 = 0x64
static let f9: UInt16 = 0x65
static let f10: UInt16 = 0x6D
static let f11: UInt16 = 0x67
static let f12: UInt16 = 0x6F
static let f13: UInt16 = 0x69
static let f14: UInt16 = 0x6B
static let f15: UInt16 = 0x71
static let f16: UInt16 = 0x6A
static let f17: UInt16 = 0x40
static let f18: UInt16 = 0x4F
static let f19: UInt16 = 0x50
static let f20: UInt16 = 0x5A
// US-ANSI Keyboard Positions
// eg. These key codes are for the physical key (in any keyboard layout)
// at the location of the named key in the US-ANSI layout.
static let a: UInt16 = 0x00
static let b: UInt16 = 0x0B
static let c: UInt16 = 0x08
static let d: UInt16 = 0x02
static let e: UInt16 = 0x0E
static let f: UInt16 = 0x03
static let g: UInt16 = 0x05
static let h: UInt16 = 0x04
static let i: UInt16 = 0x22
static let j: UInt16 = 0x26
static let k: UInt16 = 0x28
static let l: UInt16 = 0x25
static let m: UInt16 = 0x2E
static let n: UInt16 = 0x2D
static let o: UInt16 = 0x1F
static let p: UInt16 = 0x23
static let q: UInt16 = 0x0C
static let r: UInt16 = 0x0F
static let s: UInt16 = 0x01
static let t: UInt16 = 0x11
static let u: UInt16 = 0x20
static let v: UInt16 = 0x09
static let w: UInt16 = 0x0D
static let x: UInt16 = 0x07
static let y: UInt16 = 0x10
static let z: UInt16 = 0x06
static let zero: UInt16 = 0x1D
static let one: UInt16 = 0x12
static let two: UInt16 = 0x13
static let three: UInt16 = 0x14
static let four: UInt16 = 0x15
static let five: UInt16 = 0x17
static let six: UInt16 = 0x16
static let seven: UInt16 = 0x1A
static let eight: UInt16 = 0x1C
static let nine: UInt16 = 0x19
static let equals: UInt16 = 0x18
static let minus: UInt16 = 0x1B
static let semicolon: UInt16 = 0x29
static let apostrophe: UInt16 = 0x27
static let comma: UInt16 = 0x2B
static let period: UInt16 = 0x2F
static let forwardSlash: UInt16 = 0x2C
static let backslash: UInt16 = 0x2A
static let grave: UInt16 = 0x32
static let leftBracket: UInt16 = 0x21
static let rightBracket: UInt16 = 0x1E
static let keypadDecimal: UInt16 = 0x41
static let keypadMultiply: UInt16 = 0x43
static let keypadPlus: UInt16 = 0x45
static let keypadClear: UInt16 = 0x47
static let keypadDivide: UInt16 = 0x4B
static let keypadEnter: UInt16 = 0x4C
static let keypadMinus: UInt16 = 0x4E
static let keypadEquals: UInt16 = 0x51
static let keypad0: UInt16 = 0x52
static let keypad1: UInt16 = 0x53
static let keypad2: UInt16 = 0x54
static let keypad3: UInt16 = 0x55
static let keypad4: UInt16 = 0x56
static let keypad5: UInt16 = 0x57
static let keypad6: UInt16 = 0x58
static let keypad7: UInt16 = 0x59
static let keypad8: UInt16 = 0x5B
static let keypad9: UInt16 = 0x5C
}
// MARK: - Use as modifier instead of a React-style wrapper
extension View {
func keyboard(active: Binding<Bool>, actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void], isPassthrough: Bool) -> some View {
background(
isPassthrough
? Keyboard(active: active, actions: actions) {
EmptyView()
}.passthrough()
: Keyboard(active: active, actions: actions) {
EmptyView()
}
)
}
func keyboard(active: Binding<Bool>, actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void]) -> some View {
background(
Keyboard(active: active, actions: actions) {
EmptyView()
}
)
}
}
// MARK: - Modifier usage
import SwiftUI
struct MyView: View {
@State var keyboardActive = true
var body: some View {
SomeView()
.keyboard(
active: $keyboardActive,
actions: [
.downArrow: { modifiers in
print("down")
},
.upArrow: { modifiers in
print("up")
},
.k: { modifiers in
if modifiers.contains(.command) {
print("cmd+k")
}
},
]
)
}
}
import SwiftUI
struct MyView: View {
@State var keyboardActive = true
var body: some View {
Keyboard(
active: $keyboardActive,
actions: [
.downArrow: { modifiers in
print("down")
},
.upArrow: { modifiers in
print("up")
},
.k: { modifiers in
if modifiers.contains(.command) {
print("cmd+k")
}
},
]
) {
SomeView()
}
}
}
@rdev
Copy link
Author

rdev commented Oct 9, 2022

Updated to be smaller, 100% SwiftUI without representable and hosting views, fixes handlers being active when view is unmounted

//
//  Keyboard.swift
//  Keyboard
//
//  Created by Max Rovensky on 09/10/2022.
//

import SwiftUI

struct KeyboardView: View {
  @EnvironmentObject var window: WindowContext
  @Binding var active: Bool

  var actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void]
  var isPassthrough = false

  @State var monitors: [UInt16: Any?] = [:]

  var body: some View {
    EmptyView()
      .onAppear {
        for (keycode, action) in actions {
          monitors[keycode] = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event -> NSEvent? in
            // This is a good spot to add any overrides, since handlers are stacked and aren't aware of each other.
			// I.e. you might want to disable everything that's not a return or escape if a modal dialog is shown
            if SomeClass.shared.dialogOpen, event.keyCode != .escape, event.keyCode != .returnKey {
              // Force ignore the events to not trigger random system things like sidebar nav
              return nil
            }

            if active, event.keyCode == keycode {
              let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
              action(modifiers)
              return self.isPassthrough ? event : nil
            }

            return event
          }
        }
      }
      .onDisappear {
        for (keycode, monitor) in monitors {
          if let monitor = monitor {
            NSEvent.removeMonitor(monitor)
            monitors[keycode] = nil
          }
        }
      }
  }
}

extension View {
  func keyboard(active: Binding<Bool>, actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void], isPassthrough: Bool) -> some View {
    background(
      KeyboardView(active: active, actions: actions, isPassthrough: isPassthrough)
    )
  }

  func keyboard(active: Binding<Bool>, actions: [UInt16: (_ modifiers: NSEvent.ModifierFlags) -> Void]) -> some View {
    background(
      KeyboardView(active: active, actions: actions)
    )
  }
}

// Convenience
extension UInt16 {
  // Layout-independent Keys
  // eg.These key codes are always the same key on all layouts.
  static let returnKey: UInt16 = 0x24
  static let enter: UInt16 = 0x4C
  static let tab: UInt16 = 0x30
  static let space: UInt16 = 0x31
  static let delete: UInt16 = 0x33
  static let escape: UInt16 = 0x35
  static let command: UInt16 = 0x37
  static let shift: UInt16 = 0x38
  static let capsLock: UInt16 = 0x39
  static let option: UInt16 = 0x3A
  static let control: UInt16 = 0x3B
  static let rightShift: UInt16 = 0x3C
  static let rightOption: UInt16 = 0x3D
  static let rightControl: UInt16 = 0x3E
  static let leftArrow: UInt16 = 0x7B
  static let rightArrow: UInt16 = 0x7C
  static let downArrow: UInt16 = 0x7D
  static let upArrow: UInt16 = 0x7E
  static let volumeUp: UInt16 = 0x48
  static let volumeDown: UInt16 = 0x49
  static let mute: UInt16 = 0x4A
  static let help: UInt16 = 0x72
  static let home: UInt16 = 0x73
  static let pageUp: UInt16 = 0x74
  static let forwardDelete: UInt16 = 0x75
  static let end: UInt16 = 0x77
  static let pageDown: UInt16 = 0x79
  static let function: UInt16 = 0x3F
  static let f1: UInt16 = 0x7A
  static let f2: UInt16 = 0x78
  static let f4: UInt16 = 0x76
  static let f5: UInt16 = 0x60
  static let f6: UInt16 = 0x61
  static let f7: UInt16 = 0x62
  static let f3: UInt16 = 0x63
  static let f8: UInt16 = 0x64
  static let f9: UInt16 = 0x65
  static let f10: UInt16 = 0x6D
  static let f11: UInt16 = 0x67
  static let f12: UInt16 = 0x6F
  static let f13: UInt16 = 0x69
  static let f14: UInt16 = 0x6B
  static let f15: UInt16 = 0x71
  static let f16: UInt16 = 0x6A
  static let f17: UInt16 = 0x40
  static let f18: UInt16 = 0x4F
  static let f19: UInt16 = 0x50
  static let f20: UInt16 = 0x5A

  // US-ANSI Keyboard Positions
  // eg. These key codes are for the physical key (in any keyboard layout)
  // at the location of the named key in the US-ANSI layout.
  static let a: UInt16 = 0x00
  static let b: UInt16 = 0x0B
  static let c: UInt16 = 0x08
  static let d: UInt16 = 0x02
  static let e: UInt16 = 0x0E
  static let f: UInt16 = 0x03
  static let g: UInt16 = 0x05
  static let h: UInt16 = 0x04
  static let i: UInt16 = 0x22
  static let j: UInt16 = 0x26
  static let k: UInt16 = 0x28
  static let l: UInt16 = 0x25
  static let m: UInt16 = 0x2E
  static let n: UInt16 = 0x2D
  static let o: UInt16 = 0x1F
  static let p: UInt16 = 0x23
  static let q: UInt16 = 0x0C
  static let r: UInt16 = 0x0F
  static let s: UInt16 = 0x01
  static let t: UInt16 = 0x11
  static let u: UInt16 = 0x20
  static let v: UInt16 = 0x09
  static let w: UInt16 = 0x0D
  static let x: UInt16 = 0x07
  static let y: UInt16 = 0x10
  static let z: UInt16 = 0x06

  static let zero: UInt16 = 0x1D
  static let one: UInt16 = 0x12
  static let two: UInt16 = 0x13
  static let three: UInt16 = 0x14
  static let four: UInt16 = 0x15
  static let five: UInt16 = 0x17
  static let six: UInt16 = 0x16
  static let seven: UInt16 = 0x1A
  static let eight: UInt16 = 0x1C
  static let nine: UInt16 = 0x19

  static let equals: UInt16 = 0x18
  static let minus: UInt16 = 0x1B
  static let semicolon: UInt16 = 0x29
  static let apostrophe: UInt16 = 0x27
  static let comma: UInt16 = 0x2B
  static let period: UInt16 = 0x2F
  static let forwardSlash: UInt16 = 0x2C
  static let backslash: UInt16 = 0x2A
  static let grave: UInt16 = 0x32
  static let leftBracket: UInt16 = 0x21
  static let rightBracket: UInt16 = 0x1E

  static let keypadDecimal: UInt16 = 0x41
  static let keypadMultiply: UInt16 = 0x43
  static let keypadPlus: UInt16 = 0x45
  static let keypadClear: UInt16 = 0x47
  static let keypadDivide: UInt16 = 0x4B
  static let keypadEnter: UInt16 = 0x4C
  static let keypadMinus: UInt16 = 0x4E
  static let keypadEquals: UInt16 = 0x51
  static let keypad0: UInt16 = 0x52
  static let keypad1: UInt16 = 0x53
  static let keypad2: UInt16 = 0x54
  static let keypad3: UInt16 = 0x55
  static let keypad4: UInt16 = 0x56
  static let keypad5: UInt16 = 0x57
  static let keypad6: UInt16 = 0x58
  static let keypad7: UInt16 = 0x59
  static let keypad8: UInt16 = 0x5B
  static let keypad9: UInt16 = 0x5C
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment