mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-07 13:27:27 +00:00
- Hover overlay: bounds-check the hovered row / column before asking the table for a cell view (NSTableView raised on a row removed by a reload, aborting the app), and refresh after the update pass instead of inside SwiftUI's updateNSView / constraints pass. - Status bar details use a click-through hover panel (0.5 s, updated live) instead of .help, whose delay restarted at every refresh. - First walks of root folders are announced: status bar "Indexing <folder>… N found" and per-folder status in Settings > Indexes > Folders, plus "Not available" for unreachable roots. - New roots and folders stream into the index every 3 s while walked, on a dedicated queue so long walks never delay FSEvents updates. - A new folder's own entry is merged only when its walk completes, so an interrupted walk is redone on the next launch or replay. - CHANGELOG: 1.0.10.
940 lines
35 KiB
Swift
940 lines
35 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
// Actions the results table asks its owner to perform on a set of records.
|
|
enum ResultAction {
|
|
case primary // double-click / Return (open or reveal, per Settings)
|
|
case open // open with the default app
|
|
case openWith(URL) // open with a specific app
|
|
case reveal // reveal in Finder
|
|
case quickLook // Quick Look panel
|
|
case copyFiles // copy the files themselves
|
|
case copyNames // copy names, one per line
|
|
case copyPaths // copy full paths, one per line
|
|
case trash // move to Trash
|
|
case authorize // stage an unreadable file through an admin prompt
|
|
}
|
|
|
|
// ResultsTable shows every search result in a native NSTableView. Only the
|
|
// rows on screen get views, so the list isn't capped: a query matching the
|
|
// whole index scrolls as smoothly as one matching ten files. It also gives
|
|
// Finder-accurate drag and drop (file URLs on the pasteboard), column
|
|
// order / width / visibility autosave, and Finder-style keyboard shortcuts.
|
|
struct ResultsTable: NSViewRepresentable {
|
|
|
|
// all results, already filtered and sorted
|
|
let records: ResultList
|
|
// bumped by the search model whenever records change (cheap identity)
|
|
let version: Int
|
|
// what to emphasize in names / folders
|
|
let highlights: SearchEngine.Highlights
|
|
// false to show plain text (Settings > General)
|
|
let highlightEnabled: Bool
|
|
// selected records, in row order (handed out directly so the window
|
|
// never has to look them up among hundreds of thousands of results)
|
|
@Binding var selection: [FileRecord]
|
|
// active sort, driven by header clicks
|
|
@Binding var sort: FileSortDescriptor
|
|
// true when the record isn't readable without authorization
|
|
let needsAuthorization: (FileRecord) -> Bool
|
|
// runs an action on records
|
|
let perform: (ResultAction, [FileRecord]) -> Void
|
|
|
|
// column identifiers, also the autosave keys
|
|
static let kNameColumn = NSUserInterfaceItemIdentifier("name")
|
|
static let kPathColumn = NSUserInterfaceItemIdentifier("path")
|
|
static let kSizeColumn = NSUserInterfaceItemIdentifier("size")
|
|
static let kCreatedColumn = NSUserInterfaceItemIdentifier("created")
|
|
static let kModifiedColumn = NSUserInterfaceItemIdentifier("modified")
|
|
|
|
// creates the coordinator acting as data source and delegate
|
|
func makeCoordinator() -> Coordinator {
|
|
return Coordinator(self)
|
|
}
|
|
|
|
// builds the scroll view, table and columns
|
|
func makeNSView(context inContext: Context) -> NSScrollView {
|
|
let vTable = ResultsNSTableView()
|
|
vTable.coordinator = inContext.coordinator
|
|
vTable.allowsMultipleSelection = true
|
|
vTable.allowsColumnReordering = true
|
|
vTable.allowsColumnResizing = true
|
|
vTable.usesAlternatingRowBackgroundColors = true
|
|
vTable.style = .fullWidth
|
|
vTable.columnAutoresizingStyle = .noColumnAutoresizing
|
|
vTable.intercellSpacing = NSSize(width: 6, height: 2)
|
|
|
|
for (vId, vTitle, vWidth, vMin) in [
|
|
(Self.kNameColumn, "Name", 320.0, 160.0),
|
|
(Self.kPathColumn, "Path", 380.0, 120.0),
|
|
(Self.kSizeColumn, "Size", 90.0, 60.0),
|
|
(Self.kCreatedColumn, "Created", 140.0, 90.0),
|
|
(Self.kModifiedColumn, "Modified", 140.0, 90.0)
|
|
] {
|
|
let vColumn = NSTableColumn(identifier: vId)
|
|
vColumn.title = vTitle
|
|
vColumn.width = vWidth
|
|
vColumn.minWidth = vMin
|
|
vColumn.sortDescriptorPrototype = NSSortDescriptor(key: vId.rawValue, ascending: true)
|
|
vTable.addTableColumn(vColumn)
|
|
}
|
|
// remembers column order, widths and hidden columns across launches
|
|
vTable.autosaveName = "Allofit.ResultsTable"
|
|
vTable.autosaveTableColumns = true
|
|
|
|
vTable.dataSource = inContext.coordinator
|
|
vTable.delegate = inContext.coordinator
|
|
vTable.target = inContext.coordinator
|
|
vTable.doubleAction = #selector(Coordinator.doubleClicked(_:))
|
|
// dragging out of the app copies the files (like Finder)
|
|
vTable.setDraggingSourceOperationMask(.copy, forLocal: false)
|
|
|
|
let vMenu = NSMenu()
|
|
vMenu.delegate = inContext.coordinator
|
|
vTable.menu = vMenu
|
|
inContext.coordinator.installHeaderMenu(on: vTable)
|
|
|
|
let vScroll = NSScrollView()
|
|
vScroll.documentView = vTable
|
|
vScroll.hasVerticalScroller = true
|
|
vScroll.hasHorizontalScroller = true
|
|
vScroll.autohidesScrollers = true
|
|
vScroll.borderType = .noBorder
|
|
inContext.coordinator.table = vTable
|
|
inContext.coordinator.apply(inParent: self, inForce: true)
|
|
return vScroll
|
|
}
|
|
|
|
// pushes new results / sort / selection into the table
|
|
func updateNSView(_ inView: NSScrollView, context inContext: Context) {
|
|
inContext.coordinator.apply(inParent: self, inForce: false)
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Sort mapping
|
|
// ===========================
|
|
|
|
// sort mode for a column key + direction
|
|
static func descriptor(forKey inKey: String, ascending inAscending: Bool) -> FileSortDescriptor {
|
|
switch inKey {
|
|
case kPathColumn.rawValue: return inAscending ? .pathAscending : .pathDescending
|
|
case kSizeColumn.rawValue: return inAscending ? .sizeAscending : .sizeDescending
|
|
case kCreatedColumn.rawValue: return inAscending ? .createdAscending : .createdDescending
|
|
case kModifiedColumn.rawValue: return inAscending ? .modifiedAscending : .modifiedDescending
|
|
default: return inAscending ? .nameAscending : .nameDescending
|
|
}
|
|
}
|
|
|
|
// column key + direction for a sort mode
|
|
static func sortDescriptor(for inSort: FileSortDescriptor) -> NSSortDescriptor {
|
|
switch inSort {
|
|
case .nameAscending: return NSSortDescriptor(key: kNameColumn.rawValue, ascending: true)
|
|
case .nameDescending: return NSSortDescriptor(key: kNameColumn.rawValue, ascending: false)
|
|
case .pathAscending: return NSSortDescriptor(key: kPathColumn.rawValue, ascending: true)
|
|
case .pathDescending: return NSSortDescriptor(key: kPathColumn.rawValue, ascending: false)
|
|
case .sizeAscending: return NSSortDescriptor(key: kSizeColumn.rawValue, ascending: true)
|
|
case .sizeDescending: return NSSortDescriptor(key: kSizeColumn.rawValue, ascending: false)
|
|
case .createdAscending: return NSSortDescriptor(key: kCreatedColumn.rawValue, ascending: true)
|
|
case .createdDescending: return NSSortDescriptor(key: kCreatedColumn.rawValue, ascending: false)
|
|
case .modifiedAscending: return NSSortDescriptor(key: kModifiedColumn.rawValue, ascending: true)
|
|
case .modifiedDescending: return NSSortDescriptor(key: kModifiedColumn.rawValue, ascending: false)
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Coordinator
|
|
// ===========================
|
|
|
|
// Coordinator feeds the table from the current records and turns table
|
|
// events (selection, sort clicks, menus, drags) into SwiftUI updates.
|
|
@MainActor
|
|
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
|
|
|
// latest SwiftUI description of the table
|
|
var parent: ResultsTable
|
|
// the table, set once created
|
|
weak var table: NSTableView?
|
|
// rows currently shown
|
|
private var records = ResultList()
|
|
// version of `records`, to skip redundant reloads
|
|
private var shownVersion = -1
|
|
// highlight settings the visible rows were drawn with
|
|
private var shownHighlights = SearchEngine.Highlights()
|
|
private var shownHighlightEnabled = true
|
|
// sort the header currently shows
|
|
private var shownSort: FileSortDescriptor?
|
|
// true while the coordinator itself changes selection / sort, so the
|
|
// resulting delegate callbacks aren't echoed back to SwiftUI
|
|
private var isApplying = false
|
|
|
|
// binds to the initial SwiftUI description
|
|
init(_ inParent: ResultsTable) {
|
|
parent = inParent
|
|
}
|
|
|
|
// syncs the table with a (possibly) new SwiftUI description
|
|
func apply(inParent: ResultsTable, inForce: Bool) {
|
|
parent = inParent
|
|
guard let vTable = table else { return }
|
|
isApplying = true
|
|
defer { isApplying = false }
|
|
|
|
if shownSort != inParent.sort {
|
|
shownSort = inParent.sort
|
|
vTable.sortDescriptors = [ResultsTable.sortDescriptor(for: inParent.sort)]
|
|
}
|
|
let vHighlightsChanged = shownHighlights != inParent.highlights
|
|
|| shownHighlightEnabled != inParent.highlightEnabled
|
|
if inForce || shownVersion != inParent.version || vHighlightsChanged {
|
|
shownVersion = inParent.version
|
|
shownHighlights = inParent.highlights
|
|
shownHighlightEnabled = inParent.highlightEnabled
|
|
records = inParent.records
|
|
vTable.reloadData()
|
|
// background refreshes reload often: keep the overlay of
|
|
// the hovered cell instead of making it flicker away
|
|
(vTable as? ResultsNSTableView)?.refreshHover()
|
|
restoreSelection(inTable: vTable)
|
|
} else if selectedIds(inTable: vTable) != Set(inParent.selection.map(\.id)) {
|
|
restoreSelection(inTable: vTable)
|
|
}
|
|
}
|
|
|
|
// selects the rows of the SwiftUI selection in the (new) list. One
|
|
// pass over the rows, only with a selection; records that are gone
|
|
// (trashed, filtered out) are dropped from the selection so later
|
|
// updates don't keep searching for them
|
|
private func restoreSelection(inTable: NSTableView) {
|
|
let vWanted = Set(parent.selection.map(\.id))
|
|
var vRows = IndexSet()
|
|
if !vWanted.isEmpty {
|
|
for vRow in 0..<records.count where vWanted.contains(records[vRow].id) {
|
|
vRows.insert(vRow)
|
|
if vRows.count == vWanted.count { break }
|
|
}
|
|
}
|
|
inTable.selectRowIndexes(vRows, byExtendingSelection: false)
|
|
let vFound = selectedRecords(inTable: inTable)
|
|
if vFound.count != parent.selection.count || vFound != parent.selection {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.parent.selection = vFound
|
|
}
|
|
}
|
|
}
|
|
|
|
// selected rows' records, in row order (cost follows the selection)
|
|
private func selectedRecords(inTable: NSTableView) -> [FileRecord] {
|
|
return inTable.selectedRowIndexes.compactMap { $0 < records.count ? records[$0] : nil }
|
|
}
|
|
|
|
// ids of the rows selected in the table
|
|
private func selectedIds(inTable: NSTableView) -> Set<FileRecord.ID> {
|
|
var vIds = Set<FileRecord.ID>()
|
|
for vRow in inTable.selectedRowIndexes where vRow < records.count {
|
|
vIds.insert(records[vRow].id)
|
|
}
|
|
return vIds
|
|
}
|
|
|
|
// the selected records, for the actions
|
|
var currentSelection: [FileRecord] {
|
|
guard let vTable = table else { return [] }
|
|
return selectedRecords(inTable: vTable)
|
|
}
|
|
|
|
// runs an action on the current selection
|
|
func perform(_ inAction: ResultAction) {
|
|
let vRecords = currentSelection
|
|
guard !vRecords.isEmpty else { return }
|
|
parent.perform(inAction, vRecords)
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Data source
|
|
// ===========================
|
|
|
|
// number of results
|
|
func numberOfRows(in inTableView: NSTableView) -> Int {
|
|
return records.count
|
|
}
|
|
|
|
// drag: each row writes its file URL, exactly like a Finder drag
|
|
func tableView(_ inTableView: NSTableView, pasteboardWriterForRow inRow: Int) -> NSPasteboardWriting? {
|
|
guard inRow < records.count else { return nil }
|
|
return URL(fileURLWithPath: records[inRow].fullPath) as NSURL
|
|
}
|
|
|
|
// header click: switch the sort (written back after the callback)
|
|
func tableView(_ inTableView: NSTableView, sortDescriptorsDidChange inOld: [NSSortDescriptor]) {
|
|
guard !isApplying, let vFirst = inTableView.sortDescriptors.first, let vKey = vFirst.key else { return }
|
|
let vSort = ResultsTable.descriptor(forKey: vKey, ascending: vFirst.ascending)
|
|
shownSort = vSort
|
|
// deferred so SwiftUI isn't updated from inside a table callback
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.parent.sort = vSort
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Delegate
|
|
// ===========================
|
|
|
|
// cell view for one row / column, reused from the table's pool
|
|
func tableView(_ inTableView: NSTableView, viewFor inColumn: NSTableColumn?, row inRow: Int) -> NSView? {
|
|
guard let vColumn = inColumn, inRow < records.count else { return nil }
|
|
let vRecord = records[inRow]
|
|
let vCell = (inTableView.makeView(withIdentifier: vColumn.identifier, owner: nil) as? ResultCellView)
|
|
?? ResultCellView(inIdentifier: vColumn.identifier, inWithIcon: vColumn.identifier == ResultsTable.kNameColumn)
|
|
let vTerms = parent.highlightEnabled ? parent.highlights : SearchEngine.Highlights()
|
|
switch vColumn.identifier {
|
|
case ResultsTable.kNameColumn:
|
|
vCell.imageView?.image = IconCache.icon(forName: vRecord.name, isDirectory: vRecord.isDirectory)
|
|
vCell.show(inText: vRecord.name, inTerms: vTerms.name, inSecondary: false, inMiddleTruncation: false)
|
|
case ResultsTable.kPathColumn:
|
|
vCell.show(inText: vRecord.parentPath, inTerms: vTerms.path, inSecondary: true, inMiddleTruncation: true)
|
|
case ResultsTable.kSizeColumn:
|
|
vCell.show(inText: vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size), inTerms: [], inSecondary: true, inMiddleTruncation: false)
|
|
case ResultsTable.kCreatedColumn:
|
|
vCell.show(inText: Formatters.date(vRecord.dateCreated), inTerms: [], inSecondary: true, inMiddleTruncation: false)
|
|
default:
|
|
vCell.show(inText: Formatters.date(vRecord.dateModified), inTerms: [], inSecondary: true, inMiddleTruncation: false)
|
|
}
|
|
return vCell
|
|
}
|
|
|
|
// selection changed in the table: publish the ids
|
|
func tableViewSelectionDidChange(_ inNotification: Notification) {
|
|
guard !isApplying, let vTable = table else { return }
|
|
let vRecords = selectedRecords(inTable: vTable)
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let vSelf = self, vSelf.parent.selection != vRecords else { return }
|
|
vSelf.parent.selection = vRecords
|
|
}
|
|
}
|
|
|
|
// double-click on a row
|
|
@objc func doubleClicked(_ inSender: Any?) {
|
|
guard let vTable = table, vTable.clickedRow >= 0 else { return }
|
|
perform(.primary)
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Menus
|
|
// ===========================
|
|
|
|
// right-click menu, rebuilt for the clicked row: like Finder, a click
|
|
// outside the selection selects that row first
|
|
func menuNeedsUpdate(_ inMenu: NSMenu) {
|
|
inMenu.removeAllItems()
|
|
guard let vTable = table else { return }
|
|
let vClicked = vTable.clickedRow
|
|
if vClicked >= 0, !vTable.selectedRowIndexes.contains(vClicked) {
|
|
vTable.selectRowIndexes(IndexSet(integer: vClicked), byExtendingSelection: false)
|
|
}
|
|
let vSelected = currentSelection
|
|
guard !vSelected.isEmpty else { return }
|
|
|
|
// key equivalents are shown as hints (the table handles the keys)
|
|
inMenu.addItem(ClosureMenuItem("Open") { [weak self] in self?.perform(.open) }.showingKey("\r", []))
|
|
if let vFirst = vSelected.first {
|
|
inMenu.addItem(openWithItem(for: vFirst))
|
|
}
|
|
inMenu.addItem(ClosureMenuItem("Reveal in Finder") { [weak self] in self?.perform(.reveal) }.showingKey("\r", [.command]))
|
|
inMenu.addItem(ClosureMenuItem("Quick Look") { [weak self] in self?.perform(.quickLook) }.showingKey("y", [.command]))
|
|
inMenu.addItem(.separator())
|
|
inMenu.addItem(ClosureMenuItem("Copy") { [weak self] in self?.perform(.copyFiles) }.showingKey("c", [.command]))
|
|
inMenu.addItem(ClosureMenuItem("Copy Name") { [weak self] in self?.perform(.copyNames) })
|
|
inMenu.addItem(ClosureMenuItem("Copy Path") { [weak self] in self?.perform(.copyPaths) }.showingKey("c", [.command, .option]))
|
|
if vSelected.count == 1, let vOnly = vSelected.first, parent.needsAuthorization(vOnly) {
|
|
inMenu.addItem(.separator())
|
|
inMenu.addItem(ClosureMenuItem("Authorize Access…") { [weak self] in self?.perform(.authorize) })
|
|
}
|
|
inMenu.addItem(.separator())
|
|
inMenu.addItem(ClosureMenuItem("Move to Trash") { [weak self] in self?.perform(.trash) }.showingKey("\u{8}", [.command]))
|
|
}
|
|
|
|
// "Open With" submenu: default app first, then the other candidates
|
|
private func openWithItem(for inRecord: FileRecord) -> NSMenuItem {
|
|
let vItem = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "")
|
|
let vSub = NSMenu()
|
|
let vUrl = URL(fileURLWithPath: inRecord.fullPath)
|
|
let vDefault = NSWorkspace.shared.urlForApplication(toOpen: vUrl)
|
|
let vApps = [vDefault].compactMap { $0 }
|
|
+ NSWorkspace.shared.urlsForApplications(toOpen: vUrl).filter { $0 != vDefault }
|
|
for vApp in vApps {
|
|
let vTitle = FileManager.default.displayName(atPath: vApp.path) + (vApp == vDefault ? " (default)" : "")
|
|
let vAppItem = ClosureMenuItem(vTitle) { [weak self] in self?.perform(.openWith(vApp)) }
|
|
let vIcon = NSWorkspace.shared.icon(forFile: vApp.path)
|
|
vIcon.size = NSSize(width: 16, height: 16)
|
|
vAppItem.image = vIcon
|
|
vSub.addItem(vAppItem)
|
|
}
|
|
vItem.submenu = vSub
|
|
vItem.isEnabled = !vApps.isEmpty
|
|
return vItem
|
|
}
|
|
|
|
// header right-click menu to show / hide columns (Name stays)
|
|
func installHeaderMenu(on inTable: NSTableView) {
|
|
let vMenu = NSMenu()
|
|
for vColumn in inTable.tableColumns where vColumn.identifier != ResultsTable.kNameColumn {
|
|
let vItem = ClosureMenuItem(vColumn.title) { [weak vColumn] in
|
|
vColumn?.isHidden.toggle()
|
|
}
|
|
vItem.representedObject = vColumn
|
|
vMenu.addItem(vItem)
|
|
}
|
|
vMenu.delegate = HeaderMenuStates.shared
|
|
inTable.headerView?.menu = vMenu
|
|
}
|
|
}
|
|
}
|
|
|
|
// ResultsNSTableView adds Finder-style keyboard shortcuts and Edit > Copy to
|
|
// the results table.
|
|
final class ResultsNSTableView: NSTableView {
|
|
|
|
// owner that performs the actions
|
|
weak var coordinator: ResultsTable.Coordinator?
|
|
|
|
// how long the pointer rests on a cut-off cell before its full text shows
|
|
private let kHoverDelaySeconds: TimeInterval = 0.5
|
|
// cell under the pointer (row, column), nil when none
|
|
private var hoveredCell: (row: Int, column: Int)?
|
|
// pending overlay display for the hovered cell
|
|
private var hoverTimer: Timer?
|
|
|
|
// follows the pointer over the visible part of the table
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
for vArea in trackingAreas where vArea.owner === self {
|
|
removeTrackingArea(vArea)
|
|
}
|
|
addTrackingArea(NSTrackingArea(
|
|
rect: .zero,
|
|
options: [.mouseMoved, .mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect],
|
|
owner: self,
|
|
userInfo: nil
|
|
))
|
|
}
|
|
|
|
// pointer moved: restart the hover delay when it enters another cell
|
|
override func mouseMoved(with inEvent: NSEvent) {
|
|
super.mouseMoved(with: inEvent)
|
|
let vPoint = convert(inEvent.locationInWindow, from: nil)
|
|
let vRow = row(at: vPoint)
|
|
let vColumn = column(at: vPoint)
|
|
if let vCurrent = hoveredCell, vCurrent.row == vRow, vCurrent.column == vColumn { return }
|
|
endHover()
|
|
guard vRow >= 0, vColumn >= 0 else { return }
|
|
hoveredCell = (vRow, vColumn)
|
|
hoverTimer = Timer.scheduledTimer(withTimeInterval: kHoverDelaySeconds, repeats: false) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
self?.showHoverOverlay(inRow: vRow, inColumn: vColumn)
|
|
}
|
|
}
|
|
}
|
|
|
|
// shows the full text if that cell is still hovered and cut off
|
|
private func showHoverOverlay(inRow: Int, inColumn: Int) {
|
|
// the list may have shrunk since the pointer settled on this cell;
|
|
// NSTableView raises (and the app aborts) on an out-of-range row
|
|
guard inRow >= 0, inRow < numberOfRows, inColumn >= 0, inColumn < numberOfColumns else {
|
|
endHover()
|
|
return
|
|
}
|
|
guard let vCurrent = hoveredCell, vCurrent.row == inRow, vCurrent.column == inColumn,
|
|
let vWindow = window, vWindow.isKeyWindow || vWindow.isMainWindow,
|
|
let vCell = view(atColumn: inColumn, row: inRow, makeIfNecessary: false) as? ResultCellView,
|
|
vCell.isTruncated, let vFrame = vCell.labelScreenFrame
|
|
else {
|
|
HoverOverlay.shared.hide()
|
|
return
|
|
}
|
|
// updated in place when already shown (no flicker on reloads)
|
|
HoverOverlay.shared.show(inText: vCell.overlayText(), inFrame: vFrame, inParent: vWindow)
|
|
}
|
|
|
|
// after a reload: redraw the overlay for the still-hovered cell (its
|
|
// content may have changed), or hide it if that text now fits
|
|
func refreshHover() {
|
|
guard hoveredCell != nil, hoverTimer?.isValid != true else { return }
|
|
// runs after the current update pass: this is called from SwiftUI's
|
|
// updateNSView, often in the middle of a layout / constraints pass,
|
|
// where asking the table for cell views (or forcing a layout) is
|
|
// unsafe. The hovered cell is re-checked then (the reload may have
|
|
// removed its row).
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let vSelf = self, let vCurrent = vSelf.hoveredCell, vSelf.hoverTimer?.isValid != true else { return }
|
|
vSelf.showHoverOverlay(inRow: vCurrent.row, inColumn: vCurrent.column)
|
|
}
|
|
}
|
|
|
|
// listens for ↓ in the search field of the same window
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
NotificationCenter.default.removeObserver(self, name: .allofitFocusResults, object: nil)
|
|
if let vWindow = window {
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(focusFromSearchField(_:)),
|
|
name: .allofitFocusResults,
|
|
object: vWindow
|
|
)
|
|
}
|
|
}
|
|
|
|
// takes the focus from the search field and selects the first row
|
|
// when nothing is selected yet
|
|
@objc private func focusFromSearchField(_ inNotification: Notification) {
|
|
guard numberOfRows > 0 else { return }
|
|
window?.makeFirstResponder(self)
|
|
if selectedRow < 0 {
|
|
selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
|
|
}
|
|
scrollRowToVisible(max(selectedRow, 0))
|
|
}
|
|
|
|
// cancels the pending overlay and hides the shown one
|
|
func endHover() {
|
|
hoverTimer?.invalidate()
|
|
hoverTimer = nil
|
|
hoveredCell = nil
|
|
HoverOverlay.shared.hide()
|
|
}
|
|
|
|
// pointer left the table
|
|
override func mouseExited(with inEvent: NSEvent) {
|
|
super.mouseExited(with: inEvent)
|
|
endHover()
|
|
}
|
|
|
|
// scrolling moves the rows under the overlay: hide it
|
|
override func scrollWheel(with inEvent: NSEvent) {
|
|
endHover()
|
|
super.scrollWheel(with: inEvent)
|
|
}
|
|
|
|
// a click dismisses the overlay
|
|
override func mouseDown(with inEvent: NSEvent) {
|
|
endHover()
|
|
super.mouseDown(with: inEvent)
|
|
}
|
|
|
|
// leaving the window (closed / moved) dismisses the overlay
|
|
override func viewWillMove(toWindow inWindow: NSWindow?) {
|
|
endHover()
|
|
super.viewWillMove(toWindow: inWindow)
|
|
}
|
|
|
|
// Space / Return / ⌘Return / ⌘Y / ⌥⌘C / ⌘⌫; everything else (arrows,
|
|
// type-select, ⌘A) goes to NSTableView
|
|
override func keyDown(with inEvent: NSEvent) {
|
|
endHover()
|
|
let vMods = inEvent.modifierFlags.intersection([.command, .option, .shift, .control])
|
|
let vChars = inEvent.charactersIgnoringModifiers?.lowercased() ?? ""
|
|
let vAction: ResultAction?
|
|
switch (Int(inEvent.keyCode), vMods) {
|
|
case (49, []): vAction = .quickLook // Space
|
|
case (36, []), (76, []): vAction = .primary // Return / Enter
|
|
case (36, [.command]), (76, [.command]): vAction = .reveal
|
|
case (51, [.command]): vAction = .trash // ⌘⌫
|
|
case (126, []) where selectedRow <= 0: // ↑ on the first row
|
|
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
|
|
return
|
|
default:
|
|
if vChars == "y" && vMods == [.command] {
|
|
vAction = .quickLook
|
|
} else if vChars == "c" && vMods == [.command, .option] {
|
|
vAction = .copyPaths
|
|
} else {
|
|
vAction = nil
|
|
}
|
|
}
|
|
guard let vRun = vAction, selectedRow >= 0 else {
|
|
super.keyDown(with: inEvent)
|
|
return
|
|
}
|
|
coordinator?.perform(vRun)
|
|
}
|
|
|
|
// Edit > Copy (⌘C) copies the files themselves, like Finder
|
|
@objc func copy(_ inSender: Any?) {
|
|
coordinator?.perform(.copyFiles)
|
|
}
|
|
|
|
// enables Edit > Copy only with a selection
|
|
override func validateUserInterfaceItem(_ inItem: NSValidatedUserInterfaceItem) -> Bool {
|
|
if inItem.action == #selector(copy(_:)) {
|
|
return selectedRow >= 0
|
|
}
|
|
return super.validateUserInterfaceItem(inItem)
|
|
}
|
|
}
|
|
|
|
// ResultCellView is one cell: optional icon + a label. Highlights are drawn
|
|
// in bold; the colors follow the row's selection state so bold text stays
|
|
// readable on the blue selection.
|
|
final class ResultCellView: NSTableCellView {
|
|
|
|
// text, highlight terms and style of the current content
|
|
private var text = ""
|
|
private var terms: [String] = []
|
|
private var isSecondary = false
|
|
|
|
// builds the cell's subviews
|
|
init(inIdentifier: NSUserInterfaceItemIdentifier, inWithIcon: Bool) {
|
|
super.init(frame: .zero)
|
|
identifier = inIdentifier
|
|
let vLabel = NSTextField(labelWithString: "")
|
|
vLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
vLabel.lineBreakMode = .byTruncatingTail
|
|
vLabel.cell?.usesSingleLineMode = true
|
|
// the full text of a cut-off cell comes from HoverOverlay (shown by
|
|
// the table after a short hover), not AppKit's expansion tooltip
|
|
vLabel.allowsExpansionToolTips = false
|
|
addSubview(vLabel)
|
|
textField = vLabel
|
|
if inWithIcon {
|
|
let vImage = NSImageView()
|
|
vImage.translatesAutoresizingMaskIntoConstraints = false
|
|
vImage.imageScaling = .scaleProportionallyDown
|
|
addSubview(vImage)
|
|
imageView = vImage
|
|
NSLayoutConstraint.activate([
|
|
vImage.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
|
|
vImage.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
vImage.widthAnchor.constraint(equalToConstant: 16),
|
|
vImage.heightAnchor.constraint(equalToConstant: 16),
|
|
vLabel.leadingAnchor.constraint(equalTo: vImage.trailingAnchor, constant: 6)
|
|
])
|
|
} else {
|
|
vLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2).isActive = true
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
vLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -2),
|
|
vLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
|
|
])
|
|
}
|
|
|
|
// not used: cells are created in code
|
|
required init?(coder inCoder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
// sets the content and redraws it
|
|
func show(inText: String, inTerms: [String], inSecondary: Bool, inMiddleTruncation: Bool) {
|
|
text = inText
|
|
terms = inTerms
|
|
isSecondary = inSecondary
|
|
textField?.lineBreakMode = inMiddleTruncation ? .byTruncatingMiddle : .byTruncatingTail
|
|
render()
|
|
}
|
|
|
|
// selection highlight changed: recolor
|
|
override var backgroundStyle: NSView.BackgroundStyle {
|
|
didSet { render() }
|
|
}
|
|
|
|
// draws the label, bold where the terms match
|
|
private func render() {
|
|
guard let vLabel = textField else { return }
|
|
let vEmphasized = backgroundStyle == .emphasized
|
|
let vColor: NSColor = vEmphasized
|
|
? .alternateSelectedControlTextColor
|
|
: (isSecondary ? .secondaryLabelColor : .labelColor)
|
|
vLabel.attributedStringValue = styledText(inColor: vColor)
|
|
}
|
|
|
|
// the text in the cell's fonts, bold where the terms match
|
|
private func styledText(inColor: NSColor) -> NSAttributedString {
|
|
let vFont = isSecondary && terms.isEmpty
|
|
? NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
|
: NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
|
let vString = NSMutableAttributedString(string: text, attributes: [.font: vFont, .foregroundColor: inColor])
|
|
let vBold = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
|
let vNs = text as NSString
|
|
for vTerm in terms {
|
|
var vSearch = NSRange(location: 0, length: vNs.length)
|
|
while vSearch.length > 0 {
|
|
let vFound = vNs.range(of: vTerm, options: [.caseInsensitive, .diacriticInsensitive], range: vSearch)
|
|
if vFound.location == NSNotFound || vFound.length == 0 { break }
|
|
vString.addAttribute(.font, value: vBold, range: vFound)
|
|
let vNext = vFound.location + vFound.length
|
|
vSearch = NSRange(location: vNext, length: vNs.length - vNext)
|
|
}
|
|
}
|
|
return vString
|
|
}
|
|
|
|
// true when the text doesn't fit and is shown cut off
|
|
var isTruncated: Bool {
|
|
guard let vLabel = textField else { return false }
|
|
let vAvailable = bounds.width - vLabel.frame.minX - 2
|
|
return styledText(inColor: .labelColor).size().width + 4 > vAvailable
|
|
}
|
|
|
|
// the full text for the hover overlay, in normal (unselected) colors
|
|
func overlayText() -> NSAttributedString {
|
|
return styledText(inColor: isSecondary ? .secondaryLabelColor : .labelColor)
|
|
}
|
|
|
|
// the label's frame in screen coordinates, where the overlay goes
|
|
var labelScreenFrame: NSRect? {
|
|
guard let vLabel = textField, let vWindow = window else { return nil }
|
|
return vWindow.convertToScreen(vLabel.convert(vLabel.bounds, to: nil))
|
|
}
|
|
}
|
|
|
|
// HoverOverlay is the panel showing the full text of a cut-off cell. It
|
|
// ignores the mouse, so it can't take the hover away from the table (the
|
|
// built-in expansion tooltip vanished when the pointer touched it), and it
|
|
// floats exactly over the cell's text.
|
|
@MainActor
|
|
final class HoverOverlay {
|
|
|
|
// shared overlay (only one cell is hovered at a time)
|
|
static let shared = HoverOverlay()
|
|
|
|
// the borderless panel, created on first use
|
|
private var panel: NSPanel?
|
|
// the label inside the panel
|
|
private let label = NSTextField(labelWithString: "")
|
|
// padding between the panel edge and the text
|
|
private let kPadding = NSSize(width: 4, height: 1)
|
|
|
|
// shows inText over inFrame (screen coordinates) of inParent's window
|
|
func show(inText: NSAttributedString, inFrame: NSRect, inParent: NSWindow) {
|
|
let vPanel = panel ?? makePanel()
|
|
label.attributedStringValue = inText
|
|
let vTextSize = inText.size()
|
|
var vFrame = NSRect(
|
|
x: inFrame.minX - kPadding.width,
|
|
y: inFrame.midY - (vTextSize.height / 2) - kPadding.height,
|
|
width: ceil(vTextSize.width) + kPadding.width * 2 + 4,
|
|
height: ceil(vTextSize.height) + kPadding.height * 2
|
|
)
|
|
// keep it on screen: shift left when it would run past the edge
|
|
if let vScreen = inParent.screen?.visibleFrame, vFrame.maxX > vScreen.maxX {
|
|
vFrame.origin.x = max(vScreen.minX, vScreen.maxX - vFrame.width)
|
|
}
|
|
vPanel.setFrame(vFrame, display: true)
|
|
label.frame = NSRect(x: kPadding.width, y: kPadding.height, width: vFrame.width - kPadding.width * 2, height: ceil(vTextSize.height))
|
|
if vPanel.parent !== inParent {
|
|
vPanel.parent?.removeChildWindow(vPanel)
|
|
inParent.addChildWindow(vPanel, ordered: .above)
|
|
}
|
|
vPanel.orderFront(nil)
|
|
}
|
|
|
|
// shows multi-line details (plain text) just above inAnchor (screen
|
|
// coordinates), right-aligned with it and kept on screen
|
|
func showDetails(inText: String, inAnchor: NSRect, inParent: NSWindow) {
|
|
let vPanel = panel ?? makePanel()
|
|
let vText = NSAttributedString(string: inText, attributes: [
|
|
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
|
.foregroundColor: NSColor.labelColor
|
|
])
|
|
label.attributedStringValue = vText
|
|
let vPad = NSSize(width: 8, height: 6)
|
|
let vTextSize = vText.boundingRect(
|
|
with: NSSize(width: 10_000, height: 10_000),
|
|
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
|
).size
|
|
var vFrame = NSRect(
|
|
x: inAnchor.maxX - ceil(vTextSize.width) - vPad.width * 2,
|
|
y: inAnchor.maxY + 4,
|
|
width: ceil(vTextSize.width) + vPad.width * 2,
|
|
height: ceil(vTextSize.height) + vPad.height * 2
|
|
)
|
|
if let vScreen = inParent.screen?.visibleFrame {
|
|
vFrame.origin.x = min(max(vFrame.minX, vScreen.minX), vScreen.maxX - vFrame.width)
|
|
vFrame.origin.y = min(vFrame.minY, vScreen.maxY - vFrame.height)
|
|
}
|
|
vPanel.setFrame(vFrame, display: true)
|
|
label.frame = NSRect(x: vPad.width, y: vPad.height, width: ceil(vTextSize.width) + 2, height: ceil(vTextSize.height))
|
|
if vPanel.parent !== inParent {
|
|
vPanel.parent?.removeChildWindow(vPanel)
|
|
inParent.addChildWindow(vPanel, ordered: .above)
|
|
}
|
|
vPanel.orderFront(nil)
|
|
}
|
|
|
|
// hides the overlay
|
|
func hide() {
|
|
guard let vPanel = panel, vPanel.isVisible else { return }
|
|
vPanel.parent?.removeChildWindow(vPanel)
|
|
vPanel.orderOut(nil)
|
|
}
|
|
|
|
// builds the panel: borderless, tooltip-like background, click-through
|
|
private func makePanel() -> NSPanel {
|
|
let vPanel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
|
|
vPanel.isOpaque = false
|
|
vPanel.backgroundColor = .clear
|
|
vPanel.hasShadow = true
|
|
vPanel.ignoresMouseEvents = true
|
|
vPanel.level = .floating
|
|
let vBackground = NSVisualEffectView()
|
|
vBackground.material = .toolTip
|
|
vBackground.state = .active
|
|
vBackground.wantsLayer = true
|
|
vBackground.layer?.cornerRadius = 4
|
|
vBackground.layer?.borderWidth = 0.5
|
|
vBackground.layer?.borderColor = NSColor.separatorColor.cgColor
|
|
vPanel.contentView = vBackground
|
|
label.lineBreakMode = .byClipping
|
|
label.maximumNumberOfLines = 0
|
|
label.usesSingleLineMode = false
|
|
vBackground.addSubview(label)
|
|
panel = vPanel
|
|
return vPanel
|
|
}
|
|
}
|
|
|
|
// ClosureMenuItem is an NSMenuItem that runs a closure.
|
|
final class ClosureMenuItem: NSMenuItem {
|
|
|
|
// what the item does
|
|
private let handler: () -> Void
|
|
|
|
// creates an item titled inTitle running inHandler
|
|
init(_ inTitle: String, _ inHandler: @escaping () -> Void) {
|
|
handler = inHandler
|
|
super.init(title: inTitle, action: #selector(run(_:)), keyEquivalent: "")
|
|
target = self
|
|
}
|
|
|
|
// not used: items are created in code
|
|
required init(coder inCoder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
// menu callback
|
|
@objc private func run(_ inSender: Any?) {
|
|
handler()
|
|
}
|
|
|
|
// shows a shortcut next to the title and returns the item
|
|
func showingKey(_ inKey: String, _ inModifiers: NSEvent.ModifierFlags) -> ClosureMenuItem {
|
|
keyEquivalent = inKey
|
|
keyEquivalentModifierMask = inModifiers
|
|
return self
|
|
}
|
|
}
|
|
|
|
// HeaderMenuStates ticks the visible columns each time the header menu opens.
|
|
final class HeaderMenuStates: NSObject, NSMenuDelegate {
|
|
|
|
// shared delegate for every results table's header menu
|
|
static let shared = HeaderMenuStates()
|
|
|
|
// marks shown columns with a checkmark
|
|
func menuNeedsUpdate(_ inMenu: NSMenu) {
|
|
for vItem in inMenu.items {
|
|
if let vColumn = vItem.representedObject as? NSTableColumn {
|
|
vItem.state = vColumn.isHidden ? .off : .on
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// HoverDetailsAnchor shows a multi-line details text in HoverOverlay after
|
|
// the pointer rests on its area for kDelaySeconds, keeps it while hovered and
|
|
// updates it in place when the text changes. A help tag (.help) restarts its
|
|
// delay every time its text changes, so on a status bar refreshing every
|
|
// second or two it appeared late or not at all.
|
|
struct HoverDetailsAnchor: NSViewRepresentable {
|
|
|
|
// the details to show (re-evaluated by SwiftUI on each refresh)
|
|
let text: String
|
|
|
|
// hover delay, same as the result cells' overlay
|
|
static let kDelaySeconds: TimeInterval = 0.5
|
|
|
|
// creates the tracking view
|
|
func makeNSView(context inContext: Context) -> AnchorView {
|
|
let vView = AnchorView(frame: .zero)
|
|
vView.text = text
|
|
return vView
|
|
}
|
|
|
|
// passes the latest text; a visible overlay is updated in place
|
|
func updateNSView(_ inView: AnchorView, context inContext: Context) {
|
|
inView.text = text
|
|
}
|
|
|
|
// AnchorView tracks the pointer over its bounds; clicks pass through
|
|
final class AnchorView: NSView {
|
|
|
|
// latest details text
|
|
var text = "" {
|
|
didSet {
|
|
if isShowing && text != oldValue { show() }
|
|
}
|
|
}
|
|
// pending display after the hover delay
|
|
private var timer: Timer?
|
|
// true while this anchor's details are on screen
|
|
private var isShowing = false
|
|
|
|
// follows the pointer over the visible part of the view
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
for vArea in trackingAreas where vArea.owner === self {
|
|
removeTrackingArea(vArea)
|
|
}
|
|
addTrackingArea(NSTrackingArea(
|
|
rect: .zero,
|
|
options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect],
|
|
owner: self,
|
|
userInfo: nil
|
|
))
|
|
}
|
|
|
|
// starts the hover delay
|
|
override func mouseEntered(with inEvent: NSEvent) {
|
|
timer?.invalidate()
|
|
timer = Timer.scheduledTimer(withTimeInterval: HoverDetailsAnchor.kDelaySeconds, repeats: false) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
self?.show()
|
|
}
|
|
}
|
|
}
|
|
|
|
// pointer left: cancel or hide
|
|
override func mouseExited(with inEvent: NSEvent) {
|
|
dismiss()
|
|
}
|
|
|
|
// never takes clicks (the status bar text stays selectable / inert)
|
|
override func hitTest(_ inPoint: NSPoint) -> NSView? {
|
|
return nil
|
|
}
|
|
|
|
// leaving the window hides the details
|
|
override func viewWillMove(toWindow inWindow: NSWindow?) {
|
|
dismiss()
|
|
super.viewWillMove(toWindow: inWindow)
|
|
}
|
|
|
|
// shows (or refreshes) the details above the anchor
|
|
private func show() {
|
|
guard let vWindow = window, !text.isEmpty else { return }
|
|
let vFrame = vWindow.convertToScreen(convert(bounds, to: nil))
|
|
HoverOverlay.shared.showDetails(inText: text, inAnchor: vFrame, inParent: vWindow)
|
|
isShowing = true
|
|
}
|
|
|
|
// cancels a pending display and hides a shown one
|
|
private func dismiss() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
if isShowing {
|
|
isShowing = false
|
|
HoverOverlay.shared.hide()
|
|
}
|
|
}
|
|
}
|
|
}
|