feat: add calculator and unit conversion
This commit is contained in:
@@ -30,9 +30,9 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
|
|||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
- Type to filter applications; the first match is selected automatically.
|
- Type to filter applications, calculate an expression, or convert units; the first result is selected automatically.
|
||||||
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
|
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
|
||||||
- Return, keypad Enter, the Open Application button, or a double-click opens the selection.
|
- Return, keypad Enter, the footer button, or a double-click opens the selected application or copies a calculator answer and dismisses the launcher.
|
||||||
- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search.
|
- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search.
|
||||||
- Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back.
|
- Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back.
|
||||||
- Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
- Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
||||||
@@ -75,6 +75,31 @@ Background discovery preserves the selected application even if its row changes.
|
|||||||
|
|
||||||
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
|
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
|
||||||
|
|
||||||
|
## Calculator and unit conversion
|
||||||
|
|
||||||
|
Type directly into search, as with [Raycast's calculator](https://manual.raycast.com/calculator). A recognized expression or conversion replaces application results with one **Calculator** row. Return, keypad Enter, double-click, or **Copy Answer** copies the displayed answer and dismisses the launcher, restoring the previously active application. Conversion answers include the destination unit. Application actions are disabled for calculator results; calculations do not affect launch ranking.
|
||||||
|
|
||||||
|
Arithmetic supports `+`, `-`, `*` (also `x` or `×`), `/` (also `÷`), parentheses, scientific notation, and right-associative `^` powers. Powers bind more tightly than unary signs: `-2^2` is `-4`, while `(-2)^2` is `4`. Functions are `sqrt`, `abs`, `round`, `floor`, `ceil`, `sin`, `cos`, `tan`, `log` (base ten), and `ln`; use parentheses around their arguments. Trigonometric arguments are radians; constants are `pi`/`π` and `e`. A trailing `%` divides by 100; `%` before an unsigned operand is remainder, and `mod` accepts signed operands. Percentage queries such as `52% of 900` and `20% off 80` also work.
|
||||||
|
|
||||||
|
Convert with `<expression> <source unit> to <target unit>`; `in` and `as` also work. Spaces between numbers and units are optional, and most units accept abbreviations and singular/plural English names.
|
||||||
|
|
||||||
|
| Unit family | Supported units | Example query |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Length | mm, cm, m, km, in, ft, yd, mi | `10ft in m` |
|
||||||
|
| Mass | mg, g, kg, oz, lb | `1 kg to pounds` |
|
||||||
|
| Temperature | Celsius, Fahrenheit, Kelvin | `32 f to c` |
|
||||||
|
| Volume | mL, L, US teaspoon, tablespoon, fluid ounce, cup, pint, quart, gallon | `1 cup to fl oz` |
|
||||||
|
| Duration | milliseconds, seconds, minutes, hours, days, weeks | `2 hours in minutes` |
|
||||||
|
| Speed | m/s, km/h, mph | `60 mph to km/h` |
|
||||||
|
| Area | cm², m², km², in², ft²; also `square` names | `144in² to ft²` |
|
||||||
|
| Data | bits/bytes, decimal kilo/mega/giga/tera and binary kibi/mebi/gibi/tebi | `1 GiB to MiB` |
|
||||||
|
|
||||||
|
Data abbreviations distinguish lowercase `b` (bits) from uppercase `B` (bytes): `8b to B` gives `1 B`. Decimal `KB` is 1,000 bytes; binary `KiB` is 1,024. Culinary volumes use US customary measures, not imperial measures or the 240 mL legal cup.
|
||||||
|
|
||||||
|
All calculation is offline, with no added dependencies or script evaluation. Output uses a decimal point and up to 12 significant digits; physical units generally use Foundation's native conversion precision, with exact defining Fahrenheit ratios and consistent US customary volume subdivisions. Division by zero, invalid function domains, nonfinite results, and incompatible unit families show a non-copyable error. Ordinary app names, standalone numeric literals, incomplete expressions, and unsupported syntax remain application searches. Input is bounded to 512 UTF-8 bytes and recursive parser depth below 16 to keep live search safe.
|
||||||
|
|
||||||
|
This is arithmetic and physical/data unit conversion, not full Raycast parity: currency rates, calendar/date/time-zone calculations, design units, and unrestricted natural-language math are not supported.
|
||||||
|
|
||||||
## Application actions and uninstall
|
## Application actions and uninstall
|
||||||
|
|
||||||
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
|
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
|
||||||
@@ -112,7 +137,7 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView`
|
|||||||
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
|
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
|
||||||
|
|
||||||
- `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching.
|
- `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching.
|
||||||
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
|
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, calculator and unit conversion, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
|
||||||
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks.
|
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, calculator precedence/conversion/error boundaries, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks.
|
||||||
|
|
||||||
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private let tableView = ApplicationTableView()
|
private let tableView = ApplicationTableView()
|
||||||
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
||||||
private let emptyLabel = 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 openButton = NSButton(title: "Open Application", target: nil, action: nil)
|
||||||
private let history: LaunchHistory
|
private let history: LaunchHistory
|
||||||
private var applications: [Application] = []
|
private var applications: [Application] = []
|
||||||
private var results: [Application] = []
|
private var results: [Application] = []
|
||||||
|
private var calculation: CalculatorResult?
|
||||||
private var locationLabels: [URL: String] = [:]
|
private var locationLabels: [URL: String] = [:]
|
||||||
private var discoveryTask: Task<Void, Never>?
|
private var discoveryTask: Task<Void, Never>?
|
||||||
private var isLaunching = false
|
private var isLaunching = false
|
||||||
@@ -179,7 +181,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
let searchFont = NSFont.systemFont(ofSize: 20)
|
let searchFont = NSFont.systemFont(ofSize: 20)
|
||||||
searchField.font = searchFont
|
searchField.font = searchFont
|
||||||
searchField.placeholderAttributedString = NSAttributedString(
|
searchField.placeholderAttributedString = NSAttributedString(
|
||||||
string: "Search for apps…",
|
string: "Search apps, calculate, or convert units…",
|
||||||
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
|
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
|
||||||
)
|
)
|
||||||
searchField.textColor = SunsetPalette.text
|
searchField.textColor = SunsetPalette.text
|
||||||
@@ -193,7 +195,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
searchField.sendsSearchStringImmediately = true
|
searchField.sendsSearchStringImmediately = true
|
||||||
searchField.sendsWholeSearchString = false
|
searchField.sendsWholeSearchString = false
|
||||||
searchField.delegate = self
|
searchField.delegate = self
|
||||||
searchField.setAccessibilityLabel("Search applications")
|
searchField.setAccessibilityLabel("Search applications, calculate, or convert units")
|
||||||
|
|
||||||
let symbol = NSImageView()
|
let symbol = NSImageView()
|
||||||
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
||||||
@@ -204,7 +206,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
header.alignment = .centerY
|
header.alignment = .centerY
|
||||||
header.spacing = 12
|
header.spacing = 12
|
||||||
|
|
||||||
let sectionLabel = NSTextField(labelWithString: "Applications")
|
|
||||||
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||||
sectionLabel.textColor = SunsetPalette.secondaryText
|
sectionLabel.textColor = SunsetPalette.secondaryText
|
||||||
|
|
||||||
@@ -372,19 +373,26 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private func updateResults(preservingSelection: Bool = false) {
|
private func updateResults(preservingSelection: Bool = false) {
|
||||||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||||||
? results[tableView.selectedRow].id : nil
|
? 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)
|
results = history.suggestions(in: applications)
|
||||||
} else {
|
} else {
|
||||||
results = history.results(for: searchField.stringValue, in: applications)
|
results = history.results(for: searchField.stringValue, in: applications)
|
||||||
}
|
}
|
||||||
|
sectionLabel.stringValue = calculation == nil ? "Applications" : "Calculator"
|
||||||
|
tableView.setAccessibilityLabel(calculation == nil ? "Applications" : "Calculator result")
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
if !results.isEmpty {
|
if resultCount > 0 {
|
||||||
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
|
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
|
||||||
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||||
tableView.scrollRowToVisible(row)
|
tableView.scrollRowToVisible(row)
|
||||||
}
|
}
|
||||||
emptyLabel.isHidden = !results.isEmpty
|
emptyLabel.isHidden = resultCount > 0
|
||||||
if discoveryTask != nil {
|
if let calculation {
|
||||||
|
statusLabel.stringValue = calculation.detail
|
||||||
|
} else if discoveryTask != nil {
|
||||||
emptyLabel.stringValue = "Finding applications…"
|
emptyLabel.stringValue = "Finding applications…"
|
||||||
statusLabel.stringValue = "Finding applications…"
|
statusLabel.stringValue = "Finding applications…"
|
||||||
} else {
|
} else {
|
||||||
@@ -414,16 +422,24 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var resultCount: Int { calculation == nil ? results.count : 1 }
|
||||||
|
|
||||||
private func moveSelection(by offset: Int) {
|
private func moveSelection(by offset: Int) {
|
||||||
guard !results.isEmpty else { return }
|
guard resultCount > 0 else { return }
|
||||||
let row = min(max(tableView.selectedRow + offset, 0), results.count - 1)
|
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
|
||||||
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||||
tableView.scrollRowToVisible(row)
|
tableView.scrollRowToVisible(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateOpenButton() {
|
private func updateOpenButton() {
|
||||||
openButton.isEnabled = !isLaunching && launcherFrameBeforeReview == nil && results.indices.contains(tableView.selectedRow)
|
let canAct = !isLaunching && launcherFrameBeforeReview == nil && tableView.selectedRow >= 0
|
||||||
actionsButton.isEnabled = openButton.isEnabled
|
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) {
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||||
@@ -431,20 +447,32 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func numberOfRows(in tableView: NSTableView) -> Int {
|
func numberOfRows(in tableView: NSTableView) -> Int {
|
||||||
results.count
|
resultCount
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
|
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
|
||||||
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
|
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
|
||||||
?? makeCell(identifier: identifier)
|
?? makeCell(identifier: identifier)
|
||||||
|
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]
|
let application = results[row]
|
||||||
cell.textField?.stringValue = application.name
|
cell.textField?.stringValue = application.name
|
||||||
let location = locationLabels[application.id]
|
let location = locationLabels[application.id]
|
||||||
cell.locationLabel.stringValue = location ?? ""
|
cell.locationLabel.stringValue = location ?? ""
|
||||||
cell.locationLabel.isHidden = location == nil
|
cell.locationLabel.isHidden = location == nil
|
||||||
|
cell.kindLabel.stringValue = "Application"
|
||||||
|
cell.imageView?.contentTintColor = nil
|
||||||
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
|
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
|
||||||
cell.toolTip = application.url.path
|
cell.toolTip = application.url.path
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +504,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
title.orientation = .horizontal
|
title.orientation = .horizontal
|
||||||
title.alignment = .centerY
|
title.alignment = .centerY
|
||||||
title.spacing = 8
|
title.spacing = 8
|
||||||
let kind = NSTextField(labelWithString: "Application")
|
let kind = cell.kindLabel
|
||||||
kind.font = .systemFont(ofSize: 14)
|
kind.font = .systemFont(ofSize: 14)
|
||||||
kind.textColor = SunsetPalette.secondaryText
|
kind.textColor = SunsetPalette.secondaryText
|
||||||
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
|
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
@@ -582,6 +610,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openSelection() {
|
@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 }
|
guard results.indices.contains(tableView.selectedRow) else { return }
|
||||||
openApplication(results[tableView.selectedRow])
|
openApplication(results[tableView.selectedRow])
|
||||||
}
|
}
|
||||||
@@ -622,6 +660,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class ApplicationCell: NSTableCellView {
|
private final class ApplicationCell: NSTableCellView {
|
||||||
let locationLabel = NSTextField(labelWithString: "")
|
let locationLabel = NSTextField(labelWithString: "")
|
||||||
|
let kindLabel = NSTextField(labelWithString: "Application")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,4 @@
|
|||||||
- show in finder
|
- show in finder
|
||||||
- reset ranking
|
- reset ranking
|
||||||
- quit application
|
- quit application
|
||||||
- [ ] add unit conversion and calculator behavior like raylib has.
|
- [x] add unit conversion and calculator behavior like raylib has.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import ConjureKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorHonorsPrecedenceUnaryAndRightAssociativePowers() {
|
||||||
|
let examples: [(String, String)] = [
|
||||||
|
("2 + 3 * 4", "14"), ("(2 + 3) * 4", "20"),
|
||||||
|
("2^3^2", "512"), ("-2^2", "-4"), ("(-2)^2", "4"),
|
||||||
|
("2^-2", "0.25"), ("--2 + +3", "5"),
|
||||||
|
("8 ÷ 2 × 3", "12"), ("2x3", "6"), ("2 x (3 + 1)", "8"),
|
||||||
|
("10 % 3", "1"), ("10 mod -3", "1"),
|
||||||
|
]
|
||||||
|
for (query, expected) in examples {
|
||||||
|
let result = Calculator.evaluate(query)
|
||||||
|
#expect(result?.displayValue == expected, "\(query)")
|
||||||
|
#expect(result?.copyValue == expected, "\(query)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorSupportsFunctionsPercentagesAndScientificNotation() {
|
||||||
|
let examples: [(String, String)] = [
|
||||||
|
("sqrt(81) + abs(-3)", "12"), ("round(2.5) + floor(2.9) + ceil(2.1)", "8"),
|
||||||
|
("sin(pi/2) + cos(0) + tan(0)", "2"), ("log(100) + ln(e)", "3"),
|
||||||
|
("50%", "0.5"), ("200 * 10%", "20"), ("50% + 2", "2.5"),
|
||||||
|
("52% of 900", "468"), ("20% off 80", "64"), ("20% off (80 + 20)", "80"),
|
||||||
|
(".5e2 + 1E-2", "50.01"), ("π - pi", "0"),
|
||||||
|
("pi", "3.14159265359"), ("e", "2.71828182846"), ("1 / 3", "0.333333333333"),
|
||||||
|
]
|
||||||
|
for (query, expected) in examples {
|
||||||
|
#expect(Calculator.evaluate(query)?.copyValue == expected, "\(query)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorConvertsDimensionsAndTemperatureOffsets() throws {
|
||||||
|
let examples: [(String, Double, String)] = [
|
||||||
|
("10 km to mi", 6.21371192237, "mi"), ("10ft in m", 3.048, "m"),
|
||||||
|
("29 inches to cm", 73.66, "cm"), ("2 * 5 cm to inches", 3.93700787402, "in"),
|
||||||
|
("1 kilograms to pounds", 2.20462262185, "lb"), ("16oz to lb", 1, "lb"),
|
||||||
|
("32 f to c", 0, "°C"), ("23C to F", 73.4, "°F"),
|
||||||
|
("100°C to °F", 212, "°F"), ("0 c to kelvin", 273.15, "K"),
|
||||||
|
("2 hours in minutes", 120, "min"), ("1 week as seconds", 604800, "s"),
|
||||||
|
("1000 milliseconds to s", 1, "s"),
|
||||||
|
("36 km/h to m/s", 10, "m/s"), ("60 mph to km/h", 96.56064, "km/h"),
|
||||||
|
("1 square m to square cm", 10000, "cm²"), ("144in² to ft²", 1, "ft²"),
|
||||||
|
("2 liters to ml", 2000, "mL"), ("1 US gallon to l", 3.785411784, "L"),
|
||||||
|
("1 tbsp to tsp", 3, "tsp"), ("1 cup to fl oz", 8, "fl oz"),
|
||||||
|
]
|
||||||
|
for (query, expected, unit) in examples {
|
||||||
|
let result = try #require(Calculator.evaluate(query), "\(query)")
|
||||||
|
let copied = try #require(result.copyValue, "\(query): \(result.displayValue)")
|
||||||
|
#expect(copied.hasSuffix(" \(unit)"), "\(query)")
|
||||||
|
let value = try #require(Double(copied.dropLast(unit.count + 1)), "\(query)")
|
||||||
|
// Foundation's built-in physical-unit coefficients have limited decimal precision.
|
||||||
|
#expect(abs(value - expected) < max(1e-9, abs(expected) * 1e-5), "\(query): \(copied)")
|
||||||
|
}
|
||||||
|
#expect(Calculator.evaluate("32 f to c")?.copyValue == "0 °C")
|
||||||
|
#expect(Calculator.evaluate("0 c to f")?.copyValue == "32 °F")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorDistinguishesDecimalBinaryAndBitsFromBytes() {
|
||||||
|
let examples: [(String, String)] = [
|
||||||
|
("1 GB to MB", "1000 MB"), ("1GB to B", "1000000000 B"),
|
||||||
|
("1 GiB to MiB", "1024 MiB"), ("1 KiB to B", "1024 B"),
|
||||||
|
("1 MB to KiB", "976.5625 KiB"), ("8b to B", "1 B"),
|
||||||
|
("1 B to b", "8 b"), ("1 Mb to MB", "0.125 MB"), ("8 bits to bytes", "1 B"),
|
||||||
|
("1 kilobyte to bytes", "1000 B"), ("1 mebibyte to KiB", "1024 KiB"),
|
||||||
|
("8 megabits to MB", "1 MB"),
|
||||||
|
]
|
||||||
|
for (query, expected) in examples {
|
||||||
|
#expect(Calculator.evaluate(query)?.copyValue == expected, "\(query)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorLeavesApplicationQueriesAndIncompleteInputAlone() {
|
||||||
|
for query in ["", "Safari", "Xcode", "1Password", "Calculator", "123", "-123", "1e3",
|
||||||
|
"2 +", "sqrt(", "2^", "1e+", "(1+2", "2 3", "2 ** 3", "pi app", "sin 2",
|
||||||
|
"alert(1)", "foo(2)", "1/0 Safari", "10 km to", "10 km to widgets", "2 of 3", "20 off 80"] {
|
||||||
|
#expect(Calculator.evaluate(query) == nil, "\(query)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorReportsRecognizedErrorsWithoutCopyableResults() throws {
|
||||||
|
for query in ["1/0", "1 mod 0", "sqrt(-1)", "ln(0)", "log(-2)", "(-2)^0.5",
|
||||||
|
"1e308 * 1e308", "1e999 + 1", "1e308 TB to B", "10 m to kg", "1 GB to seconds"] {
|
||||||
|
let result = try #require(Calculator.evaluate(query), "\(query)")
|
||||||
|
#expect(result.copyValue == nil, "\(query)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func calculatorBoundsLiveSearchInputAndRecursiveGrammar() {
|
||||||
|
#expect(Calculator.evaluate(String(repeating: "(", count: 70) + "1" + String(repeating: ")", count: 70)) == nil)
|
||||||
|
#expect(Calculator.evaluate(String(repeating: "(", count: 16) + "1" + String(repeating: ")", count: 16)) == nil)
|
||||||
|
#expect(Calculator.evaluate(String(repeating: "-", count: 70) + "1 + 2") == nil)
|
||||||
|
#expect(Calculator.evaluate(String(repeating: "2^", count: 70) + "2") == nil)
|
||||||
|
#expect(Calculator.evaluate(String(repeating: "1+", count: 300) + "1") == nil)
|
||||||
|
#expect(Calculator.evaluate(String(repeating: "(", count: 15) + "1+2" + String(repeating: ")", count: 15))?.copyValue == "3")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user