Sources/Miri/GestureSettings.swift
import AppKit
import MiriCore
import SwiftUI
/// Settings > Gestures: the strip swipe's state and test, its finger count and direction, and the change report.
struct GestureSettings: View {
let model: AppModel
@State private var testing = false
@State private var confirmingReport = false
var body: some View {
SettingsPage(
title: String(localized: "Gestures"),
subtitle:
String(localized: "Scroll the strip with a swipe on the trackpad.")
) {
ConfigurationIssueNotice(model: model)
hero
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: setting(\.stripGesture),
options: [
(String(localized: "Three"), .three), (String(localized: "Four"), .four),
(String(localized: "Off"), .off),
])
}
SettingsRow(
title: String(localized: "Direction"),
detail: String(localized: "Changing fingers or direction needs a new test.")
) {
SegmentedChoice(
label: String(localized: "Direction"), selection: setting(\.reverseSwipe),
options: [(String(localized: "Follow fingers"), false), (String(localized: "Reversed"), true)])
}
if case .conflict(let message) = model.gestureState {
NoticeRow(kind: .warning, text: message) {
if let url = conflictCheck?.settingsURL {
Button("Open Trackpad Settings") { NSWorkspace.shared.open(url) }
}
}
}
}
SettingsSection(String(localized: "Scrolling")) {
SettingsRow(
title: String(localized: "Scroll by"),
detail: String(
localized:
"Window by window lets each swipe follow your fingers, then land on one window. The trackpad clicks when the window it would land on changes. Each Option-scroll step moves one window."
)
) {
SegmentedChoice(
label: String(localized: "Scroll by"), selection: setting(\.stripScrolling),
options: [
(String(localized: "Continuous"), .continuous),
(String(localized: "Window by window"), .window),
])
}
}
Text("Keyboard shortcuts work whether the swipe is on or off. Miri never changes macOS settings.")
.font(.detail)
.foregroundStyle(Palette.secondaryText)
}
.onAppear { model.refreshSetup() }
.sheet(isPresented: $testing) { GestureTestSheet(model: model) }
.confirmationDialog("Report a macOS gesture change?", isPresented: $confirmingReport) {
Button("Report Change") { model.reportGestureChange() }
} message: {
Text("Miri turns the strip swipe off until you test it again.")
}
}
private var hero: some View {
HStack(spacing: 22) {
TrackpadPicture(fingers: model.settings.stripGesture.fingers, reversed: model.settings.reverseSwipe)
VStack(alignment: .leading, spacing: 4) {
Text(eyebrow.text)
.font(.small.weight(.semibold))
.tracking(0.66)
.textCase(.uppercase)
.foregroundStyle(eyebrow.color)
Text(heroTitle)
.font(.heroTitle)
.foregroundStyle(Palette.primaryText)
.modifier(StatusValue(value: model.gestureSummary))
Text(heroDetail)
.font(.detail)
.foregroundStyle(Palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .trailing, spacing: 8) {
// A disabled test is an ordinary button, so it does not look like the next step.
if model.gestureTestBlocker == nil {
Button("Test Gesture…") { testing = true }
.buttonStyle(.borderedProminent)
.tint(Palette.accentFill)
} else {
// macOS shows no tooltip on a disabled button, so the help sits on a wrapper around it.
Button("Test Gesture…") {}
.disabled(true)
.accessibilityHint(model.gestureTestBlocker ?? "")
.padding(1)
.contentShape(.rect)
.help(model.gestureTestBlocker ?? "")
}
if model.gestureVerification != nil {
Button("Report a macOS Gesture Change") { confirmingReport = true }
}
}
.fixedSize()
}
.padding(20)
.background(heroTint, in: .rect(cornerRadius: 10))
}
private var heroTint: Color {
if case .conflict = model.gestureState { Palette.warningTint } else { Palette.heroTint }
}
private var eyebrow: (text: String, color: Color) {
switch model.gestureState {
case .off: (String(localized: "Off"), Palette.secondaryText)
case .enabled: (String(localized: "On"), Palette.greenText)
case .needsTest: (String(localized: "Needs a test"), Palette.accentText)
case .conflict: (String(localized: "Blocked by macOS"), Palette.orange)
}
}
private var heroTitle: String {
switch model.settings.stripGesture {
case .three: String(localized: "Three-finger swipe")
case .four: String(localized: "Four-finger swipe")
case .off: String(localized: "Strip swipe is off")
}
}
private var heroDetail: String {
switch model.gestureState {
case .off:
String(localized: "Choose three or four fingers below to use the swipe.")
case .enabled(let date): AppModel.confirmedSentence(date)
case .needsTest(let reason, let detectionUnavailable):
[
reason.sentence, String(localized: "Test it to turn it on."),
detectionUnavailable
? String(localized: "Miri can't read your macOS gesture settings, so the test is the only check.")
: nil,
model.gestureTestBlocker,
].compactMap(\.self).joined(separator: " ")
case .conflict:
String(localized: "Resolve the macOS conflict below, then test again.")
}
}
private var conflictCheck: SetupCheck? {
model.settings.stripGesture.fingers.flatMap(model.gestureCheck)
}
private func setting<Value>(_ key: WritableKeyPath<MiriSettings, Value>) -> Binding<Value> {
Binding(get: { model.settings[keyPath: key] }, set: { value in model.update { $0[keyPath: key] = value } })
}
}
/// A sketch of a trackpad with one dot per finger and an arrow for the direction the strip moves.
private struct TrackpadPicture: View {
let fingers: GestureFingers?
let reversed: Bool
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
ForEach(0..<count, id: \.self) { _ in
Circle().fill(fingers == nil ? Palette.gray : Palette.accent).frame(width: 13, height: 13)
}
}
Image(systemName: reversed ? "arrow.left" : "arrow.right")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(fingers == nil ? Palette.gray : Palette.accent)
}
.frame(width: 132, height: 92)
.background(Palette.group, in: .rect(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Palette.heroTileBorder, lineWidth: 1))
.accessibilityHidden(true)
}
private var count: Int {
switch fingers {
case .three?: 3
case .four?, nil: 4
}
}
}
extension AppModel {
/// Says when the user confirmed the swipe, in the same words on the Gestures page and in Set Up Miri.
static func confirmedSentence(_ date: Date) -> String {
String(
localized:
"You confirmed on \(date.formatted(date: .abbreviated, time: .omitted)) that the strip follows your fingers."
)
}
/// One line about the strip swipe, which Settings and the test tools read as its status.
var gestureSummary: String {
switch gestureState {
case .off: String(localized: "Off")
case .enabled(let date):
String(localized: "On, verified by user test on \(date.formatted(date: .abbreviated, time: .omitted))")
case .needsTest(let reason, _): String(localized: "Off until tested: \(reason.explanation)")
case .conflict: String(localized: "Off: macOS uses this swipe")
}
}
}
extension MiriCore.GestureState.TestReason {
var explanation: String {
switch self {
case .neverTested: String(localized: "not tested yet")
case .fingersChanged: String(localized: "the finger count changed")
case .directionChanged: String(localized: "the direction changed")
case .macOSChanged: String(localized: "macOS was updated")
case .gestureSettingsChanged: String(localized: "macOS trackpad gestures changed")
}
}
/// The reason as a sentence for the Gestures page, or nothing when the swipe was never tested.
var sentence: String? {
switch self {
case .neverTested: nil
case .fingersChanged: String(localized: "The finger count changed.")
case .directionChanged: String(localized: "The direction changed.")
case .macOSChanged: String(localized: "macOS was updated.")
case .gestureSettingsChanged: String(localized: "Your macOS trackpad gestures changed.")
}
}
}
/// Guides one gesture test, from swiping to the user's confirmation.
struct GestureTestSheet: View {
let model: AppModel
@State private var test: GestureTest
@Environment(\.dismiss) private var dismiss
init(model: AppModel) {
self.model = model
_test = State(initialValue: GestureTest(model: model))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Gesture test")
.font(.small.weight(.semibold))
.tracking(0.66)
.textCase(.uppercase)
.foregroundStyle(Palette.secondaryText)
.padding(.bottom, 8)
Text(title)
.font(.heroTitle)
.foregroundStyle(Palette.primaryText)
.fixedSize(horizontal: false, vertical: true)
.accessibilityAddTraits(.isHeader)
.padding(.bottom, 6)
Text(detail)
.font(.detail)
.foregroundStyle(Palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 18)
switch test.phase {
case .failed, .inconclusive:
// The title already says what went wrong, so a status box would only repeat it.
EmptyView()
case .idle, .watching, .passedAwaitingConfirmation:
progress.padding(.bottom, 20)
}
HStack(spacing: 8) {
Spacer(minLength: 0)
buttons
}
}
.padding(.horizontal, 28)
.padding(.top, 26)
.padding(.bottom, 22)
.frame(width: 480, alignment: .leading)
.background(Palette.content)
.onAppear { test.start() }
.onDisappear { test.cancel() }
// VoiceOver users hear each step, because the focus stays on the buttons while the text changes.
.onChange(of: test.phase) { AccessibilityNotification.Announcement(announcement).post() }
// Closing Settings hides its window without removing this view, so onDisappear alone can miss it.
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { _ in test.cancel() }
}
private var title: String {
switch test.phase {
case .idle, .watching: swipeInstruction
case .passedAwaitingConfirmation: String(localized: "Did the strip follow your fingers?")
case .failed(let reason): reason
case .inconclusive:
String(
localized:
"Miri did not see the strip move both ways within \(GestureTest.timeout.components.seconds) seconds."
)
}
}
private var detail: String {
switch test.phase {
case .idle, .watching: String(localized: "Keep at least two windows on this display so the strip can move.")
case .passedAwaitingConfirmation:
String(
localized: "Miri saw the strip move both ways and no macOS action. Only your answer turns the swipe on."
)
case .failed, .inconclusive: String(localized: "The swipe stays off. Keyboard shortcuts keep working.")
}
}
/// What Miri has seen so far, in a tinted box like the status cards in Settings.
private var progress: some View {
HStack(spacing: 10) {
switch test.phase {
case .idle, .watching(movementSeen: false):
ProgressView().controlSize(.small)
Text("Waiting for the strip to move…")
case .watching(movementSeen: true):
checkmark
Text("The strip moved. Keep swiping left and right.")
case .passedAwaitingConfirmation:
checkmark
Text("The strip moved both ways.")
case .failed, .inconclusive:
EmptyView()
}
}
.font(.detail)
.foregroundStyle(Palette.primaryText)
.padding(.horizontal, 14)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.background(Palette.heroTint, in: .rect(cornerRadius: 10))
}
private var announcement: String {
switch test.phase {
case .idle, .watching(movementSeen: false): title
case .watching(movementSeen: true): String(localized: "The strip moved. Keep swiping left and right.")
case .passedAwaitingConfirmation: String(localized: "The strip moved both ways. Did it follow your fingers?")
case .failed, .inconclusive: "\(title) \(detail)"
}
}
private var checkmark: some View {
NoticeGlyph(kind: .success, size: 18)
.accessibilityHidden(true)
}
@ViewBuilder private var buttons: some View {
switch test.phase {
case .idle, .watching:
Button("Cancel", role: .cancel) { dismiss() }
case .passedAwaitingConfirmation:
Button("Cancel", role: .cancel) { dismiss() }
Button("No") { test.deny() }
Button("Yes") {
test.confirm()
dismiss()
}
.buttonStyle(.borderedProminent)
.tint(Palette.accentFill)
.keyboardShortcut(.defaultAction)
case .failed, .inconclusive:
Button("Done", role: .cancel) { dismiss() }
Button("Try Again") { test.start() }
.buttonStyle(.borderedProminent)
.tint(Palette.accentFill)
.keyboardShortcut(.defaultAction)
}
}
/// A whole sentence for each finger count, so a translation never shows an English number word.
private var swipeInstruction: String {
switch test.fingers ?? model.settings.stripGesture.fingers {
case .three?: String(localized: "Swipe left and right with three fingers on the trackpad.")
case .four?, nil: String(localized: "Swipe left and right with four fingers on the trackpad.")
}
}
}