516 lines
19 KiB
Swift
516 lines
19 KiB
Swift
import AppKit
|
|
import Carbon
|
|
|
|
// NOTE: This is the corner radius of the backgrounView view that acts as
|
|
// a window frame.
|
|
fileprivate let windowCornerRadius = 15.0
|
|
|
|
// NOTE: This is the maximum number of elements shown when searching.
|
|
fileprivate let maxItems = 20
|
|
|
|
class SearchViewController: NSViewController, NSTextFieldDelegate,
|
|
NSTableViewDataSource, NSTableViewDelegate
|
|
{
|
|
private var keyboardEvents: EventMonitor?
|
|
|
|
private var listIndex = 0
|
|
private var programsList: [Program] = []
|
|
private var programsListCells: [ProgramsTableViewCell] = []
|
|
|
|
private var programsTableViewSelection = 0
|
|
|
|
private var shadowView: ShadowView = {
|
|
let view = ShadowView()
|
|
view.translatesAutoresizingMaskIntoConstraints = false
|
|
return view
|
|
}()
|
|
|
|
private var backgroundView: NSVisualEffectView = {
|
|
let effect = NSVisualEffectView()
|
|
effect.blendingMode = .behindWindow
|
|
effect.state = .active
|
|
effect.material = .popover
|
|
|
|
effect.wantsLayer = true
|
|
effect.layer?.masksToBounds = true
|
|
|
|
effect.layer?.borderWidth = 1
|
|
effect.layer?.cornerRadius = windowCornerRadius
|
|
|
|
effect.translatesAutoresizingMaskIntoConstraints = false
|
|
return effect
|
|
}()
|
|
|
|
private var contentView: NSView = {
|
|
let view = NSView()
|
|
// Clip all content to window's rounded frame emulated by
|
|
// backgroundView.
|
|
view.wantsLayer = true
|
|
view.layer?.masksToBounds = true
|
|
view.layer?.cornerRadius = windowCornerRadius
|
|
|
|
view.translatesAutoresizingMaskIntoConstraints = false
|
|
return view
|
|
}()
|
|
|
|
private var searchInput: EditableNSTextField = {
|
|
let textField = EditableNSTextField()
|
|
textField.isBezeled = false
|
|
textField.maximumNumberOfLines = 1
|
|
textField.usesSingleLineMode = true
|
|
textField.lineBreakMode = .byTruncatingHead
|
|
textField.focusRingType = .none
|
|
textField.placeholderString = "Program Search"
|
|
textField.isAutomaticTextCompletionEnabled = false
|
|
textField.bezelStyle = .roundedBezel
|
|
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: [.white, .systemGray]))
|
|
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.automaticallyAdjustsContentInsets = false
|
|
scroll.contentInsets = NSEdgeInsets(top: 0, left: 0,
|
|
bottom: ViewConstants.spacing10,
|
|
right: 0)
|
|
scroll.drawsBackground = false
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
return scroll
|
|
}()
|
|
|
|
private var programsTableView: ProgramsTableView = {
|
|
let table = ProgramsTableView()
|
|
|
|
table.style = .plain
|
|
table.intercellSpacing = NSSize.zero
|
|
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(shadowView)
|
|
view.addSubview(backgroundView)
|
|
view.addSubview(contentView)
|
|
|
|
contentView.addSubview(searchInput)
|
|
contentView.addSubview(settingsButton)
|
|
contentView.addSubview(tableScrollView)
|
|
}
|
|
|
|
var tableViewHeightAnchor: NSLayoutConstraint?
|
|
|
|
private func setConstraints() {
|
|
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),
|
|
|
|
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),
|
|
|
|
searchInput.widthAnchor
|
|
.constraint(equalToConstant: 400),
|
|
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),
|
|
|
|
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)
|
|
])
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// NOTE: This needs removeObserver on deinit. However, technically
|
|
// it doesn't matter because if this view controller doesn't
|
|
// exists, then the whole program is pointless. So, let the OS
|
|
// clean it up when the program is killed.
|
|
DistributedNotificationCenter.default
|
|
.addObserver(self, selector: #selector(osThemeChanged(sender:)),
|
|
name: NSNotification
|
|
.Name(rawValue: "AppleInterfaceThemeChangedNotification"),
|
|
object: nil)
|
|
|
|
// Initialize an array of programs and reusable cells.
|
|
for i in 0..<maxItems {
|
|
programsList.append(Program())
|
|
|
|
let cell = ProgramsTableViewCell()
|
|
cell.indexLabel.stringValue = "\(i+1)"
|
|
programsListCells.append(cell)
|
|
}
|
|
|
|
updateViewsBasedOnOSTheme()
|
|
|
|
view.wantsLayer = true
|
|
view.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
keyboardEvents = LocalEventMonitor(mask: [.keyDown])
|
|
{ [weak self] event in
|
|
let key = event.keyCode
|
|
let modifiers = event.modifierFlags.rawValue
|
|
|
|
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
|
|
} else if modsContains(keys: OSCtrl | OSCmd, in: modifiers) &&
|
|
key == kVK_ANSI_P ||
|
|
modsContains(keys: OSCmd, in: modifiers) &&
|
|
key == kVK_UpArrow
|
|
{
|
|
controller.programsTableViewSelection = 0
|
|
} else if modsContains(keys: OSCtrl | OSCmd, in: modifiers) &&
|
|
key == kVK_ANSI_N ||
|
|
modsContains(keys: OSCmd, in: modifiers) &&
|
|
key == kVK_DownArrow
|
|
{
|
|
controller.programsTableViewSelection = controller.listIndex-1
|
|
} else if modsContains(keys: OSCmd, in: modifiers) &&
|
|
isNumericalCode(key)
|
|
{
|
|
if key == kVK_ANSI_1
|
|
{ controller.programsTableViewSelection = 0 }
|
|
if key == kVK_ANSI_2
|
|
{ controller.programsTableViewSelection = 1 }
|
|
if key == kVK_ANSI_3
|
|
{ controller.programsTableViewSelection = 2 }
|
|
if key == kVK_ANSI_4
|
|
{ controller.programsTableViewSelection = 3 }
|
|
if key == kVK_ANSI_5
|
|
{ controller.programsTableViewSelection = 4 }
|
|
if key == kVK_ANSI_6
|
|
{ controller.programsTableViewSelection = 5 }
|
|
if key == kVK_ANSI_7
|
|
{ controller.programsTableViewSelection = 6 }
|
|
if key == kVK_ANSI_8
|
|
{ controller.programsTableViewSelection = 7 }
|
|
if key == kVK_ANSI_9
|
|
{ controller.programsTableViewSelection = 8 }
|
|
}
|
|
|
|
if controller.programsTableViewSelection >
|
|
controller.listIndex-1
|
|
{
|
|
controller.programsTableViewSelection =
|
|
controller.listIndex-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
|
|
}
|
|
|
|
searchInput.delegate = self
|
|
|
|
tableScrollView.documentView = programsTableView
|
|
programsTableView.dataSource = self
|
|
programsTableView.delegate = self
|
|
|
|
addSubviews()
|
|
setConstraints()
|
|
}
|
|
|
|
override func viewDidAppear() {
|
|
super.viewDidAppear()
|
|
|
|
keyboardEvents?.start()
|
|
|
|
centerWindow()
|
|
|
|
view.window?.makeFirstResponder(searchInput)
|
|
// searchInput select all text whenever window appears.
|
|
NSApp.sendAction(#selector(NSResponder.selectAll(_:)), to: nil,
|
|
from: self)
|
|
}
|
|
|
|
override func viewWillAppear() {
|
|
super.viewWillAppear()
|
|
|
|
PathManager.shared.touchPaths()
|
|
}
|
|
|
|
override func viewDidDisappear() {
|
|
super.viewDidDisappear()
|
|
|
|
keyboardEvents?.stop()
|
|
}
|
|
|
|
override func loadView() {
|
|
self.view = NSView()
|
|
}
|
|
|
|
func centerWindow() {
|
|
if let win = view.window, let scrn = NSScreen.main {
|
|
let x = (scrn.visibleFrame.origin.x +
|
|
scrn.visibleFrame.size.width / 2) -
|
|
(win.frame.size.width / 2)
|
|
let y = (scrn.visibleFrame.origin.y +
|
|
scrn.visibleFrame.size.height * 0.9) -
|
|
win.frame.size.height
|
|
view.window?.setFrameOrigin(NSPoint(x: x, y: y))
|
|
}
|
|
}
|
|
|
|
private func reloadProgramsTableViewData() {
|
|
if listIndex > 0 {
|
|
// TODO: Why is this located here, randomly? Make it a global
|
|
// config variable.
|
|
tableViewHeightAnchor?.constant = 250
|
|
} else {
|
|
tableViewHeightAnchor?.constant = 0
|
|
}
|
|
programsTableView.reloadData()
|
|
}
|
|
|
|
@objc
|
|
func openSettings() {
|
|
delegate.showSettingsWindow()
|
|
}
|
|
|
|
@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)
|
|
DispatchQueue.main.async { [weak self] in
|
|
if let window = self?.view.window {
|
|
window.resignKey()
|
|
}
|
|
}
|
|
}
|
|
|
|
func controlTextDidChange(_ obj: Notification) {
|
|
guard let searchInput =
|
|
obj.object as? EditableNSTextField else { return }
|
|
|
|
listIndex = 0
|
|
if !searchInput.stringValue.isEmpty {
|
|
outerloop: for path in PathManager.shared.paths {
|
|
for i in path.value.indices {
|
|
if listIndex >= maxItems { break outerloop }
|
|
let prog = path.value[i]
|
|
|
|
if (prog.name.lowercased() + prog.ext)
|
|
.contains(searchInput.stringValue.lowercased())
|
|
{
|
|
programsList[listIndex].path = prog.path
|
|
programsList[listIndex].name = prog.name
|
|
programsList[listIndex].ext = prog.ext
|
|
programsList[listIndex].img = NSWorkspace.shared
|
|
.icon(forFile: URL(
|
|
fileURLWithPath: prog.path
|
|
).appendingPathComponent(
|
|
prog.name+prog.ext).path
|
|
)
|
|
listIndex += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
reloadProgramsTableViewData()
|
|
|
|
programsTableViewSelection = 0
|
|
programsTableView.selectRowIndexes(
|
|
IndexSet(integer: programsTableViewSelection),
|
|
byExtendingSelection: false
|
|
)
|
|
programsTableView.scrollRowToVisible(programsTableViewSelection)
|
|
}
|
|
|
|
func control(_ control: NSControl, textView: NSTextView,
|
|
doCommandBy commandSelector: Selector) -> Bool
|
|
{
|
|
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
|
if listIndex > 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 up and down because we use those to navigate
|
|
// the programs list.
|
|
return true
|
|
} else if commandSelector ==
|
|
#selector(NSResponder.moveToBeginningOfDocument(_:))
|
|
{
|
|
// Ignore command plus up and down arrows because we use those
|
|
// to move to the beginning and end of the list.
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func popoverWillShow(_ notification: Notification) {
|
|
searchInput.abortEditing()
|
|
}
|
|
|
|
func popoverWillClose(_ notification: Notification) {
|
|
searchInput.becomeFirstResponder()
|
|
}
|
|
|
|
func numberOfRows(in tableView: NSTableView) -> Int {
|
|
return listIndex
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView,
|
|
rowViewForRow row: Int) -> NSTableRowView?
|
|
{
|
|
return ProgramsTableRowView()
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView,
|
|
viewFor tableColumn: NSTableColumn?,
|
|
row: Int) -> NSView?
|
|
{
|
|
let cell = programsListCells[row]
|
|
let program = programsList[row]
|
|
|
|
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.systemYellow.withAlphaComponent(0.5)],
|
|
range: rangeToHighlight)
|
|
cell.titleField.attributedStringValue = attributedString
|
|
cell.progPathLabel.stringValue = program.path
|
|
cell.appIconImage.image = program.img
|
|
cell.id = row
|
|
|
|
return cell
|
|
}
|
|
|
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
|
if programsTableView.selectedRow != programsTableViewSelection {
|
|
programsTableViewSelection = programsTableView.selectedRow
|
|
}
|
|
}
|
|
|
|
@objc func osThemeChanged(sender: NSNotification) {
|
|
updateViewsBasedOnOSTheme()
|
|
}
|
|
|
|
@objc func updateViewsBasedOnOSTheme() {
|
|
if NSApp.windows.first?.effectiveAppearance
|
|
.bestMatch(from: [.darkAqua, .vibrantDark]) == .darkAqua
|
|
{ // dark
|
|
backgroundView.layer?.borderColor =
|
|
NSColor.white.withAlphaComponent(0.2).cgColor
|
|
} else { // light
|
|
backgroundView.layer?.borderColor =
|
|
NSColor.black.withAlphaComponent(0.2).cgColor
|
|
}
|
|
}
|
|
}
|