Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 12 additions & 5 deletions Sources/CommandKit/Calculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions Sources/CommandKit/Converter.swift
Original file line number Diff line number Diff line change
@@ -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[..<range.lowerBound].trimmingCharacters(in: .whitespaces)
let target = text[range.upperBound...].trimmingCharacters(in: .whitespaces)
guard let to = units[target], let (amount, from) = amount(source, locale: locale),
type(of: from.dimension) == type(of: to.dimension)
else { continue }
let base = from.dimension.converter.baseUnitValue(fromValue: amount)
let value = to.dimension.converter.value(fromBaseUnitValue: base)
guard value.isFinite else { return nil }
return Conversion(value: value, unit: to.symbol)
}
return nil
}

/// "10 km" or "10km" → (10, kilometres): the longest unit name the text ends with, after an amount.
static func amount(_ text: String, locale: Locale) -> (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
}()
}
10 changes: 9 additions & 1 deletion Sources/CommandKit/Results.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
67 changes: 67 additions & 0 deletions Tests/CommandKitTests/ConverterTests.swift
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Loading