Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 77 additions & 15 deletions Sources/OmarchyAppleInstallerApp/InstallerComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct Panel<Content: View>: View {
VStack(alignment: .leading, spacing: 10) {
content
}
.padding(.horizontal, 14)
.padding(.horizontal, OmarchyTheme.contentInset)
.padding(.vertical, 13)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Expand Down Expand Up @@ -181,34 +181,96 @@ struct DiskBar: View {

struct ProgressTrack: View {
let fraction: Double?
var height: CGFloat = 6

@State private var sweep = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
ZStack(alignment: .leading) {
Capsule().fill(OmarchyTheme.track)
if let fraction {
// Never thinner than the bar is tall, so a started bar is a dot, not
// a sliver; updates glide instead of stepping.
let filled = fraction > 0 ? max(OmarchyTheme.progressHeight, width * min(1, fraction)) : 0
Capsule()
.fill(OmarchyTheme.accent)
.frame(width: geometry.size.width * min(1, max(0, fraction)))
.animation(.linear(duration: 0.18), value: fraction)
.frame(width: filled)
.animation(.easeOut(duration: 0.6), value: fraction)
} else if reduceMotion {
BreathingFill()
} else {
Capsule()
.fill(OmarchyTheme.accent.opacity(0.75))
.frame(width: geometry.size.width * 0.32)
.offset(x: sweep ? geometry.size.width * 0.68 : 0)
.animation(
.easeInOut(duration: 1.1).repeatForever(autoreverses: true),
value: sweep
)
.onAppear { sweep = !reduceMotion }
SweepingHighlight(width: width)
}
}
.clipShape(Capsule())
}
.frame(height: OmarchyTheme.progressHeight)
}
}

/// Work of unknown length: a highlight that keeps travelling one way. Each
/// mode owns its animation state, so switching Reduce Motion restarts it.
private struct SweepingHighlight: View {
let width: CGFloat
@State private var moving = false

var body: some View {
Capsule()
.fill(OmarchyTheme.accent.opacity(0.75))
.frame(width: width * 0.32)
.offset(x: moving ? width : -width * 0.32)
.animation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false), value: moving)
.onAppear { moving = true }
}
}

/// Work of unknown length, without motion: the whole bar breathes, so it never
/// reads as a stalled partial fill.
private struct BreathingFill: View {
@State private var bright = false

var body: some View {
Capsule()
.fill(OmarchyTheme.accent.opacity(bright ? 0.9 : 0.35))
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: bright)
.onAppear { bright = true }
}
}

/// A checkbox with its label, and optional help text that starts under the
/// label rather than under the box.
struct CheckboxRow: View {
let title: String
var help: String? = nil
var emphasized = false
let isOn: Bool
let onChange: (Bool) -> Void

var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Toggle("", isOn: Binding(get: { isOn }, set: onChange))
.labelsHidden()
.toggleStyle(.checkbox)
.controlSize(.large)
.tint(OmarchyTheme.accent)
.accessibilityLabel(title)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(emphasized ? OmarchyTheme.heading : OmarchyTheme.control)
.foregroundStyle(emphasized ? OmarchyTheme.accent : OmarchyTheme.text)
.fixedSize(horizontal: false, vertical: true)
.contentShape(Rectangle())
.onTapGesture { onChange(!isOn) }
// The checkbox already carries the title for VoiceOver.
.accessibilityHidden(true)
if let help {
Text(help).omarchyHelpText()
}
}
Spacer(minLength: 0)
}
.frame(height: height)
.padding(.horizontal, OmarchyTheme.contentInset)
}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/OmarchyAppleInstallerApp/InstallerTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ enum OmarchyTheme {
// MARK: Metrics

static let cardRadius: CGFloat = 8
/// A panel's inner side margin. Rows outside a panel use it too, so their
/// text lines up with the panel's content.
static let contentInset: CGFloat = 14
/// Every progress bar is this tall, so a stage change never resizes it.
static let progressHeight: CGFloat = 8
static let buttonRadius: CGFloat = 6
static let buttonHeight: CGFloat = 32
static let buttonTracking: CGFloat = 0.35
Expand Down
85 changes: 44 additions & 41 deletions Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,17 @@ struct OnePageInstallerView: View {
header.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 14) { stage }
.frame(maxWidth: 560)
.id(stageKey)
// The new screen fades in; the old one leaves at once, so the two
// never overlap while the window changes height.
.transition(.asymmetric(insertion: .opacity, removal: .identity))
HStack(spacing: 16) { actions }
.padding(.top, 24)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 40)
.animation(.easeInOut(duration: 0.25), value: stageKey)
.background {
GeometryReader { geometry in
Color.clear.preference(key: InstallerContentHeight.self, value: geometry.size.height)
Expand Down Expand Up @@ -180,6 +185,23 @@ struct OnePageInstallerView: View {

// MARK: The middle of the page

/// Which screen the stage shows. Phases that keep the same screen share a
/// key, so only a real screen change crossfades and resets its state.
private var stageKey: String {
switch session.phase {
case .inspecting: "inspecting"
case .unsupported: "unsupported"
case .welcome: "welcome"
case .existingInstallRefused: "existing"
case .preparingPlan, .planPrepared: "preparing"
case .planReview, .awaitingInstall: "review"
case .installing: "installing"
case .awaitingRecovery: "recovery"
case .done: "done"
case .failed: "failed"
}
}

@ViewBuilder
private var stage: some View {
switch session.phase {
Expand Down Expand Up @@ -235,9 +257,11 @@ struct OnePageInstallerView: View {
} else {
Text("Private M3 test: Linux will be installed without disk encryption.")
.font(OmarchyTheme.body).foregroundStyle(OmarchyTheme.caution)
.padding(.horizontal, OmarchyTheme.contentInset)
}
if let notice = session.allocationNotice {
Text(notice).font(OmarchyTheme.body).foregroundStyle(OmarchyTheme.caution)
.padding(.horizontal, OmarchyTheme.contentInset)
}
acknowledgement(acknowledged, resizesMacOS: plan.fixedMacOSBytes == nil)

Expand All @@ -256,6 +280,7 @@ struct OnePageInstallerView: View {
} else {
Text("Private M3 test: Linux will be installed without disk encryption.")
.font(OmarchyTheme.body).foregroundStyle(OmarchyTheme.caution)
.padding(.horizontal, OmarchyTheme.contentInset)
}
if !helper.isReady {
helperNote
Expand Down Expand Up @@ -380,7 +405,7 @@ struct OnePageInstallerView: View {
Text(title)
.font(OmarchyTheme.heading)
.foregroundStyle(OmarchyTheme.secondaryText)
ProgressTrack(fraction: nil, height: 14)
ProgressTrack(fraction: nil)
}
.padding(.vertical, 4)
}
Expand Down Expand Up @@ -459,26 +484,13 @@ struct OnePageInstallerView: View {
/// The tick and its text. Both are inert while a re-plan runs so a tick
/// cannot land between the drag and the new plan.
private func acknowledgement(_ acknowledged: Bool, resizesMacOS: Bool) -> some View {
let text = PlainLanguage.planAcknowledgement(resizesMacOS: resizesMacOS)
return HStack(alignment: .center, spacing: 12) {
Toggle(
"",
isOn: Binding(get: { acknowledged }, set: { session.setAcknowledged($0) })
)
.labelsHidden()
.toggleStyle(.checkbox)
.controlSize(.large)
.tint(OmarchyTheme.accent)
.accessibilityLabel(text)
Text(text)
.font(OmarchyTheme.heading)
.foregroundStyle(OmarchyTheme.accent)
.fixedSize(horizontal: false, vertical: true)
.onTapGesture { session.setAcknowledged(!acknowledged) }
Spacer(minLength: 0)
}
CheckboxRow(
title: PlainLanguage.planAcknowledgement(resizesMacOS: resizesMacOS),
emphasized: true,
isOn: acknowledged,
onChange: { session.setAcknowledged($0) }
)
.disabled(session.isBusy || session.isEditingSize)
.padding(.horizontal, 4)
.padding(.top, 4)
}

Expand All @@ -490,7 +502,7 @@ struct OnePageInstallerView: View {
.omarchyHelpText()
Spacer(minLength: 0)
}
.padding(.horizontal, 4)
.padding(.horizontal, OmarchyTheme.contentInset)
}

private func recoveryPanel(_ handoff: HandoffDisplay) -> some View {
Expand Down Expand Up @@ -605,7 +617,7 @@ private struct PrefetchStrip: View {
Button(PlainLanguage.prefetchRetry, action: onRetry)
}
} else {
ProgressTrack(fraction: fraction, height: 10)
ProgressTrack(fraction: fraction)
}
}
.padding(.vertical, 2)
Expand Down Expand Up @@ -648,23 +660,14 @@ private struct EncryptDiskToggle: View {
let onChange: (Bool) -> Void

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Toggle(
PlainLanguage.encryptLinuxDiskTitle,
isOn: Binding(
get: { isOn },
set: { value in onChange(value) }
)
)
.toggleStyle(.checkbox)
.controlSize(.large)
.tint(OmarchyTheme.accent)
.disabled(!enabled)
// One help paragraph: the password, and that nothing else unlocks the disk.
Text(PlainLanguage.encryptLinuxDiskPassword + " " + PlainLanguage.encryptLinuxDiskNoRecovery)
.omarchyHelpText()
}
.padding(.horizontal, 4)
// One help paragraph: the password, and that nothing else unlocks the disk.
CheckboxRow(
title: PlainLanguage.encryptLinuxDiskTitle,
help: PlainLanguage.encryptLinuxDiskPassword + " " + PlainLanguage.encryptLinuxDiskNoRecovery,
isOn: isOn,
onChange: onChange
)
.disabled(!enabled)
.padding(.top, 4)
}
}
Expand Down Expand Up @@ -696,7 +699,7 @@ private struct DownloadPanel: View {
.foregroundStyle(OmarchyTheme.secondaryText)
}
}
ProgressTrack(fraction: fraction, height: 18)
ProgressTrack(fraction: fraction)
}
.padding(.vertical, 4)
}
Expand Down Expand Up @@ -943,7 +946,7 @@ private struct InstallPanel: View {
TimelineView(.periodic(from: progress.startedAt, by: 1)) { context in
let elapsed = max(0, context.date.timeIntervalSince(progress.startedAt))
VStack(alignment: .leading, spacing: 6) {
ProgressTrack(fraction: estimate.fraction, height: 18)
ProgressTrack(fraction: estimate.fraction)
HStack {
Text("Estimated progress · \(Int(estimate.fraction * 100))%")
Spacer()
Expand Down
Loading