diff --git a/MarkEditMac/Modules/Sources/SharedUI/ActionableInfoBar.swift b/MarkEditMac/Modules/Sources/SharedUI/ActionableInfoBar.swift index bf4c9a04..fad53823 100644 --- a/MarkEditMac/Modules/Sources/SharedUI/ActionableInfoBar.swift +++ b/MarkEditMac/Modules/Sources/SharedUI/ActionableInfoBar.swift @@ -33,6 +33,7 @@ public struct ActionableInfoBar: View { if let systemImage { Image(systemName: systemImage) .foregroundStyle(iconColor) + .accessibilityHidden(true) } Text(message) diff --git a/MarkEditMac/Sources/Extension/ExtensionsModel.swift b/MarkEditMac/Sources/Extension/ExtensionsModel.swift index 46c32a54..afd8469a 100644 --- a/MarkEditMac/Sources/Extension/ExtensionsModel.swift +++ b/MarkEditMac/Sources/Extension/ExtensionsModel.swift @@ -126,6 +126,7 @@ final class ExtensionsModel { var mode: Mode = .discover var phase: Phase = .loading + var hasLoadedIndex = false var pendingRelaunch = false var isBusy = false @@ -348,6 +349,10 @@ private extension ExtensionsModel { /// Rebuilds both lists; `installed` is injectable so the mapping can be tested in isolation. func rebuildItems(index: ExtensionIndex?, installed: [ExtensionConfig.Installed] = ExtensionConfig.installed) { + if index != nil { + hasLoadedIndex = true + } + let entries = index?.extensions ?? [] let entryByID = Dictionary(entries.map { ($0.id, $0) }) { lhs, _ in lhs } diff --git a/MarkEditMac/Sources/Extension/ExtensionsOverlays.swift b/MarkEditMac/Sources/Extension/ExtensionsOverlays.swift index d6964424..dc191472 100644 --- a/MarkEditMac/Sources/Extension/ExtensionsOverlays.swift +++ b/MarkEditMac/Sources/Extension/ExtensionsOverlays.swift @@ -20,11 +20,16 @@ struct ExtensionsStateView: View { Double($0.completed) / Double($0.total) } ) - } else if model.phase == .loading { + } else if model.phase == .loading && !model.hasLoadedIndex { + // Only spin on a cold start with no catalog yet ProgressView() } else if model.items.isEmpty { // Gate on the live item count so a mode switch doesn't flash a stale empty message - emptyState + EmptyStateView(isRegistryError: isRegistryError, emptyMessage: emptyMessage) { + Task { + await model.load(forceRefresh: true) + } + } } } } @@ -51,28 +56,41 @@ struct ExtensionsInfoBar: View { // MARK: - Private private extension ExtensionsStateView { - var isRegistryError: Bool { - model.mode == .discover && model.phase == .failed - } + struct EmptyStateView: View { + @Environment(\.accessibilityReduceMotion) + private var reduceMotion + @State private var visible = false + + let isRegistryError: Bool + let emptyMessage: String + let retryAction: () -> Void - var emptyState: some View { - VStack(spacing: 10) { - Image(systemName: isRegistryError ? Icons.wifiSlash : Icons.puzzlepieceExtension) - .font(.largeTitle) - .foregroundStyle(.secondary) + var body: some View { + VStack(spacing: 10) { + Image(systemName: isRegistryError ? Icons.wifiSlash : Icons.puzzlepieceExtension) + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) - Text(emptyMessage) - .foregroundStyle(.secondary) + Text(emptyMessage) + .foregroundStyle(.secondary) - if isRegistryError { - Button(Localized.Extension.retry) { - Task { - await model.load(forceRefresh: true) - } + if isRegistryError { + Button(Localized.Extension.retry, action: retryAction) + } + } + .padding() + .opacity(visible ? 1 : 0) + .onAppear { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.25)) { + visible = true } } } - .padding() + } + + var isRegistryError: Bool { + model.mode == .discover && model.phase == .failed } var emptyMessage: String { diff --git a/MarkEditMac/Sources/Extension/ExtensionsViewController.swift b/MarkEditMac/Sources/Extension/ExtensionsViewController.swift index ecdf31f7..9fcbb572 100644 --- a/MarkEditMac/Sources/Extension/ExtensionsViewController.swift +++ b/MarkEditMac/Sources/Extension/ExtensionsViewController.swift @@ -311,6 +311,7 @@ private extension ExtensionsViewController { _ = model.mode _ = model.items _ = model.phase + _ = model.hasLoadedIndex _ = model.pendingRelaunch } onChange: { [weak self] in Task { @MainActor in