Sources/Miri/InitialSetup.swift
import AppKit
import MiriCore
import SwiftUI
/// The window Miri opens on first launch. It shows what blocks window management and recommends the strip swipe.
/// The v2 shortcut tour (#81) is deliberately not here.
struct InitialSetupView: View {
let model: AppModel
@Environment(\.presenter) private var presenter
@State private var testing = false
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 22) {
header
ConfigurationIssueNotice(model: model)
SettingsSection(String(localized: "Window management")) {
status
ForEach(model.startBlockers) { check in SetupCheckItem(check: check) }
}
swipe
SettingsSection(String(localized: "Dock")) { ShowInDockRow(model: model) }
}
.padding(.horizontal, 40)
.padding(.top, 52)
.padding(.bottom, 0)
.frame(maxWidth: .infinity, alignment: .leading)
// Any spare height goes above the footer, so the footer always sits on the bottom edge.
Spacer(minLength: 0)
footer
}
// The window takes the height of its content, so it fits with or without blocking checks and never scrolls.
.frame(width: 600)
.frame(maxHeight: .infinity)
.ignoresSafeArea()
.background(Palette.content)
.onAppear { model.refreshSetup() }
.sheet(isPresented: $testing) { GestureTestSheet(model: model) }
}
private var header: some View {
HStack(alignment: .center, spacing: 16) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.padding(-4)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
Text("Welcome to Miri")
.font(.pageTitle)
.foregroundStyle(Palette.primaryText)
.accessibilityAddTraits(.isHeader)
Text("Miri lines up the windows on each display side by side in a strip that you scroll.")
.font(.rowTitle)
.foregroundStyle(Palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var status: some View {
HStack(spacing: 12) {
NoticeGlyph(kind: stateKind, size: 18)
VStack(alignment: .leading, spacing: 2) {
// The blocking checks are listed right below, so the title does not repeat the first one.
Text(statusTitle)
.font(.rowTitle.weight(.semibold))
.accessibilityLabel(stateKind.spokenPrefix + statusTitle)
.foregroundStyle(Palette.primaryText)
.modifier(StatusValue(value: model.statusText))
Text(statusDetail)
.font(.detail)
.foregroundStyle(Palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
if canStart {
// Starting is the next step here, so it is the prominent button and Return presses it.
Button("Start Window Management") { model.start() }
.buttonStyle(.borderedProminent)
.tint(Palette.accentFill)
.keyboardShortcut(.defaultAction)
} else if case .failed = model.engineState {
Button("Restart Engine") { model.restart() }
.buttonStyle(.borderedProminent)
.tint(Palette.accentFill)
.keyboardShortcut(.defaultAction)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(minHeight: 50)
}
/// The notice kind of the state, so its glyph and spoken prefix match the Settings banners.
private var stateKind: NoticeKind {
switch model.engineState {
case .running: .success
case .restarting: .warning
case .failed: .error
case .stopped: model.startBlockers.isEmpty ? .neutral : .warning
}
}
private var statusTitle: String {
model.startBlockers.isEmpty ? model.statusText : String(localized: "Waiting for setup")
}
private var canStart: Bool { model.engineState == .stopped && model.startBlockers.isEmpty }
/// Whether the status row holds the main action, Start or Restart, so Done steps back to a plain button.
private var statusHasMainAction: Bool {
if case .failed = model.engineState { return true }
return canStart
}
private var statusDetail: String {
switch model.engineState {
case .running: String(localized: "Keyboard shortcuts work now.")
case .restarting: String(localized: "The engine is restarting. Keyboard shortcuts resume when it is ready.")
case .failed: "Restart the engine to manage windows again. Settings > Diagnostics has the log."
case .stopped where !model.startBlockers.isEmpty: AppModel.startsWhenReadySentence
case .stopped: String(localized: "Keyboard shortcuts work once Miri starts.")
}
}
private var swipe: some View {
SettingsSection(String(localized: "Strip swipe")) {
SettingsRow(
title: String(localized: "Fingers"),
detail: String(localized: "Four is recommended. Three fingers can clash with Three Finger Drag.")
) {
SegmentedChoice(
label: String(localized: "Fingers"),
selection: Binding(
get: { model.settings.stripGesture },
set: { value in model.update { $0.stripGesture = value } }),
options: [
(String(localized: "Three"), .three), (String(localized: "Four"), .four),
(String(localized: "Off"), .off),
])
}
if model.settings.stripGesture != .off {
SettingsRow(title: String(localized: "Swipe test"), detail: testDetail) {
Button("Test Gesture…") { testing = true }
.disabled(model.gestureTestBlocker != nil)
.accessibilityHint(model.gestureTestBlocker ?? "")
.fixedSize()
.padding(1)
.contentShape(.rect)
.help(model.gestureTestBlocker ?? "")
}
if case .conflict(let message) = model.gestureState {
NoticeRow(kind: .warning, text: message) {
if let url = model.settings.stripGesture.fingers.flatMap(model.gestureCheck)?.settingsURL {
Button("Open Trackpad Settings") { NSWorkspace.shared.open(url) }
}
}
}
}
}
}
/// The swipe's state, and why the test is not available yet. Only a macOS conflict gets a warning.
private var testDetail: String {
switch model.gestureState {
case .off: String(localized: "Off.")
case .enabled(let date): AppModel.confirmedSentence(date)
// The same reason as on the Gestures page, such as "Start window management to test the swipe."
case .needsTest: model.gestureTestBlocker ?? String(localized: "The swipe stays off until you test it.")
case .conflict: String(localized: "Off, because macOS uses this swipe.")
}
}
private var footer: some View {
HStack(spacing: 12) {
Text("You can change all of this later in Settings.")
.font(.detail)
.foregroundStyle(Palette.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
if statusHasMainAction {
Button("Done") { presenter?.close(.initialSetup) }
} else {
Button("Done") { presenter?.close(.initialSetup) }
.buttonStyle(.borderedProminent)
.tint(Palette.accentFill)
.keyboardShortcut(.defaultAction)
}
}
.padding(.horizontal, 40)
.padding(.vertical, 16)
.overlay(alignment: .top) { Rectangle().fill(Palette.divider).frame(height: 1) }
}
}