From 25d6410c6f741f5a206fcb5a29ad977d01b3997b Mon Sep 17 00:00:00 2001 From: Oddur Sigurdsson Date: Fri, 16 Oct 2026 19:25:38 -0400 Subject: [PATCH] feat(calc): convert units Spotlight converts units and people reach for it to do so; without it this was a reason to keep Spotlight around. Foundation's Unit types supply the factors, so they're Apple's and offline. The amount goes through the calculator, so it can be arithmetic and follows the region's decimal separator. --- CHANGELOG.md | 1 + README.md | 1 + Sources/CommandKit/Calculator.swift | 17 ++- Sources/CommandKit/Converter.swift | 129 +++++++++++++++++++++ Sources/CommandKit/Results.swift | 10 +- Tests/CommandKitTests/ConverterTests.swift | 67 +++++++++++ 6 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 Sources/CommandKit/Converter.swift create mode 100644 Tests/CommandKitTests/ConverterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3c764..1f78c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project are documented here. The format follows [Kee - Liquid Glass on macOS 26 and the popover material on earlier versions, or a solid background. - System Settings topics and keywords from System Settings' own search terms, so "dark mode", "low power", or "firewall" find the right pane, shown with the pane's icon. - A result chosen for a query also leans shorter and longer starts of that query its way, so you can type less next time. +- Unit conversions such as "10 km to mi", "70 f in c", or "5 GB in MB", offline, with Return to copy. ### Fixed diff --git a/README.md b/README.md index 8eba078..a733352 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ What a query finds, in order: 1. **Links.** `github.com/apple`, `https://…`, and `localhost:3000` open in your browser. Only well-known endings count, so `notes.md` and `Info.plist` stay file searches. 2. **Calculations.** `2^10`, `15% * 80`, `sqrt(2)`, `(1+2)(3+4)`, `5!`, with `pi`, `e`, and functions such as `sin`, `log`, and `round`. Return copies the result. Numbers follow your region, so `1,5 + 1` works where the comma is the decimal separator. A bare number isn't treated as a sum, so `1Password` and `2024` stay searches. + **Conversions** work the same way: `10 km to mi`, `70 f in c`, `5 GB in MB`, `2 cups to ml`, `6*12 in to cm`. Length, mass, temperature, volume, speed, area, data, time, and angles, using Foundation's units, shown to six figures. 3. **Apps, System Settings, and Common Command's own commands** (Settings, Quit). Settings covers each pane and the topics inside it, found by the same keywords System Settings' own search uses: "dark mode" finds Appearance, "low power" finds Low Power Mode, "firewall" finds the firewall switch. Panes show their own icons. Laptop-only settings appear only on laptops. 4. **Files and folders** in your home folder whose name has a word starting with each word you typed. Build output, caches, and dot-folders are left out, and files you've opened recently rank higher. 5. **A web search** with your chosen engine: Google, DuckDuckGo, Bing, Kagi, Ecosia, or Startpage. diff --git a/Sources/CommandKit/Calculator.swift b/Sources/CommandKit/Calculator.swift index 8dfa5e7..e5e30f4 100644 --- a/Sources/CommandKit/Calculator.swift +++ b/Sources/CommandKit/Calculator.swift @@ -16,21 +16,28 @@ public enum Calculator { /// The value of `input`, or nil when it isn't arithmetic. A bare number isn't: the field is for /// searching, and "2024" or "1Password" shouldn't turn the top result into a calculation. public static func evaluate(_ input: String, locale: Locale = .current) -> Double? { + value(of: input, locale: locale, requiringOperation: true) + } + + /// `requiringOperation: false` accepts a bare number too, for the amount in "10 km to mi". + static func value(of input: String, locale: Locale, requiringOperation: Bool) -> Double? { let decimal = Character(locale.decimalSeparator ?? ".") - guard let tokens = tokenize(input, decimalSeparator: decimal), tokens.contains(where: \.isOperation), - tokens.contains(where: \.isNumber) + guard let tokens = tokenize(input, decimalSeparator: decimal), + !requiringOperation || tokens.contains(where: \.isOperation), tokens.contains(where: \.isNumber) else { return nil } var parser = Parser(tokens: tokens) guard let value = parser.parseExpression(), parser.atEnd, value.isFinite else { return nil } return value } - /// The value as people read it in their region, with up to 12 significant digits. - public static func format(_ value: Double, locale: Locale = .current, grouping: Bool = true) -> String { + /// The value as people read it in their region, with up to `digits` significant digits. + public static func format(_ value: Double, locale: Locale = .current, grouping: Bool = true, digits: Int = 12) + -> String + { let formatter = NumberFormatter() formatter.locale = locale formatter.usesSignificantDigits = true - formatter.maximumSignificantDigits = 12 + formatter.maximumSignificantDigits = digits formatter.usesGroupingSeparator = grouping let magnitude = abs(value) formatter.numberStyle = magnitude != 0 && (magnitude >= 1e15 || magnitude < 1e-9) ? .scientific : .decimal diff --git a/Sources/CommandKit/Converter.swift b/Sources/CommandKit/Converter.swift new file mode 100644 index 0000000..fe49073 --- /dev/null +++ b/Sources/CommandKit/Converter.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Unit conversions typed into the search field: "10 km to mi", "70 f in c", "5 GB in MB", "3 cups to ml". +/// The amount can be arithmetic ("6*12 in to cm"). Foundation's units do the conversion, so the factors +/// are Apple's, not a table kept here. +public enum Converter { + /// Measurements are rarely known to more than a few figures, so results stop at six. + public static let significantDigits = 6 + + public struct Conversion: Equatable, Sendable { + public var value: Double + public var unit: String + } + + struct Unit { + let dimension: Dimension + /// How the result is written: "mi", "°F", "fl oz". + let symbol: String + } + + /// Words that separate the amount from the unit wanted. Searched from the right, so "5 in in cm" works. + static let separators = [" to ", " in ", " as ", " into ", " -> ", " → ", " = ", "->", "→", "="] + + /// Converts `input`, or returns nil when it isn't a conversion between two units of one kind. + public static func convert(_ input: String, locale: Locale = .current) -> Conversion? { + let text = " " + input.lowercased().trimmingCharacters(in: .whitespaces) + " " + for separator in separators { + guard let range = text.range(of: separator, options: .backwards) else { continue } + let source = text[.. (Double, Unit)? { + for length in stride(from: min(text.count, longestName), through: 1, by: -1) { + let name = String(text.suffix(length)).trimmingCharacters(in: .whitespaces) + guard let unit = units[name] else { continue } + let number = String(text.dropLast(length)).trimmingCharacters(in: .whitespaces) + guard let value = Calculator.value(of: number, locale: locale, requiringOperation: false) else { continue } + return (value, unit) + } + return nil + } + + static let longestName = units.keys.map(\.count).max() ?? 0 + + static let units: [String: Unit] = { + var table: [String: Unit] = [:] + func add(_ dimension: Dimension, _ symbol: String, _ names: String...) { + for name in names + [symbol.lowercased()] { table[name] = Unit(dimension: dimension, symbol: symbol) } + } + add(UnitLength.millimeters, "mm", "millimeter", "millimeters", "millimetre", "millimetres") + add(UnitLength.centimeters, "cm", "centimeter", "centimeters", "centimetre", "centimetres") + add(UnitLength.meters, "m", "meter", "meters", "metre", "metres") + add(UnitLength.kilometers, "km", "kilometer", "kilometers", "kilometre", "kilometres") + add(UnitLength.inches, "in", "inch", "inches", "\"") + add(UnitLength.feet, "ft", "foot", "feet", "'") + add(UnitLength.yards, "yd", "yard", "yards") + add(UnitLength.miles, "mi", "mile", "miles") + add(UnitLength.nauticalMiles, "nmi", "nautical mile", "nautical miles") + + add(UnitMass.milligrams, "mg", "milligram", "milligrams") + add(UnitMass.grams, "g", "gram", "grams") + add(UnitMass.kilograms, "kg", "kilo", "kilos", "kilogram", "kilograms") + add(UnitMass.metricTons, "t", "tonne", "tonnes") + add(UnitMass.ounces, "oz", "ounce", "ounces") + add(UnitMass.pounds, "lb", "lbs", "pound", "pounds") + add(UnitMass.stones, "st", "stone", "stones") + + add(UnitTemperature.celsius, "°C", "c", "celsius", "degc") + add(UnitTemperature.fahrenheit, "°F", "f", "fahrenheit", "degf") + add(UnitTemperature.kelvin, "K", "k", "kelvin") + + add(UnitVolume.milliliters, "ml", "milliliter", "milliliters", "millilitre", "millilitres") + add(UnitVolume.centiliters, "cl", "centiliter", "centiliters", "centilitre", "centilitres") + add(UnitVolume.deciliters, "dl", "deciliter", "deciliters", "decilitre", "decilitres") + add(UnitVolume.liters, "l", "liter", "liters", "litre", "litres") + add(UnitVolume.teaspoons, "tsp", "teaspoon", "teaspoons") + add(UnitVolume.tablespoons, "tbsp", "tablespoon", "tablespoons") + add(UnitVolume.cups, "cups", "cup") + add(UnitVolume.fluidOunces, "fl oz", "floz", "fluid ounce", "fluid ounces") + add(UnitVolume.pints, "pt", "pint", "pints") + add(UnitVolume.quarts, "qt", "quart", "quarts") + add(UnitVolume.gallons, "gal", "gallon", "gallons") + + add(UnitSpeed.kilometersPerHour, "km/h", "kmh", "kph") + add(UnitSpeed.milesPerHour, "mph") + add(UnitSpeed.metersPerSecond, "m/s") + add(UnitSpeed.knots, "kn", "knot", "knots") + + add(UnitArea.squareMeters, "m²", "m2", "sqm", "square meters", "square metres") + add(UnitArea.squareKilometers, "km²", "km2", "square kilometers", "square kilometres") + add(UnitArea.squareFeet, "ft²", "ft2", "sqft", "square feet") + add(UnitArea.squareMiles, "mi²", "mi2", "square miles") + add(UnitArea.acres, "acres", "acre") + add(UnitArea.hectares, "ha", "hectare", "hectares") + + // Decimal multiples, as Finder counts them; the binary ones have their own names. + add(UnitInformationStorage.bytes, "B", "byte", "bytes") + add(UnitInformationStorage.kilobytes, "KB", "kilobyte", "kilobytes") + add(UnitInformationStorage.megabytes, "MB", "megabyte", "megabytes") + add(UnitInformationStorage.gigabytes, "GB", "gigabyte", "gigabytes") + add(UnitInformationStorage.terabytes, "TB", "terabyte", "terabytes") + add(UnitInformationStorage.kibibytes, "KiB") + add(UnitInformationStorage.mebibytes, "MiB") + add(UnitInformationStorage.gibibytes, "GiB") + add(UnitInformationStorage.tebibytes, "TiB") + + add(UnitDuration.milliseconds, "ms", "millisecond", "milliseconds") + add(UnitDuration.seconds, "s", "sec", "secs", "second", "seconds") + add(UnitDuration.minutes, "min", "mins", "minute", "minutes") + add(UnitDuration.hours, "h", "hr", "hrs", "hour", "hours") + add(UnitDuration(symbol: "d", converter: UnitConverterLinear(coefficient: 86_400)), "days", "d", "day") + add(UnitDuration(symbol: "wk", converter: UnitConverterLinear(coefficient: 604_800)), "weeks", "wk", "week") + + add(UnitAngle.degrees, "°", "deg", "degree", "degrees") + add(UnitAngle.radians, "rad", "radian", "radians") + return table + }() +} diff --git a/Sources/CommandKit/Results.swift b/Sources/CommandKit/Results.swift index 43f9f6f..2bef58c 100644 --- a/Sources/CommandKit/Results.swift +++ b/Sources/CommandKit/Results.swift @@ -29,7 +29,15 @@ public enum Results { action: .open(url)) results.append(Ranked(item: item, score: .max)) } - if options.calculator, let value = Calculator.evaluate(text, locale: options.locale) { + if options.calculator, let conversion = Converter.convert(text, locale: options.locale) { + let digits = Converter.significantDigits + let shown = Calculator.format(conversion.value, locale: options.locale, digits: digits) + let copied = Calculator.format(conversion.value, locale: options.locale, grouping: false, digits: digits) + let item = Item( + id: "convert", title: "= \(shown) \(conversion.unit)", subtitle: "Copy", kind: .calculation, + action: .copy(copied)) + results.append(Ranked(item: item, score: .max)) + } else if options.calculator, let value = Calculator.evaluate(text, locale: options.locale) { let shown = Calculator.format(value, locale: options.locale) let copied = Calculator.format(value, locale: options.locale, grouping: false) let item = Item( diff --git a/Tests/CommandKitTests/ConverterTests.swift b/Tests/CommandKitTests/ConverterTests.swift new file mode 100644 index 0000000..22a09e8 --- /dev/null +++ b/Tests/CommandKitTests/ConverterTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing + +@testable import CommandKit + +@Suite struct ConverterTests { + let us = Locale(identifier: "en_US") + + func convert(_ input: String, _ locale: Locale = Locale(identifier: "en_US")) -> Converter.Conversion? { + Converter.convert(input, locale: locale) + } + + // Foundation's cup is the 240 ml US legal cup, as on nutrition labels, not the 236.6 ml customary one. + @Test(arguments: [ + ("10 km to mi", 6.21371192, "mi"), ("10km to miles", 6.21371192, "mi"), ("212 f in c", 100, "°C"), + ("-40 c to f", -40, "°F"), ("0 c to k", 273.15, "K"), ("5 in in cm", 12.7, "cm"), + ("6*12 in to cm", 182.88, "cm"), ("1 lb to g", 453.59237, "g"), ("2 cups to ml", 480, "ml"), + ("5 GB in MB", 5000, "MB"), ("1 GiB in MiB", 1024, "MiB"), ("90 min to h", 1.5, "h"), + ("2 weeks in days", 14, "days"), ("100 kmh to mph", 62.1371192, "mph"), ("1 acre to m2", 4046.8564224, "m²"), + ("180 deg to rad", .pi, "rad"), ("10km=mi", 6.21371192, "mi"), ("3 FT → M", 0.9144, "m"), + ]) + func converts(input: String, value: Double, unit: String) throws { + let result = try #require(convert(input)) + #expect(abs(result.value - value) < 1e-6 * max(1, abs(value))) + #expect(result.unit == unit) + } + + @Test(arguments: [ + "10 km", "km to mi", "10 km to kg", "notes in m", "photos to mb", "3 in", "10 km to", "x km to mi", + "10 parsecs to km", "safari", "2+2", + ]) + func leavesEverythingElseAlone(input: String) { + #expect(convert(input) == nil) + } + + @Test func readsTheRegionsDecimalComma() throws { + let result = try #require(convert("1,5 km to m", Locale(identifier: "de_DE"))) + #expect(abs(result.value - 1500) < 1e-9) + } + + @Test func showsAsACopyableResultAboveEverythingElse() { + var options = SearchOptions() + options.locale = us + let results = Results.compose( + query: "1 mi to km", candidates: [app("Miro")], files: [], usage: Usage(), now: Date(), options: options) + #expect(results.first?.item.title == "= 1.60934 km") + #expect(results.first?.item.action == .copy("1.60934")) + #expect(results.filter { $0.item.kind == .calculation }.count == 1) + } + + @Test func showsSixFiguresAndCopiesWhatItShows() { + var options = SearchOptions() + options.locale = us + let results = Results.compose( + query: "70 f in c", candidates: [], files: [], usage: Usage(), now: Date(), options: options) + #expect(results.first?.item.title == "= 21.1111 °C") + #expect(results.first?.item.action == .copy("21.1111")) + } + + @Test func turnsOffWithTheCalculator() { + var options = SearchOptions() + options.calculator = false + let results = Results.compose( + query: "1 mi to km", candidates: [], files: [], usage: Usage(), now: Date(), options: options) + #expect(!results.contains { $0.item.kind == .calculation }) + } +}