Sources/MiriCore/Settings.swift

/// Miri's user settings, read from and written to the `[miri]` section and the engine keys Miri owns.
public struct MiriSettings: Equatable, Sendable {
    /// Provisional range in points. The engine clamps per-window padding to 50, so larger inner gaps would be lost.
    public static let gapRange = 0...32
    public static let presetRange = 0.05...1.0
    public static let presetCountRange = 1...10
    /// The engine's exponential ease-out rate when animation is on. Unset in the engine means instant movement.
    public static let animationSpeed = 14.0
    public static let gapRuleName = "miri-gaps"
    public static let floatingRuleName = "miri-settings"

    public var innerGap = 8
    public var outerGap = 8
    public var widthPresets = [0.25, 0.33, 0.5, 0.66, 0.75]
    public var stackLimit = StackLimit.count(5)
    public var maximizedWindowSide = MaximizedWindowSide.right
    public var animations = true
    public var launchAtLogin = true
    /// Shows Miri's icon in the Dock and the Command-Tab switcher. Off leaves only the menu bar icon.
    public var showInDock = true
    public var stripGesture = StripGesture.off
    /// Reverses the strip swipe. Off means the strip follows the fingers.
    public var reverseSwipe = false
    public var stripScrolling = StripScrolling.continuous
    /// Lets the engine show the contents of a hidden window on an edge next to another display. It needs Screen
    /// Recording permission.
    public var sharedEdgeSlices = false
    /// Moves the pointer to the centre of a window that a Miri command focuses. A click, Cmd-Tab, or an app that
    /// brings its own window forward leaves the pointer in place.
    public var mouseFollowsFocus = false

    public init() {}

    /// Reads settings from a document. Missing keys keep their defaults.
    public init(document: TOMLDocument) throws(ConfigurationIssue) {
        if let value = document.value(at: ["miri", "inner_gap"]) { innerGap = try Self.gap(value, key: "inner_gap") }
        if let value = document.value(at: ["miri", "outer_gap"]) { outerGap = try Self.gap(value, key: "outer_gap") }
        if let value = document.value(at: ["miri", "animations"]) {
            animations = try Self.boolean(value, key: "miri.animations")
        }
        if let value = document.value(at: ["miri", "launch_at_login"]) {
            launchAtLogin = try Self.boolean(value, key: "miri.launch_at_login")
        }
        if let value = document.value(at: ["miri", "show_in_dock"]) {
            showInDock = try Self.boolean(value, key: "miri.show_in_dock")
        }
        if let value = document.value(at: ["miri", "strip_gesture"]) {
            guard case .string(let name) = value, let gesture = StripGesture(rawValue: name) else {
                throw .invalidValue(
                    key: "miri.strip_gesture",
                    message: String(localized: "miri.strip_gesture must be \"off\", \"three\", or \"four\"."))
            }
            stripGesture = gesture
        }
        if let value = document.value(at: ["miri", "reverse_swipe"]) {
            reverseSwipe = try Self.boolean(value, key: "miri.reverse_swipe")
        }
        if let value = document.value(at: ["miri", "strip_scrolling"]) {
            guard case .string(let name) = value, let scrolling = StripScrolling(rawValue: name) else {
                throw .invalidValue(
                    key: "miri.strip_scrolling",
                    message: String(localized: "miri.strip_scrolling must be \"continuous\" or \"window\"."))
            }
            stripScrolling = scrolling
        }
        if let value = document.value(at: ["options", "preset_column_widths"]) {
            guard case .array(let items) = value else { throw Self.presetIssue }
            widthPresets = try items.map { item throws(ConfigurationIssue) in
                switch item {
                case .float(let ratio): ratio
                case .integer(let ratio): Double(ratio)
                default: throw Self.presetIssue
                }
            }
        }
        if let value = document.value(at: ["options", "stack_limit"]) {
            switch value {
            case .integer(let count) where count >= 1: stackLimit = .count(Int(clamping: count))
            case .string("fit"): stackLimit = .fit
            default: throw Self.stackLimitIssue
            }
        }
        if let value = document.value(at: ["options", "maximized_window_side"]) {
            guard case .string(let name) = value, let side = MaximizedWindowSide(rawValue: name) else {
                throw .invalidValue(
                    key: "options.maximized_window_side",
                    message: String(localized: "options.maximized_window_side must be \"left\" or \"right\"."))
            }
            maximizedWindowSide = side
        }
        if let value = document.value(at: ["options", "shared_edge_slices"]) {
            sharedEdgeSlices = try Self.boolean(value, key: "options.shared_edge_slices")
        }
        if let value = document.value(at: ["options", "mouse_follows_focus"]) {
            mouseFollowsFocus = try Self.boolean(value, key: "options.mouse_follows_focus")
        }
        try validate()
    }

    public func validate() throws(ConfigurationIssue) {
        for (key, gap) in [("inner_gap", innerGap), ("outer_gap", outerGap)] where !Self.gapRange.contains(gap) {
            throw Self.gapIssue(key)
        }
        guard Self.presetCountRange.contains(widthPresets.count),
            widthPresets.allSatisfy(Self.presetRange.contains),
            zip(widthPresets, widthPresets.dropFirst()).allSatisfy({ $0 < $1 })
        else { throw Self.presetIssue }
        if case .count(let count) = stackLimit, count < 1 { throw Self.stackLimitIssue }
    }

    /// Per-window padding. Adjacent windows each contribute it, so two of them make the inner gap.
    public var windowPadding: Int { innerGap / 2 }

    /// Display-edge padding. The edge window's own padding adds to it, so together they make the outer gap.
    public var edgePadding: Int { max(0, outerGap - windowPadding) }

    /// Writes these settings and the engine keys derived from them, changing only values that differ.
    /// `appBundleIdentifier` names Miri's own app, whose windows always float. `gestureFingers` is the strip swipe
    /// the engine intercepts, which is on only while it is tested or being tested. `nil` leaves macOS gestures alone.
    /// Leaves each key the file defines in a form Miri does not rewrite, such as an inline table, and returns each of
    /// those keys in `skipped` with the value Miri wanted there, or `nil` to remove it.
    func applying(
        to document: TOMLDocument, reduceMotion: Bool, appBundleIdentifier: String, layout: KeyLayout = .positional,
        gestureFingers: GestureFingers? = nil
    ) throws
        -> (document: TOMLDocument, skipped: [String: TOMLValue?])
    {
        try validate()
        var result = document
        var skipped: [String: TOMLValue?] = [:]
        func edit(_ wanted: TOMLValue?, _ change: () throws -> TOMLDocument) throws {
            do { result = try change() } catch TOMLEditError.notEditable(let path) { skipped[path] = wanted }
        }
        func set(_ value: TOMLValue, _ table: [String], _ key: String) throws {
            if result.value(at: table + [key]) != value {
                try edit(value) { try result.setting(value, table: table, key: key) }
            }
        }
        try set(.integer(Int64(innerGap)), ["miri"], "inner_gap")
        try set(.integer(Int64(outerGap)), ["miri"], "outer_gap")
        try set(.boolean(animations), ["miri"], "animations")
        try set(.boolean(launchAtLogin), ["miri"], "launch_at_login")
        try set(.boolean(showInDock), ["miri"], "show_in_dock")
        try set(.string(stripGesture.rawValue), ["miri"], "strip_gesture")
        try set(.boolean(reverseSwipe), ["miri"], "reverse_swipe")
        try set(.string(stripScrolling.rawValue), ["miri"], "strip_scrolling")
        try set(.array(widthPresets.map(TOMLValue.float)), ["options"], "preset_column_widths")
        try set(stackLimit.tomlValue, ["options"], "stack_limit")
        try set(.string(maximizedWindowSide.rawValue), ["options"], "maximized_window_side")
        try set(.boolean(sharedEdgeSlices), ["options"], "shared_edge_slices")
        try set(.boolean(mouseFollowsFocus), ["options"], "mouse_follows_focus")
        if animations && !reduceMotion {
            if result.value(at: ["options", "animation_speed"]) == nil {
                try set(.float(Self.animationSpeed), ["options"], "animation_speed")
            }
        } else {
            try edit(nil) { try result.removing(table: ["options"], key: "animation_speed") }
        }
        for side in ["top", "right", "bottom", "left"] {
            try set(.integer(Int64(edgePadding)), ["padding"], side)
        }
        let rule = ["windows", Self.gapRuleName]
        try set(.string(".*"), rule, "title")
        try set(.integer(Int64(windowPadding)), rule, "horizontal_padding")
        try set(.integer(Int64(windowPadding)), rule, "vertical_padding")
        // Miri's own menu bar item replaces the engine's.
        try set(.boolean(false), ["decorations", "menu"], "visible")
        let floating = ["windows", Self.floatingRuleName]
        try set(.string(".*"), floating, "title")
        try set(.string(appBundleIdentifier), floating, "bundle_id")
        try set(.boolean(true), floating, "floating")
        let gesture = ["swipe", "gesture"]
        if let gestureFingers {
            try set(.integer(Int64(gestureFingers.rawValue)), gesture, "fingers_count")
        } else {
            try edit(nil) { try result.removing(table: gesture, key: "fingers_count") }
        }
        // Not interface text: engine values.
        try set(.string(reverseSwipe ? "Reversed" : "Natural"), gesture, "direction")
        // V1 strip swipes are horizontal. The engine otherwise takes vertical swipes with the same finger count,
        // which macOS uses for Mission Control and App Exposé.
        try set(.boolean(false), gesture, "vertical")
        try set(.boolean(stripScrolling == .window), gesture, "window_landing")
        try set(.boolean(stripScrolling == .window), ["swipe", "scroll"], "window_step")
        // Paging steps focus while the strip stays still under the fingers, and it overrides window_landing.
        try edit(nil) { try result.removing(table: gesture, key: "paging") }
        // The engine falls back to these deprecated keys, which would keep a swipe on that Miri turned off.
        for legacy in ["swipe_gesture_fingers", "swipe_gesture_direction"] {
            try edit(nil) { try result.removing(table: ["options"], key: legacy) }
        }
        return (try DefaultBinding.adding(to: result, layout: layout), skipped)
    }

    private static func gap(_ value: TOMLValue, key: String) throws(ConfigurationIssue) -> Int {
        guard case .integer(let gap) = value, gapRange.contains(Int(clamping: gap)) else { throw gapIssue(key) }
        return Int(gap)
    }

    private static func boolean(_ value: TOMLValue, key: String) throws(ConfigurationIssue) -> Bool {
        guard case .boolean(let flag) = value else {
            throw .invalidValue(key: key, message: String(localized: "\(key) must be true or false."))
        }
        return flag
    }

    private static func gapIssue(_ key: String) -> ConfigurationIssue {
        .invalidValue(
            key: "miri.\(key)",
            message:
                String(
                    localized:
                        "miri.\(key) must be a whole number of points from \(gapRange.lowerBound) to \(gapRange.upperBound)."
                ))
    }

    private static let presetIssue = ConfigurationIssue.invalidValue(
        key: "options.preset_column_widths",
        message:
            String(
                localized:
                    "options.preset_column_widths must list 1 to 10 increasing ratios from 0.05 to 1, such as [0.33, 0.5, 0.66]."
            )
    )

    private static let stackLimitIssue = ConfigurationIssue.invalidValue(
        key: "options.stack_limit",
        message: String(localized: "options.stack_limit must be a whole number from 1 or \"fit\".")
    )
}

/// How a strip swipe and a modifier-scroll wheel input move the strip, stored in `[miri].strip_scrolling`.
public enum StripScrolling: String, CaseIterable, Sendable {
    /// The strip follows the fingers or the wheel, then settles on a column.
    case continuous
    /// A strip swipe follows the fingers, then lands on one window, and each wheel input focuses the adjacent window:
    /// the engine's `[swipe.gesture].window_landing` and `[swipe.scroll].window_step`.
    case window
}

/// The side of its stack where a maximized stacked window opens as a column of its own. The raw value is the engine's
/// `[options].maximized_window_side`.
public enum MaximizedWindowSide: String, CaseIterable, Sendable {
    case left, right
}

/// How many windows one stack holds. The engine caps a count at what fits on the display the stack is on.
public enum StackLimit: Hashable, Sendable {
    /// As many windows as fit in a column at the engine's minimum window height.
    case fit
    case count(Int)

    /// The engine's `MIN_WINDOW_HEIGHT` in points.
    public static let minimumWindowHeight = 200

    /// How many windows fit in a column `columnHeight` points tall, counted as the engine counts them.
    public static func fitCount(columnHeight: Double) -> Int {
        max(1, Int(columnHeight) / minimumWindowHeight)
    }

    /// The counts Settings offers: 1 up to `fit`, plus `configured` when it is larger, so a number set by hand stays selectable.
    public static func offeredCounts(fit: Int, configured: StackLimit) -> [Int] {
        var counts = Array(1...max(1, fit))
        if case .count(let count) = configured, count > counts.count { counts.append(count) }
        return counts
    }

    var tomlValue: TOMLValue {
        switch self {
        case .fit: .string("fit")
        case .count(let count): .integer(Int64(count))
        }
    }
}

/// A problem that stops Miri from using or changing its configuration file. Miri keeps the last valid settings.
public enum ConfigurationIssue: Error, Equatable, Sendable, CustomStringConvertible {
    case invalidTOML(TOMLError)
    case invalidValue(key: String, message: String)
    case unreadable(String)
    case unwritable(String)
    case notEditable(String)

    public var description: String {
        switch self {
        case .invalidTOML(let error):
            String(localized: "The configuration file is not valid TOML. \(error.description)")
        case .invalidValue(_, let message): message
        case .unreadable(let reason): String(localized: "Miri cannot read the configuration file. \(reason)")
        case .unwritable(let reason): String(localized: "Miri cannot save the configuration file. \(reason)")
        case .notEditable(let reason): reason
        }
    }

    private static let menuSummaryLimit = 80

    /// One line for the menu bar menu, cut to fit with the line number before the message so the cut keeps it.
    public var menuSummary: String {
        let summary =
            switch self {
            case .invalidTOML(let error):
                String(localized: "Configuration error on line \(error.line): \(error.message)")
            default: String(localized: "Configuration error: \(description)")
            }
        guard summary.count > Self.menuSummaryLimit else { return summary }
        return summary.prefix(Self.menuSummaryLimit - 1) + "…"
    }
}