From 5ed67e0cefded7e50037f8bd511ad1961bf50b6d Mon Sep 17 00:00:00 2001 From: igor Date: Thu, 6 Feb 2025 17:03:16 -0800 Subject: [PATCH] Filesystem monitor and refactor. --- src/AboutViewController.swift | 211 +++++++++++++++++++++++++++ src/AppDelegate.swift | 66 +++++++-- src/DirMonitor.swift | 94 ++++++++++++ src/EditableNSTextField.swift | 37 +++-- src/EventMonitor.swift | 26 ++-- src/Helpers.swift | 47 +++--- src/HotKeyManager.swift | 46 ++---- src/KeyDetectorButton.swift | 12 +- src/Makefile | 80 +++++------ src/MenulessWindow.swift | 33 +++++ src/PathManager.swift | 147 ++++++++++++------- src/PopoverPanel.swift | 4 +- src/ProgramsTable.swift | 30 ++-- src/SearchViewController.swift | 185 +++++++++--------------- src/SettingsViewController.swift | 240 +++++++++++++------------------ 15 files changed, 777 insertions(+), 481 deletions(-) create mode 100644 src/AboutViewController.swift create mode 100644 src/DirMonitor.swift create mode 100644 src/MenulessWindow.swift diff --git a/src/AboutViewController.swift b/src/AboutViewController.swift new file mode 100644 index 0000000..b1b5e05 --- /dev/null +++ b/src/AboutViewController.swift @@ -0,0 +1,211 @@ +import AppKit + +// TODO: Change to appropriate links. +fileprivate enum AboutLinks { + // static let website = "https://cmdbar.app" + // static let documentation = "https://cmdbar.app/documentation" + // static let privacy = "https://cmdbar.app/#privacy-policy" + static let author = "https://kolokolnikov.org" +} + +enum Strings { + static let copyright = "Copyright © 2024\nGarikMI. All rights reserved." + static let evaluationTitle = "License - Evaluation" + static let evaluationMessage = "You are currently using evaluation license. CmdBar will quit after 20 minutes. If you already own a license, enter it below or purchase a license." + static let activate = "Activate" + static let proTitle = "License - Activated" + static let proMessage = "Thank you for purchasing CmdBar! Enjoy!" + static let deactivate = "Deactivate" + static let activating = "Activating..." +} + +class AboutViewController: NSViewController, NSTextFieldDelegate { + private var appIconImage: NSImageView = { + //let image = NSImageView(image: NSApp.applicationIconImage) + let image = NSImageView() + image.image = NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) + image.imageScaling = .scaleAxesIndependently + image.translatesAutoresizingMaskIntoConstraints = false + return image + }() + + private var appNameLabel: NSTextField = { + let textField = NSTextField() + textField.stringValue = (Bundle.main.infoDictionary?["CFBundleName"] as? String) ?? "NOT FOUND" + textField.isEditable = false + textField.isBezeled = false + textField.drawsBackground = false + textField.alignment = .center + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor .preferredFontDescriptor(forTextStyle: .title1).pointSize, weight: .bold) + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private var versionLabel: NSTextField = { + let textField = NSTextField() + textField.stringValue = "Version \((Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "-.--")" + textField.isEditable = false + textField.isBezeled = false + textField.drawsBackground = false + textField.alignment = .center + textField.textColor = NSColor.systemGray + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .subheadline).pointSize, weight: .regular) + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private var copyrightLabel: NSTextField = { + let textField = NSTextField() + textField.stringValue = Strings.copyright + textField.maximumNumberOfLines = 4 + textField.cell?.truncatesLastVisibleLine = true + textField.isEditable = false + textField.isBezeled = false + textField.drawsBackground = false + textField.alignment = .center + textField.textColor = NSColor.systemGray + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .subheadline).pointSize, weight: .regular) + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private var authorButton: NSButton = { + let button = NSButton() + button.title = "Author" + button.sizeToFit() + button.bezelStyle = .rounded + button.action = #selector(author) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + // private var privacyButton: NSButton = { + // let button = NSButton() + // button.title = "Privacy Policy" + // button.sizeToFit() + // button.bezelStyle = .rounded + // button.action = #selector(privacy) + // button.translatesAutoresizingMaskIntoConstraints = false + // return button + // }() + + // private var documentationButton: NSButton = { + // let button = NSButton() + // button.title = "Docs" + // button.sizeToFit() + // button.bezelStyle = .rounded + // button.action = #selector(documentation) + // button.translatesAutoresizingMaskIntoConstraints = false + // return button + // }() + // + // private var websiteButton: NSButton = { + // let button = NSButton() + // button.title = "CmdBar.app" + // button.sizeToFit() + // button.bezelStyle = .rounded + // button.action = #selector(website) + // button.translatesAutoresizingMaskIntoConstraints = false + // return button + // }() + + private var buttonsContainer: NSLayoutGuide = { + let container = NSLayoutGuide() + return container + }() + + override func viewDidLoad() { + super.viewDidLoad() + + // Program info + view.addSubview(appIconImage) + view.addSubview(appNameLabel) + view.addSubview(versionLabel) + view.addSubview(copyrightLabel) + + // Buttons + view.addLayoutGuide(buttonsContainer) + // view.addSubview(privacyButton) + // view.addSubview(documentationButton) + // view.addSubview(websiteButton) + view.addSubview(authorButton) + + setupConstraints() + } + + override func viewDidAppear() { + super.viewDidAppear() + self.view.window?.center() + } + + override func loadView() { + self.view = NSView() + } + + private func setupConstraints() { + // View. + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: 300), + view.heightAnchor.constraint(lessThanOrEqualToConstant: 500), + ]) + + // App image. + NSLayoutConstraint.activate([ + appIconImage.widthAnchor.constraint(equalToConstant: 100), + appIconImage.heightAnchor.constraint(equalTo: appIconImage.widthAnchor, multiplier: 1), + appIconImage.topAnchor.constraint(equalTo: view.topAnchor, constant: ViewConstants.spacing20), + appIconImage.centerXAnchor.constraint(equalTo: view.centerXAnchor), + ]) + + // Title + NSLayoutConstraint.activate([ + appNameLabel.topAnchor.constraint(equalTo: appIconImage.bottomAnchor, constant: ViewConstants.spacing20), + appNameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + versionLabel.topAnchor.constraint(equalTo: appNameLabel.bottomAnchor, constant: ViewConstants.spacing2), + versionLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + copyrightLabel.topAnchor.constraint(equalTo: versionLabel.bottomAnchor, constant: ViewConstants.spacing10), + copyrightLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + ]) + + // Buttons + NSLayoutConstraint.activate([ + buttonsContainer.topAnchor .constraint(equalTo: copyrightLabel.bottomAnchor, constant: ViewConstants.spacing20), + buttonsContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -ViewConstants.spacing20), + buttonsContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + authorButton.topAnchor.constraint(equalTo: buttonsContainer.topAnchor), + authorButton.bottomAnchor.constraint(equalTo: buttonsContainer.bottomAnchor), + authorButton.leadingAnchor.constraint(equalTo: buttonsContainer.leadingAnchor), + authorButton.trailingAnchor.constraint(equalTo: buttonsContainer.trailingAnchor), + + // privacyButton.topAnchor.constraint(equalTo: buttonsContainer.topAnchor), + // privacyButton.bottomAnchor.constraint(equalTo: buttonsContainer.bottomAnchor), + // privacyButton.leadingAnchor.constraint(equalTo: buttonsContainer.leadingAnchor), + // + // documentationButton.firstBaselineAnchor.constraint(equalTo: privacyButton.firstBaselineAnchor), + // documentationButton.leadingAnchor.constraint(equalTo: privacyButton.trailingAnchor,constant: ViewConstants.spacing10), + // + // websiteButton.firstBaselineAnchor.constraint(equalTo: privacyButton.firstBaselineAnchor), + // websiteButton.leadingAnchor.constraint(equalTo: documentationButton.trailingAnchor,constant: ViewConstants.spacing10), + // websiteButton.trailingAnchor.constraint(equalTo: buttonsContainer.trailingAnchor), + ]) + } + + @objc private func author() { + NSWorkspace.shared.open(URL(string: AboutLinks.author)!) + } + + // @objc private func privacy() { + // NSWorkspace.shared.open(URL(string: AboutLinks.privacy)!) + // } + // + // @objc private func documentation() { + // NSWorkspace.shared.open(URL(string: AboutLinks.documentation)!) + // } + // + // @objc private func website() { + // NSWorkspace.shared.open(URL(string: AboutLinks.website)!) + // } +} diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index c0ea758..698f863 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -6,9 +6,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let fileManager = FileManager.default let window = PopoverPanel(viewController: SearchViewController()) + let aboutWindow = MenulessWindow(viewController: AboutViewController()) func applicationDidFinishLaunching(_ notification: Notification) { - PathManager.shared.rebuildIndex() + aboutWindow.level = .statusBar + + PathManager.shared.updateIndex() window.delegate = self @@ -30,19 +33,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } HotKeyManager.shared.enable() - if let code = - UserDefaults.standard.object(forKey: "keyCode") as? Int, - let mods = - UserDefaults.standard.object(forKey: "keyModifiers") as? Int + 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) + HotKeyManager.shared.registerHotKey(key: code, modifiers: mods) } else { // 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) + HotKeyManager.shared.registerHotKey(key: kVK_Space, modifiers: optionKey) } } @@ -52,9 +51,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - func applicationShouldHandleReopen(_ sender: NSApplication, - hasVisibleWindows: Bool) -> Bool - { + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { if !window.isKeyWindow { window.makeKeyAndOrderFront(nil) } @@ -79,4 +76,49 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { return false } } + + public func showAboutWindow() { + NSApplication.shared.activate(ignoringOtherApps: true) + aboutWindow.makeKeyAndOrderFront(nil) + } + + // NOTE: This function is triggered by DirMonitor. + public func fsEventTriggered(_ path: String, _ flags: Int) { + var shouldReload = false + print("PATH: \(path)") + // if containsFlags(key: kFSEventStreamEventFlagNone, in: flags) { + // } + // if containsFlags(key: kFSEventStreamEventFlagRootChanged, in: flags) { + // } + // if containsFlags(key: kFSEventStreamEventFlagMustScanSubDirs, in: flags) { + // } + // if containsFlags(key: kFSEventStreamEventFlagItemInodeMetaMod, in: flags) { + // } + if containsFlags(key: kFSEventStreamEventFlagItemCreated, in: flags) { + print(" CREATED") + shouldReload = true + } + if containsFlags(key: kFSEventStreamEventFlagItemRemoved, in: flags) { + print(" REMOVED") + shouldReload = true + } + if containsFlags(key: kFSEventStreamEventFlagItemCloned, in: flags) { + print(" CLONED") + shouldReload = true + } + if containsFlags(key: kFSEventStreamEventFlagItemRenamed, in: flags) { + print(" RENAMED") + shouldReload = true + } + + // TODO: This should also trigger SearchViewController's search re-index. + if shouldReload { + for dir in PathManager.shared.paths { + if path.hasPrefix(dir.key) { + PathManager.shared.rebuildIndex(at: dir.key) + } + } + PathManager.shared.refreshFilesystemWatchers() + } + } } diff --git a/src/DirMonitor.swift b/src/DirMonitor.swift new file mode 100644 index 0000000..8ed8c47 --- /dev/null +++ b/src/DirMonitor.swift @@ -0,0 +1,94 @@ +import AppKit + +class DirMonitor { + init(paths: [String], queue: DispatchQueue) { + for path in paths { + if isDirectory(path) { + self.dirs.add(path) + } + } + self.queue = queue + } + + deinit { + precondition(self.stream == nil, "released a running monitor") + } + + private var dirs: NSMutableArray = [] + private let queue: DispatchQueue + // var handler: ((Int, UnsafeMutablePointer>, UnsafeBufferPointer, UnsafeBufferPointer) -> Void)? + private var stream: FSEventStreamRef? = nil + + func start() -> Bool { + precondition(self.stream == nil, "started a running monitor") + if dirs.count < 1 { return false } + + var context = FSEventStreamContext() + context.info = Unmanaged.passUnretained(self).toOpaque() + + // func test(count: Int, paths: UnsafeMutablePointer>, flags: UnsafeBufferPointer, ids: UnsafeBufferPointer) { } + // test(count: numEvents, paths: pathsBase, flags: flagsBuffer, ids: eventIDsBuffer) + + guard let stream = FSEventStreamCreate(nil, + { + (stream, info, numEvents, eventPaths, eventFlags, eventIds) in + let pathsBase = eventPaths .assumingMemoryBound(to: UnsafePointer.self) + let pathsBuffer = UnsafeBufferPointer(start: pathsBase, count: numEvents) + let flagsBuffer = UnsafeBufferPointer(start: eventFlags, count: numEvents) + // let eventIDsBuffer = UnsafeBufferPointer(start: eventIds, count: numEvents) + + // stream -> OpaquePointer + // info -> Optional + // numEvents -> Int + // eventPaths -> UnsafeMutableRawPointer + // eventFlags -> UnsafePointer + // eventIds -> UnsafePointer + + // pathsBase -> UnsafeMutablePointer> + // pathsBuffer -> UnsafeBufferPointer> + // flagsBuffer -> UnsafeBufferPointer + // eventIDsBuffer -> UnsafeBufferPointer + + for i in 0.. NSEvent? - + private let handler: Handler - + init(mask: NSEvent.EventTypeMask, handler: @escaping Handler) { self.handler = handler super.init(mask: mask) } - + override func start() { - monitor = NSEvent.addLocalMonitorForEvents(matching: mask, - handler: handler) + monitor = NSEvent.addLocalMonitorForEvents(matching: mask, handler: handler) } } final class GlobalEventMonitor: EventMonitor { typealias Handler = (NSEvent) -> Void - + private let handler: Handler - + init(mask: NSEvent.EventTypeMask, handler: @escaping Handler) { self.handler = handler super.init(mask: mask) } - + override func start() { - monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, - handler: handler) + monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) } } diff --git a/src/Helpers.swift b/src/Helpers.swift index 72a91bd..d47187f 100644 --- a/src/Helpers.swift +++ b/src/Helpers.swift @@ -15,9 +15,13 @@ func modsContainsNone(in modifiers: UInt) -> Bool { return (modifiers & OSMods) == 0 } +func containsFlags(key: Int, in flags: Int) -> Bool { + return (flags & key) == key +} + enum ViewConstants { - static let spacing2: CGFloat = 2 - static let spacing5: CGFloat = 2 + static let spacing2: CGFloat = 2 + static let spacing5: CGFloat = 2 static let spacing10: CGFloat = 10 static let spacing15: CGFloat = 15 static let spacing20: CGFloat = 20 @@ -38,10 +42,8 @@ func keyName(virtualKeyCode: UInt16) -> String? { //let source = // TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue() - let source = TISCopyInputSourceForLanguage("en-US" as CFString) - .takeRetainedValue(); - guard let ptr = TISGetInputSourceProperty(source, - kTISPropertyUnicodeKeyLayoutData) + let source = TISCopyInputSourceForLanguage("en-US" as CFString).takeRetainedValue(); + guard let ptr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { print("Could not get keyboard layout data") return nil @@ -49,11 +51,9 @@ func keyName(virtualKeyCode: UInt16) -> String? { 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) + UCKeyTranslate($0.bindMemory(to: UCKeyboardLayout.self).baseAddress, virtualKeyCode, + UInt16(kUCKeyActionDown), modifierKeys, keyboardType, UInt32(kUCKeyTranslateNoDeadKeysMask), + &deadKeys, maxNameLength, &nameLength, &nameBuffer) } guard osStatus == noErr else { print("Code: \(virtualKeyCode) Status: \(osStatus)") @@ -72,15 +72,18 @@ func keyName(virtualKeyCode: UInt16) -> String? { return character } -func systemImage(_ name: String, _ size: NSFont.TextStyle, - _ scale: NSImage.SymbolScale, - _ configuration: NSImage.SymbolConfiguration) -> NSImage? -{ +func isDirectory(_ path: String) -> Bool { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue { + return true + } else { + return false + } +} + +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) - .applying(configuration) - ) + .withSymbolConfiguration(NSImage.SymbolConfiguration(textStyle: size, scale: scale).applying(configuration)) } func isDirectory(atPath path: String) -> Bool { @@ -92,16 +95,16 @@ func isDirectory(atPath path: String) -> Bool { } extension String { - /// This converts string to UInt as a fourCharCode + // This converts string to UInt as a fourCharCode public var fourCharCodeValue: Int { var result: Int = 0 if let data = self.data(using: String.Encoding.macOSRoman) { - data.withUnsafeBytes({ (rawBytes) in + data.withUnsafeBytes { (rawBytes) in let bytes = rawBytes.bindMemory(to: UInt8.self) for i in 0 ..< data.count { result = result << 8 + Int(bytes[i]) } - }) + } } return result } diff --git a/src/HotKeyManager.swift b/src/HotKeyManager.swift index 8675692..56854d6 100644 --- a/src/HotKeyManager.swift +++ b/src/HotKeyManager.swift @@ -2,26 +2,16 @@ 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 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 let hotKeyID: EventHotKeyID = EventHotKeyID(signature: OSType("grap".fourCharCodeValue), id: 1) private init() {} - deinit {} // TODO: Handle errors. @@ -30,13 +20,11 @@ final class HotKeyManager { disable() } - let err = InstallEventHandler( - GetApplicationEventTarget(), handler, 1, &eventType, - nil, &eventHandlerRef) + let err = InstallEventHandler(GetApplicationEventTarget(), handler, 1, &eventType, nil, &eventHandlerRef) if err == noErr { - Self.logger.debug("Installed event handler.") + print("Installed event handler.") } else { - Self.logger.error("Failed to install event handler.") + print("Failed to install event handler.") } } @@ -44,30 +32,25 @@ final class HotKeyManager { 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.") + eventHandlerRef = nil // WARNING: Does it remove no matter what on error? + print("Removed event handler.") } else { - Self.logger.error("Failed to remove event handler.") + print("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) + let err = RegisterEventHotKey(UInt32(key), UInt32(modifiers), hotKeyID, GetApplicationEventTarget(), UInt32(kEventHotKeyNoOptions), &hotKeyRef) if err == noErr { - Self.logger.debug("Registered hot key.") + print("Registered hot key.") } else { - Self.logger.error("Failed to register hot key.") + print("Failed to register hot key.") } } @@ -76,11 +59,10 @@ final class HotKeyManager { 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.") + hotKeyRef = nil // WARNING: Does it unregister no matter what on error? + print("Successfully unregistered hot key.") } else { - Self.logger.error("Failed to unregester hot key.") + print("Failed to unregistered hot key.") } } } diff --git a/src/KeyDetectorButton.swift b/src/KeyDetectorButton.swift index e940900..a7ee9af 100644 --- a/src/KeyDetectorButton.swift +++ b/src/KeyDetectorButton.swift @@ -13,8 +13,7 @@ final class KeyDetectorButton: NSButton { override var acceptsFirstResponder: Bool { true } - // This removes default bahavior from NSButton, thus allowing mouse up - // events. + // This removes default bahavior from NSButton, thus allowing mouse up events. override func mouseDown(with event: NSEvent) {} override func mouseUp(with event: NSEvent) { @@ -23,16 +22,13 @@ final class KeyDetectorButton: NSButton { override func keyDown(with event: NSEvent) { if event.keyCode == kVK_Escape || event.keyCode == kVK_Return { + // Ignore escape and return keys. } else if event.keyCode == kVK_Delete { - if let key = defaultKey, - let character = keyName(virtualKeyCode: UInt16(key)) - { + if let key = defaultKey, let character = keyName(virtualKeyCode: UInt16(key)) { title = character } } else { - if let character = - keyName(virtualKeyCode: UInt16(event.keyCode)) - { + if let character = keyName(virtualKeyCode: UInt16(event.keyCode)) { title = character } delegate?.keyWasSet(to: Int(event.keyCode)) diff --git a/src/Makefile b/src/Makefile index f935ccc..9652f7d 100644 --- a/src/Makefile +++ b/src/Makefile @@ -7,63 +7,46 @@ XCODE_PATH = $(shell xcode-select --print-path) EXEC = Grapp -SRCMODULES = Helpers.swift EditableNSTextField.swift EventMonitor.swift \ - PopoverPanel.swift SearchViewController.swift \ - SettingsViewController.swift HotKeyManager.swift \ - KeyDetectorButton.swift PathManager.swift PathsTableCellView.swift \ - ProgramsTable.swift ShadowView.swift \ - AppDelegate.swift main.swift +SRCMODULES = Helpers.swift EditableNSTextField.swift EventMonitor.swift PopoverPanel.swift SearchViewController.swift \ + SettingsViewController.swift HotKeyManager.swift KeyDetectorButton.swift PathManager.swift PathsTableCellView.swift \ + ProgramsTable.swift ShadowView.swift DirMonitor.swift MenulessWindow.swift AboutViewController.swift AppDelegate.swift \ + main.swift ARMOBJMODULES = $(addprefix ./arm64/,$(SRCMODULES:.swift=.o)) X86OBJMODULES = $(addprefix ./x86_64/,$(SRCMODULES:.swift=.o)) -LIBS = +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. +# 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 $@ + 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 $@ + @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) - @ld -syslibroot $(SDK) -lSystem $(FRAMEWORKS) -arch arm64 \ - -macos_version_min $(MACOS_VERSION).0 \ + @ld -syslibroot $(SDK) -lSystem $(FRAMEWORKS) -arch arm64 -macos_version_min $(MACOS_VERSION).0 \ /Library/Developer/CommandLineTools/usr/lib/swift/macosx/libswiftCompatibilityPacks.a \ - -sectcreate __TEXT __info_plist Info.plist \ - -L /Library/Developer/CommandLineTools/usr/lib/swift/macosx -L \ - /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib/swift \ - -no_objc_category_merging -L $(XCODE_PATH) -rpath \ - Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx \ - ./arm64/main.o $(filter-out ./arm64/main.o, $(ARMOBJMODULES)) \ - -o $@ + -sectcreate __TEXT __info_plist Info.plist -L /Library/Developer/CommandLineTools/usr/lib/swift/macosx -L \ + /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib/swift -no_objc_category_merging -L $(XCODE_PATH) -rpath \ + Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx ./arm64/main.o $(filter-out ./arm64/main.o, $(ARMOBJMODULES)) -o $@ ifdef UNIVERSAL ./x86_64/$(EXEC): $(X86OBJMODULES) - @ld -syslibroot $(SDK) -lSystem $(FRAMEWORKS) -arch x86_64 \ - -macos_version_min $(MACOS_VERSION).0 \ + @ld -syslibroot $(SDK) -lSystem $(FRAMEWORKS) -arch x86_64 -macos_version_min $(MACOS_VERSION).0 \ /Library/Developer/CommandLineTools/usr/lib/swift/macosx/libswiftCompatibilityPacks.a \ - -sectcreate __TEXT __info_plist Info.plist \ - -L /Library/Developer/CommandLineTools/usr/lib/swift/macosx -L \ - /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib/swift \ - -no_objc_category_merging -L $(XCODE_PATH) -rpath \ - Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx \ - ./x86_64/main.o $(filter-out ./x86_64/main.o, $(X86OBJMODULES)) \ - -o $@ + -sectcreate __TEXT __info_plist Info.plist -L /Library/Developer/CommandLineTools/usr/lib/swift/macosx -L \ + /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib/swift -no_objc_category_merging -L $(XCODE_PATH) -rpath \ + Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx ./x86_64/main.o $(filter-out ./x86_64/main.o, $(X86OBJMODULES)) -o $@ endif ifdef UNIVERSAL @@ -74,7 +57,6 @@ $(EXEC): ./arm64/$(EXEC) @lipo -create -output $(EXEC) $^ endif - $(EXEC).app: $(EXEC) @rm -rf $@ @mkdir -p $@/Contents/MacOS/ && \ @@ -82,16 +64,20 @@ $(EXEC).app: $(EXEC) cp Info.plist $@/Contents/ && \ cp resources/AppIcon.icns $@/Contents/Resources/ && \ cp $(EXEC) $@/Contents/MacOS/ && \ - $(if $(DEBUG), \ - codesign --entitlements Grapp.entitlements \ - -s ${APPLE_DEVELOPMENT} -f --timestamp -o runtime $(EXEC).app, \ - codesign -s ${APPLE_DEVELOPER_ID_APPLICATION} -f --timestamp \ - -o runtime $(EXEC).app) + $(if $(DEBUG), codesign --entitlements Grapp.entitlements -s ${APPLE_DEVELOPMENT} -f --timestamp -o runtime $(EXEC).app, \ + codesign -s ${APPLE_DEVELOPER_ID_APPLICATION} -f --timestamp -o runtime $(EXEC).app) all: $(EXEC).app -run: all - @open $(EXEC).app +clear: + clear + +kill: + -pkill $(EXEC) + +run: clear kill all +# @open $(EXEC).app + ./$(EXEC) clean: rm -rf $(EXEC) $(EXEC).app arm64 x86_64 diff --git a/src/MenulessWindow.swift b/src/MenulessWindow.swift new file mode 100644 index 0000000..d04c020 --- /dev/null +++ b/src/MenulessWindow.swift @@ -0,0 +1,33 @@ +import AppKit +import Carbon + +class MenulessWindow: NSWindow { + init(viewController: NSViewController) { + super.init( + contentRect: CGRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + super.contentViewController = viewController + + title = "" + titlebarAppearsTransparent = true + collectionBehavior = [.managed, .fullScreenNone] + isReleasedWhenClosed = false + hidesOnDeactivate = false + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + let modifiers = event.modifierFlags.rawValue + let key = event.keyCode + + if event.type == NSEvent.EventType.keyDown { + if modsContains(keys: OSCmd, in: modifiers) && key == kVK_ANSI_W { + performClose(nil) + } + } + + return super.performKeyEquivalent(with: event) + } +} diff --git a/src/PathManager.swift b/src/PathManager.swift index 15ef278..f949e74 100644 --- a/src/PathManager.swift +++ b/src/PathManager.swift @@ -3,86 +3,133 @@ import AppKit struct Program { let path: String let name: String - let ext: String - var img: NSImage? + let ext : String + var img : NSImage? } final class PathManager { static let shared = PathManager() - // TODO: Filesystem events to watch changes on these directories and - // rebuild index when needed. + private var dirMonitor: DirMonitor? + // 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", + 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] = [] + "/System/Library/CoreServices/Applications" + ] + private(set) var paths: [String: [Program]] = [:] private let fileManager = FileManager.default private init() { - if let paths = - UserDefaults.standard.stringArray(forKey: "programPaths") - { - for path in paths { - addPath(path) + // UserDefaults.standard.removeObject(forKey: "programPaths") + if let dirs = UserDefaults.standard.stringArray(forKey: "programPaths"), !dirs.isEmpty { + for dir in dirs { + addPath(dir) } } else { - userPaths += Self.defaultPaths + for path in PathManager.defaultPaths { + addPath(path) + } } } deinit {} public func addPath(_ path: String) { - if !userPaths.contains(path) { - userPaths.append(path) + if isDirectory(path) { + paths[path] = [] } } public func removePath(_ path: String) { - userPaths.removeAll { $0 == path } + paths.removeValue(forKey: path) } - public func removeEmpty() { - userPaths.removeAll { $0.isEmpty } + public func resetPaths() { + paths = [:] + for path in PathManager.defaultPaths { + addPath(path) + } + } + + public func contains(_ name: String) -> Bool { + for path in paths { + for prog in path.value { + if prog.name == name { + return true + } + } + } + return false + } + + public func refreshFilesystemWatchers() { + dirMonitor?.stop() + dirMonitor = nil + + var buf: [String] = [] + for path in paths { + buf.append(path.key) + } + + dirMonitor = DirMonitor(paths: buf, queue: DispatchQueue.global(qos: .userInitiated)) + // _ = dirMonitor!.start() + if dirMonitor!.start() { + print("Started monitoring directories.") + } else { + print("Failed to start monitoring directories.") + } } 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", - img: nil)) - } - } - } - } catch { - print("Error reading directory: \(error.localizedDescription)") - } + var buf: [String] = [] + for path in paths { + buf.append(path.key) } + UserDefaults.standard.set(buf, forKey: "programPaths") + } + + // PERF: Optimize some more. Do not rebuild the entire array, instead + // remove or add only needed programs. Thereby, limiting the + // amount of allocations. + public func rebuildIndex(at path: String) { + paths[path] = indexDirs(at: path, deepness: 2) + } + + public func indexDirs(at path: String, deepness: Int) -> [Program] { + var array: [Program] = [] + + do { + var items = try fileManager.contentsOfDirectory(atPath: path) + items = items.filter({ isDirectory((path + "/" + $0)) }) + + for item in items { + let name = String(item.dropLast(4)) + + if item.hasSuffix(".app"), !contains(name) { + array.append(Program(path: path, name: name, ext: ".app", img: nil)) + } + if deepness > 0 { + array += indexDirs(at: path + "/" + item, deepness: deepness-1) + } + } + } catch { print("Error: \(error.localizedDescription)") } + + return array + } + + public func updateIndex() { + print("updateIndex()") + for path in paths { + rebuildIndex(at: path.key) + } + refreshFilesystemWatchers() } } diff --git a/src/PopoverPanel.swift b/src/PopoverPanel.swift index 06567dc..6225d55 100644 --- a/src/PopoverPanel.swift +++ b/src/PopoverPanel.swift @@ -47,12 +47,12 @@ class PopoverPanel: NSPanel { { resignKey() return true - + } else if modsContains(keys: OSCmd | OSShift, in: modifiers) && key == kVK_ANSI_R { - PathManager.shared.rebuildIndex() + PathManager.shared.updateIndex() return true } else if key == kVK_Escape { resignKey() diff --git a/src/ProgramsTable.swift b/src/ProgramsTable.swift index 26f6fff..c6dc1ec 100644 --- a/src/ProgramsTable.swift +++ b/src/ProgramsTable.swift @@ -7,8 +7,7 @@ final class ProgramsTableView: NSTableView { class ProgramsTableRowView: NSTableRowView { override func drawSelection(in dirtyRect: NSRect) { if self.selectionHighlightStyle != .none { - let selectionColor = NSColor.controlAccentColor - .withAlphaComponent(0.8) + let selectionColor = NSColor.controlAccentColor.withAlphaComponent(0.8) selectionColor.setFill() self.bounds.fill() } @@ -22,8 +21,7 @@ class ProgramsTableViewCell: NSTableCellView { public var appIconImage: NSImageView = { 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,9 +38,7 @@ class ProgramsTableViewCell: NSTableCellView { public var progPathLabel: NSTextField = { let textField = NSTextField(labelWithString: "") textField.cell?.lineBreakMode = .byTruncatingTail - textField.font = NSFont.systemFont( - ofSize: NSFontDescriptor.preferredFontDescriptor( - forTextStyle: .caption1).pointSize, weight: .medium) + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .caption1).pointSize, weight: .medium) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() @@ -59,23 +55,15 @@ class ProgramsTableViewCell: NSTableCellView { appIconImage.heightAnchor.constraint(equalToConstant: 40), appIconImage.topAnchor.constraint(equalTo: topAnchor), appIconImage.bottomAnchor.constraint(equalTo: bottomAnchor), - appIconImage.leadingAnchor.constraint(equalTo: leadingAnchor, - constant: ViewConstants.spacing5), + appIconImage.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ViewConstants.spacing5), - titleField.topAnchor.constraint( - equalTo: appIconImage.topAnchor, - constant: ViewConstants.spacing2), - titleField.leadingAnchor.constraint( - equalTo: appIconImage.trailingAnchor, - constant: ViewConstants.spacing5), + titleField.topAnchor.constraint(equalTo: appIconImage.topAnchor, constant: ViewConstants.spacing2), + titleField.leadingAnchor.constraint(equalTo: appIconImage.trailingAnchor, constant: ViewConstants.spacing5), titleField.trailingAnchor.constraint(equalTo: trailingAnchor), - progPathLabel.topAnchor.constraint( - equalTo: titleField.bottomAnchor), - progPathLabel.leadingAnchor.constraint( - equalTo: titleField.leadingAnchor), - progPathLabel.trailingAnchor.constraint( - equalTo: titleField.trailingAnchor), + progPathLabel.topAnchor.constraint(equalTo: titleField.bottomAnchor), + progPathLabel.leadingAnchor.constraint(equalTo: titleField.leadingAnchor), + progPathLabel.trailingAnchor.constraint(equalTo: titleField.trailingAnchor), ]) } diff --git a/src/SearchViewController.swift b/src/SearchViewController.swift index bd3ab51..6e3d8a3 100644 --- a/src/SearchViewController.swift +++ b/src/SearchViewController.swift @@ -6,12 +6,16 @@ import Carbon // elements inside of it. fileprivate let windowCornerRadius = 15.0 +struct ProgramWeighted { + let program: Program + let weight: Int +} + class SearchViewController: NSViewController, NSTextFieldDelegate, NSPopoverDelegate, NSTableViewDataSource, NSTableViewDelegate { private var keyboardEvents: EventMonitor? - private var foundProgram: Program? = nil private var programsList: [Program] = [] private var programsTableViewSelection = 0 @@ -38,8 +42,7 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, effect.wantsLayer = true effect.layer?.masksToBounds = true - effect.layer?.borderColor = NSColor.labelColor - .withAlphaComponent(0.1).cgColor + effect.layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.1).cgColor effect.layer?.borderWidth = 1 effect.layer?.cornerRadius = windowCornerRadius @@ -68,17 +71,14 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, textField.focusRingType = .none textField.placeholderString = "Program Search" textField.bezelStyle = .roundedBezel - textField.font = NSFont.systemFont( - ofSize: NSFontDescriptor.preferredFontDescriptor( - forTextStyle: .largeTitle).pointSize, weight: .medium) + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .largeTitle).pointSize, weight: .medium) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() private var settingsButton: NSButton = { let button = NSButton() - button.image = systemImage("gear.circle.fill", .title1, .large, - .init(paletteColors: [.labelColor, .systemGray])) + button.image = systemImage("gear.circle.fill", .title1, .large, .init(paletteColors: [.labelColor, .systemGray])) button.isBordered = false button.action = #selector(openSettings) button.sizeToFit() @@ -90,10 +90,7 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, private var tableScrollView: NSScrollView = { let scroll = NSScrollView() scroll.automaticallyAdjustsContentInsets = false - scroll.contentInsets = NSEdgeInsets( - top: 0, left: 0, - bottom: ViewConstants.spacing10, - right: 0) + scroll.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: ViewConstants.spacing10, right: 0) scroll.drawsBackground = false scroll.translatesAutoresizingMaskIntoConstraints = false return scroll @@ -133,64 +130,37 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, var tableViewHeightAnchor: NSLayoutConstraint? private func setConstraints() { - tableViewHeightAnchor = tableScrollView.heightAnchor - .constraint(equalToConstant: 0) + tableViewHeightAnchor = tableScrollView.heightAnchor.constraint(equalToConstant: 0) tableViewHeightAnchor?.isActive = true NSLayoutConstraint.activate([ - view.topAnchor.constraint(equalTo: contentView.topAnchor, - constant: -100), - view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, - constant: 100), - view.leadingAnchor.constraint( - equalTo: contentView.leadingAnchor, constant: -100), - view.trailingAnchor.constraint( - equalTo: contentView.trailingAnchor, constant: 100), + view.topAnchor.constraint(equalTo: contentView.topAnchor, constant: -100), + view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 100), + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: -100), + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 100), - shadowView.topAnchor.constraint( - equalTo: backgroundView.topAnchor), - shadowView.bottomAnchor.constraint( - equalTo: backgroundView.bottomAnchor), - shadowView.leadingAnchor.constraint( - equalTo: backgroundView.leadingAnchor), - shadowView.trailingAnchor.constraint( - equalTo: backgroundView.trailingAnchor), + shadowView.topAnchor.constraint(equalTo: backgroundView.topAnchor), + shadowView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor), + shadowView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor), + shadowView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor), - backgroundView.topAnchor.constraint( - equalTo: contentView.topAnchor), - backgroundView.bottomAnchor.constraint( - equalTo: contentView.bottomAnchor), - backgroundView.leadingAnchor.constraint( - equalTo: contentView.leadingAnchor), - backgroundView.trailingAnchor.constraint( - equalTo: contentView.trailingAnchor), + backgroundView.topAnchor.constraint(equalTo: contentView.topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + backgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), searchInput.widthAnchor.constraint(equalToConstant: 350), - searchInput.topAnchor.constraint( - equalTo: contentView.topAnchor, - constant: ViewConstants.spacing10), - searchInput.leadingAnchor.constraint( - equalTo: contentView.leadingAnchor, - constant: ViewConstants.spacing15), + searchInput.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ViewConstants.spacing10), + searchInput.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: ViewConstants.spacing15), - settingsButton.centerYAnchor.constraint( - equalTo: searchInput.centerYAnchor), - settingsButton.leadingAnchor.constraint( - equalTo: searchInput.trailingAnchor, - constant: ViewConstants.spacing5), - settingsButton.trailingAnchor.constraint( - equalTo: contentView.trailingAnchor, - constant: -ViewConstants.spacing10), + settingsButton.centerYAnchor.constraint(equalTo: searchInput.centerYAnchor), + settingsButton.leadingAnchor.constraint(equalTo: searchInput.trailingAnchor, constant: ViewConstants.spacing5), + settingsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -ViewConstants.spacing10), - tableScrollView.topAnchor.constraint( - equalTo: searchInput.bottomAnchor, - constant: ViewConstants.spacing10), - tableScrollView.bottomAnchor.constraint( - equalTo: contentView.bottomAnchor), - tableScrollView.leadingAnchor.constraint( - equalTo: contentView.leadingAnchor), - tableScrollView.trailingAnchor.constraint( - equalTo: contentView.trailingAnchor) + tableScrollView.topAnchor.constraint(equalTo: searchInput.bottomAnchor, constant: ViewConstants.spacing10), + tableScrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + tableScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + tableScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) ]) } @@ -200,45 +170,34 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, view.wantsLayer = true view.layer?.backgroundColor = NSColor.clear.cgColor - keyboardEvents = LocalEventMonitor(mask: [.keyDown], handler: - { [weak self] event in + keyboardEvents = LocalEventMonitor(mask: [.keyDown]) { [weak self] event in let key = event.keyCode let modifiers = event.modifierFlags.rawValue - // TODO: Implement helper functions for modifiers. if let controller = self { - if modsContains(keys: OSCtrl, in: modifiers) && - key == kVK_ANSI_P || - modsContainsNone(in: modifiers) && - key == kVK_UpArrow + if modsContains(keys: OSCtrl, in: modifiers) && key == kVK_ANSI_P || + modsContainsNone(in: modifiers) && key == kVK_UpArrow { controller.programsTableViewSelection -= 1 - } else if modsContains(keys: OSCtrl, in: modifiers) && - key == kVK_ANSI_N || - modsContainsNone(in: modifiers) && - (key == kVK_DownArrow) + } else if modsContains(keys: OSCtrl, in: modifiers) && key == kVK_ANSI_N || + modsContainsNone(in: modifiers) && key == kVK_DownArrow { controller.programsTableViewSelection += 1 } - if controller.programsTableViewSelection > - controller.programsList.count-1 - { - controller.programsTableViewSelection = - controller.programsList.count-1 + if controller.programsTableViewSelection > controller.programsList.count-1 { + controller.programsTableViewSelection = controller.programsList.count-1 } else if controller.programsTableViewSelection < 0 { controller.programsTableViewSelection = 0 } let select = controller.programsTableViewSelection - self?.programsTableView.selectRowIndexes( - IndexSet(integer: select), - byExtendingSelection: false) + self?.programsTableView.selectRowIndexes(IndexSet(integer: select), byExtendingSelection: false) self?.programsTableView.scrollRowToVisible(select) } return event - }) + } settingsPopover.delegate = self @@ -290,7 +249,7 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, @objc func openSettings() { - // HACK: This is an interseting behavior. When NSPopover appears + // HACK: This is an interesting behavior. When NSPopover appears // the first time, it always displays in the wrong location; // however, showing it twice does result in the right // location. @@ -307,19 +266,16 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, } private func openProgram(_ program: Program) { - 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) - { [weak self] application, error in + // NOTE: This needs a window! Do not just copy-paste + // this block elsewhere. + NSWorkspace.shared.openApplication(at: url, configuration: config) { [weak self] application, error in if let error = error { print("\(error.localizedDescription)") } else { print("Program opened successfully") - // NOTE: This needs a window! Do not just copy-paste - // this block elsewhere. DispatchQueue.main.async { if let window = self?.view.window { window.resignKey() @@ -331,51 +287,46 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, func controlTextDidChange(_ obj: Notification) { guard let searchInput = obj.object as? EditableNSTextField - else { return } - - let programs = PathManager.shared.programs + else { return } programsList = [] - for i in programs.indices { - var program = programs[i] - if programsList.count >= 10 { - break - } - if program.name.lowercased().contains( - searchInput.stringValue.lowercased()) - { - let url = URL(fileURLWithPath: program.path) - .appendingPathComponent(program.name+program.ext) - let image = NSWorkspace.shared.icon(forFile: url.path) - program.img = image - programsList.append(program) + + if !searchInput.stringValue.isEmpty { + for path in PathManager.shared.paths { + if programsList.count >= 10 { break } + + for i in path.value.indices { + var prog = path.value[i] + if programsList.count >= 10 { break } + + if prog.name.lowercased().contains(searchInput.stringValue.lowercased()) + { + let url = URL(fileURLWithPath: prog.path).appendingPathComponent(prog.name+prog.ext) + let image = NSWorkspace.shared.icon(forFile: url.path) + prog.img = image + programsList.append(prog) + } + } } } reloadProgramsTableViewData() programsTableViewSelection = 0 - programsTableView.selectRowIndexes( - IndexSet(integer: programsTableViewSelection), - byExtendingSelection: false) + programsTableView.selectRowIndexes(IndexSet(integer: programsTableViewSelection), byExtendingSelection: false) programsTableView.scrollRowToVisible(programsTableViewSelection) } - 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 programsList.count > 0 { let program = programsList[programsTableViewSelection] openProgram(program) - 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 - } else if commandSelector == #selector(NSResponder.moveUp(_:)) || - commandSelector == #selector(NSResponder.moveDown(_:)) - { + } else if commandSelector == #selector(NSResponder.moveUp(_:)) || commandSelector == #selector(NSResponder.moveDown(_:)) { // Ignore arrows keys up or down because we use those to // navigate the programs list. return true @@ -414,7 +365,7 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, // searchInput.stringValue let app = program.name + program.ext - let rangeToHighlight = + let rangeToHighlight = (app.lowercased() as NSString) .range(of: searchInput.stringValue.lowercased()) let attributedString = NSMutableAttributedString(string: app) diff --git a/src/SettingsViewController.swift b/src/SettingsViewController.swift index a322bf1..4ec0bc6 100644 --- a/src/SettingsViewController.swift +++ b/src/SettingsViewController.swift @@ -2,10 +2,9 @@ import AppKit import Carbon import ServiceManagement -class SettingsViewController: NSViewController, NSTextFieldDelegate, - KeyDetectorButtonDelegate, - NSTableViewDataSource, NSTableViewDelegate, - PathsTableCellViewDelegate +class SettingsViewController: NSViewController, + NSTextFieldDelegate, KeyDetectorButtonDelegate, NSTableViewDataSource, + NSTableViewDelegate, PathsTableCellViewDelegate { private var recording = false @@ -14,6 +13,8 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, private var keyCode = Int(kVK_Space) private var modifiers = Int(optionKey) + private var paths: [String] = [] + // 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 @@ -29,13 +30,22 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, private var shortcutsLabel: NSTextField = { let textField = NSTextField(labelWithString: "Shortcut") - textField.font = NSFont.systemFont( - ofSize: NSFontDescriptor.preferredFontDescriptor( - forTextStyle: .title2).pointSize, weight: .bold) + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .title2).pointSize, weight: .bold) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() + private var aboutButton: NSButton = { + let button = NSButton() + button.image = systemImage("info.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemGray])) + button.isBordered = false + button.action = #selector(showAbout) + button.sizeToFit() + button.toolTip = "About" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + private var ctrlButton: NSButton = { let button = NSButton() button.title = "⌃" @@ -87,9 +97,7 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, textField.isBezeled = false textField.drawsBackground = false textField.alignment = .center - 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 }() @@ -104,11 +112,8 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, }() private var pathsLabel: NSTextField = { - let textField = - NSTextField(labelWithString: "Application Directories") - textField.font = NSFont.systemFont( - ofSize: NSFontDescriptor.preferredFontDescriptor( - forTextStyle: .title2).pointSize, weight: .bold) + let textField = NSTextField(labelWithString: "Application Directories") + textField.font = NSFont.systemFont(ofSize: NSFontDescriptor.preferredFontDescriptor(forTextStyle: .title2).pointSize, weight: .bold) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() @@ -132,8 +137,7 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, table.allowsColumnReordering = false table.allowsColumnResizing = false table.allowsColumnSelection = false - table.addTableColumn(NSTableColumn( - identifier: NSUserInterfaceItemIdentifier("Paths"))) + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Paths"))) //rowHeight cgfloat must see doc @@ -146,12 +150,8 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, 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.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) @@ -187,6 +187,7 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, private func addSubviews() { view.addSubview(shortcutsLabel) + view.addSubview(aboutButton) view.addSubview(ctrlButton) view.addSubview(cmdButton) view.addSubview(optButton) @@ -204,87 +205,49 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, private func setConstraints() { NSLayoutConstraint.activate([ - shortcutsLabel.topAnchor.constraint( - equalTo: view.topAnchor, - constant: ViewConstants.spacing10), - shortcutsLabel.leadingAnchor.constraint( - equalTo: view.leadingAnchor, - constant: ViewConstants.spacing10), + 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), + aboutButton.firstBaselineAnchor.constraint(equalTo: shortcutsLabel.firstBaselineAnchor), + aboutButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -ViewConstants.spacing10), - cmdButton.centerYAnchor.constraint( - equalTo: ctrlButton.centerYAnchor), - cmdButton.leadingAnchor.constraint( - equalTo: ctrlButton.trailingAnchor, - constant: ViewConstants.spacing5), + ctrlButton.topAnchor.constraint(equalTo: shortcutsLabel.bottomAnchor, constant: ViewConstants.spacing10), + ctrlButton.leadingAnchor.constraint(equalTo: shortcutsLabel.leadingAnchor), - optButton.centerYAnchor.constraint( - equalTo: ctrlButton.centerYAnchor), - optButton.leadingAnchor.constraint( - equalTo: cmdButton.trailingAnchor, - constant: ViewConstants.spacing5), + cmdButton.centerYAnchor.constraint(equalTo: ctrlButton.centerYAnchor), + cmdButton.leadingAnchor.constraint(equalTo: ctrlButton.trailingAnchor, constant: ViewConstants.spacing5), - shiftButton.centerYAnchor.constraint( - equalTo: ctrlButton.centerYAnchor), - shiftButton.leadingAnchor.constraint( - equalTo: optButton.trailingAnchor, - constant: ViewConstants.spacing5), + optButton.centerYAnchor.constraint(equalTo: ctrlButton.centerYAnchor), + optButton.leadingAnchor.constraint(equalTo: cmdButton.trailingAnchor, constant: ViewConstants.spacing5), - plusLabel.centerYAnchor.constraint( - equalTo: ctrlButton.centerYAnchor), - plusLabel.leadingAnchor.constraint( - equalTo: shiftButton.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), + 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), + pathsLabel.topAnchor.constraint(equalTo: ctrlButton.bottomAnchor, constant: ViewConstants.spacing20), + pathsLabel.leadingAnchor.constraint(equalTo: shortcutsLabel.leadingAnchor), tableScrollView.widthAnchor.constraint(equalToConstant: 350), tableScrollView.heightAnchor.constraint(equalToConstant: 150), - tableScrollView.topAnchor.constraint( - equalTo: pathsLabel.bottomAnchor), - tableScrollView.leadingAnchor.constraint( - equalTo: view.leadingAnchor), - tableScrollView.trailingAnchor.constraint( - equalTo: view.trailingAnchor), + 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), + 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), + 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), + resetAllButton.centerYAnchor.constraint(equalTo: launchAtLoginButton.centerYAnchor), + resetAllButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -ViewConstants.spacing10), + resetAllButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -ViewConstants.spacing10), ]) } @@ -320,26 +283,20 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, // 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 - { + if let code = UserDefaults.standard.object(forKey: "keyCode") as? Int { keyCode = code } - if let mods = - UserDefaults.standard.object(forKey: "keyModifiers") as? Int - { + if let mods = UserDefaults.standard.object(forKey: "keyModifiers") as? Int { modifiers = mods } - pathsTableView.reloadData() + loadPaths() syncModifierButtons() launchAtLoginStatus() } override func viewDidAppear() { super.viewDidAppear() - - self.view.window?.center() } override func viewWillDisappear() { @@ -351,19 +308,34 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, UserDefaults.standard.set(keyCode, forKey: "keyCode") UserDefaults.standard.set(modifiers, forKey: "keyModifiers") - PathManager.shared.removeEmpty() + // Merge PathManagers paths and user paths. + // WARNING: This seems a bit error prone. + for path in paths { + if !PathManager.shared.contains(path) { + PathManager.shared.addPath(path) + } + } + for path in PathManager.shared.paths { + if !paths.contains(path.key) { + PathManager.shared.removePath(path.key) + } + } + PathManager.shared.updateIndex() PathManager.shared.savePaths() - PathManager.shared.rebuildIndex() } override func loadView() { self.view = NSView() } + @objc + private func showAbout() { + delegate.showAboutWindow() + } + @objc private func handleModifiers() { - // NOTE: Revert to default modifier if none of the modifier - // buttons are on. + // 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 { @@ -391,14 +363,23 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, @objc private func reset() { - keyCode = Int(kVK_Space) + 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() + PathManager.shared.resetPaths() + loadPaths() + } + + private func loadPaths() { + paths = [] + for path in PathManager.shared.paths { + paths.append(path.key) + } + paths.sort() pathsTableView.reloadData() } @@ -454,25 +435,17 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, let selectedSegment = sender.selectedSegment switch selectedSegment { case 0: - // NOTE: Seems a bit error prone. - var row = PathManager.shared.userPaths.count-1 - if !PathManager.shared.userPaths[row].isEmpty { - row += 1 - PathManager.shared.addPath("") - pathsTableView.insertRows(at: IndexSet(integer: row), - withAnimation: []) - } + let row = paths.count + paths.append("") + pathsTableView.insertRows(at: IndexSet(integer: row), withAnimation: []) + pathsTableView.scrollRowToVisible(row) - pathsTableView.selectRowIndexes(IndexSet(integer: row), - byExtendingSelection: false) - (pathsTableView.view(atColumn: 0, row: row, - makeIfNecessary: false) as? PathsTableCellView)? - .startEditing() + pathsTableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + (pathsTableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? PathsTableCellView)?.startEditing() break case 1: if pathsTableView.selectedRow > -1 { - PathManager.shared.userPaths - .remove(at: pathsTableView.selectedRow) + paths.remove(at: pathsTableView.selectedRow) pathsTableView.reloadData() } break @@ -484,23 +457,18 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, @objc private func editItem(_ sender: NSTableView) { pathsTableView.deselectAll(nil) - pathsTableView.selectRowIndexes( - IndexSet(integer: pathsTableView.clickedRow), - byExtendingSelection: false) + pathsTableView.selectRowIndexes(IndexSet(integer: pathsTableView.clickedRow), byExtendingSelection: false) - if let cell = pathsTableView.view(atColumn: 0, - row: pathsTableView.clickedRow, - makeIfNecessary: false) as? PathsTableCellView - { + if let cell = pathsTableView.view(atColumn: 0, row: pathsTableView.clickedRow, makeIfNecessary: false) as? PathsTableCellView { cell.startEditing() } } func titleFieldFinishedEditing(tag: Int, text: String) { if text.isEmpty { - PathManager.shared.userPaths.remove(at: tag) + paths.remove(at: tag) } else { - PathManager.shared.userPaths[tag] = text + paths[tag] = text } pathsTableView.reloadData() } @@ -515,34 +483,32 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, func selectionButtonClicked(tag: Int) { NSRunningApplication.current.activate(options: .activateAllWindows) delegate.window.level = .normal + delegate.aboutWindow.performClose(nil) if dirPicker.runModal() == .OK { if let url = dirPicker.url { - PathManager.shared.userPaths[tag] = url.path + paths[tag] = url.path pathsTableView.reloadData() } } delegate.window.level = .statusBar delegate.window.makeKeyAndOrderFront(nil) - if let controller = - delegate.window.contentViewController as? SearchViewController - { + if let controller = delegate.window.contentViewController as? SearchViewController { controller.openSettings() } } func numberOfRows(in tableView: NSTableView) -> Int { - return PathManager.shared.userPaths.count + return paths.count } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let rect = NSRect(x: 0, y: 0, - width: tableColumn!.width, height: 20) + let rect = NSRect(x: 0, y: 0, width: tableColumn!.width, height: 20) let cell = PathsTableCellView(frame: rect) - cell.titleField.stringValue = PathManager.shared.userPaths[row] + cell.titleField.stringValue = paths[row] cell.delegate = self cell.id = row return cell