From 83bb184fe3758813cc149bbecec29c71cd38ee9f Mon Sep 17 00:00:00 2001 From: igor Date: Fri, 3 Jan 2025 16:05:03 -0800 Subject: [PATCH] Added settings window. --- src/AppDelegate.swift | 105 +++--- src/EditableNSTextField.swift | 38 +- src/EventMonitor.swift | 8 +- src/GlobalEventTap.swift | 12 +- src/Helpers.swift | 63 +++- src/HotKeyManager.swift | 86 +++++ src/KeyDetectorButton.swift | 42 +++ src/Makefile | 11 +- src/MyTableCellView.swift | 107 ++++++ src/PathManager.swift | 85 +++++ src/PopoverPanel.swift | 26 +- src/SearchViewController.swift | 136 ++++++-- src/SettingsViewController.swift | 572 +++++++++++++++++++++++++++++++ 13 files changed, 1162 insertions(+), 129 deletions(-) create mode 100644 src/HotKeyManager.swift create mode 100644 src/KeyDetectorButton.swift create mode 100644 src/MyTableCellView.swift create mode 100644 src/PathManager.swift create mode 100644 src/SettingsViewController.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index a406ab5..56be162 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -3,29 +3,12 @@ import Carbon import ServiceManagement import OSLog -struct Program { - let path: String - let name: String - let ext: String -} - -func appActivatedHandler(nextHandler: EventHandlerCallRef?, theEvent: EventRef?, userData: UnsafeMutableRawPointer?) -> OSStatus { - print("App was activated!") - return noErr -} - class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { fileprivate static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: AppDelegate.self) ) - var paths = ["/Applications", "/System/Applications", - "/System/Applications/Utilities", - "/Applications/Xcode.app/Contents/Applications", - "/System/Library/CoreServices/Applications"] - var programs: [Program] = [] - let fileManager = FileManager.default let window = PopoverPanel(viewController: SearchViewController()) @@ -33,45 +16,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_ notification: Notification) { Self.logger.debug("applicationDidFinishLaunching") - NSRunningApplication.current.hide() + PathManager.shared.rebuildIndex() window.delegate = self - //GlobalEventTap.shared.enable() - - for path in paths { - do { - let items = try fileManager.contentsOfDirectory(atPath: path) - for item in items { - let name = String(item.dropLast(4)) - if item.hasSuffix(".app") { - if !programs.contains(where: { name == $0.name }) { - programs.append(Program(path: path, name: name, ext: ".app")) - } - } - } - } catch { - print("Error reading directory: \(error.localizedDescription)") - } - } - window.makeKeyAndOrderFront(nil) - // TODO: Implement Unregister and Uninstall. - // TODO: A user should be able to enter hot keys to trigger. - // We either can use local event monitor or let user choose - // from list. - var hotKeyRef: EventHotKeyRef? - let hotKeyID: EventHotKeyID = EventHotKeyID(signature: OSType("grap".fourCharCodeValue), id: 1) - - // GetEventDispatcherTarget - var err = RegisterEventHotKey(UInt32(kVK_Space), UInt32(optionKey), hotKeyID, GetApplicationEventTarget(), UInt32(kEventHotKeyNoOptions), &hotKeyRef) - //let handler = NewEventHandlerUPP() - - // Handler get executed on main thread. - let handler: EventHandlerUPP = { (inHandlerCallRef, inEvent, inUserData) -> OSStatus in + HotKeyManager.shared.handler = + { (inHandlerCallRef, inEvent, inUserData) -> OSStatus in AppDelegate.logger.debug("Shortcut handler fired off.") - if let delegate = NSApplication.shared.delegate as? AppDelegate { + if let delegate = + NSApplication.shared.delegate as? AppDelegate + { let window = delegate.window if window.isKeyWindow { window.resignKey() @@ -79,24 +35,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { window.makeKeyAndOrderFront(nil) } } - return noErr } - var eventHandlerRef: EventHandlerRef? = nil - if err == noErr { - Self.logger.debug("Registered hot key.") - - var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) - err = InstallEventHandler(GetApplicationEventTarget(), handler, 1, &eventType, nil, &eventHandlerRef) - - if err == noErr { - Self.logger.debug("Event handler installed.") - } else { - Self.logger.debug("Failed to install event handler.") - } + HotKeyManager.shared.enable() + if let code = + UserDefaults.standard.object(forKey: "keyCode") as? Int, + let mods = + UserDefaults.standard.object(forKey: "keyModifiers") as? Int + { + HotKeyManager.shared.registerHotKey(key: code, + modifiers: mods) } else { - Self.logger.debug("Failed to register hot key.") + // NOTE: This is the default shortcut. If you want to change + // it, do not forget to change it in other files + // (SettingsViewController). + HotKeyManager.shared.registerHotKey(key: kVK_Space, + modifiers: optionKey) } } @@ -115,7 +70,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + func applicationShouldHandleReopen(_ sender: NSApplication, + hasVisibleWindows: Bool) -> Bool + { Self.logger.debug("Application reopened.") if !window.isKeyWindow { @@ -124,6 +81,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { return true } + + public func toggleLaunchAtLogin() { + let service = SMAppService.mainApp + if service.status == .enabled { + try? service.unregister() + } else { + try? service.register() + } + } + + public func willLaunchAtLogin() -> Bool { + let service = SMAppService.mainApp + if service.status == .enabled { + return true + } else { + return false + } + } } extension String { diff --git a/src/EditableNSTextField.swift b/src/EditableNSTextField.swift index deeef27..58d47c5 100644 --- a/src/EditableNSTextField.swift +++ b/src/EditableNSTextField.swift @@ -3,28 +3,48 @@ import os final class EditableNSTextField: NSTextField { private let commandKey = NSEvent.ModifierFlags.command.rawValue - private let commandShiftKey = NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue + private let commandShiftKey = NSEvent.ModifierFlags.command.rawValue | + NSEvent.ModifierFlags.shift.rawValue override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.type == NSEvent.EventType.keyDown { - if (event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) == commandKey { + if (event.modifierFlags.rawValue & + NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) + == commandKey + { switch event.charactersIgnoringModifiers! { case "x": - if NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: self) { return true } + if NSApp.sendAction(#selector(NSText.cut(_:)), + to: nil, from: self) + { return true } case "c": - if NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: self) { return true } + if NSApp.sendAction(#selector(NSText.copy(_:)), + to: nil, from: self) + { return true } case "v": - if NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: self) { return true } + if NSApp.sendAction(#selector(NSText.paste(_:)), + to: nil, from: self) + { return true } case "z": - if NSApp.sendAction(Selector(("undo:")), to: nil, from: self) { return true } + if NSApp.sendAction(Selector(("undo:")), + to: nil, from: self) + { return true } case "a": - if NSApp.sendAction(#selector(NSResponder.selectAll(_:)), to: nil, from: self) { return true } + if NSApp.sendAction( + #selector(NSResponder.selectAll(_:)), to: nil, + from: self) + { return true } default: break } - } else if (event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) == commandShiftKey { + } else if (event.modifierFlags.rawValue & + NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) + == commandShiftKey + { if event.charactersIgnoringModifiers == "Z" { - if NSApp.sendAction(Selector(("redo:")), to: nil, from: self) { return true } + if NSApp.sendAction(Selector(("redo:")), to: nil, + from: self) + { return true } } } } diff --git a/src/EventMonitor.swift b/src/EventMonitor.swift index 1efd0bc..99fb645 100644 --- a/src/EventMonitor.swift +++ b/src/EventMonitor.swift @@ -13,7 +13,7 @@ class EventMonitor { } func start() { - fatalError("start must be implemented by a subclass of EventMonitor") + fatalError("start must be implemented by a subclass") } func stop() { @@ -35,7 +35,8 @@ final class LocalEventMonitor: EventMonitor { } override func start() { - monitor = NSEvent.addLocalMonitorForEvents(matching: mask, handler: handler) + monitor = NSEvent.addLocalMonitorForEvents(matching: mask, + handler: handler) } } @@ -50,6 +51,7 @@ final class GlobalEventMonitor: EventMonitor { } override func start() { - monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) + monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, + handler: handler) } } diff --git a/src/GlobalEventTap.swift b/src/GlobalEventTap.swift index 636b566..cc68cf1 100644 --- a/src/GlobalEventTap.swift +++ b/src/GlobalEventTap.swift @@ -15,12 +15,14 @@ fileprivate func handleGlobalEvents(proxy: CGEventTapProxy, case .keyDown: //logger.debug(".keyDown") - if (event.flags.rawValue & CGEventFlags.maskAlternate.rawValue) == CGEventFlags.maskAlternate.rawValue && - (event.flags.rawValue & (CGEventFlags.maskShift.rawValue | CGEventFlags.maskControl.rawValue | CGEventFlags.maskCommand.rawValue)) == 0 { - logger.debug("maskAlternate") - } + let keyCode = "keyCode: \(event.getIntegerValueField(.keyboardEventKeycode))" + logger.debug("\(keyCode, privacy: .public)") - logger.debug("Option rawValue=\(CGEventFlags.maskAlternate.rawValue)") + //if (event.flags.rawValue & CGEventFlags.maskAlternate.rawValue) == CGEventFlags.maskAlternate.rawValue && + // (event.flags.rawValue & (CGEventFlags.maskShift.rawValue | CGEventFlags.maskControl.rawValue | CGEventFlags.maskCommand.rawValue)) == 0 { + // logger.debug("maskAlternate") + //} + //logger.debug("Option rawValue=\(CGEventFlags.maskAlternate.rawValue)") // var keyCode = event.getIntegerValueField(.keyboardEventKeycode) //if keyCode == 49 { diff --git a/src/Helpers.swift b/src/Helpers.swift index a3d5e93..e2bbdd9 100644 --- a/src/Helpers.swift +++ b/src/Helpers.swift @@ -1,6 +1,67 @@ import AppKit +import Carbon +import OSLog -func systemImage(_ name: String, _ size: NSFont.TextStyle, _ scale: NSImage.SymbolScale, _ configuration: NSImage.SymbolConfiguration) -> NSImage? { +fileprivate let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String("Helpers") +) + +struct Program { + let path: String + let name: String + let ext: String +} + +func keyName(virtualKeyCode: UInt16) -> String? { + let maxNameLength = 4 + var nameBuffer = [UniChar](repeating: 0, count : maxNameLength) + var nameLength = 0 + + let modifierKeys = UInt32(alphaLock >> 8) & 0xFF // Caps Lock + var deadKeys: UInt32 = 0 + let keyboardType = UInt32(LMGetKbdType()) + + //let source = + // TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue() + let source = TISCopyInputSourceForLanguage("en-US" as CFString) + .takeRetainedValue(); + guard let ptr = TISGetInputSourceProperty(source, + kTISPropertyUnicodeKeyLayoutData) + else { + logger.log("Could not get keyboard layout data") + return nil + } + let layoutData = Unmanaged.fromOpaque(ptr) + .takeUnretainedValue() as Data + let osStatus = layoutData.withUnsafeBytes { + UCKeyTranslate( + $0.bindMemory(to: UCKeyboardLayout.self).baseAddress, + virtualKeyCode, UInt16(kUCKeyActionDown), modifierKeys, + keyboardType, UInt32(kUCKeyTranslateNoDeadKeysMask), + &deadKeys, maxNameLength, &nameLength, &nameBuffer) + } + guard osStatus == noErr else { + logger.debug("Code: \(virtualKeyCode) Status: \(osStatus)") + return nil + } + + // NOTE: This is way too specific. This will need an additional func + // flag or be re-written to a more generic version if it's going + // to be used for something other than hot key representation. + var character = String(utf16CodeUnits: nameBuffer, count: nameLength) + if character == " " { + character = "␣" + } else { + character = character.uppercased() + } + return character +} + +func systemImage(_ name: String, _ size: NSFont.TextStyle, + _ scale: NSImage.SymbolScale, + _ configuration: NSImage.SymbolConfiguration) -> NSImage? +{ return NSImage(systemSymbolName: name, accessibilityDescription: nil)? .withSymbolConfiguration( NSImage.SymbolConfiguration(textStyle: size, scale: scale) diff --git a/src/HotKeyManager.swift b/src/HotKeyManager.swift new file mode 100644 index 0000000..8675692 --- /dev/null +++ b/src/HotKeyManager.swift @@ -0,0 +1,86 @@ +import Carbon +import OSLog + +final class HotKeyManager { + fileprivate static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + //category: String(describing: HotKeyManager.self) + category: String(describing: AppDelegate.self) + ) + + static let shared = HotKeyManager() + + private var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)) + private var eventHandlerRef: EventHandlerRef? + public var handler: EventHandlerUPP? + + private var hotKeyRef: EventHotKeyRef? + private let hotKeyID: EventHotKeyID = EventHotKeyID( + signature: OSType("grap".fourCharCodeValue), id: 1) + + private init() {} + + deinit {} + + // TODO: Handle errors. + public func enable() { + if eventHandlerRef != nil { + disable() + } + + let err = InstallEventHandler( + GetApplicationEventTarget(), handler, 1, &eventType, + nil, &eventHandlerRef) + if err == noErr { + Self.logger.debug("Installed event handler.") + } else { + Self.logger.error("Failed to install event handler.") + } + } + + public func disable() { + guard eventHandlerRef != nil else { return } + let err = RemoveEventHandler(eventHandlerRef) + if err == noErr { + eventHandlerRef = nil // WARNING: Does it remove no matter + // what on error? + Self.logger.debug("Removed event handler.") + } else { + Self.logger.error("Failed to remove event handler.") + } + } + + // TODO: Handle errors. + // NOTE: Multiple modifiers should be ORed. + public func registerHotKey(key: Int, modifiers: Int) { + // GetEventDispatcherTarget + if hotKeyRef != nil { + unregisterHotKey() + } + + let err = RegisterEventHotKey( + UInt32(key), UInt32(modifiers), hotKeyID, + GetApplicationEventTarget(), UInt32(kEventHotKeyNoOptions), + &hotKeyRef) + if err == noErr { + Self.logger.debug("Registered hot key.") + } else { + Self.logger.error("Failed to register hot key.") + } + } + + // TODO: Handle errors. + public func unregisterHotKey() { + guard hotKeyRef != nil else { return } + let err = UnregisterEventHotKey(hotKeyRef) + if err == noErr { + hotKeyRef = nil // WARNING: Does it unregister no matter + // what on error? + Self.logger.debug("Successfully unregestered hot key.") + } else { + Self.logger.error("Failed to unregester hot key.") + } + } +} diff --git a/src/KeyDetectorButton.swift b/src/KeyDetectorButton.swift new file mode 100644 index 0000000..e940900 --- /dev/null +++ b/src/KeyDetectorButton.swift @@ -0,0 +1,42 @@ +import AppKit +import Carbon +import OSLog + +protocol KeyDetectorButtonDelegate: AnyObject { + func keyWasSet(to keyCode: Int) +} + +final class KeyDetectorButton: NSButton { + var defaultKey: Int? + + weak var delegate: KeyDetectorButtonDelegate? + + override var acceptsFirstResponder: Bool { true } + + // This removes default bahavior from NSButton, thus allowing mouse up + // events. + override func mouseDown(with event: NSEvent) {} + + override func mouseUp(with event: NSEvent) { + self.window?.makeFirstResponder(self) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == kVK_Escape || event.keyCode == kVK_Return { + } else if event.keyCode == kVK_Delete { + if let key = defaultKey, + let character = keyName(virtualKeyCode: UInt16(key)) + { + title = character + } + } else { + if let character = + keyName(virtualKeyCode: UInt16(event.keyCode)) + { + title = character + } + delegate?.keyWasSet(to: Int(event.keyCode)) + } + self.window?.makeFirstResponder(nil) + } +} diff --git a/src/Makefile b/src/Makefile index b3a9859..4040591 100644 --- a/src/Makefile +++ b/src/Makefile @@ -9,6 +9,8 @@ EXEC = Grapp SRCMODULES = Helpers.swift EditableNSTextField.swift EventMonitor.swift \ GlobalEventTap.swift PopoverPanel.swift SearchViewController.swift \ + SettingsViewController.swift HotKeyManager.swift \ + KeyDetectorButton.swift PathManager.swift MyTableCellView.swift \ AppDelegate.swift main.swift ARMOBJMODULES = $(addprefix ./arm64/,$(SRCMODULES:.swift=.o)) X86OBJMODULES = $(addprefix ./x86_64/,$(SRCMODULES:.swift=.o)) @@ -17,17 +19,24 @@ LIBS = FRAMEWORKS = -framework AppKit -framework ServiceManagement +# HACK: Target is getting touched because timestamps of the generated +# object file don't change unless there's an actual change in the +# outputted object code. This results in this target running every +# single time. I'm not sure whether that's the exact reason, but +# I can't imagine why timestamps wouldn't change. When clang +# generates same exact executable, timestamps do change. ./arm64/%.o: %.swift swift -frontend -c -target arm64-apple-macos$(MACOS_VERSION) $(FLAGS) \ -primary-file $< $(filter-out $<, $(SRCMODULES)) $(LIBS) $(FRAMEWORKS) -sdk $(SDK) \ -module-name $(EXEC) -o $@ -emit-module - + @touch $@ ifdef UNIVERSAL ./x86_64/%.o: %.swift @swift -frontend -c -target x86_64-apple-macos$(MACOS_VERSION) \ $(FLAGS) -primary-file $< $(filter-out $<, $(SRCMODULES)) $(LIBS) $(FRAMEWORKS) \ -sdk $(SDK) -module-name $(EXEC) -o $@ -emit-module + @touch $@ endif ./arm64/$(EXEC): $(ARMOBJMODULES) diff --git a/src/MyTableCellView.swift b/src/MyTableCellView.swift new file mode 100644 index 0000000..22010ba --- /dev/null +++ b/src/MyTableCellView.swift @@ -0,0 +1,107 @@ +import AppKit + +protocol MyTableCellViewDelegate: AnyObject { + func selectionButtonClicked(tag: Int) + func titleFieldTextChanged(tag: Int, text: String) + func titleFieldFinishedEditing(tag: Int, text: String) +} + +class MyTableCellView: NSTableCellView, NSTextFieldDelegate { + var id: Int = -1 + weak var delegate: MyTableCellViewDelegate? + + private(set) var isEditing = false + + public var titleField: NSTextField = { + let field = NSTextField() + field.isEditable = false + field.maximumNumberOfLines = 1 + field.lineBreakMode = .byTruncatingTail + field.isBezeled = false + field.drawsBackground = false + field.translatesAutoresizingMaskIntoConstraints = false + return field + }() + + var selectionButton: NSButton = { + let button = NSButton() + button.image = systemImage("hand.point.up.fill", .headline, .large, + .init(paletteColors: [.white, .systemRed])) + button.isBordered = false + button.sizeToFit() + button.toolTip = "Select Path" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + titleField.delegate = self + + selectionButton.target = self + selectionButton.action = #selector(makeSelection) + + addSubview(titleField) + addSubview(selectionButton) + + titleField.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleField.setContentCompressionResistancePriority(.defaultLow, + for: .horizontal) + + NSLayoutConstraint.activate([ + //titleField.topAnchor.constraint(equalTo: topAnchor), + //titleField.bottomAnchor.constraint(equalTo: bottomAnchor), + titleField.centerYAnchor.constraint(equalTo: centerYAnchor), + titleField.leadingAnchor.constraint(equalTo: leadingAnchor), + titleField.trailingAnchor.constraint( + equalTo: selectionButton.leadingAnchor), + + selectionButton.centerYAnchor.constraint( + equalTo: centerYAnchor), + selectionButton.trailingAnchor.constraint( + equalTo: trailingAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc + private func makeSelection() { + delegate?.selectionButtonClicked(tag: id) + } + + public func startEditing() { + isEditing = true + titleField.isEditable = true + window?.makeFirstResponder(titleField) + } + + public func stopEditing() { + isEditing = false + titleField.isEditable = false + window?.makeFirstResponder(nil) + } + + func controlTextDidChange(_ obj: Notification) { + delegate?.titleFieldTextChanged(tag: id, + text: titleField.stringValue) + } + + func control(_ control: NSControl, textView: NSTextView, + doCommandBy commandSelector: Selector) -> Bool + { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + stopEditing() + delegate?.titleFieldFinishedEditing(tag: id, + text: titleField.stringValue) + return true + } else if commandSelector == #selector(NSResponder.insertTab(_:)) { + return true + } + + return false + } +} diff --git a/src/PathManager.swift b/src/PathManager.swift new file mode 100644 index 0000000..70eacb3 --- /dev/null +++ b/src/PathManager.swift @@ -0,0 +1,85 @@ +import AppKit +import OSLog + +final class PathManager { + fileprivate static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: PathManager.self) + ) + + static let shared = PathManager() + + // TODO: Filesystem events to watch changes on these directories and + // rebuild index when needed. + // NOTE: These are default paths where MacOS's default programs are + // stored. This list should be updated if something changes in + // newer MacOS version. + static let defaultPaths = ["/Applications", "/System/Applications", + "/System/Applications/Utilities", "/System/Library/CoreServices", + "/Applications/Xcode.app/Contents/Applications", + "/System/Library/CoreServices/Applications"] + var userPaths: [String] = [] + private(set) var programs: [Program] = [] + + private let fileManager = FileManager.default + + private init() { + if let paths = + UserDefaults.standard.stringArray(forKey: "programPaths") + { + for path in paths { + addPath(path) + } + } else { + userPaths += Self.defaultPaths + } + } + + deinit {} + + public func addPath(_ path: String) { + if !userPaths.contains(path) { + userPaths.append(path) + } + } + + public func removePath(_ path: String) { + userPaths.removeAll { $0 == path } + } + + public func removeEmpty() { + userPaths.removeAll { $0.isEmpty } + } + + public func savePaths() { + UserDefaults.standard.set(userPaths, forKey: "programPaths") + } + + public func reset() { + userPaths = [] + userPaths += Self.defaultPaths + savePaths() + } + + public func rebuildIndex() { + programs.removeAll(keepingCapacity: true) + for path in userPaths { + do { + let items = try fileManager.contentsOfDirectory( + atPath: path) + for item in items { + let name = String(item.dropLast(4)) + if item.hasSuffix(".app") { + if !programs.contains(where: { name == $0.name }) { + programs.append( + Program( + path: path, name: name, ext: ".app")) + } + } + } + } catch { + Self.logger.error("Error reading directory: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/src/PopoverPanel.swift b/src/PopoverPanel.swift index dfb66fd..204200c 100644 --- a/src/PopoverPanel.swift +++ b/src/PopoverPanel.swift @@ -12,15 +12,16 @@ class PopoverPanel: NSPanel { init(viewController: NSViewController) { super.init( contentRect: CGRect(x: 0, y: 0, width: 100, height: 100), - styleMask: [.titled, .nonactivatingPanel, .utilityWindow, .fullSizeContentView], + styleMask: [.titled, .nonactivatingPanel, .utilityWindow, + .fullSizeContentView], backing: .buffered, defer: false ) super.contentViewController = viewController title = "" - //isMovable = false - isMovableByWindowBackground = false + isMovable = true + isMovableByWindowBackground = true isFloatingPanel = true isOpaque = false level = .statusBar @@ -28,7 +29,8 @@ class PopoverPanel: NSPanel { titlebarAppearsTransparent = true animationBehavior = .none - collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary, .transient] + collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary, + .transient] isReleasedWhenClosed = false hidesOnDeactivate = false @@ -41,13 +43,21 @@ class PopoverPanel: NSPanel { Self.logger.debug("performKeyEquivalent keyCode=\(event.keyCode)") let commandKey = NSEvent.ModifierFlags.command.rawValue + // TODO: Make these depend on virtual keycodes, instead of + // characters. if event.type == NSEvent.EventType.keyDown { - if (event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) == commandKey, - event.charactersIgnoringModifiers! == "w" { + if (event.modifierFlags.rawValue & + NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) + == commandKey, + event.charactersIgnoringModifiers! == "w" + { resignKey() return true - } else if (event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) == commandKey, - event.charactersIgnoringModifiers! == "q" { + } else if (event.modifierFlags.rawValue & + NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) + == commandKey, + event.charactersIgnoringModifiers! == "q" + { NSApplication.shared.terminate(self) return true } else if event.keyCode == 53 { diff --git a/src/SearchViewController.swift b/src/SearchViewController.swift index 498a96c..3e41594 100644 --- a/src/SearchViewController.swift +++ b/src/SearchViewController.swift @@ -8,7 +8,9 @@ fileprivate enum ViewConstants { static let spacing40: CGFloat = 40 } -class SearchViewController: NSViewController, NSTextFieldDelegate { +class SearchViewController: NSViewController, NSTextFieldDelegate, + NSPopoverDelegate +{ fileprivate static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: SearchViewController.self) @@ -16,10 +18,17 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { var foundProgram: Program? = nil + private var settingsPopover: NSPopover = { + let popover = NSPopover() + popover.contentViewController = SettingsViewController() + popover.behavior = .transient + return popover + }() + private var appIconImage: NSImageView = { - //let image = NSImageView(image: NSApp.applicationIconImage) let image = NSImageView() - image.image = NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) + image.image = + NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) image.imageScaling = .scaleAxesIndependently image.translatesAutoresizingMaskIntoConstraints = false return image @@ -40,14 +49,17 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { textField.isBezeled = false textField.drawsBackground = false textField.alignment = .left - textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .body).pointSize, weight: .bold) + textField.font = NSFont.systemFont( + ofSize: NSFontDescriptor.preferredFontDescriptor( + forTextStyle: .body).pointSize, weight: .bold) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() private var settingsButton: NSButton = { let button = NSButton() - button.image = systemImage("gearshape.fill", .title2, .large, .init(paletteColors: [.white, .systemRed])) + button.image = systemImage("gearshape.fill", .title2, .large, + .init(paletteColors: [.white, .systemRed])) button.isBordered = false button.action = #selector(openSettings) button.sizeToFit() @@ -56,7 +68,6 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { return button }() - private func addSubviews() { view.addSubview(appIconImage) view.addSubview(searchInput) @@ -67,23 +78,42 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { private func setConstraints() { NSLayoutConstraint.activate([ appIconImage.widthAnchor.constraint(equalToConstant: 70), - appIconImage.heightAnchor.constraint(equalTo: appIconImage.widthAnchor, multiplier: 1), + appIconImage.heightAnchor.constraint( + equalTo: appIconImage.widthAnchor, multiplier: 1), - appIconImage.topAnchor.constraint(equalTo: view.topAnchor, constant: ViewConstants.spacing20), - appIconImage.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -ViewConstants.spacing10), - appIconImage.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: ViewConstants.spacing10), + appIconImage.topAnchor.constraint(equalTo: view.topAnchor, + constant: ViewConstants.spacing20), + appIconImage.bottomAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -ViewConstants.spacing10), + appIconImage.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: ViewConstants.spacing10), searchInput.widthAnchor.constraint(equalToConstant: 300), - searchInput.topAnchor.constraint(equalTo: appIconImage.topAnchor), - searchInput.leadingAnchor.constraint(equalTo: appIconImage.trailingAnchor, constant: ViewConstants.spacing10), + searchInput.topAnchor.constraint( + equalTo: appIconImage.topAnchor), + searchInput.leadingAnchor.constraint( + equalTo: appIconImage.trailingAnchor, + constant: ViewConstants.spacing10), - settingsButton.firstBaselineAnchor.constraint(equalTo: searchInput.firstBaselineAnchor), - settingsButton.leadingAnchor.constraint(equalTo: searchInput.trailingAnchor, constant: ViewConstants.spacing10), - settingsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -ViewConstants.spacing10), + settingsButton.firstBaselineAnchor.constraint( + equalTo: searchInput.firstBaselineAnchor), + settingsButton.leadingAnchor.constraint( + equalTo: searchInput.trailingAnchor, + constant: ViewConstants.spacing10), + settingsButton.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -ViewConstants.spacing10), - programsLabel.topAnchor.constraint(equalTo: searchInput.bottomAnchor, constant: ViewConstants.spacing10), - programsLabel.leadingAnchor.constraint(equalTo: appIconImage.trailingAnchor, constant: ViewConstants.spacing10), - programsLabel.trailingAnchor.constraint(equalTo: searchInput.trailingAnchor), + programsLabel.topAnchor.constraint( + equalTo: searchInput.bottomAnchor, + constant: ViewConstants.spacing10), + programsLabel.leadingAnchor.constraint( + equalTo: appIconImage.trailingAnchor, + constant: ViewConstants.spacing10), + programsLabel.trailingAnchor.constraint( + equalTo: searchInput.trailingAnchor), ]) } @@ -91,6 +121,8 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { override func viewDidLoad() { super.viewDidLoad() + settingsPopover.delegate = self + searchInput.delegate = self addSubviews() @@ -101,27 +133,39 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { super.viewDidAppear() self.view.window?.center() + + // searchInput should select all text whenever window appears. + NSApp.sendAction(#selector(NSResponder.selectAll(_:)), + to: nil, from: self) } override func loadView() { self.view = NSView() } - //private func fetchIcon() { - // for key in resultPaths.keys { - // } - //} - @objc - private func openSettings() { + func openSettings() { + // HACK: This is an interseting behavior. When NSPopover appears + // the first time, it always displays in the wrong location; + // however, showing it twice does result in the right + // location. + settingsPopover.show(relativeTo: settingsButton.bounds, + of: settingsButton, preferredEdge: .maxY) + settingsPopover.show(relativeTo: settingsButton.bounds, + of: settingsButton, preferredEdge: .maxY) } func controlTextDidChange(_ obj: Notification) { + guard let searchInput = obj.object as? EditableNSTextField + else { return } + var list = "" - let programs = delegate.programs + let programs = PathManager.shared.programs for program in programs { - if program.name.lowercased().contains(searchInput.stringValue.lowercased()) { + if program.name.lowercased().contains( + searchInput.stringValue.lowercased()) + { if !list.isEmpty { list += ", " } @@ -134,37 +178,47 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { } if let program = foundProgram { - programsLabel.stringValue = program.name + program.ext + programsLabel.stringValue = + program.name + program.ext + " (\(program.path))" - let url = URL(fileURLWithPath: program.path).appendingPathComponent(program.name+program.ext) + let url = URL(fileURLWithPath: program.path) + .appendingPathComponent(program.name+program.ext) appIconImage.image = NSWorkspace.shared.icon(forFile: url.path) } else { programsLabel.stringValue = "" - appIconImage.image = NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) + appIconImage.image = + NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) } } - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + func control(_ control: NSControl, textView: NSTextView, + doCommandBy commandSelector: Selector) -> Bool + { if commandSelector == #selector(NSResponder.insertNewline(_:)) { if let program = foundProgram { - let url = URL(fileURLWithPath: program.path).appendingPathComponent(program.name+program.ext) + let url = URL(fileURLWithPath: program.path) + .appendingPathComponent(program.name+program.ext) let config = NSWorkspace.OpenConfiguration() - NSWorkspace.shared.openApplication(at: url, configuration: config, completionHandler: { [weak self] application, error in + + NSWorkspace.shared.openApplication(at: url, + configuration: config) + { [weak self] application, error in if let error = error { - Self.logger.debug("Failed to open application: \(error.localizedDescription)") + Self.logger.debug("\(error.localizedDescription)") } else { - Self.logger.debug("Application opened successfully") + Self.logger.debug("Program opened successfully") DispatchQueue.main.async { if let window = self?.view.window { window.resignKey() } } } - }) + } } - // TODO: Send this whenever SearchViewController becomes visible. - NSApp.sendAction(#selector(NSResponder.selectAll(_:)), to: nil, from: self) + NSApp.sendAction(#selector(NSResponder.selectAll(_:)), + to: nil, from: self) + return true } else if commandSelector == #selector(NSResponder.insertTab(_:)) { return true @@ -172,4 +226,12 @@ class SearchViewController: NSViewController, NSTextFieldDelegate { return false } + + func popoverWillShow(_ notification: Notification) { + searchInput.abortEditing() + } + + func popoverWillClose(_ notification: Notification) { + searchInput.becomeFirstResponder() + } } diff --git a/src/SettingsViewController.swift b/src/SettingsViewController.swift new file mode 100644 index 0000000..4ba4a2b --- /dev/null +++ b/src/SettingsViewController.swift @@ -0,0 +1,572 @@ +import AppKit +import Carbon +import ServiceManagement +import OSLog + +fileprivate enum ViewConstants { + static let spacing2: CGFloat = 2 + static let spacing5: CGFloat = 2 + static let spacing10: CGFloat = 10 + static let spacing20: CGFloat = 20 + static let spacing40: CGFloat = 40 +} + +class SettingsViewController: NSViewController, NSTextFieldDelegate, + KeyDetectorButtonDelegate, NSTableViewDataSource, NSTableViewDelegate, + MyTableCellViewDelegate +{ + fileprivate static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: SettingsViewController.self) + ) + + private var recording = false + + // NOTE: This is the default shortcut. If you were to change it, don't + // forget to change other places in this file and delegate, too. + private var keyCode = Int(kVK_Space) + private var modifiers = Int(optionKey) + + // NOTE: PERF: This is very slow to initialize because it creates a + // a new process. This also cannot be done on a separate + // thread. This sucks because the program now takes + // considerably longer to launch. + private let dirPicker: NSOpenPanel = { + let panel = NSOpenPanel() + panel.message = "Select a directory to search applications in . . ." + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + return panel + }() + + private var shortcutsLabel: NSTextField = { + let textField = NSTextField(labelWithString: "Shortcut") + textField.font = NSFont.systemFont( + ofSize: NSFontDescriptor.preferredFontDescriptor( + forTextStyle: .title2).pointSize, weight: .bold) + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private var ctrlButton: NSButton = { + let button = NSButton() + button.title = "⌃" + button.action = #selector(handleModifiers) + button.setButtonType(.pushOnPushOff) + button.sizeToFit() + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var cmdButton: NSButton = { + let button = NSButton() + button.title = "⌘" + button.action = #selector(handleModifiers) + button.setButtonType(.pushOnPushOff) + button.sizeToFit() + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var optButton: NSButton = { + let button = NSButton() + button.title = "⌥" + button.action = #selector(handleModifiers) + button.setButtonType(.pushOnPushOff) + button.sizeToFit() + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var shiftButton: NSButton = { + let button = NSButton() + button.title = "⇧" + button.action = #selector(handleModifiers) + button.setButtonType(.pushOnPushOff) + button.sizeToFit() + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var plusLabel: NSTextField = { + let textField = NSTextField() + textField.stringValue = "+" + textField.isEditable = false + textField.isBezeled = false + textField.drawsBackground = false + textField.alignment = .center + textField.font = NSFont.systemFont( + ofSize: NSFontDescriptor.preferredFontDescriptor( + forTextStyle: .body).pointSize, weight: .bold) + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private var recordButton: KeyDetectorButton = { + let button = KeyDetectorButton() + button.title = "Record" + button.sizeToFit() + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var pathsLabel: NSTextField = { + let textField = + NSTextField(labelWithString: "Application Directories") + textField.font = NSFont.systemFont( + ofSize: NSFontDescriptor.preferredFontDescriptor( + forTextStyle: .title2).pointSize, weight: .bold) + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private var tableScrollView: NSScrollView = { + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.translatesAutoresizingMaskIntoConstraints = false + return scroll + }() + + private var pathsTableView: NSTableView = { + let table = NSTableView() + + table.backgroundColor = .clear + + table.doubleAction = #selector(editItem) + + table.headerView = nil + table.allowsMultipleSelection = true + table.allowsColumnReordering = false + table.allowsColumnResizing = false + table.allowsColumnSelection = false + table.addTableColumn(NSTableColumn( + identifier: NSUserInterfaceItemIdentifier("Paths"))) + + //rowHeight cgfloat must see doc + + table.translatesAutoresizingMaskIntoConstraints = false + return table + }() + + private var pathsControl: NSSegmentedControl = { + let control = NSSegmentedControl() + control.segmentCount = 2 + control.segmentStyle = .roundRect + + control.setImage( + NSImage(systemSymbolName: "plus", + accessibilityDescription: nil), forSegment: 0) + control.setImage( + NSImage(systemSymbolName: "minus", + accessibilityDescription: nil), forSegment: 1) + + control.setToolTip("Add Path", forSegment: 0) + control.setToolTip("Remove Path", forSegment: 1) + control.trackingMode = .momentary + + control.translatesAutoresizingMaskIntoConstraints = false + return control + }() + + private var launchAtLoginButton: NSButton = { + let button = NSButton() + button.title = "Launch at login - OFF" + button.action = #selector(launchAtLogin) + button.setButtonType(.toggle) + button.sizeToFit() + button.bezelStyle = .rounded + button.isBordered = false + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var resetAllButton: NSButton = { + let button = NSButton() + button.title = "Reset" + button.action = #selector(reset) + button.setButtonType(.momentaryLight) + button.sizeToFit() + button.bezelStyle = .rounded + button.isBordered = false + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private func addSubviews() { + view.addSubview(shortcutsLabel) + view.addSubview(ctrlButton) + view.addSubview(cmdButton) + view.addSubview(optButton) + view.addSubview(shiftButton) + view.addSubview(plusLabel) + view.addSubview(recordButton) + + view.addSubview(pathsLabel) + view.addSubview(tableScrollView) + view.addSubview(pathsControl) + + view.addSubview(launchAtLoginButton) + view.addSubview(resetAllButton) + } + + private func setConstraints() { + NSLayoutConstraint.activate([ + shortcutsLabel.topAnchor.constraint( + equalTo: view.topAnchor, + constant: ViewConstants.spacing10), + shortcutsLabel.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: ViewConstants.spacing10), + + ctrlButton.topAnchor.constraint( + equalTo: shortcutsLabel.bottomAnchor, + constant: ViewConstants.spacing10), + ctrlButton.leadingAnchor.constraint( + equalTo: shortcutsLabel.leadingAnchor), + + cmdButton.centerYAnchor.constraint( + equalTo: ctrlButton.centerYAnchor), + cmdButton.leadingAnchor.constraint( + equalTo: ctrlButton.trailingAnchor, + constant: ViewConstants.spacing5), + + optButton.centerYAnchor.constraint( + equalTo: ctrlButton.centerYAnchor), + optButton.leadingAnchor.constraint( + equalTo: cmdButton.trailingAnchor, + constant: ViewConstants.spacing5), + + shiftButton.centerYAnchor.constraint( + equalTo: ctrlButton.centerYAnchor), + shiftButton.leadingAnchor.constraint( + equalTo: optButton.trailingAnchor, + constant: ViewConstants.spacing5), + + plusLabel.centerYAnchor.constraint( + equalTo: ctrlButton.centerYAnchor), + plusLabel.leadingAnchor.constraint( + equalTo: shiftButton.trailingAnchor, + constant: ViewConstants.spacing5), + + recordButton.widthAnchor.constraint(equalToConstant: 40), + recordButton.centerYAnchor.constraint( + equalTo: ctrlButton.centerYAnchor), + recordButton.leadingAnchor.constraint( + equalTo: plusLabel.trailingAnchor, + constant: ViewConstants.spacing5), + + pathsLabel.topAnchor.constraint( + equalTo: ctrlButton.bottomAnchor, + constant: ViewConstants.spacing20), + pathsLabel.leadingAnchor.constraint( + equalTo: shortcutsLabel.leadingAnchor), + + tableScrollView.widthAnchor.constraint(equalToConstant: 350), + tableScrollView.heightAnchor.constraint(equalToConstant: 100), + tableScrollView.topAnchor.constraint( + equalTo: pathsLabel.bottomAnchor), + tableScrollView.leadingAnchor.constraint( + equalTo: view.leadingAnchor), + tableScrollView.trailingAnchor.constraint( + equalTo: view.trailingAnchor), + + pathsControl.topAnchor.constraint( + equalTo: tableScrollView.bottomAnchor, + constant: ViewConstants.spacing10), + pathsControl.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: ViewConstants.spacing10), + + launchAtLoginButton.topAnchor.constraint( + equalTo: pathsControl.bottomAnchor, + constant: ViewConstants.spacing10), + launchAtLoginButton.trailingAnchor.constraint( + equalTo: resetAllButton.leadingAnchor, + constant: -ViewConstants.spacing10), + + resetAllButton.centerYAnchor.constraint( + equalTo: launchAtLoginButton.centerYAnchor), + resetAllButton.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -ViewConstants.spacing10), + resetAllButton.bottomAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -ViewConstants.spacing10), + ]) + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableScrollView.documentView = pathsTableView + + cmdButton.target = self + optButton.target = self + ctrlButton.target = self + shiftButton.target = self + recordButton.delegate = self + launchAtLoginButton.target = self + resetAllButton.target = self + + recordButton.defaultKey = kVK_Space + + recordButton.target = self + + pathsTableView.dataSource = self + pathsTableView.delegate = self + + pathsTableView.delegate = self + + pathsControl.target = self + pathsControl.action = #selector(affectPaths(_:)) + + addSubviews() + setConstraints() + } + + override func viewWillAppear() { + super.viewWillAppear() + + // PERF: Maybe we shouldn't fetch it on every appearance? + // Only do it in AppDelegate? + if let code = + UserDefaults.standard.object(forKey: "keyCode") as? Int + { + keyCode = code + } + if let mods = + UserDefaults.standard.object(forKey: "keyModifiers") as? Int + { + modifiers = mods + } + + syncModifierButtons() + launchAtLoginStatus() + } + + override func viewDidAppear() { + super.viewDidAppear() + + self.view.window?.center() + } + + override func viewWillDisappear() { + super.viewWillDisappear() + + HotKeyManager.shared.registerHotKey(key: keyCode, + modifiers: modifiers) + + UserDefaults.standard.set(keyCode, forKey: "keyCode") + UserDefaults.standard.set(modifiers, forKey: "keyModifiers") + + PathManager.shared.savePaths() + PathManager.shared.rebuildIndex() + } + + override func loadView() { + self.view = NSView() + } + + @objc + private func handleModifiers() { + // NOTE: Revert to default modifier if none of the modifier + // buttons are on. + if cmdButton.state != .on, optButton.state != .on, + ctrlButton.state != .on, shiftButton.state != .on + { + optButton.state = .on + } + + detectModifers() + } + + @objc + private func launchAtLogin() { + delegate.toggleLaunchAtLogin() + launchAtLoginStatus() + } + + private func launchAtLoginStatus() { + if delegate.willLaunchAtLogin() { + launchAtLoginButton.title = "Launch at login - ON" + launchAtLoginButton.state = .on + } else { + launchAtLoginButton.title = "Launch at login - OFF" + launchAtLoginButton.state = .off + } + } + + @objc + private func reset() { + keyCode = Int(kVK_Space) + modifiers = Int(optionKey) + HotKeyManager.shared.registerHotKey(key: keyCode, modifiers: modifiers) + UserDefaults.standard.set(keyCode, forKey: "keyCode") + UserDefaults.standard.set(modifiers, forKey: "keyModifiers") + syncModifierButtons() + + PathManager.shared.reset() + pathsTableView.reloadData() + } + + private func detectModifers() { + var mods = 0 + + if cmdButton.state == .on { + mods |= cmdKey + } + if optButton.state == .on { + mods |= optionKey + } + if ctrlButton.state == .on { + mods |= controlKey + } + if shiftButton.state == .on { + mods |= shiftKey + } + + if mods == 0 { + mods |= optionKey + } else { + modifiers = mods + } + } + + private func syncModifierButtons() { + ctrlButton.state = .off + cmdButton.state = .off + optButton.state = .off + shiftButton.state = .off + + if modifiers & controlKey != 0 { + ctrlButton.state = .on + } + if modifiers & cmdKey != 0 { + cmdButton.state = .on + } + if modifiers & optionKey != 0 { + optButton.state = .on + } + if modifiers & shiftKey != 0 { + shiftButton.state = .on + } + + if let character = keyName(virtualKeyCode: UInt16(keyCode)) { + recordButton.title = character + } + } + + @objc + private func affectPaths(_ sender: NSSegmentedControl) { + // PERF: All of this could be written better. + let selectedSegment = sender.selectedSegment + switch selectedSegment { + case 0: + PathManager.shared.addPath("") + pathsTableView.reloadData() + + let row = PathManager.shared.userPaths.count-1 + pathsTableView.selectRowIndexes(IndexSet(integer: row), + byExtendingSelection: false) + pathsTableView.scrollRowToVisible(row) + (pathsTableView.view(atColumn: 0, row: row, + makeIfNecessary: false) as? MyTableCellView)? + .startEditing() + break + case 1: + var toRemove: [String] = [] + for row in pathsTableView.selectedRowIndexes { + toRemove.append(PathManager.shared.userPaths[row]) + } + PathManager.shared.userPaths.removeAll( + where: { toRemove.contains($0) }) + pathsTableView.reloadData() + break + default: + break + } + } + + @objc + private func editItem(_ sender: NSTableView) { + pathsTableView.deselectAll(nil) + pathsTableView.selectRowIndexes( + IndexSet(integer: pathsTableView.clickedRow), + byExtendingSelection: false) + + if let cell = pathsTableView.view(atColumn: 0, + row: pathsTableView.clickedRow, + makeIfNecessary: false) as? MyTableCellView + { + cell.startEditing() + } + } + + func titleFieldFinishedEditing(tag: Int, text: String) { + PathManager.shared.userPaths[tag] = text + if PathManager.shared.userPaths[tag].isEmpty { + PathManager.shared.userPaths.remove(at: tag) + pathsTableView.reloadData() + } + pathsTableView.deselectAll(nil) + } + + func titleFieldTextChanged(tag: Int, text: String) { + } + + func keyWasSet(to keyCode: Int) { + self.keyCode = Int(keyCode) + } + + func selectionButtonClicked(tag: Int) { + NSRunningApplication.current.activate(options: .activateAllWindows) + delegate.window.level = .normal + + if dirPicker.runModal() == .OK { + if let url = dirPicker.url { + PathManager.shared.userPaths[tag] = url.path + pathsTableView.reloadData() + } + } + + delegate.window.level = .statusBar + delegate.window.makeKeyAndOrderFront(nil) + if let controller = + delegate.window.contentViewController as? SearchViewController + { + controller.openSettings() + } + } + + func numberOfRows(in tableView: NSTableView) -> Int { + return PathManager.shared.userPaths.count + } + + func tableView(_ tableView: NSTableView, + viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + let rect = NSRect(x: 0, y: 0, + width: tableColumn!.width, height: 20) + let cell = MyTableCellView(frame: rect) + cell.titleField.stringValue = PathManager.shared.userPaths[row] + cell.delegate = self + cell.id = row + + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { +/* + let selectedRow = tableView.selectedRow + if selectedRow >= 0 { + print("Selected: \(items[selectedRow])") + } +*/ + } +}