diff --git a/MTMR/CustomButtonTouchBarItem.swift b/MTMR/CustomButtonTouchBarItem.swift index 6b734d2..51d4d7e 100644 --- a/MTMR/CustomButtonTouchBarItem.swift +++ b/MTMR/CustomButtonTouchBarItem.swift @@ -9,17 +9,28 @@ import Cocoa class CustomButtonTouchBarItem: NSCustomTouchBarItem, NSGestureRecognizerDelegate { - var tapClosure: (() -> Void)? + var tapClosure: (() -> Void)? { + didSet { + actions[.singleTap] = tapClosure + } + } var longTapClosure: (() -> Void)? { didSet { - longClick.isEnabled = longTapClosure != nil + actions[.longTap] = longTapClosure + } + } + typealias TriggerClosure = (() -> Void)? + var actions: [Action.Trigger: TriggerClosure] = [:] { + didSet { + singleAndDoubleClick.isDoubleClickEnabled = actions[.doubleTap] != nil + longClick.isEnabled = actions[.longTap] != nil } } var finishViewConfiguration: ()->() = {} private var button: NSButton! - private var singleClick: HapticClickGestureRecognizer! private var longClick: LongPressGestureRecognizer! + private var singleAndDoubleClick: DoubleClickGestureRecognizer! init(identifier: NSTouchBarItem.Identifier, title: String) { attributedTitle = title.defaultTouchbarAttributedString @@ -31,10 +42,11 @@ class CustomButtonTouchBarItem: NSCustomTouchBarItem, NSGestureRecognizerDelegat longClick.isEnabled = false longClick.allowedTouchTypes = .direct longClick.delegate = self - - singleClick = HapticClickGestureRecognizer(target: self, action: #selector(handleGestureSingle)) - singleClick.allowedTouchTypes = .direct - singleClick.delegate = self + + singleAndDoubleClick = DoubleClickGestureRecognizer(target: self, action: #selector(handleGestureSingleTap), doubleAction: #selector(handleGestureDoubleTap)) + singleAndDoubleClick.allowedTouchTypes = .direct + singleAndDoubleClick.delegate = self + singleAndDoubleClick.isDoubleClickEnabled = false reinstallButton() button.attributedTitle = attributedTitle @@ -100,33 +112,35 @@ class CustomButtonTouchBarItem: NSCustomTouchBarItem, NSGestureRecognizerDelegat view = button view.addGestureRecognizer(longClick) - view.addGestureRecognizer(singleClick) + // view.addGestureRecognizer(singleClick) + view.addGestureRecognizer(singleAndDoubleClick) finishViewConfiguration() } func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: NSGestureRecognizer) -> Bool { - if gestureRecognizer == singleClick && otherGestureRecognizer == longClick - || gestureRecognizer == longClick && otherGestureRecognizer == singleClick // need it + if gestureRecognizer == singleAndDoubleClick && otherGestureRecognizer == longClick + || gestureRecognizer == longClick && otherGestureRecognizer == singleAndDoubleClick // need it { return false } return true } - - @objc func handleGestureSingle(gr: NSClickGestureRecognizer) { - switch gr.state { - case .ended: - tapClosure?() - break - default: - break - } + + @objc func handleGestureSingleTap() { + guard let singleTap = self.actions[.singleTap] else { return } + singleTap?() + } + + @objc func handleGestureDoubleTap() { + guard let doubleTap = self.actions[.doubleTap] else { return } + doubleTap?() } @objc func handleGestureLong(gr: NSPressGestureRecognizer) { switch gr.state { case .possible: // tiny hack because we're calling action manually - (self.longTapClosure ?? self.tapClosure)?() + guard let longTap = self.actions[.longTap] else { return } + longTap?() break default: break @@ -176,15 +190,62 @@ class CustomButtonCell: NSButtonCell { } } -class HapticClickGestureRecognizer: NSClickGestureRecognizer { +// Thanks to https://stackoverflow.com/a/49843893 +final class DoubleClickGestureRecognizer: NSClickGestureRecognizer { + + private let _action: Selector + private let _doubleAction: Selector + private var _clickCount: Int = 0 + + public var isDoubleClickEnabled = true + + override var action: Selector? { + get { + return nil /// prevent base class from performing any actions + } set { + if newValue != nil { // if they are trying to assign an actual action + fatalError("Only use init(target:action:doubleAction) for assigning actions") + } + } + } + + required init(target: AnyObject, action: Selector, doubleAction: Selector) { + _action = action + _doubleAction = doubleAction + super.init(target: target, action: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(target:action:doubleAction) is only support atm") + } + override func touchesBegan(with event: NSEvent) { HapticFeedback.shared?.tap(strong: 2) super.touchesBegan(with: event) } - + override func touchesEnded(with event: NSEvent) { HapticFeedback.shared?.tap(strong: 1) super.touchesEnded(with: event) + _clickCount += 1 + + guard isDoubleClickEnabled else { + _ = target?.perform(_action) + return + } + + let delayThreshold = 0.20 // fine tune this as needed + perform(#selector(_resetAndPerformActionIfNecessary), with: nil, afterDelay: delayThreshold) + if _clickCount == 2 { + _ = target?.perform(_doubleAction) + } + } + + @objc private func _resetAndPerformActionIfNecessary() { + if _clickCount == 1 { + _ = target?.perform(_action) + } + _clickCount = 0 } } diff --git a/MTMR/ItemsParsing.swift b/MTMR/ItemsParsing.swift index a16beef..dd20069 100644 --- a/MTMR/ItemsParsing.swift +++ b/MTMR/ItemsParsing.swift @@ -3,7 +3,7 @@ import Foundation extension Data { func barItemDefinitions() -> [BarItemDefinition]? { - return try? JSONDecoder().decode([BarItemDefinition].self, from: utf8string!.stripComments().data(using: .utf8)!) + return try! JSONDecoder().decode([BarItemDefinition].self, from: utf8string!.stripComments().data(using: .utf8)!) } } @@ -51,25 +51,31 @@ class SupportedTypesHolder { private var supportedTypes: [String: ParametersDecoder] = [ "escape": { _ in ( item: .staticButton(title: "esc"), - action: .keyPress(keycode: 53), + action: .none, longAction: .none, - parameters: [.align: .align(.left)] + parameters: [.align: .align(.left), .actions: .actions([ + Action(trigger: .singleTap, value: .keyPress(keycode: 53)) + ])] ) }, "delete": { _ in ( item: .staticButton(title: "del"), - action: .keyPress(keycode: 117), + action: .none, longAction: .none, - parameters: [:] + parameters: [.actions: .actions([ + Action(trigger: .singleTap, value: .keyPress(keycode: 117)) + ])] ) }, "brightnessUp": { _ in let imageParameter = GeneralParameter.image(source: #imageLiteral(resourceName: "brightnessUp")) return ( item: .staticButton(title: ""), - action: .keyPress(keycode: 144), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .keyPress(keycode: 144)) + ])] ) }, @@ -77,9 +83,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: #imageLiteral(resourceName: "brightnessDown")) return ( item: .staticButton(title: ""), - action: .keyPress(keycode: 145), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .keyPress(keycode: 145)) + ])] ) }, @@ -87,9 +95,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: #imageLiteral(resourceName: "ill_up")) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_ILLUMINATION_UP), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_ILLUMINATION_UP)) + ])] ) }, @@ -97,9 +107,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: #imageLiteral(resourceName: "ill_down")) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_ILLUMINATION_DOWN), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_ILLUMINATION_DOWN)) + ])] ) }, @@ -107,9 +119,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: NSImage(named: NSImage.touchBarVolumeDownTemplateName)!) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_SOUND_DOWN), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_SOUND_DOWN)) + ])] ) }, @@ -117,9 +131,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: NSImage(named: NSImage.touchBarVolumeUpTemplateName)!) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_SOUND_UP), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_SOUND_UP)) + ])] ) }, @@ -127,9 +143,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: NSImage(named: NSImage.touchBarAudioOutputMuteTemplateName)!) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_MUTE), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_MUTE)) + ])] ) }, @@ -137,9 +155,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: NSImage(named: NSImage.touchBarRewindTemplateName)!) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_PREVIOUS), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_PREVIOUS)) + ])] ) }, @@ -147,9 +167,11 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: NSImage(named: NSImage.touchBarPlayPauseTemplateName)!) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_PLAY), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_PLAY)) + ])] ) }, @@ -157,24 +179,30 @@ class SupportedTypesHolder { let imageParameter = GeneralParameter.image(source: NSImage(named: NSImage.touchBarFastForwardTemplateName)!) return ( item: .staticButton(title: ""), - action: .hidKey(keycode: NX_KEYTYPE_NEXT), + action: .none, longAction: .none, - parameters: [.image: imageParameter] + parameters: [.image: imageParameter, .actions: .actions([ + Action(trigger: .singleTap, value: .hidKey(keycode: NX_KEYTYPE_NEXT)) + ])] ) }, "sleep": { _ in ( item: .staticButton(title: "☕️"), - action: .shellScript(executable: "/usr/bin/pmset", parameters: ["sleepnow"]), + action: .none, longAction: .none, - parameters: [:] + parameters: [.actions: .actions([ + Action(trigger: .singleTap, value: .shellScript(executable: "/usr/bin/pmset", parameters: ["sleepnow"])) + ])] ) }, "displaySleep": { _ in ( item: .staticButton(title: "☕️"), - action: .shellScript(executable: "/usr/bin/pmset", parameters: ["displaysleepnow"]), + action: .none, longAction: .none, - parameters: [:] + parameters: [.actions: .actions([ + Action(trigger: .singleTap, value: .shellScript(executable: "/usr/bin/pmset", parameters: ["displaysleepnow"])) + ])] ) }, ] @@ -393,6 +421,92 @@ enum ItemType: Decodable { } } +struct FailableDecodable : Decodable { + + let base: Base? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.base = try? container.decode(Base.self) + } +} + +struct Action: Decodable { + enum Trigger: String, Decodable { + case singleTap + case doubleTap + case longTap + } + + enum Value { + case none + case hidKey(keycode: Int32) + case keyPress(keycode: Int) + case appleScript(source: SourceProtocol) + case shellScript(executable: String, parameters: [String]) + case custom(closure: () -> Void) + case openUrl(url: String) + } + + private enum ActionTypeRaw: String, Decodable { + case hidKey + case keyPress + case appleScript + case shellScript + case openUrl + } + + enum CodingKeys: String, CodingKey { + case trigger + case action + case keycode + case actionAppleScript + case executablePath + case shellArguments + case url + } + + let trigger: Trigger + let value: Value + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + trigger = try container.decode(Trigger.self, forKey: .trigger) + let type = try container.decodeIfPresent(ActionTypeRaw.self, forKey: .action) + + switch type { + case .some(.hidKey): + let keycode = try container.decode(Int32.self, forKey: .keycode) + value = .hidKey(keycode: keycode) + + case .some(.keyPress): + let keycode = try container.decode(Int.self, forKey: .keycode) + value = .keyPress(keycode: keycode) + + case .some(.appleScript): + let source = try container.decode(Source.self, forKey: .actionAppleScript) + value = .appleScript(source: source) + + case .some(.shellScript): + let executable = try container.decode(String.self, forKey: .executablePath) + let parameters = try container.decodeIfPresent([String].self, forKey: .shellArguments) ?? [] + value = .shellScript(executable: executable, parameters: parameters) + + case .some(.openUrl): + let url = try container.decode(String.self, forKey: .url) + value = .openUrl(url: url) + case .none: + value = .none + } + } + + init(trigger: Trigger, value: Value) { + self.trigger = trigger + self.value = value + } +} + enum ActionType: Decodable { case none case hidKey(keycode: Int32) @@ -516,6 +630,7 @@ enum GeneralParameter { case bordered(_: Bool) case background(_: NSColor) case title(_: String) + case actions(_: [Action]) } struct GeneralParameters: Decodable { @@ -528,6 +643,7 @@ struct GeneralParameters: Decodable { case bordered case background case title + case actions } init(from decoder: Decoder) throws { @@ -556,6 +672,10 @@ struct GeneralParameters: Decodable { if let title = try container.decodeIfPresent(String.self, forKey: .title) { result[.title] = .title(title) } + + if let actions = try container.decodeIfPresent([Action].self, forKey: .actions) { + result[.actions] = .actions(actions)//.compactMap { $0.base }) + } parameters = result } diff --git a/MTMR/TouchBarController.swift b/MTMR/TouchBarController.swift index e3a5898..0dbc6d0 100644 --- a/MTMR/TouchBarController.swift +++ b/MTMR/TouchBarController.swift @@ -313,6 +313,11 @@ class TouchBarController: NSObject, NSTouchBarDelegate { if let longAction = self.longAction(forItem: item), let item = barItem as? CustomButtonTouchBarItem { item.longTapClosure = longAction } + if case let .actions(actions)? = item.additionalParameters[.actions], let item = barItem as? CustomButtonTouchBarItem { + for action in actions { + item.actions[action.trigger] = self.action(for: action) + } + } if case let .bordered(bordered)? = item.additionalParameters[.bordered], let item = barItem as? CustomButtonTouchBarItem { item.isBordered = bordered } @@ -334,6 +339,50 @@ class TouchBarController: NSObject, NSTouchBarDelegate { } return barItem } + + func action(for action: Action) -> (() -> Void)? { + switch action.value { + case let .hidKey(keycode: keycode): + return { HIDPostAuxKey(keycode) } + case let .keyPress(keycode: keycode): + return { GenericKeyPress(keyCode: CGKeyCode(keycode)).send() } + case let .appleScript(source: source): + guard let appleScript = source.appleScript else { + print("cannot create apple script for item \(action)") + return {} + } + return { + DispatchQueue.appleScriptQueue.async { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + if let error = error { + print("error \(error) when handling \(action) ") + } + } + } + case let .shellScript(executable: executable, parameters: parameters): + return { + let task = Process() + task.launchPath = executable + task.arguments = parameters + task.launch() + } + case let .openUrl(url: url): + return { + if let url = URL(string: url), NSWorkspace.shared.open(url) { + #if DEBUG + print("URL was successfully opened") + #endif + } else { + print("error", url) + } + } + case let .custom(closure: closure): + return closure + case .none: + return nil + } + } func action(forItem item: BarItemDefinition) -> (() -> Void)? { switch item.action {