Sources/Miri/Views.swift

import AppKit
import MiriCore
import SwiftUI

extension AppModel {
    /// One line that says whether Miri is managing windows, and why not.
    var statusText: String {
        switch engineState {
        case .running: "Managing windows"
        case .restarting: "Restarting the engine…"
        case .failed: "Stopped after repeated engine failures"
        case .stopped:
            if let blocker = startBlockers.first {
                "Waiting: \(blocker.title)"
            } else {
                "Stopped"
            }
        }
    }

    var menuIcon: Image {
        switch engineState {
        case .running: .menuRunning
        case .restarting: .menuRunning
        case .failed: Image(systemName: "exclamationmark.triangle")
        case .stopped: startBlockers.isEmpty ? .menuStopped : Image(systemName: "exclamationmark.triangle")
        }
    }

    func showLogsInFinder() {
        NSApp.activate()
        NSWorkspace.shared.activateFileViewerSelecting([log.url])
    }
}

extension Image {
    fileprivate static let menuRunning = menuTemplate("menubar-icon")
    fileprivate static let menuStopped = menuTemplate("menubar-icon-stopped")

    private static func menuTemplate(_ name: String) -> Image {
        let image = NSImage(contentsOf: Bundle.main.url(forResource: name, withExtension: "svg")!)!
        image.isTemplate = true
        return Image(nsImage: image)
    }
}

/// Text that keeps VoiceOver current inside a `LabeledContent`.
/// On macOS 27, `LabeledContent` keeps the first accessibility value of its content, so this text restates its string as its value.
struct LiveText: View {
    let string: String

    init(_ string: String) {
        self.string = string
    }

    var body: some View {
        Text(string).accessibilityValue(string)
    }
}

struct MenuContent: View {
    let model: AppModel
    let presenter: WindowPresenter

    var body: some View {
        Text(model.statusText)
        if let issue = model.configurationIssue {
            Text(issue.menuSummary)
                .help(issue.description)
        }
        Divider()
        switch model.engineState {
        case .running, .restarting:
            Button("Stop Window Management") { model.stop() }
            Button("Restart Engine") { model.restart() }
        case .failed:
            Button("Restart Engine") { model.restart() }
            Button("Show Logs in Finder") { model.showLogsInFinder() }
        case .stopped:
            if model.startBlockers.isEmpty {
                Button("Start Window Management") { model.start() }
            } else {
                Button("Stop Window Management") { model.stop() }
            }
        }
        Divider()
        Button("Settings…") { presenter.show(.settings) }
            .keyboardShortcut(",")
        Button("Copy Diagnostics…") { presenter.show(.diagnostics) }
        Divider()
        Button("Quit Miri") { NSApp.terminate(nil) }
            .keyboardShortcut("q")
    }
}

struct SettingsView: View {
    enum Page: String { case general, layout, gestures, shortcuts, setup, diagnostics }

    let model: AppModel
    @AppStorage("settingsTab") private var page = Page.general

    var body: some View {
        TabView(selection: $page) {
            Tab("General", systemImage: "gearshape", value: .general) { GeneralSettings(model: model) }
            Tab("Layout", systemImage: "rectangle.split.3x1", value: .layout) { LayoutSettings(model: model) }
            Tab("Gestures", systemImage: "hand.draw", value: .gestures) { GestureSettings(model: model) }
            Tab("Shortcuts", systemImage: "keyboard", value: .shortcuts) { ShortcutsSettings(model: model) }
            Tab("Setup", systemImage: "checklist", value: .setup) { SetupSettings(model: model) }
            Tab("Diagnostics", systemImage: "stethoscope", value: .diagnostics) { DiagnosticsSettings(model: model) }
        }
        .frame(width: 560, height: 500)
    }
}

/// Shows a configuration problem above the settings it affects.
struct ConfigurationIssueBanner: View {
    let model: AppModel

    var body: some View {
        if let issue = model.configurationIssue {
            Section {
                Label {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(issue.description)
                        Text("Miri does not change the file until it is fixed.").foregroundStyle(.secondary)
                    }
                } icon: {
                    Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
                }
                Button("Show Configuration File in Finder") {
                    NSWorkspace.shared.activateFileViewerSelecting([model.paths.configuration])
                }
            }
        }
    }
}

struct GeneralSettings: View {
    let model: AppModel

    var body: some View {
        Form {
            ConfigurationIssueBanner(model: model)
            Section {
                LabeledContent("Status") { LiveText(model.statusText) }
                if case .failed(let reason) = model.engineState {
                    Text(reason).foregroundStyle(.secondary)
                }
                HStack {
                    switch model.engineState {
                    case .stopped:
                        if model.startBlockers.isEmpty {
                            Button("Start Window Management") { model.start() }
                        } else {
                            Button("Stop Window Management") { model.stop() }
                        }
                    case .failed:
                        Button("Restart Engine") { model.restart() }
                        Button("Show Logs in Finder") { model.showLogsInFinder() }
                    case .running, .restarting:
                        Button("Stop Window Management") { model.stop() }
                        Button("Restart Engine") { model.restart() }
                    }
                }
            }
            Section {
                Toggle(
                    "Launch at login",
                    isOn: Binding(get: { model.settings.launchAtLogin }, set: { model.setLaunchAtLogin($0) }))
                if let error = model.loginItemError {
                    Text(error).foregroundStyle(.secondary)
                }
                Toggle(
                    "Animate window movement",
                    isOn: Binding(
                        get: { model.settings.animations }, set: { value in model.update { $0.animations = value } }))
                if model.reduceMotion {
                    Text("Reduce Motion is on in System Settings, so windows move instantly.")
                        .foregroundStyle(.secondary)
                }
            }
        }
        .formStyle(.grouped)
    }
}

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

    var body: some View {
        Form {
            ConfigurationIssueBanner(model: model)
            Section("Gaps") {
                gapRow("Between windows", \.innerGap)
                gapRow("At screen edges", \.outerGap)
            }
            Section {
                LabeledContent("Width presets") {
                    TextField("Width presets", text: $presetsText, prompt: Text("0.25, 0.5, 0.75"))
                        .labelsHidden()
                        .multilineTextAlignment(.trailing)
                        .frame(width: 240)
                        .onSubmit(applyPresets)
                }
                if let presetsError {
                    Text(presetsError).foregroundStyle(.red)
                }
            } footer: {
                Text(
                    "Fractions of the display width, from narrowest to widest, separated by commas. \(cycleShortcutText)"
                )
                .foregroundStyle(.secondary)
            }
            Section {
                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))
                    }
                }
            } footer: {
                Text(
                    "Stacking a window onto a full stack starts a new column instead. Up to \(mainDisplayFit) windows fit on the main display at the minimum height of \(StackLimit.minimumWindowHeight) points. A Stack command never fills a stack past what fits on its display, and lowering the limit leaves existing stacks unchanged."
                )
                .foregroundStyle(.secondary)
            }
            Section {
                Picker("Maximized stacked window opens", selection: maximizedWindowSide) {
                    Text("Left of its stack").tag(MaximizedWindowSide.left)
                    Text("Right of its stack").tag(MaximizedWindowSide.right)
                }
            } footer: {
                Text(
                    "Maximizing a stacked window moves it out of its stack into a column of its own. When you restore it, it returns to the stack if the stack has room."
                )
                .foregroundStyle(.secondary)
            }
            Section {
                Toggle(
                    "Show window contents on edges next to another display",
                    isOn: Binding(
                        get: { model.settings.sharedEdgeSlices }, set: { model.setSharedEdgeSlices($0) }))
                if model.sharedEdgeSlicesNeedPermission {
                    Text(
                        "Miri does not have Screen Recording permission, so this is off. Turn on Miri in System Settings > Privacy & Security > Screen & System Audio Recording, then turn this on again."
                    )
                    .foregroundStyle(.secondary)
                    if let url = SystemScreenRecording.settingsURL {
                        Button("Open Screen Recording Settings") { NSWorkspace.shared.open(url) }
                    }
                }
            } footer: {
                Text("Needs Screen Recording permission.").foregroundStyle(.secondary)
            }
        }
        .formStyle(.grouped)
        .onAppear(perform: showPresets)
        .onChange(of: model.settings.widthPresets) { showPresets() }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) {
            _ in
            mainDisplayHeight = Self.currentMainDisplayHeight()
        }
    }

    /// 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.label(for:))
        return labels.isEmpty
            ? "\(action.title) has no shortcut. Add one in Shortcuts."
            : "\(labels.formatted(.list(type: .or))) 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 {
        LabeledContent(title) {
            Stepper(value: gap(key), in: MiriSettings.gapRange) {
                LiveText("\(model.settings[keyPath: key]) pt").monospacedDigit()
            }
        }
    }

    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 = model.settings.widthPresets.map { $0.formatted(.number.precision(.fractionLength(0...4))) }
            .joined(separator: ", ")
    }

    private func applyPresets() {
        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: "Enter numbers separated by commas.")
            }
            try candidate.validate()
            presetsError = nil
            model.update { $0.widthPresets = candidate.widthPresets }
        } catch {
            presetsError = error.description
        }
    }
}

struct SetupSettings: View {
    let model: AppModel

    var body: some View {
        Form {
            Section {
                ForEach(model.setupChecks) { check in
                    SetupCheckRow(check: check)
                }
            } footer: {
                HStack(alignment: .firstTextBaseline) {
                    Text(
                        "Miri never changes macOS settings. Each Open button shows the System Settings page to change."
                    )
                    .foregroundStyle(.secondary)
                    Spacer()
                    Button("Check Again") { model.refreshSetup() }
                }
            }
        }
        .formStyle(.grouped)
        .onAppear { model.refreshSetup() }
    }
}

struct SetupCheckRow: View {
    let check: SetupCheck

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 10) {
            symbol.frame(width: 18)
            VStack(alignment: .leading, spacing: 3) {
                Text(check.title)
                if let detail {
                    Text(detail).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
                }
            }
            Spacer(minLength: 12)
            Text(label).foregroundStyle(.secondary)
            if let url = check.settingsURL, check.status != .passed {
                Button("Open") { NSWorkspace.shared.open(url) }
            }
        }
    }

    private var label: String {
        switch check.status {
        case .passed: check.isGestureCheck ? "No conflict found" : "Passed"
        case .problem: check.blocksStart ? "Blocks start" : "Problem"
        case .notChecked: "Not checked"
        case .unableToVerify: "Unable to verify"
        }
    }

    private var detail: String? {
        switch check.status {
        case .passed: nil
        case .problem(let text), .notChecked(let text), .unableToVerify(let text): text
        }
    }

    @ViewBuilder private var symbol: some View {
        switch check.status {
        case .passed: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
        case .problem: Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
        case .notChecked, .unableToVerify: Image(systemName: "questionmark.circle").foregroundStyle(.secondary)
        }
    }
}

extension SetupCheck {
    /// A gesture check only looks for conflicts. Only a gesture test turns the swipe on, so it never says Passed.
    var isGestureCheck: Bool { id == .fourFingerGesture || id == .threeFingerGesture }
}

struct DiagnosticsSettings: View {
    let model: AppModel
    @Environment(\.presenter) private var presenter

    var body: some View {
        Form {
            Section {
                LabeledContent("Logs") {
                    Button("Show in Finder") { model.showLogsInFinder() }
                }
                LabeledContent("Configuration file") {
                    Button("Show in Finder") {
                        NSWorkspace.shared.activateFileViewerSelecting([model.paths.configuration])
                    }
                }
            } footer: {
                Text("Logs stay on this Mac. Miri sends no telemetry.").foregroundStyle(.secondary)
            }
            Section {
                Button("Copy Diagnostics…") { presenter?.show(.diagnostics) }
                Button("Permission Experiment…") { presenter?.show(.permissionExperiment) }
            }
        }
        .formStyle(.grouped)
    }
}

/// Previews the redacted report before anything reaches the clipboard.
struct DiagnosticsView: View {
    let model: AppModel
    @State private var report = ""
    @State private var copied = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Review the report before copying it.")
                .font(.headline)
            Text("Window titles, document paths, user names, and the configuration file are removed.")
                .foregroundStyle(.secondary)
            ScrollView {
                Text(report)
                    .font(.system(.body, design: .monospaced))
                    .textSelection(.enabled)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(8)
            }
            .background(.background.secondary, in: .rect(cornerRadius: 6))
            HStack {
                Button("Refresh") { refresh() }
                Spacer()
                if copied { Text("Copied").foregroundStyle(.secondary) }
                Button("Copy to Clipboard") {
                    NSPasteboard.general.clearContents()
                    NSPasteboard.general.setString(report, forType: .string)
                    copied = true
                }
                .keyboardShortcut(.defaultAction)
            }
        }
        .padding(20)
        .frame(width: 640, height: 520)
        .onAppear(perform: refresh)
    }

    private func refresh() {
        report = model.diagnostics()
        copied = false
    }
}