feat: add calculator and unit conversion

This commit is contained in:
2026-10-13 17:48:00 +02:00
parent 400917de3b
commit a57cbc219c
5 changed files with 567 additions and 25 deletions
+59 -20
View File
@@ -7,10 +7,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let tableView = ApplicationTableView()
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
private let sectionLabel = NSTextField(labelWithString: "Applications")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory
private var applications: [Application] = []
private var results: [Application] = []
private var calculation: CalculatorResult?
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
@@ -179,7 +181,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let searchFont = NSFont.systemFont(ofSize: 20)
searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString(
string: "Search for apps…",
string: "Search apps, calculate, or convert units…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
)
searchField.textColor = SunsetPalette.text
@@ -193,7 +195,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.sendsSearchStringImmediately = true
searchField.sendsWholeSearchString = false
searchField.delegate = self
searchField.setAccessibilityLabel("Search applications")
searchField.setAccessibilityLabel("Search applications, calculate, or convert units")
let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
@@ -204,7 +206,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
header.alignment = .centerY
header.spacing = 12
let sectionLabel = NSTextField(labelWithString: "Applications")
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
sectionLabel.textColor = SunsetPalette.secondaryText
@@ -372,19 +373,26 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateResults(preservingSelection: Bool = false) {
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
calculation = Calculator.evaluate(searchField.stringValue)
if calculation != nil {
results = []
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
results = history.suggestions(in: applications)
} else {
results = history.results(for: searchField.stringValue, in: applications)
}
sectionLabel.stringValue = calculation == nil ? "Applications" : "Calculator"
tableView.setAccessibilityLabel(calculation == nil ? "Applications" : "Calculator result")
tableView.reloadData()
if !results.isEmpty {
if resultCount > 0 {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
}
emptyLabel.isHidden = !results.isEmpty
if discoveryTask != nil {
emptyLabel.isHidden = resultCount > 0
if let calculation {
statusLabel.stringValue = calculation.detail
} else if discoveryTask != nil {
emptyLabel.stringValue = "Finding applications…"
statusLabel.stringValue = "Finding applications…"
} else {
@@ -414,16 +422,24 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true
}
private var resultCount: Int { calculation == nil ? results.count : 1 }
private func moveSelection(by offset: Int) {
guard !results.isEmpty else { return }
let row = min(max(tableView.selectedRow + offset, 0), results.count - 1)
guard resultCount > 0 else { return }
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
}
private func updateOpenButton() {
openButton.isEnabled = !isLaunching && launcherFrameBeforeReview == nil && results.indices.contains(tableView.selectedRow)
actionsButton.isEnabled = openButton.isEnabled
let canAct = !isLaunching && launcherFrameBeforeReview == nil && tableView.selectedRow >= 0
openButton.title = calculation == nil ? "Open Application" : "Copy Answer"
openButton.toolTip = calculation == nil
? "Open the selected application (Return). Use ↑ and ↓ to select."
: "Copy the answer to the clipboard (Return)."
openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil }
?? results.indices.contains(tableView.selectedRow))
actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow)
}
func tableViewSelectionDidChange(_ notification: Notification) {
@@ -431,20 +447,32 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func numberOfRows(in tableView: NSTableView) -> Int {
results.count
resultCount
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
?? makeCell(identifier: identifier)
let application = results[row]
cell.textField?.stringValue = application.name
let location = locationLabels[application.id]
cell.locationLabel.stringValue = location ?? ""
cell.locationLabel.isHidden = location == nil
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path
if let calculation {
cell.textField?.stringValue = calculation.displayValue
cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)?
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = calculation.detail
} else {
let application = results[row]
cell.textField?.stringValue = application.name
let location = locationLabels[application.id]
cell.locationLabel.stringValue = location ?? ""
cell.locationLabel.isHidden = location == nil
cell.kindLabel.stringValue = "Application"
cell.imageView?.contentTintColor = nil
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path
}
return cell
}
@@ -476,7 +504,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
title.orientation = .horizontal
title.alignment = .centerY
title.spacing = 8
let kind = NSTextField(labelWithString: "Application")
let kind = cell.kindLabel
kind.font = .systemFont(ofSize: 14)
kind.textColor = SunsetPalette.secondaryText
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
@@ -582,6 +610,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
@objc private func openSelection() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
if pasteboard.setString(value, forType: .string) {
dismiss()
}
return
}
guard results.indices.contains(tableView.selectedRow) else { return }
openApplication(results[tableView.selectedRow])
}
@@ -622,6 +660,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@MainActor
private final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application")
}
@MainActor