From 8b0e42a7c854a38d12a2647ec6951df2e9b1b7ad Mon Sep 17 00:00:00 2001 From: igor Date: Wed, 8 Jan 2025 15:19:40 -0800 Subject: [PATCH] Added a programs list. --- src/Helpers.swift | 9 + src/Makefile | 20 ++- src/PathManager.swift | 4 +- src/ProgramTableViewCell.swift | 78 ++++++++ src/SearchViewController.swift | 298 +++++++++++++++++++++++-------- src/SettingsViewController.swift | 24 +-- 6 files changed, 331 insertions(+), 102 deletions(-) create mode 100644 src/ProgramTableViewCell.swift diff --git a/src/Helpers.swift b/src/Helpers.swift index e2bbdd9..2f4ad7a 100644 --- a/src/Helpers.swift +++ b/src/Helpers.swift @@ -7,10 +7,19 @@ fileprivate let logger = Logger( category: String("Helpers") ) +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 +} + struct Program { let path: String let name: String let ext: String + var img: NSImage? } func keyName(virtualKeyCode: UInt16) -> String? { diff --git a/src/Makefile b/src/Makefile index d70268a..8608f35 100644 --- a/src/Makefile +++ b/src/Makefile @@ -11,7 +11,7 @@ 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 + ProgramTableViewCell.swift AppDelegate.swift main.swift ARMOBJMODULES = $(addprefix ./arm64/,$(SRCMODULES:.swift=.o)) X86OBJMODULES = $(addprefix ./x86_64/,$(SRCMODULES:.swift=.o)) @@ -27,19 +27,21 @@ FRAMEWORKS = -framework AppKit -framework ServiceManagement # 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 $@ + -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 $@ + $(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 \ @@ -51,8 +53,8 @@ endif 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 \ diff --git a/src/PathManager.swift b/src/PathManager.swift index 70eacb3..b29d5eb 100644 --- a/src/PathManager.swift +++ b/src/PathManager.swift @@ -69,11 +69,13 @@ final class PathManager { 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")) + path: path, name: name, ext: ".app", + img: nil)) } } } diff --git a/src/ProgramTableViewCell.swift b/src/ProgramTableViewCell.swift new file mode 100644 index 0000000..a6334a3 --- /dev/null +++ b/src/ProgramTableViewCell.swift @@ -0,0 +1,78 @@ +import AppKit + +class ProgramTableRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + if self.selectionHighlightStyle != .none { + let selectionColor = NSColor.systemBlue + selectionColor.setFill() + self.bounds.fill() + } + } +} + +class ProgramTableViewCell: NSTableCellView { + var id: Int = -1 + + private(set) var isEditing = false + + public var appIconImage: NSImageView = { + let image = NSImageView() + image.image = + NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) + image.imageScaling = .scaleAxesIndependently + image.translatesAutoresizingMaskIntoConstraints = false + return image + }() + + public var titleField: NSTextField = { + let field = NSTextField(labelWithString: "") + field.lineBreakMode = .byTruncatingTail + field.translatesAutoresizingMaskIntoConstraints = false + return field + }() + + 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.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + addSubview(appIconImage) + addSubview(titleField) + addSubview(progPathLabel) + + NSLayoutConstraint.activate([ + appIconImage.widthAnchor.constraint(equalToConstant: 40), + appIconImage.heightAnchor.constraint(equalToConstant: 40), + appIconImage.topAnchor.constraint(equalTo: topAnchor), + appIconImage.bottomAnchor.constraint(equalTo: bottomAnchor), + appIconImage.leadingAnchor.constraint(equalTo: leadingAnchor), + + 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), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/src/SearchViewController.swift b/src/SearchViewController.swift index 5431848..366703b 100644 --- a/src/SearchViewController.swift +++ b/src/SearchViewController.swift @@ -1,22 +1,20 @@ import AppKit import OSLog -fileprivate enum ViewConstants { - static let spacing2: CGFloat = 2 - static let spacing10: CGFloat = 10 - static let spacing20: CGFloat = 20 - static let spacing40: CGFloat = 40 -} - class SearchViewController: NSViewController, NSTextFieldDelegate, - NSPopoverDelegate + NSPopoverDelegate, NSTableViewDataSource, NSTableViewDelegate { fileprivate static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: SearchViewController.self) ) - var foundProgram: Program? = nil + private var keyboardEvents: EventMonitor? + + private var foundProgram: Program? = nil + private var programsList: [Program] = [] + + private var programsTableViewSelection = 0 private var settingsPopover: NSPopover = { let popover = NSPopover() @@ -37,21 +35,11 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, private var searchInput: EditableNSTextField = { let textField = EditableNSTextField() textField.placeholderString = "Search programs . . ." + textField.usesSingleLineMode = false textField.bezelStyle = .roundedBezel - textField.translatesAutoresizingMaskIntoConstraints = false - return textField - }() - - private var programsLabel: NSTextField = { - let textField = NSTextField() - textField.stringValue = "" - textField.isEditable = false - textField.isBezeled = false - textField.drawsBackground = false - textField.alignment = .left textField.font = NSFont.systemFont( ofSize: NSFontDescriptor.preferredFontDescriptor( - forTextStyle: .body).pointSize, weight: .bold) + forTextStyle: .title3).pointSize, weight: .medium) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() @@ -68,31 +56,70 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, return button }() + private var tableScrollView: NSScrollView = { + let scroll = NSScrollView() + scroll.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + scroll.drawsBackground = false + scroll.translatesAutoresizingMaskIntoConstraints = false + return scroll + }() + + private var programsTableView: MyNSTableView = { + let table = MyNSTableView() + + table.style = NSTableView.Style.plain + table.backgroundColor = .clear + table.usesAutomaticRowHeights = true + + table.headerView = nil + table.allowsMultipleSelection = false + table.allowsColumnReordering = false + table.allowsColumnResizing = false + table.allowsColumnSelection = false + table.addTableColumn(NSTableColumn( + identifier: NSUserInterfaceItemIdentifier("Program"))) + + table.doubleAction = #selector(tableDoubleClick) + + table.translatesAutoresizingMaskIntoConstraints = false + return table + }() + private func addSubviews() { view.addSubview(appIconImage) view.addSubview(searchInput) - view.addSubview(programsLabel) view.addSubview(settingsButton) + view.addSubview(tableScrollView) } + var viewBottomAnchorTable: NSLayoutConstraint? + var viewBottomAnchorImage: NSLayoutConstraint? + private func setConstraints() { + viewBottomAnchorTable = tableScrollView.bottomAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -ViewConstants.spacing10) + viewBottomAnchorImage = appIconImage.bottomAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -ViewConstants.spacing10) + + viewBottomAnchorTable?.isActive = false + viewBottomAnchorImage?.isActive = true + NSLayoutConstraint.activate([ - appIconImage.widthAnchor.constraint(equalToConstant: 70), + appIconImage.widthAnchor.constraint(equalToConstant: 60), 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), + constant: ViewConstants.spacing10), appIconImage.leadingAnchor.constraint( equalTo: view.leadingAnchor, constant: ViewConstants.spacing10), searchInput.widthAnchor.constraint(equalToConstant: 300), - searchInput.topAnchor.constraint( - equalTo: appIconImage.topAnchor), + searchInput.centerYAnchor.constraint( + equalTo: appIconImage.centerYAnchor), searchInput.leadingAnchor.constraint( equalTo: appIconImage.trailingAnchor, constant: ViewConstants.spacing10), @@ -106,24 +133,74 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, equalTo: view.trailingAnchor, constant: -ViewConstants.spacing10), - programsLabel.topAnchor.constraint( - equalTo: searchInput.bottomAnchor, + tableScrollView.heightAnchor.constraint(equalToConstant: 210), + tableScrollView.topAnchor.constraint( + equalTo: appIconImage.bottomAnchor, constant: ViewConstants.spacing10), - programsLabel.leadingAnchor.constraint( - equalTo: appIconImage.trailingAnchor, - constant: ViewConstants.spacing10), - programsLabel.trailingAnchor.constraint( - equalTo: searchInput.trailingAnchor), + tableScrollView.leadingAnchor.constraint( + equalTo: view.leadingAnchor), + tableScrollView.trailingAnchor.constraint( + equalTo: view.trailingAnchor) ]) } override func viewDidLoad() { super.viewDidLoad() + keyboardEvents = LocalEventMonitor(mask: [.keyDown], handler: + { [weak self] event in + let key = event.keyCode + let modifiers = event.modifierFlags.rawValue + let command = NSEvent.ModifierFlags.command.rawValue + let shift = NSEvent.ModifierFlags.shift.rawValue + let control = NSEvent.ModifierFlags.control.rawValue + let option = NSEvent.ModifierFlags.option.rawValue + + // TODO: Implement helper functions for modifiers. + if let controller = self { + if ((modifiers & control) == control && + (modifiers & (command | shift | option)) == 0 && + key == 35) || // P + (modifiers & (command | control | shift | option)) == 0 && + (key == 126) // UP + { + controller.programsTableViewSelection -= 1 + } else if ((modifiers & control) == control && + (modifiers & (command | shift | option)) == 0 && + key == 45) || // N + (modifiers & (command | control | shift | option)) == 0 && + (key == 125) // DOWN + { + controller.programsTableViewSelection += 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.scrollRowToVisible(select) + } + + return event + }) + settingsPopover.delegate = self searchInput.delegate = self + tableScrollView.documentView = programsTableView + programsTableView.dataSource = self + programsTableView.delegate = self + addSubviews() setConstraints() } @@ -131,17 +208,37 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, override func viewDidAppear() { super.viewDidAppear() - self.view.window?.center() + keyboardEvents?.start() + view.window?.center() + + view.window?.makeFirstResponder(searchInput) // searchInput should select all text whenever window appears. NSApp.sendAction(#selector(NSResponder.selectAll(_:)), to: nil, from: self) } + override func viewDidDisappear() { + super.viewDidDisappear() + + keyboardEvents?.stop() + } + override func loadView() { self.view = NSView() } + private func reloadProgramsTableViewData() { + if programsList.count > 0 { + viewBottomAnchorTable?.isActive = true + viewBottomAnchorImage?.isActive = false + } else { + viewBottomAnchorTable?.isActive = false + viewBottomAnchorImage?.isActive = true + } + programsTableView.reloadData() + } + @objc func openSettings() { // HACK: This is an interseting behavior. When NSPopover appears @@ -154,38 +251,71 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, of: settingsButton, preferredEdge: .maxY) } + @objc + private func tableDoubleClick() { + let program = programsList[programsTableView.clickedRow] + openProgram(program) + } + + private func openProgram(_ program: Program) { + 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 + if let error = error { + Self.logger.debug("\(error.localizedDescription)") + } else { + Self.logger.debug("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() + } + } + } + } + } + func controlTextDidChange(_ obj: Notification) { guard let searchInput = obj.object as? EditableNSTextField else { return } - var list = "" - let programs = PathManager.shared.programs - for program in programs { + + programsList = [] + for i in programs.indices { + var program = programs[i] + if programsList.count >= 10 { + break + } if program.name.lowercased().contains( searchInput.stringValue.lowercased()) { - if !list.isEmpty { - list += ", " - } - list += program.name + program.ext - foundProgram = program - break - } else { - foundProgram = nil + 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 let program = foundProgram { - programsLabel.stringValue = - program.name + program.ext + " (\(program.path))" + reloadProgramsTableViewData() + programsTableViewSelection = 0 + programsTableView.selectRowIndexes( + IndexSet(integer: programsTableViewSelection), + byExtendingSelection: false) + programsTableView.scrollRowToVisible(programsTableViewSelection) + + if programsList.count > 0 { + let program = programsList[0] 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) } @@ -195,32 +325,19 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, 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 config = NSWorkspace.OpenConfiguration() - - NSWorkspace.shared.openApplication(at: url, - configuration: config) - { [weak self] application, error in - if let error = error { - Self.logger.debug("\(error.localizedDescription)") - } else { - Self.logger.debug("Program opened successfully") - DispatchQueue.main.async { - if let window = self?.view.window { - window.resignKey() - } - } - } - } - } + let program = programsList[programsTableViewSelection] + openProgram(program) 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(_:)) + { + // Ignore arrows keys up or down because we use those to + // navigate the programs list. + return true } return false @@ -233,4 +350,35 @@ class SearchViewController: NSViewController, NSTextFieldDelegate, func popoverWillClose(_ notification: Notification) { searchInput.becomeFirstResponder() } + + func numberOfRows(in tableView: NSTableView) -> Int { + return programsList.count + } + + func tableView(_ tableView: NSTableView, + rowViewForRow row: Int) -> NSTableRowView? + { + return ProgramTableRowView() + } + + func tableView(_ tableView: NSTableView, + viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + let cell = ProgramTableViewCell() + let program = programsList[row] + + // PERF: This is very slow, even with 10 items on the list! It has + // to be the image of concern. UIKit has reusable cells, + // is that possible? Or is fetching an image is slow? + cell.titleField.stringValue = program.name + program.ext + cell.progPathLabel.stringValue = program.path + cell.appIconImage.image = program.img + cell.id = row + + return cell + } +} + +final class MyNSTableView: NSTableView { + override var acceptsFirstResponder: Bool { false } } diff --git a/src/SettingsViewController.swift b/src/SettingsViewController.swift index 4ba4a2b..8a88faf 100644 --- a/src/SettingsViewController.swift +++ b/src/SettingsViewController.swift @@ -3,14 +3,6 @@ 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 @@ -51,13 +43,13 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, 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 + button.title = "⌃" + button.action = #selector(handleModifiers) + button.setButtonType(.pushOnPushOff) + button.sizeToFit() + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + return button }() private var cmdButton: NSButton = { @@ -321,8 +313,6 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate, pathsTableView.dataSource = self pathsTableView.delegate = self - pathsTableView.delegate = self - pathsControl.target = self pathsControl.action = #selector(affectPaths(_:))