Files
Allofit/sources/ui/ResultsTable.swift
Bitsy 8b751b6ea4 Fix hover crash on list changes, show first-walk progress, stream new roots
- 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.
2026-10-06 10:09:47 +02:00

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()
}
}
}
}