Files
Grapp/src/SearchViewController.swift
T
2025-01-09 18:15:20 -08:00

381 lines
13 KiB
Swift

import AppKit
import Carbon
class SearchViewController: NSViewController, NSTextFieldDelegate,
NSPopoverDelegate, NSTableViewDataSource, NSTableViewDelegate
{
private var keyboardEvents: EventMonitor?
private var foundProgram: Program? = nil
private var programsList: [Program] = []
private var programsTableViewSelection = 0
private var settingsPopover: NSPopover = {
let popover = NSPopover()
popover.contentViewController = SettingsViewController()
popover.behavior = .transient
return popover
}()
private var appIconImage: NSImageView = {
let image = NSImageView()
image.image =
NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath)
image.imageScaling = .scaleAxesIndependently
image.translatesAutoresizingMaskIntoConstraints = false
return image
}()
private var searchInput: EditableNSTextField = {
let textField = EditableNSTextField()
textField.placeholderString = "Search programs . . ."
textField.usesSingleLineMode = false
textField.bezelStyle = .roundedBezel
textField.font = NSFont.systemFont(
ofSize: NSFontDescriptor.preferredFontDescriptor(
forTextStyle: .title3).pointSize, weight: .medium)
textField.translatesAutoresizingMaskIntoConstraints = false
return textField
}()
private var settingsButton: NSButton = {
let button = NSButton()
button.image = systemImage("gearshape.fill", .title2, .large,
.init(paletteColors: [.white, .systemRed]))
button.isBordered = false
button.action = #selector(openSettings)
button.sizeToFit()
button.toolTip = "Quit"
button.translatesAutoresizingMaskIntoConstraints = false
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: ProgramsTableView = {
let table = ProgramsTableView()
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(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: 60),
appIconImage.heightAnchor.constraint(
equalTo: appIconImage.widthAnchor, multiplier: 1),
appIconImage.topAnchor.constraint(equalTo: view.topAnchor,
constant: ViewConstants.spacing10),
appIconImage.leadingAnchor.constraint(
equalTo: view.leadingAnchor,
constant: ViewConstants.spacing10),
searchInput.widthAnchor.constraint(equalToConstant: 300),
searchInput.centerYAnchor.constraint(
equalTo: appIconImage.centerYAnchor),
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),
tableScrollView.heightAnchor.constraint(equalToConstant: 210),
tableScrollView.topAnchor.constraint(
equalTo: appIconImage.bottomAnchor,
constant: ViewConstants.spacing10),
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
// 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
{
controller.programsTableViewSelection -= 1
} 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
} 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()
}
override func viewDidAppear() {
super.viewDidAppear()
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
// 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)
}
@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 {
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()
}
}
}
}
}
func controlTextDidChange(_ obj: Notification) {
guard let searchInput = obj.object as? EditableNSTextField
else { return }
let programs = PathManager.shared.programs
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)
}
}
reloadProgramsTableViewData()
programsTableViewSelection = 0
programsTableView.selectRowIndexes(
IndexSet(integer: programsTableViewSelection),
byExtendingSelection: false)
programsTableView.scrollRowToVisible(programsTableViewSelection)
if programsList.count > 0 {
appIconImage.image = programsList[0].img
} else {
appIconImage.image =
NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath)
}
}
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)
}
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
}
func popoverWillShow(_ notification: Notification) {
searchInput.abortEditing()
}
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?
// searchInput.stringValue
let app = program.name + program.ext
let rangeToHighlight =
(app.lowercased() as NSString)
.range(of: searchInput.stringValue.lowercased())
let attributedString = NSMutableAttributedString(string: app)
attributedString.addAttributes(
[.backgroundColor: NSColor.systemOrange],
range: rangeToHighlight)
cell.titleField.attributedStringValue = attributedString
cell.progPathLabel.stringValue = program.path
cell.appIconImage.image = program.img
cell.id = row
return cell
}
}