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