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
+374
View File
@@ -0,0 +1,374 @@
import Foundation
public struct CalculatorResult: Sendable {
public let displayValue: String
public let copyValue: String?
public let detail: String
}
public enum Calculator {
private static let outputLocale = Locale(identifier: "en_US_POSIX")
private static let functionNames: Set<String> = [
"sqrt", "abs", "round", "floor", "ceil", "sin", "cos", "tan", "log", "ln",
]
// ponytail: cap recursion for small debug worker stacks; use an iterative parser if deeper input is needed.
private static let maximumDepth = 16
public static func evaluate(_ query: String) -> CalculatorResult? {
guard query.utf8.count <= 512 else { return nil }
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return nil }
let words = query.split(whereSeparator: \.isWhitespace).map(String.init)
if words.count >= 3 {
for separator in 1..<(words.count - 1)
where ["to", "in", "as"].contains(words[separator].lowercased()) {
guard let target = ConversionUnit.parse(words[(separator + 1)...].joined(separator: " ")) else {
continue
}
let sourceText = words[..<separator].joined(separator: " ")
// A suffix boundary also accepts Raycast-style quantities such as 10ft and 23°C.
let characters = Array(sourceText)
guard characters.count > 1 else { continue }
for length in stride(from: min(32, characters.count - 1), through: 1, by: -1) {
guard let source = ConversionUnit.parse(String(characters.suffix(length))) else { continue }
let expression = String(characters.dropLast(length))
guard let parsed = parse(expression) else { continue }
let detail = "\(expression.trimmingCharacters(in: .whitespaces)) \(source.symbol) → \(target.symbol)"
if let error = parsed.error { return failure(error, detail: detail) }
guard source.family == target.family else {
return failure("Incompatible units", detail: detail)
}
let value: Double
if let sourceDimension = source.dimension, let targetDimension = target.dimension {
value = Measurement(value: parsed.value, unit: sourceDimension)
.converted(to: targetDimension).value
} else {
value = parsed.value * source.scale / target.scale
}
guard value.isFinite else { return failure("Result is not finite", detail: detail) }
let text = "\(format(value)) \(target.symbol)"
return CalculatorResult(displayValue: text, copyValue: text, detail: detail)
}
}
}
guard let parsed = parse(query), parsed.isExpression else { return nil }
if let error = parsed.error { return failure(error, detail: query) }
let text = format(parsed.value)
return CalculatorResult(displayValue: text, copyValue: text, detail: query)
}
private static func failure(_ error: String, detail: String) -> CalculatorResult {
CalculatorResult(displayValue: error, copyValue: nil, detail: detail)
}
private static func format(_ value: Double) -> String {
String(format: "%.12g", locale: outputLocale, value == 0 ? 0 : value)
}
private static func parse(_ expression: String) -> Parser? {
guard let tokens = tokenize(expression), !tokens.isEmpty else { return nil }
var parser = Parser(tokens: tokens)
do {
parser.value = try parser.sum(depth: 0)
guard parser.index == tokens.count else { return nil }
return parser
} catch { return nil }
}
private enum Token: Equatable {
case number(Double), name(String)
case plus, minus, multiply, divide, power, percent, modulo, of, off, left, right
}
private static func tokenize(_ expression: String) -> [Token]? {
let characters = Array(expression)
var tokens: [Token] = []
var index = 0
func digit(_ character: Character) -> Bool { character >= "0" && character <= "9" }
while index < characters.count {
let character = characters[index]
if character.isWhitespace { index += 1; continue }
if digit(character) || character == "." {
let start = index
var digits = 0
while index < characters.count, digit(characters[index]) { index += 1; digits += 1 }
if index < characters.count, characters[index] == "." {
index += 1
while index < characters.count, digit(characters[index]) { index += 1; digits += 1 }
}
guard digits > 0 else { return nil }
if index < characters.count, characters[index] == "e" || characters[index] == "E" {
index += 1
if index < characters.count, characters[index] == "+" || characters[index] == "-" { index += 1 }
let exponentStart = index
while index < characters.count, digit(characters[index]) { index += 1 }
guard index > exponentStart else { return nil }
}
guard let number = Double(String(characters[start..<index])) else { return nil }
tokens.append(.number(number))
continue
}
switch character {
case "+": tokens.append(.plus)
case "-", "−": tokens.append(.minus)
case "*", "×": tokens.append(.multiply)
case "/", "÷": tokens.append(.divide)
case "^": tokens.append(.power)
case "%": tokens.append(.percent)
case "(": tokens.append(.left)
case ")": tokens.append(.right)
case "π": tokens.append(.name("pi"))
default:
guard character.isASCII, character.isLetter else { return nil }
let start = index
while index < characters.count, characters[index].isASCII, characters[index].isLetter { index += 1 }
let name = String(characters[start..<index]).lowercased()
if name == "x" { tokens.append(.multiply) }
else if name == "mod" || name == "modulo" { tokens.append(.modulo) }
else if name == "of" { tokens.append(.of) }
else if name == "off" { tokens.append(.off) }
else { tokens.append(.name(name)) }
continue
}
index += 1
}
return tokens
}
private enum ParseError: Error { case invalid }
private struct Parser {
let tokens: [Token]
var index = 0
var value = 0.0
var error: String?
var isExpression = false
var current: Token? { index < tokens.count ? tokens[index] : nil }
mutating func checked(_ value: Double, message: String = "Result is not finite") -> Double {
guard value.isFinite else {
if error == nil { error = message }
return 0
}
return value
}
mutating func sum(depth: Int) throws -> Double {
var value = try product(depth: depth)
while current == .plus || current == .minus {
let subtract = current == .minus
index += 1
isExpression = true
let rhs = try product(depth: depth)
value = checked(subtract ? value - rhs : value + rhs)
}
return value
}
mutating func product(depth: Int) throws -> Double {
var value = try unary(depth: depth)
while current == .multiply || current == .divide || current == .modulo || current == .percent
|| current == .of || current == .off {
let operation = current
if operation == .of || operation == .off {
guard index > 0, tokens[index - 1] == .percent else { throw ParseError.invalid }
}
index += 1
isExpression = true
let rhs = operation == .off ? try sum(depth: depth + 1) : try unary(depth: depth)
switch operation {
case .multiply, .of: value = checked(value * rhs)
case .off: value = checked(rhs * (1 - value))
case .divide:
if rhs == 0 { if error == nil { error = "Division by zero" }; value = 0 }
else { value = checked(value / rhs) }
default:
if rhs == 0 { if error == nil { error = "Division by zero" }; value = 0 }
else { value = checked(value.truncatingRemainder(dividingBy: rhs)) }
}
}
return value
}
mutating func unary(depth: Int) throws -> Double {
guard depth < Calculator.maximumDepth else { throw ParseError.invalid }
if current == .plus || current == .minus {
let negate = current == .minus
index += 1
let value = try unary(depth: depth + 1)
return negate ? -value : value
}
return try power(depth: depth)
}
mutating func power(depth: Int) throws -> Double {
var value = try primary(depth: depth)
while current == .percent {
// An unsigned operand after % means remainder; otherwise % is postfix percentage.
if index + 1 < tokens.count {
switch tokens[index + 1] {
case .number, .name, .left: return try exponent(value, depth: depth)
default: break
}
}
index += 1
isExpression = true
value = checked(value / 100)
}
return try exponent(value, depth: depth)
}
mutating func exponent(_ value: Double, depth: Int) throws -> Double {
guard current == .power else { return value }
index += 1
isExpression = true
let rhs = try unary(depth: depth + 1)
return checked(pow(value, rhs), message: "Power is outside its domain")
}
mutating func primary(depth: Int) throws -> Double {
guard let token = current else { throw ParseError.invalid }
index += 1
switch token {
case .number(let value): return checked(value)
case .left:
guard depth < Calculator.maximumDepth else { throw ParseError.invalid }
isExpression = true
let value = try sum(depth: depth + 1)
guard current == .right else { throw ParseError.invalid }
index += 1
return value
case .name(let name):
isExpression = true
if name == "pi" { return .pi }
if name == "e" { return exp(1) }
guard Calculator.functionNames.contains(name),
current == .left, depth < Calculator.maximumDepth else { throw ParseError.invalid }
index += 1
let argument = try sum(depth: depth + 1)
guard current == .right else { throw ParseError.invalid }
index += 1
let value: Double
switch name {
case "sqrt": value = sqrt(argument)
case "abs": value = abs(argument)
case "round": value = argument.rounded()
case "floor": value = floor(argument)
case "ceil": value = ceil(argument)
case "sin": value = sin(argument)
case "cos": value = cos(argument)
case "tan": value = tan(argument)
case "log": value = log10(argument)
default: value = log(argument)
}
return checked(value, message: "\(name) is outside its domain")
default: throw ParseError.invalid
}
}
}
private struct ConversionUnit {
let family: String
let symbol: String
let dimension: Dimension?
let scale: Double
init(_ family: String, _ symbol: String, _ dimension: Dimension) {
self.family = family
self.symbol = symbol
self.dimension = dimension
self.scale = 1
}
init(_ family: String, _ symbol: String, scale: Double) {
self.family = family
self.symbol = symbol
self.dimension = nil
self.scale = scale
}
private static let dataPrefixes: [String: Double] = [
"": 1, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12,
"Ki": 1024, "Mi": 1_048_576, "Gi": 1_073_741_824, "Ti": 1_099_511_627_776,
]
private static let namedDataPrefixes: [String: (String, Double)] = [
"": ("", 1), "kilo": ("K", 1e3), "mega": ("M", 1e6), "giga": ("G", 1e9), "tera": ("T", 1e12),
"kibi": ("Ki", 1024), "mebi": ("Mi", 1_048_576),
"gibi": ("Gi", 1_073_741_824), "tebi": ("Ti", 1_099_511_627_776),
]
private static let gallonLiters = UnitVolume.gallons.converter.baseUnitValue(fromValue: 1)
// Native Fahrenheit coefficients leave 32 °F slightly above zero; preserve the defining ratio.
private static let fahrenheit = UnitTemperature(
symbol: "°F",
converter: UnitConverterLinear(coefficient: 5.0 / 9.0, constant: 273.15 - 32.0 * 5.0 / 9.0)
)
private static func usVolume(_ symbol: String, subdivisions: Double) -> ConversionUnit {
// Foundation's cup is a 240 mL legal cup; these are consistent US customary gallon subdivisions.
.init("volume", symbol, UnitVolume(
symbol: symbol, converter: UnitConverterLinear(coefficient: gallonLiters / subdivisions)
))
}
static func parse(_ text: String) -> ConversionUnit? {
let text = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
// Keep the final b/B case-sensitive: bits and bytes differ by a factor of eight.
if let suffix = text.last, suffix == "b" || suffix == "B" {
let prefix = String(text.dropLast())
if let scale = dataPrefixes[prefix] {
return ConversionUnit("data", text, scale: suffix == "b" ? scale / 8 : scale)
}
}
let lowercase = text.lowercased()
for suffix in ["bytes", "byte", "bits", "bit"] where lowercase.hasSuffix(suffix) {
if let (prefix, scale) = namedDataPrefixes[String(lowercase.dropLast(suffix.count))] {
let bytes = suffix.hasPrefix("byte")
return .init("data", prefix + (bytes ? "B" : "b"), scale: bytes ? scale : scale / 8)
}
}
switch lowercase {
case "mm", "millimeter", "millimeters", "millimetre", "millimetres": return .init("length", "mm", UnitLength.millimeters)
case "cm", "centimeter", "centimeters", "centimetre", "centimetres": return .init("length", "cm", UnitLength.centimeters)
case "m", "meter", "meters", "metre", "metres": return .init("length", "m", UnitLength.meters)
case "km", "kilometer", "kilometers", "kilometre", "kilometres": return .init("length", "km", UnitLength.kilometers)
case "in", "inch", "inches": return .init("length", "in", UnitLength.inches)
case "ft", "foot", "feet": return .init("length", "ft", UnitLength.feet)
case "yd", "yard", "yards": return .init("length", "yd", UnitLength.yards)
case "mi", "mile", "miles": return .init("length", "mi", UnitLength.miles)
case "mg", "milligram", "milligrams": return .init("mass", "mg", UnitMass.milligrams)
case "g", "gram", "grams": return .init("mass", "g", UnitMass.grams)
case "kg", "kilogram", "kilograms": return .init("mass", "kg", UnitMass.kilograms)
case "oz", "ounce", "ounces": return .init("mass", "oz", UnitMass.ounces)
case "lb", "lbs", "pound", "pounds": return .init("mass", "lb", UnitMass.pounds)
case "c", "°c", "celsius", "degrees celsius": return .init("temperature", "°C", UnitTemperature.celsius)
case "f", "°f", "fahrenheit", "degrees fahrenheit": return .init("temperature", "°F", fahrenheit)
case "k", "°k", "kelvin", "kelvins": return .init("temperature", "K", UnitTemperature.kelvin)
case "ml", "milliliter", "milliliters", "millilitre", "millilitres": return .init("volume", "mL", UnitVolume.milliliters)
case "l", "liter", "liters", "litre", "litres": return .init("volume", "L", UnitVolume.liters)
case "tsp", "teaspoon", "teaspoons", "us tsp", "us teaspoon", "us teaspoons": return usVolume("tsp", subdivisions: 768)
case "tbsp", "tablespoon", "tablespoons", "us tbsp", "us tablespoon", "us tablespoons": return usVolume("tbsp", subdivisions: 256)
case "fl oz", "floz", "fluid ounce", "fluid ounces", "us fl oz", "us fluid ounce", "us fluid ounces": return usVolume("fl oz", subdivisions: 128)
case "cup", "cups", "us cup", "us cups": return usVolume("cup", subdivisions: 16)
case "pt", "pint", "pints", "us pint", "us pints": return usVolume("pt", subdivisions: 8)
case "qt", "quart", "quarts", "us quart", "us quarts": return usVolume("qt", subdivisions: 4)
case "gal", "gallon", "gallons", "us gallon", "us gallons": return .init("volume", "gal", UnitVolume.gallons)
case "ms", "millisecond", "milliseconds": return .init("duration", "ms", scale: 0.001)
case "s", "sec", "secs", "second", "seconds": return .init("duration", "s", scale: 1)
case "min", "mins", "minute", "minutes": return .init("duration", "min", scale: 60)
case "h", "hr", "hrs", "hour", "hours": return .init("duration", "h", scale: 3600)
case "d", "day", "days": return .init("duration", "day", scale: 86400)
case "wk", "wks", "week", "weeks": return .init("duration", "week", scale: 604800)
case "m/s", "mps", "meter per second", "meters per second", "metre per second", "metres per second": return .init("speed", "m/s", UnitSpeed.metersPerSecond)
case "km/h", "km/hr", "kph", "kmph", "kilometer per hour", "kilometers per hour", "kilometre per hour", "kilometres per hour": return .init("speed", "km/h", UnitSpeed.kilometersPerHour)
case "mph", "mi/h", "mile per hour", "miles per hour": return .init("speed", "mph", UnitSpeed.milesPerHour)
case "m²", "m2", "sqm", "sq m", "square m", "square meter", "square meters", "square metre", "square metres": return .init("area", "m²", UnitArea.squareMeters)
case "cm²", "cm2", "sqcm", "sq cm", "square cm", "square centimeter", "square centimeters", "square centimetre", "square centimetres": return .init("area", "cm²", UnitArea.squareCentimeters)
case "km²", "km2", "sqkm", "sq km", "square km", "square kilometer", "square kilometers", "square kilometre", "square kilometres": return .init("area", "km²", UnitArea.squareKilometers)
case "ft²", "ft2", "sqft", "sq ft", "square ft", "square foot", "square feet": return .init("area", "ft²", UnitArea.squareFeet)
case "in²", "in2", "sqin", "sq in", "square in", "square inch", "square inches": return .init("area", "in²", UnitArea.squareInches)
default: return nil
}
}
}
}