Sources/Miri/SettingsPages.swift

import AppKit
import MiriCore
import SwiftUI

/// Shows a configuration problem at the top of a page.
struct ConfigurationIssueNotice: View {
    let model: AppModel

    var body: some View {
        if let issue = model.configurationIssue {
            Banner(
                kind: .warning, title: String(localized: "The configuration file has a problem"),
                detail: String(localized: "\(issue.description) Miri does not change the file until it is fixed.")
            ) {
                Button("Show Configuration File in Finder") {
                    NSWorkspace.shared.activateFileViewerSelecting([model.paths.configuration])
                }
            }
        }
    }
}

// MARK: - General

struct GeneralSettings: View {
    let model: AppModel
    @Binding var page: SettingsView.Page

    var body: some View {
        SettingsPage(
            title: String(localized: "General"),
            subtitle: String(localized: "Closing this window keeps Miri in the menu bar.")
        ) {
            ConfigurationIssueNotice(model: model)
            status
            SettingsSection(String(localized: "Startup")) {
                SettingsRow(
                    title: String(localized: "Launch at login"),
                    detail: String(localized: "If you stopped Miri before logging out, it stays stopped.")
                ) {
                    RowSwitch(
                        label: String(localized: "Launch at login"),
                        isOn: Binding(get: { model.settings.launchAtLogin }, set: { model.setLaunchAtLogin($0) }))
                }
                if let error = model.loginItemError {
                    NoticeRow(kind: .warning, text: error) {
                        if !AppModel.isInApplicationsFolder {
                            Button("Show Miri in Finder") {
                                NSWorkspace.shared.activateFileViewerSelecting([Bundle.main.bundleURL])
                            }
                        }
                    }
                }
                ShowInDockRow(model: model)
            }
            SettingsSection(String(localized: "Pointer")) {
                SettingsRow(
                    title: String(localized: "Move the pointer to windows that Miri focuses"),
                    detail:
                        String(
                            localized:
                                "Focus shortcuts, display focus, and workspace switches move it to the window's centre. Clicks and Cmd-Tab leave it in place."
                        )
                ) {
                    RowSwitch(
                        label: String(localized: "Move the pointer to windows that Miri focuses"),
                        isOn: Binding(
                            get: { model.settings.mouseFollowsFocus },
                            set: { value in model.update { $0.mouseFollowsFocus = value } }))
                }
            }
            SettingsSection(String(localized: "Configuration file")) {
                SettingsRow(
                    title: (model.paths.configuration.path as NSString).abbreviatingWithTildeInPath,
                    detail: String(
                        localized: "Changes here apply right away. Settings from other tools are not touched."),
                    monospacedTitle: true
                ) {
                    Button("Show in Finder") {
                        NSWorkspace.shared.activateFileViewerSelecting([model.paths.configuration])
                    }
                    .fixedSize()
                }
            }
        }
    }

    @ViewBuilder private var status: some View {
        switch model.engineState {
        case .running:
            StatusHero(
                eyebrow: String(localized: "Running"), eyebrowColor: Palette.accentText, title: model.statusText,
                detail: displaysSentence, tint: Palette.heroTint, faded: false)
        case .restarting:
            StatusHero(
                eyebrow: String(localized: "Restarting"), eyebrowColor: Palette.orange, title: model.statusText,
                detail:
                    String(
                        localized:
                            "The engine stopped unexpectedly. Your windows are back on screen, and Miri restarts it in a moment."
                    ),
                tint: Palette.warningTint, faded: true)
        case .failed(let reason):
            Banner(
                kind: .error, title: model.statusText, detail: reason,
                accessibilityValue: model.statusText
            ) {
                Button("Show Logs in Finder") { model.showLogsInFinder() }
                Button("Restart Engine") { model.restart() }
                    .buttonStyle(.borderedProminent)
                    .tint(Palette.accentFill)
            }
        case .stopped where !model.startBlockers.isEmpty:
            Banner(
                kind: .warning, title: String(localized: "Waiting for setup"),
                detail:
                    String(
                        localized:
                            "Waiting for the \u{201C}\(model.startBlockers.first?.title ?? "required")\u{201D} check. \(AppModel.startsWhenReadySentence)"
                    ),
                accessibilityValue: model.statusText
            ) {
                Button(AppModel.cancelAutomaticStartTitle) { model.stop() }
                Button("Open Setup") { page = .setup }
                    .buttonStyle(.borderedProminent)
                    .tint(Palette.accentFill)
            }
        case .stopped:
            Banner(
                kind: .neutral, title: String(localized: "Window management is stopped"),
                // Only a stop the user chose is remembered across log-ins, so only that one promises it.
                detail: model.stoppedByUser
                    ? String(localized: "Your windows stay where they are. Miri stays stopped until you start it.")
                    : String(localized: "Your windows stay where they are."),
                accessibilityValue: model.statusText
            ) {
                Button("Start Window Management") { model.start() }
                    .buttonStyle(.borderedProminent)
                    .tint(Palette.accentFill)
            }
        }
    }

    /// Names the displays Miri manages, such as "Built-in Retina Display and LG 27UK850, one strip per Space."
    private var displaysSentence: String {
        let names = NSScreen.screens.map(\.localizedName)
        guard !names.isEmpty else { return String(localized: "One strip per Space.") }
        return String(localized: "\(names.formatted(.list(type: .and))), one strip per Space.")
    }
}

/// The status card at the top of General, with a small picture of the strips.
private struct StatusHero: View {
    let eyebrow: String
    let eyebrowColor: Color
    let title: String
    let detail: String
    let tint: Color
    let faded: Bool

    var body: some View {
        HStack(spacing: 26) {
            StripPicture(faded: faded)
            VStack(alignment: .leading, spacing: 4) {
                Text(eyebrow)
                    .font(.small.weight(.semibold))
                    .tracking(0.66)
                    .textCase(.uppercase)
                    .foregroundStyle(eyebrowColor)
                Text(title)
                    .font(.heroTitle)
                    .foregroundStyle(Palette.primaryText)
                    .modifier(StatusValue(value: title))
                Text(detail)
                    .font(.detail)
                    .foregroundStyle(Palette.secondaryText)
                    .fixedSize(horizontal: false, vertical: true)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding(20)
        .background(tint, in: .rect(cornerRadius: 10))
    }
}

/// A decorative sketch of one strip per display, up to two, with one column picked out in the accent color.
private struct StripPicture: View {
    let faded: Bool

    var body: some View {
        VStack(spacing: 6) {
            display(widths: [28, 62, 44, 44, 40], stacked: 2, highlighted: 1, height: 52)
            if NSScreen.screens.count > 1 {
                display(widths: [94, 60, 50], stacked: nil, highlighted: nil, height: 40)
            }
        }
        .frame(width: 200)
        // A picture of the screen keeps the screen's real left and right, also in Arabic.
        .environment(\.layoutDirection, .leftToRight)
        .accessibilityHidden(true)
    }

    private func display(widths: [CGFloat], stacked: Int?, highlighted: Int?, height: CGFloat) -> some View {
        HStack(spacing: 4) {
            ForEach(widths.indices, id: \.self) { index in
                if index == stacked {
                    VStack(spacing: 2) {
                        tile(highlighted: false)
                        tile(highlighted: false)
                    }
                    .frame(width: widths[index])
                } else {
                    tile(highlighted: index == highlighted).frame(width: widths[index])
                }
            }
        }
        .padding(5)
        .frame(width: 200, height: height, alignment: .leading)
        .clipped()
        .background(Palette.group, in: .rect(cornerRadius: 6))
        .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Palette.heroTileBorder, lineWidth: 1))
    }

    private func tile(highlighted: Bool) -> some View {
        RoundedRectangle(cornerRadius: 2)
            .fill(highlighted ? Palette.accent.opacity(faded ? 0.4 : 1) : Palette.heroTile)
    }
}

// MARK: - Layout

struct LayoutSettings: View {
    let model: AppModel
    @State private var presetsText = ""
    @State private var presetsError: String?
    @FocusState private var editingPresets: Bool
    @State private var mainDisplayHeight = LayoutSettings.currentMainDisplayHeight()

    var body: some View {
        SettingsPage(
            title: String(localized: "Layout"),
            subtitle: String(localized: "How columns sit on the strip. Changes apply right away.")
        ) {
            ConfigurationIssueNotice(model: model)
            SettingsSection(String(localized: "Gaps")) {
                gapRow(String(localized: "Between windows"), \.innerGap)
                gapRow(String(localized: "At screen edges"), \.outerGap)
            }
            SettingsSection(String(localized: "Column widths and motion")) {
                SettingsRow(
                    title: String(localized: "Width presets"),
                    detail: presetsDetail
                ) {
                    TextField("Width presets", text: $presetsText, prompt: Text("0.25, 0.5, 0.75"))
                        .labelsHidden()
                        .textFieldStyle(.plain)
                        .font(.rowTitle)
                        .fieldBox(focused: editingPresets)
                        .frame(width: 200)
                        .focused($editingPresets)
                        .onSubmit(applyPresets)
                        // Leaving the field applies it too, so an edit is never lost by clicking elsewhere.
                        .onChange(of: editingPresets) { if !editingPresets { applyPresets() } }
                }
                if let presetsError {
                    NoticeRow(kind: .error, text: presetsError)
                }
                SettingsRow(title: String(localized: "Animate window movement"), detail: motionDetail) {
                    RowSwitch(
                        label: String(localized: "Animate window movement"),
                        isOn: Binding(
                            get: { model.settings.animations },
                            set: { value in model.update { $0.animations = value } }))
                }
            }
            SettingsSection(String(localized: "Stacks")) {
                SettingsRow(
                    title: String(localized: "Windows per stack"),
                    detail:
                        String(
                            localized:
                                "Up to \(mainDisplayFit) fit on the main display at the minimum height of \(StackLimit.minimumWindowHeight) points. A full stack sends the next window to a new column."
                        )
                ) {
                    Picker("Windows per stack", selection: stackLimit) {
                        Text("Fit the display").tag(StackLimit.fit)
                        ForEach(
                            StackLimit.offeredCounts(fit: mainDisplayFit, configured: model.settings.stackLimit),
                            id: \.self
                        ) { count in
                            Text("\(count)").tag(StackLimit.count(count))
                        }
                    }
                    .labelsHidden()
                    .fixedSize()
                }
                SettingsRow(
                    title: String(localized: "Maximized stacked window opens"),
                    detail: String(localized: "Restoring it returns it to the stack if there is room.")
                ) {
                    SegmentedChoice(
                        label: String(localized: "Maximized stacked window opens"), selection: maximizedWindowSide,
                        options: [
                            (String(localized: "Left of its stack"), .left),
                            (String(localized: "Right of its stack"), .right),
                        ])
                }
            }
            SettingsSection(String(localized: "Displays side by side")) {
                SettingsRow(
                    title: String(localized: "Show window contents on edges next to another display"),
                    detail:
                        String(
                            localized:
                                "A column waiting past an edge next to another display shows a photo of its window. Needs Screen Recording permission."
                        )
                ) {
                    RowSwitch(
                        label: String(localized: "Show window contents on edges next to another display"),
                        isOn: Binding(
                            get: { model.settings.sharedEdgeSlices }, set: { model.setSharedEdgeSlices($0) }))
                }
                if model.sharedEdgeSlicesNeedPermission {
                    NoticeRow(
                        kind: .warning,
                        text:
                            String(
                                localized:
                                    "Off because Miri does not have Screen Recording permission. Turn on Miri there, then switch this on again."
                            )
                    ) {
                        if let url = SystemScreenRecording.settingsURL {
                            Button("Open Screen Recording Settings") { NSWorkspace.shared.open(url) }
                        }
                    }
                }
            }
        }
        .onAppear(perform: showPresets)
        .onChange(of: model.settings.widthPresets) { showPresets() }
        .onChange(of: presetsError) {
            if let presetsError { AccessibilityNotification.Announcement(presetsError).post() }
        }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) {
            _ in
            mainDisplayHeight = Self.currentMainDisplayHeight()
        }
    }

    private var presetsDetail: String {
        editingPresets && presetsText != formattedPresets
            ? String(localized: "Press Return to apply. Fractions of the display width, narrowest first.")
            : String(localized: "Fractions of the display width, narrowest first. \(cycleShortcutText)")
    }

    private var formattedPresets: String {
        model.settings.widthPresets.map { $0.formatted(.number.precision(.fractionLength(0...4))) }
            .joined(separator: ", ")
    }

    private var motionDetail: String {
        model.reduceMotion
            ? String(localized: "Reduce Motion is on in System Settings, so windows move instantly.")
            : String(localized: "Movement is always instant when macOS Reduce Motion is on.")
    }

    /// Names the shortcuts bound to cycling the presets, which the user can change in Shortcuts.
    private var cycleShortcutText: String {
        let action = ShortcutAction.cyclePresetWidths
        guard let shortcuts = model.shortcuts else { return "" }
        let labels = shortcuts.chords(for: action).map(model.keyLayout.sentenceLabel(for:))
        return labels.isEmpty
            ? String(localized: "\(action.title) has no shortcut. Add one in Shortcuts.")
            // A no-break space keeps the shortcut on the same line as its verb.
            : String(localized: "\(labels.formatted(.list(type: .or)))\u{00A0}cycles through them.")
    }

    private var stackLimit: Binding<StackLimit> {
        Binding(get: { model.settings.stackLimit }, set: { value in model.update { $0.stackLimit = value } })
    }

    private var maximizedWindowSide: Binding<MaximizedWindowSide> {
        Binding(
            get: { model.settings.maximizedWindowSide },
            set: { value in model.update { $0.maximizedWindowSide = value } })
    }

    /// How many windows fit in a column on the display with the menu bar, as the engine counts them.
    private var mainDisplayFit: Int {
        StackLimit.fitCount(columnHeight: mainDisplayHeight - Double(2 * model.settings.edgePadding))
    }

    /// The visible height of the display with the menu bar, which macOS lists first.
    private static func currentMainDisplayHeight() -> Double {
        Double(NSScreen.screens.first?.visibleFrame.height ?? 0)
    }

    private func gapRow(_ title: String, _ key: WritableKeyPath<MiriSettings, Int>) -> some View {
        SettingsRow(title: title) {
            Stepper(value: gap(key), in: MiriSettings.gapRange) {
                LiveText(String(localized: "\(model.settings[keyPath: key]) pt"))
                    .font(.rowTitle)
                    .monospacedDigit()
                    .foregroundStyle(Palette.primaryText)
            }
            .accessibilityLabel(title)
        }
    }

    private func gap(_ key: WritableKeyPath<MiriSettings, Int>) -> Binding<Int> {
        Binding(get: { model.settings[keyPath: key] }, set: { value in model.update { $0[keyPath: key] = value } })
    }

    private func showPresets() {
        presetsText = formattedPresets
    }

    private func applyPresets() {
        // Unedited text shows the presets rounded to four places, so applying it would rewrite them.
        guard presetsText != formattedPresets else {
            presetsError = nil
            return
        }
        let values = presetsText.split(separator: ",").map {
            Double($0.trimmingCharacters(in: .whitespaces))
        }
        var candidate = model.settings
        candidate.widthPresets = values.compactMap(\.self)
        do throws(ConfigurationIssue) {
            guard !values.contains(nil) else {
                throw ConfigurationIssue.invalidValue(
                    key: "", message: String(localized: "Enter numbers separated by commas."))
            }
            try candidate.validate()
            presetsError = nil
            if candidate.widthPresets != model.settings.widthPresets {
                model.update { $0.widthPresets = candidate.widthPresets }
            }
        } catch {
            presetsError = error.description
        }
    }
}

// MARK: - Setup

struct SetupSettings: View {
    let model: AppModel
    @Binding var page: SettingsView.Page
    @State private var checked = false
    @State private var resetChecked: Task<Void, Never>?

    var body: some View {
        SettingsPage(
            title: String(localized: "Setup"),
            subtitle:
                String(
                    localized:
                        "Miri checks your Mac for anything that blocks window management. It never changes your settings for you."
                )
        ) {
            Button(checked ? "Checked" : "Check Again", systemImage: checked ? "checkmark" : "arrow.clockwise") {
                model.refreshSetup()
                let count = model.setupReviewCount
                AccessibilityNotification.Announcement(
                    count == 0
                        ? String(localized: "Checked. Nothing to review.")
                        : String(localized: "Checked. \(count) to review.")
                ).post()
                // The button says Checked for a moment, so a check that changed nothing still shows it ran.
                checked = true
                // A second press restarts the two seconds instead of ending them early.
                resetChecked?.cancel()
                resetChecked = Task {
                    try? await Task.sleep(for: .seconds(2))
                    if !Task.isCancelled { checked = false }
                }
            }
        } content: {
            if !model.startBlockers.isEmpty {
                Banner(
                    kind: .warning, title: String(localized: "Waiting for setup"),
                    detail: AppModel.startsWhenReadySentence)
            }
            let required = model.setupChecks.filter(\.blocksStart)
            // Problems first, so the rows that need attention are not hidden between passing ones.
            let others = model.setupChecks.filter { !$0.blocksStart }
            let recommended = others.filter(\.needsAttention) + others.filter { !$0.needsAttention }
            if !required.isEmpty {
                SettingsSection(String(localized: "Required to start")) {
                    ForEach(required) { check in
                        SetupCheckItem(check: check, openGestures: { page = .gestures }, inRequiredSection: true)
                    }
                }
            }
            if !recommended.isEmpty {
                SettingsSection(String(localized: "Recommended")) {
                    ForEach(recommended) { check in SetupCheckItem(check: check) { page = .gestures } }
                }
            }
        }
        .onAppear { model.refreshSetup() }
    }
}

/// One setup check: its result as a colored symbol, what it found, and the ways to fix it below that.
struct SetupCheckItem: View {
    let check: SetupCheck
    /// Opens Settings > Gestures for a gesture check. Set Up Miri lists only start blockers, so it passes nothing.
    var openGestures: (() -> Void)?
    /// Whether the row sits under a "Required to start" label, which already says that it blocks start.
    var inRequiredSection = false

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            symbol
                .accessibilityHidden(true)
                .padding(.top, 1)
            VStack(alignment: .leading, spacing: 2) {
                Text(title)
                    .font(.rowTitle)
                    .foregroundStyle(Palette.primaryText)
                    .accessibilityValue(result)
                if let detail {
                    Text(detail)
                        .font(.detail)
                        .foregroundStyle(Palette.secondaryText)
                        .fixedSize(horizontal: false, vertical: true)
                }
                if check.status != .passed, check.isGestureCheck || check.settingsURL != nil {
                    ButtonRow {
                        if check.isGestureCheck, let openGestures {
                            Button("Open Gestures", action: openGestures)
                        }
                        if let url = check.settingsURL {
                            Button(check.isGestureCheck ? "Open Trackpad Settings" : "Open System Settings") {
                                NSWorkspace.shared.open(url)
                            }
                            .help("Open the System Settings page for \(check.title)")
                        }
                    }
                    .padding(.top, 8)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 12)
        .frame(minHeight: 44)
        .accessibilityElement(children: .contain)
    }

    private var title: String {
        switch check.status {
        case .passed, .problem: check.title
        case .notChecked: String(localized: "\(check.title): Not checked")
        case .unableToVerify: String(localized: "\(check.title): Unable to verify")
        }
    }

    /// The result VoiceOver reads with the title, because the colored symbol is hidden from it.
    private var result: String {
        switch check.status {
        case .passed: check.isGestureCheck ? String(localized: "No conflict found") : String(localized: "Passed")
        case .problem: check.blocksStart ? String(localized: "Blocks start") : String(localized: "Recommended change")
        case .notChecked: String(localized: "Not checked")
        case .unableToVerify: String(localized: "Unable to verify")
        }
    }

    private var detail: String? {
        switch check.status {
        case .passed: check.isGestureCheck ? String(localized: "No conflict found.") : nil
        case .problem(let text):
            check.blocksStart && !inRequiredSection ? String(localized: "Blocks start. \(text)") : text
        case .notChecked(let text), .unableToVerify(let text): text
        }
    }

    @ViewBuilder private var symbol: some View {
        switch check.status {
        case .passed:
            NoticeGlyph(kind: .success, size: 18)
        // A check that blocks start is an error, so it reads apart from a recommended change by color and shape.
        case .problem:
            NoticeGlyph(kind: check.blocksStart ? .error : .warning, size: 18)
        case .unableToVerify:
            Image(systemName: "questionmark")
                .font(.system(size: 10, weight: .heavy))
                .foregroundStyle(Palette.onStatus)
                .frame(width: 18, height: 18)
                .background(Palette.orange, in: .circle)
        case .notChecked:
            Image(systemName: "minus")
                .font(.system(size: 9, weight: .bold))
                .foregroundStyle(Palette.gray)
                .frame(width: 18, height: 18)
                .overlay(Circle().strokeBorder(Palette.gray, lineWidth: 1.2))
        }
    }
}

// MARK: - Diagnostics

struct DiagnosticsSettings: View {
    let model: AppModel
    @Environment(\.presenter) private var presenter
    @State private var logTail: [String] = []

    var body: some View {
        SettingsPage(
            title: String(localized: "Diagnostics"),
            subtitle: String(localized: "Engine health and local logs.")
        ) {
            if case .failed(let reason) = model.engineState {
                Banner(kind: .error, title: model.statusText, detail: reason) {
                    Button("Restart Engine") { model.restart() }
                        .buttonStyle(.borderedProminent)
                        .tint(Palette.accentFill)
                }
            }
            engineFacts
            VStack(alignment: .leading, spacing: 8) {
                SectionLabel(title: String(localized: "Engine log")) {
                    Button("Show Logs in Finder") { model.showLogsInFinder() }
                        .controlSize(.small)
                }
                ScrollView {
                    VStack(alignment: .leading, spacing: 3) {
                        if logTail.isEmpty { Text("The log is empty.") }
                        ForEach(logTail.indices, id: \.self) { index in logLine(logTail[index]) }
                    }
                    .font(.monoSmall)
                    .foregroundStyle(Palette.logText)
                    .textSelection(.enabled)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(14)
                }
                .defaultScrollAnchor(.bottom)
                .accessibilityLabel("Engine log")
                .frame(height: 200)
                .background(Palette.logBackground, in: .rect(cornerRadius: 10))
                // The log box is dark in both appearances, so its scroll bar is too.
                .environment(\.colorScheme, .dark)
            }
            // When a translation makes the row too long, the note moves under the buttons instead of cutting them off.
            ViewThatFits(in: .horizontal) {
                HStack(spacing: 10) {
                    logButtons
                    privacyNote
                }
                VStack(alignment: .leading, spacing: 8) {
                    HStack(spacing: 10) { logButtons }
                    privacyNote
                }
            }
            SettingsSection(String(localized: "Feedback")) {
                SettingsRow(
                    title: String(localized: "Send feedback"),
                    detail: String(localized: "Opens GitHub, where you pick a bug report or a feature request.")
                ) {
                    Button("Send Feedback…") { NSWorkspace.shared.open(Feedback.formList) }
                        // A long translated title stays whole, and the row's text wraps instead.
                        .fixedSize()
                }
            }
            SettingsSection(String(localized: "Developer")) {
                SettingsRow(
                    title: String(localized: "Accessibility permission experiment"),
                    detail: String(
                        localized: "Checks which process macOS credits with Miri's Accessibility permission.")
                ) {
                    Button("Permission Experiment…") { presenter?.show(.permissionExperiment) }.fixedSize()
                }
            }
        }
        // The log keeps growing while the engine runs, so the page reads it again every second while it shows.
        .task {
            while !Task.isCancelled {
                readLog()
                try? await Task.sleep(for: .seconds(1))
            }
        }
    }

    @ViewBuilder private var logButtons: some View {
        // A failed engine has Restart Engine in the banner above.
        switch model.engineState {
        // A healthy engine needs no next step, so Restart Engine is a plain button here.
        case .running:
            Button("Restart Engine") { model.restart() }.fixedSize()
        case .restarting:
            Button("Restart Engine") {}.disabled(true).fixedSize()
        case .failed, .stopped: EmptyView()
        }
        Button("Copy Diagnostics…") { presenter?.show(.diagnostics) }.fixedSize()
    }

    private var privacyNote: some View {
        Text("Logs stay on this Mac. Miri sends no telemetry.")
            .font(.detail)
            .foregroundStyle(Palette.secondaryText)
            .fixedSize(horizontal: false, vertical: true)
    }

    private var engineFacts: some View {
        HStack(spacing: 0) {
            fact(String(localized: "Engine"), value: engineWord, dot: model.stateColor)
            Rectangle().fill(Palette.divider).frame(width: 1)
            fact(
                String(localized: "Process"), value: processText, dot: nil,
                muted: processText == String(localized: "None"))
            Rectangle().fill(Palette.divider).frame(width: 1)
            fact(String(localized: "Displays"), value: "\(NSScreen.screens.count)", dot: nil)
        }
        .fixedSize(horizontal: false, vertical: true)
        .background(Palette.group, in: .rect(cornerRadius: 10))
        .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.groupRing, lineWidth: 1))
    }

    /// One fact. A missing value, such as no engine process, is drawn quieter than real data.
    private func fact(_ title: String, value: String, dot: Color?, muted: Bool = false) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.detail)
                .foregroundStyle(Palette.secondaryText)
            HStack(spacing: 7) {
                if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
                Text(value)
                    .font(.heroTitle)
                    .foregroundStyle(muted ? Palette.secondaryText : Palette.primaryText)
                    .lineLimit(1)
                    .minimumScaleFactor(0.75)
            }
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 14)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityElement(children: .combine)
    }

    private var engineWord: String {
        switch model.engineState {
        case .running: String(localized: "Running")
        case .restarting: String(localized: "Restarting")
        case .failed: String(localized: "Failed")
        case .stopped:
            model.startBlockers.isEmpty ? String(localized: "Stopped") : String(localized: "Waiting for setup")
        }
    }

    private var processText: String {
        switch model.engineState {
        case .running(let pid): String(localized: "pid \(String(pid))")
        case .restarting, .failed, .stopped: String(localized: "None")
        }
    }

    /// One log line. A wrapped message stays indented under its own text, so it never reads as a new entry.
    @ViewBuilder private func logLine(_ line: String) -> some View {
        if let match = line.wholeMatch(of: /(\d{2}:\d{2}:\d{2}  \S+\s+)(.*)/) {
            HStack(alignment: .firstTextBaseline, spacing: 0) {
                Text(match.1).fixedSize()
                Text(match.2).fixedSize(horizontal: false, vertical: true)
            }
            // VoiceOver reads the time and the message as one line.
            .accessibilityElement(children: .combine)
        } else {
            Text(line).fixedSize(horizontal: false, vertical: true)
        }
    }

    private func readLog() {
        // Only a changed log replaces the text, so a selection in it survives the refresh.
        let lines = model.log.tail(maximum: 6_000)
            .split(separator: "\n", omittingEmptySubsequences: true)
            .suffix(40)
            .map { Self.shortLine(String($0)) }
        if Array(lines) != logTail { logTail = Array(lines) }
    }

    /// Keeps the local time, level, and message of an engine log line, and drops the date and the Rust source location.
    /// For example `2026-11-07T09:49:05.422397Z  INFO paneru::ecs: src/ecs.rs:17: Message` becomes
    /// `20:19:05  INFO  Message` in Melbourne. A line in another shape stays as it is.
    static func shortLine(_ line: String) -> String {
        guard
            let match = line.wholeMatch(
                of: /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\S*\s+(\w+)\s+\S+?:\s+(?:\S+\.rs:\d+:\s+)?(.*)/),
            let date = try? Date(String(match.1) + "Z", strategy: .iso8601)
        else { return line }
        let time = date.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits))
        let level = String(match.2).padding(toLength: 5, withPad: " ", startingAt: 0)
        return "\(time)  \(level) \(match.3)"
    }
}

/// The Show in Dock switch, in Settings > General and in the first-launch window.
struct ShowInDockRow: View {
    let model: AppModel

    var body: some View {
        SettingsRow(
            title: String(localized: "Show in Dock"),
            detail: String(localized: "When off, open Miri from its menu bar icon.")
        ) {
            RowSwitch(
                label: String(localized: "Show in Dock"),
                isOn: Binding(get: { model.settings.showInDock }, set: { on in model.update { $0.showInDock = on } }))
        }
    }
}