381 lines
13 KiB
Swift
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
|
|
}
|
|
}
|