diff --git a/PinchBar.xcodeproj/project.pbxproj b/PinchBar.xcodeproj/project.pbxproj index ff4fc7c..2820335 100644 --- a/PinchBar.xcodeproj/project.pbxproj +++ b/PinchBar.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ 8459492227FA287800DBB447 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8459492127FA287800DBB447 /* AppDelegate.swift */; }; 8459492427FA287C00DBB447 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8459492327FA287C00DBB447 /* Assets.xcassets */; }; 846AF3CC2804F5FC00B3E06F /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846AF3CB2804F5FC00B3E06F /* Repository.swift */; }; + 84B832422966CAD10083FB73 /* StatusMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B832412966CAD10083FB73 /* StatusMenu.swift */; }; + 84C256C3294228DC0087E286 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C256C2294228DC0087E286 /* Settings.swift */; }; + 84CAEABE2929B55600407DD3 /* EventMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAEABD2929B55600407DD3 /* EventMapping.swift */; }; + 84CAEAC0292A75E000407DD3 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAEABF292A75E000407DD3 /* Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -21,6 +25,10 @@ 8459492E27FA28A100DBB447 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 846AF3CB2804F5FC00B3E06F /* Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; 84A304C828C686C70087F4F6 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 84B832412966CAD10083FB73 /* StatusMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMenu.swift; sourceTree = ""; }; + 84C256C2294228DC0087E286 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + 84CAEABD2929B55600407DD3 /* EventMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMapping.swift; sourceTree = ""; }; + 84CAEABF292A75E000407DD3 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 84D7DA58290085BF0013E257 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; /* End PBXFileReference section */ @@ -58,9 +66,13 @@ children = ( 8459492127FA287800DBB447 /* AppDelegate.swift */, 8459492327FA287C00DBB447 /* Assets.xcassets */, + 84CAEABD2929B55600407DD3 /* EventMapping.swift */, 8403F28B27FF191600CAAC3C /* EventTap.swift */, + 84CAEABF292A75E000407DD3 /* Extensions.swift */, 8459492E27FA28A100DBB447 /* Info.plist */, 846AF3CB2804F5FC00B3E06F /* Repository.swift */, + 84C256C2294228DC0087E286 /* Settings.swift */, + 84B832412966CAD10083FB73 /* StatusMenu.swift */, ); path = PinchBar; sourceTree = ""; @@ -133,9 +145,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 84C256C3294228DC0087E286 /* Settings.swift in Sources */, + 84CAEAC0292A75E000407DD3 /* Extensions.swift in Sources */, 846AF3CC2804F5FC00B3E06F /* Repository.swift in Sources */, + 84B832422966CAD10083FB73 /* StatusMenu.swift in Sources */, 8403F28C27FF191600CAAC3C /* EventTap.swift in Sources */, 8459492227FA287800DBB447 /* AppDelegate.swift in Sources */, + 84CAEABE2929B55600407DD3 /* EventMapping.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -271,7 +287,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = MW6A6MU64S; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -283,7 +299,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.2; PRODUCT_BUNDLE_IDENTIFIER = com.pnoqable.PinchBar; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -300,7 +316,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = MW6A6MU64S; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -312,7 +328,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.2; PRODUCT_BUNDLE_IDENTIFIER = com.pnoqable.PinchBar; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PinchBar/AppDelegate.swift b/PinchBar/AppDelegate.swift index fdbbc8b..5392327 100644 --- a/PinchBar/AppDelegate.swift +++ b/PinchBar/AppDelegate.swift @@ -1,98 +1,50 @@ import Cocoa -@main class AppDelegate: NSObject, NSApplicationDelegate, EventTapDelegate { - var statusItem: NSStatusItem! - var menuItemPreferences: NSMenuItem! - var menuItemConfigure: NSMenuItem! +private let unknownApp: String = "unknown Application" + +@main class AppDelegate: NSObject, NSApplicationDelegate { + var activeApp: String = unknownApp let eventTap = EventTap() let repository = Repository() + let settings = Settings() + + let statusMenu = StatusMenu() func applicationDidFinishLaunching(_ aNotification: Notification) { - NSLog("PinchBar \(repository.version), enabled for: \(eventTap.apps.keys)") - - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) - statusItem.button?.image = NSImage(named: "StatusIcon") - statusItem.button?.toolTip = "PinchBar" - statusItem.behavior = .removalAllowed - statusItem.menu = NSMenu() - statusItem.menu?.autoenablesItems = false - - let menuItemAbout = NSMenuItem() - menuItemAbout.title = "About PinchBar " + repository.version - menuItemAbout.target = self - menuItemAbout.action = #selector(openGitHub) - statusItem.menu?.addItem(menuItemAbout) - - let menuItemUpdate = NSMenuItem() - menuItemUpdate.title = "Check for Updates..." - menuItemUpdate.target = self - menuItemUpdate.action = #selector(checkForUpdates) - statusItem.menu?.addItem(menuItemUpdate) + NSLog("PinchBar \(repository.version), configured for: \(settings.appNames)") - statusItem.menu?.addItem(NSMenuItem.separator()) + statusMenu.callWhenPresetSelected = { [weak self] p in self?.changePreset(to: p) } + statusMenu.create(repository: repository, settings: settings) - menuItemPreferences = NSMenuItem() - menuItemPreferences.title = "Enable Pinchbar in Accessibility" - menuItemPreferences.target = self - menuItemPreferences.action = #selector(accessibility) - statusItem.menu?.addItem(menuItemPreferences) - - menuItemConfigure = NSMenuItem() - menuItemConfigure.title = "Enable for " + eventTap.currentApp - menuItemConfigure.target = self - menuItemConfigure.action = #selector(configure) - menuItemConfigure.isEnabled = false - statusItem.menu?.addItem(menuItemConfigure) - - statusItem.menu?.addItem(NSMenuItem.separator()) - - let menuItemQuit = NSMenuItem() - menuItemQuit.title = "Quit" - menuItemQuit.target = NSApplication.shared - menuItemQuit.action = #selector(NSApplication.stop) - statusItem.menu?.addItem(menuItemQuit) - - eventTap.delegate = self + eventTap.callWhenCreated = { [weak statusMenu] in statusMenu?.enableSubmenu() } eventTap.start() - repository.openUpdateLink = openGitHub repository.checkForUpdates(verbose: false) + + NSWorkspace.shared.notificationCenter + .addObserver(self, selector: #selector(activeAppChanged), + name: NSWorkspace.didActivateApplicationNotification, object: nil) + + activeAppChanged() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - statusItem.isVisible = true + statusMenu.statusItem.isVisible = true return true } - @objc func openGitHub() { - let url = "https://github.com/pnoqable/PinchBar" - NSWorkspace.shared.open(URL(string: url)!) - } - - @objc func checkForUpdates() { - repository.checkForUpdates(verbose: true) - } - - @objc func accessibility() { - let url = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - NSWorkspace.shared.open(URL(string: url)!) + @objc func activeAppChanged() { + activeApp = NSWorkspace.shared.frontmostApplication?.localizedName ?? unknownApp + changePreset(to: settings.appPresets[activeApp]) } - @objc func configure() { - eventTap.toggleApp() - } - - func eventTapCreated(_: EventTap) { - menuItemPreferences.state = .on - menuItemPreferences.isEnabled = false - menuItemConfigure.isEnabled = true - } - - func eventTapUpdated(_ eventTap: EventTap) { - statusItem.button?.appearsDisabled = !eventTap.isEnabled - menuItemConfigure.title = "Enable for " + eventTap.currentApp - menuItemConfigure.state = eventTap.isEnabled ? .on : .off + func changePreset(to newPreset: String?) { + settings.appPresets[activeApp] = newPreset + settings.save() + + eventTap.preset = settings.preset(named: newPreset) + statusMenu.updateSubmenu(activeApp: activeApp, activePreset: newPreset) } static func main() { diff --git a/PinchBar/EventMapping.swift b/PinchBar/EventMapping.swift new file mode 100644 index 0000000..1ab63d5 --- /dev/null +++ b/PinchBar/EventMapping.swift @@ -0,0 +1,71 @@ +import Cocoa + +struct EventMapping: Codable { + enum Replacement: Codable { + case wheel + case keys(codeA: CGKeyCode, codeB: CGKeyCode) + } + + var replaceWith: Replacement? + var flags: CGEventFlags + var sensivity: Double + + func canTap(_ event: CGEvent) -> Bool { event.subType == .magnification } + + private static var remainder: Double = 0 // subpixel residue of sent (integer) scroll events + + func tap(_ event: CGEvent, proxy: CGEventTapProxy) -> Unmanaged? { + assert(event.subType == .magnification) + + // when event is not to be replaced, just apply flags and sensivity: + guard let replacement = replaceWith else { + event.flags = flags + event.magnification *= sensivity + return .passUnretained(event) + } + + if event.phase == .began { + Self.remainder = 0 + } + + let magnification = sensivity * event.magnification + Self.remainder + let steps = round(magnification) + Self.remainder = magnification - steps + + guard steps != 0 else { return nil } + + switch replacement { + case .wheel: + let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, + wheelCount: 1, wheel1: Int32(steps), wheel2: 0, wheel3: 0)! + event.flags = flags + return .passRetained(event) + case .keys(let codeA, let codeB): + let code = steps < 0 ? codeA : codeB + sendKey(code, down: true, proxy: proxy) + sendKey(code, down: false, proxy: proxy) + return nil + } + } + + private func sendKey(_ code: CGKeyCode, down: Bool, proxy: CGEventTapProxy) { + let event = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: down)! + event.flags = flags + event.tapPostEvent(proxy) + } +} + +extension EventMapping { + static func pinchToPinch(flags: CGEventFlags = .maskNoFlags, sensivity: Double = 1) -> Self { + Self(replaceWith: nil, flags: flags, sensivity: sensivity) + } + + static func pinchToWheel(flags: CGEventFlags = .maskCommand, sensivity: Double = 200) -> Self { + Self(replaceWith: .wheel, flags: flags, sensivity: sensivity) + } + + static func pinchToKeys(flags: CGEventFlags = .maskCommand, sensivity: Double = 5, + codeA: CGKeyCode = 44, codeB: CGKeyCode = 30) -> Self { + Self(replaceWith: .keys(codeA: codeA, codeB: codeB), flags: flags, sensivity: sensivity) + } +} diff --git a/PinchBar/EventTap.swift b/PinchBar/EventTap.swift index 457bef6..dac0acf 100644 --- a/PinchBar/EventTap.swift +++ b/PinchBar/EventTap.swift @@ -1,36 +1,16 @@ import Cocoa -protocol EventTapDelegate: AnyObject { - func eventTapCreated(_ eventTap: EventTap) - func eventTapUpdated(_ eventTap: EventTap) -} - class EventTap { - static let unknownApp: String = "unknown Application" - private var eventTap: CFMachPort? - private(set) var apps: [String:Bool] = ["Cubase":true] - private(set) var currentApp: String = EventTap.unknownApp - private(set) var isEnabled: Bool = false + var preset: Settings.Preset? - weak var delegate: EventTapDelegate? - - init() { - UserDefaults.standard.register(defaults: ["apps":apps]) - if let apps = UserDefaults.standard.object(forKey: "apps") as? [String:Bool] { - self.apps = apps - } - - NSWorkspace.shared.notificationCenter - .addObserver(self, selector: #selector(updateTap), - name: NSWorkspace.didActivateApplicationNotification, object: nil) - } + var callWhenCreated: Callback? func start() { - let adapter: CGEventTapCallBack = { _, type, event, userInfo in + let adapter: CGEventTapCallBack = { proxy, type, event, userInfo in let mySelf = Unmanaged.fromOpaque(userInfo!).takeUnretainedValue() - return mySelf.tap(type: type, event: event) + return mySelf.tap(proxy: proxy, type: type, event: event) } let mySelf = Unmanaged.passUnretained(self).toOpaque() @@ -42,55 +22,22 @@ class EventTap { callback: adapter, userInfo: mySelf) - if let eventTap = eventTap { - let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - delegate?.eventTapCreated(self) - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: start) - } - - updateTap() - } - - func toggleApp() { - if apps.keys.contains(currentApp) { - apps.removeValue(forKey: currentApp) - } else { - apps[currentApp] = true - } - - UserDefaults.standard.set(apps, forKey: "apps") - - updateTap() - } - - @objc private func updateTap() { - currentApp = NSWorkspace.shared.frontmostApplication?.localizedName ?? EventTap.unknownApp - isEnabled = apps.keys.contains(currentApp) - - if let eventTap = eventTap { - if isEnabled != CGEvent.tapIsEnabled(tap: eventTap) { - CGEvent.tapEnable(tap: eventTap, enable: isEnabled) - } - } else { - isEnabled = false + guard let eventTap else { + return DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: start) } - delegate?.eventTapUpdated(self) + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + callWhenCreated?() } - private func tap(type: CGEventType, event: CGEvent) -> Unmanaged? { - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - updateTap() - } else if let nsEvent = NSEvent.init(cgEvent: event), nsEvent.type == .magnify { - let amount = Int32(round(nsEvent.deltaZ)) - let newEvent = CGEvent.init(scrollWheelEvent2Source: nil, units: .pixel, - wheelCount: 1, wheel1: amount, wheel2: 0, wheel3: 0)! - newEvent.flags = .maskCommand - return Unmanaged.passRetained(newEvent) + private func tap(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout { + CGEvent.tapEnable(tap: eventTap!, enable: true) + } else if let mapping = preset?[event.flags.purified], mapping.canTap(event) { + return mapping.tap(event, proxy: proxy) } - return Unmanaged.passUnretained(event) + return .passUnretained(event) } } diff --git a/PinchBar/Extensions.swift b/PinchBar/Extensions.swift new file mode 100644 index 0000000..a752753 --- /dev/null +++ b/PinchBar/Extensions.swift @@ -0,0 +1,73 @@ +import Cocoa + +typealias Callback = () -> () + +extension CGEventFlags: Codable, Hashable { + static let maskNoFlags = Self([]) + + init?(_ string: String) { + guard let i = UInt64(string) else { return nil } + self = Self(rawValue: i) + } + + // key without left/right info + static let pureKeyMask = UInt64.max << 8 + + var purified: Self { Self(rawValue: rawValue & Self.pureKeyMask) } +} + +extension CGEventField: Codable { + static let subType = Self(rawValue: 110)! + static let magnification = Self(rawValue: 113)! + static let phase = Self(rawValue: 132)! +} + +extension CGEvent { + enum SubType: Int64 { + case magnification = 8 + case other + } + + enum Phase: Int64 { + case began = 1 + case other + } + + var subType: SubType { SubType(rawValue: getIntegerValueField(.subType)) ?? .other } + + var magnification: Double { + get { getDoubleValueField(.magnification) } + set { setDoubleValueField(.magnification, value: newValue) } + } + + var phase: Phase { Phase(rawValue: getIntegerValueField(.phase)) ?? .other } +} + +extension Dictionary { + func mapKeys(_ transform: (Key) throws -> T) rethrows -> [T: Value] { + try .init(uniqueKeysWithValues: map{ (k, v) in try (transform(k), v) }) + } + func compactMapKeys(_ transform: (Key) throws -> T?) rethrows -> [T: Value] { + try .init(uniqueKeysWithValues: compactMap{ (k, v) in try transform(k).map{ t in (t, v) } }) + } +} + +extension NSMenuItem { + static private var assotiationKey = "callback" + + var callback: Callback? { + get { objc_getAssociatedObject(self, &Self.assotiationKey) as? Callback } + set { objc_setAssociatedObject(self, &Self.assotiationKey, newValue, .OBJC_ASSOCIATION_RETAIN) } + } + + convenience init(title: String, isChecked: Bool = false, callback: @escaping Callback) { + self.init(title: title, action: #selector(callback(sender:)), keyEquivalent: "") + self.callback = callback + self.target = self + self.state = isChecked ? .on : .off + } + + @objc private func callback(sender: Any) { + callback?() + } +} diff --git a/PinchBar/Repository.swift b/PinchBar/Repository.swift index 348f792..089e290 100644 --- a/PinchBar/Repository.swift +++ b/PinchBar/Repository.swift @@ -3,71 +3,66 @@ import Cocoa class Repository { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String - var openUpdateLink: (()->())? + func openGitHub() { + NSWorkspace.shared.open(URL(string: "https://github.com/pnoqable/PinchBar")!) + } func checkForUpdates(verbose: Bool) { let url = "https://api.github.com/repos/pnoqable/PinchBar/releases/latest" URLSession(configuration: .ephemeral).dataTask(with: URL(string: url)!) { data, _, error in - if let data = data, let json = try? JSONSerialization.jsonObject(with: data), - let dict = json as? NSDictionary, let newVersion = dict["tag_name"] as? String { - DispatchQueue.main.async() { - let updateAvailable = newVersion != self.version - let knownVersion = UserDefaults.standard.string(forKey: "knownVersion") - let knownUpdate = newVersion == knownVersion - if updateAvailable && (verbose || !knownUpdate) { - self.updateAvailable(newVersion: newVersion, known: knownUpdate) - } else if verbose { - self.upToDate() - } - } - } else if verbose { - DispatchQueue.main.async() { - let alert = NSAlert() - alert.icon.isTemplate = true - alert.alertStyle = .warning - alert.messageText = error?.localizedDescription ?? "Communication error." - alert.addButton(withTitle: "OK") - - NSApplication.shared.activate(ignoringOtherApps: true) - - alert.runModal() - } - } + self.checkUpdate(data: data, error: error, verbose: verbose) }.resume() } - func updateAvailable(newVersion: String, known: Bool) { - let alert = NSAlert() - alert.icon.isTemplate = true - alert.messageText = "PinchBar \(newVersion) is available!" - alert.alertStyle = .informational - alert.addButton(withTitle: "Update") - alert.addButton(withTitle: "OK") - alert.showsSuppressionButton = true - alert.suppressionButton?.state = known ? .on : .off - - NSApplication.shared.activate(ignoringOtherApps: true) + private func checkUpdate(data: Data?, error: Error?, verbose: Bool) { + guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String:Any], + let version = json["tag_name"] as? String, let urlString = json["html_url"] as? String, + let url = URL(string: urlString) else { + return verbose ? asyncAlert("Communication error", error?.localizedDescription) : () + } - if alert.runModal() == .alertFirstButtonReturn { - openUpdateLink?() + guard self.version.compare(version, options: .numeric) == .orderedAscending else { + return verbose ? asyncAlert("PinchBar is up-to-date!", "Current Version: \(version)") : () } - if alert.suppressionButton?.state == .on { - UserDefaults.standard.set(newVersion, forKey: "knownVersion") - } else { - UserDefaults.standard.removeObject(forKey: "knownVersion") + let updateKnown = version == UserDefaults.standard.string(forKey: "knownVersion") + + guard verbose || !updateKnown else { return } + + asyncAlert("PinchBar \(version) is now available!", "Current Version: \(version)") { alert in + alert.addButton(withTitle: "View on GitHub") + alert.addButton(withTitle: "Ignore for now") + alert.showsSuppressionButton = true + alert.suppressionButton?.state = updateKnown ? .on : .off + + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(url) + } + + if alert.suppressionButton?.state == .on { + UserDefaults.standard.set(version, forKey: "knownVersion") + } else { + UserDefaults.standard.removeObject(forKey: "knownVersion") + } } } - func upToDate() { - let alert = NSAlert() - alert.icon.isTemplate = true - alert.messageText = "PinchBar is up-to-date!" - alert.alertStyle = .informational + private static func addOkAndRun(alert: NSAlert) { alert.addButton(withTitle: "OK") - - NSApplication.shared.activate(ignoringOtherApps: true) - alert.runModal() } + + private func asyncAlert(_ messageText: String, _ informativeText: String?, + _ addButtonsAndRun: @escaping ((NSAlert)->()) = addOkAndRun) { + DispatchQueue.main.async { + NSApplication.shared.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.icon.isTemplate = true + alert.messageText = messageText + alert.informativeText = informativeText ?? "" + + addButtonsAndRun(alert) + } + } } diff --git a/PinchBar/Settings.swift b/PinchBar/Settings.swift new file mode 100644 index 0000000..842e328 --- /dev/null +++ b/PinchBar/Settings.swift @@ -0,0 +1,45 @@ +import Cocoa + +class Settings { + typealias Preset = [CGEventFlags: EventMapping] + typealias PList = [String: EventMapping] + + var appPresets: [String: String] = ["Cubase": "Cubase"] + var presets: [String: Preset] = ["Cubase": .cubase, "Font Size": .fontSize] + + var appNames: [String] { appPresets.keys.sorted() } + var presetNames: [String] { presets.keys.sorted() } + + func preset(named name: String?) -> Preset? { name.flatMap{ name in presets[name] } } + + private var plists: [String: PList] { + get { presets.mapValues{ preset in preset.mapKeys{ flags in "\(flags.rawValue)" } } } + set { presets = newValue.mapValues{ plist in plist.compactMapKeys(CGEventFlags.init) } } + } + + init() { + if let dict = UserDefaults.standard.dictionary(forKey: "presets"), + let json = try? JSONSerialization.data(withJSONObject: dict), + let plists = try? JSONDecoder().decode(type(of:plists), from: json), + let appPresets = UserDefaults.standard.dictionary(forKey: "appPresets") as? [String:String] { + self.plists = plists + self.appPresets = appPresets + } + } + + func save() { + if let json = try? JSONEncoder().encode(plists), + let dict = try? JSONSerialization.jsonObject(with: json) { + UserDefaults.standard.set(dict, forKey: "presets") + UserDefaults.standard.set(appPresets, forKey: "appPresets") + } + } +} + +extension Settings.Preset { + static let fontSize: Self = [.maskNoFlags: .pinchToKeys(flags: .maskCommand, codeA:44, codeB: 30), + .maskCommand: .pinchToPinch()] + static let cubase: Self = [.maskNoFlags: .pinchToWheel(), + .maskAlternate: .pinchToKeys(flags: .maskAlternate, codeA: 5, codeB: 4), + .maskCommand: .pinchToKeys(flags: .maskShift, codeA: 5, codeB: 4)] +} diff --git a/PinchBar/StatusMenu.swift b/PinchBar/StatusMenu.swift new file mode 100644 index 0000000..84c6e81 --- /dev/null +++ b/PinchBar/StatusMenu.swift @@ -0,0 +1,76 @@ +import Cocoa + +class StatusMenu { + var statusItem: NSStatusItem! + var menuItemPreferences: NSMenuItem! + var menuItemConfigure: NSMenuItem! + + weak var settings: Settings? + + var callWhenPresetSelected: ((String?) -> ())? + + func create(repository: Repository, settings: Settings) { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + statusItem.button?.image = NSImage(named: "StatusIcon") + statusItem.button?.toolTip = "PinchBar" + statusItem.behavior = .removalAllowed + statusItem.menu = NSMenu() + statusItem.menu?.autoenablesItems = false + + statusItem.menu?.addItem(NSMenuItem(title: "About PinchBar " + repository.version) { + [weak repository] in repository?.openGitHub() + }) + + statusItem.menu?.addItem(NSMenuItem(title: "Check for Updates...") { + [weak repository] in repository?.checkForUpdates(verbose: true) + }) + + statusItem.menu?.addItem(.separator()) + + menuItemPreferences = NSMenuItem(title: "Enable Pinchbar in Accessibility") { + let url = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + NSWorkspace.shared.open(URL(string: url)!) + } + statusItem.menu?.addItem(menuItemPreferences) + + menuItemConfigure = NSMenuItem() + menuItemConfigure.isEnabled = false + statusItem.menu?.addItem(menuItemConfigure) + + statusItem.menu?.addItem(.separator()) + + statusItem.menu?.addItem(NSMenuItem(title: "Quit") { + NSApplication.shared.stop(self) + }) + + self.settings = settings + } + + func enableSubmenu() { + menuItemPreferences.state = .on + menuItemPreferences.isEnabled = false + menuItemConfigure.isEnabled = true + } + + func updateSubmenu(activeApp: String, activePreset: String?) { + guard let settings else { fatalError("called before create") } + + let submenu = NSMenu() + + for preset in settings.presetNames { + submenu.addItem(NSMenuItem(title: preset, isChecked: activePreset == preset) { + [weak self] in self?.callWhenPresetSelected?(preset) + }) + } + + submenu.addItem(.separator()) + + submenu.addItem(NSMenuItem(title: "None", isChecked: activePreset == nil) { + [weak self] in self?.callWhenPresetSelected?(nil) + }) + + statusItem.button?.appearsDisabled = activePreset == nil + menuItemConfigure.title = "Change Preset for " + activeApp + menuItemConfigure.submenu = submenu + } +}