Files
agent-pet-companion/apps/macos/Sources/AgentPetCompanion/Views/PetLibraryView.swift
T
2026-08-09 17:18:27 +08:00

2301 lines
84 KiB
Swift

import AgentPetCompanionCore
import AppKit
import SwiftUI
enum PetLibraryGridPolicy {
static let minimumCardWidth: CGFloat = 176
static let maximumCardWidth: CGFloat = 248
static let spacing: CGFloat = 14
static func candidateColumnCounts(for shellMode: ControlCenterShellMode) -> [Int] {
shellMode == .singleContent ? [1] : [4, 3, 2]
}
static func minimumWidth(for columnCount: Int) -> CGFloat {
let boundedCount = max(1, min(4, columnCount))
return CGFloat(boundedCount) * minimumCardWidth
+ CGFloat(boundedCount - 1) * spacing
}
static func columns(count: Int) -> [GridItem] {
let boundedCount = max(1, min(4, count))
let size: GridItem.Size = boundedCount == 1
? .flexible(minimum: 0, maximum: .infinity)
: .flexible(minimum: minimumCardWidth, maximum: maximumCardWidth)
return Array(
repeating: GridItem(size, spacing: spacing, alignment: .top),
count: boundedCount
)
}
}
enum PetLibraryWorkspacePolicy {
static let minimumSplitWidth: CGFloat = 820
static let spacing: CGFloat = 16
static let detailMinimumWidth: CGFloat = 320
static let detailMaximumWidth: CGFloat = 380
static let detailWidthRatio: CGFloat = 0.38
static let compactCardMinimumWidth: CGFloat = 128
static let compactCardSpacing: CGFloat = 12
static func usesSplitLayout(
availableWidth: CGFloat,
shellMode: ControlCenterShellMode
) -> Bool {
shellMode == .allColumns && availableWidth >= minimumSplitWidth
}
static func detailWidth(for availableWidth: CGFloat) -> CGFloat {
min(
detailMaximumWidth,
max(detailMinimumWidth, availableWidth * detailWidthRatio)
)
}
static func collectionColumnCount(for availableWidth: CGFloat) -> Int {
if availableWidth >= compactCardMinimumWidth * 3
+ compactCardSpacing * 2
{
return 3
}
if availableWidth >= compactCardMinimumWidth * 2 + compactCardSpacing {
return 2
}
return 1
}
static func collectionColumns(count: Int) -> [GridItem] {
let boundedCount = max(1, min(3, count))
return Array(
repeating: GridItem(
.flexible(minimum: compactCardMinimumWidth, maximum: .infinity),
spacing: compactCardSpacing,
alignment: .top
),
count: boundedCount
)
}
static func minimumCollectionWidth(for columnCount: Int) -> CGFloat {
let boundedCount = max(1, min(3, columnCount))
return compactCardMinimumWidth * CGFloat(boundedCount)
+ compactCardSpacing * CGFloat(boundedCount - 1)
}
}
enum PetLibrarySourceFilter: String, CaseIterable, Identifiable {
case all
case bundled
case verified
case generated
case external
var id: String { rawValue }
func includes(_ pet: PetSummary) -> Bool {
switch self {
case .all:
true
case .bundled:
pet.isBundled
case .verified:
!pet.isBundled && pet.origin == .verifiedSkillSource
case .generated:
pet.origin == .generatedByPetcoreJob
case .external:
pet.origin == .externalImport
}
}
func title(
localeIdentifier: String = APCLocalization.interfaceLocaleIdentifier
) -> String {
let key: APCLocalizationKey = switch self {
case .all: .librarySourceAllTitle
case .bundled: .librarySourceBundledTitle
case .verified: .librarySourceVerifiedTitle
case .generated: .librarySourceGeneratedTitle
case .external: .librarySourceExternalTitle
}
return APCLocalization.text(key, locale: localeIdentifier)
}
}
enum PetLibraryHeroLayout {
case stacked
case workspace
}
enum PetCardLayout {
case standard
case workspace
}
struct PetLibraryView: View {
private static let maximumEditInstructionCharacters = GenerationPromptPolicy.maximumScalarCount
@EnvironmentObject private var store: AppStore
@Environment(\.controlCenterShellMode) private var shellMode
@State private var pendingDeletePet: PetSummary?
@State private var pendingPetSheet: PetLibrarySheetRequest?
@State private var searchText = ""
@State private var selectedPetID: String?
@State private var sourceFilter = PetLibrarySourceFilter.all
private var selectedPet: PetSummary? {
guard let selectedPetID else { return nil }
return store.pets.first(where: { $0.id == selectedPetID })
}
private var featuredPet: PetSummary? {
selectedPet ?? store.activePet ?? store.pets.first
}
private var filteredPets: [PetSummary] {
PetLibraryPresentation.filtered(
store.pets,
query: searchText,
warnings: store.petAssetWarningIndex
)
.filter(sourceFilter.includes)
}
private var contentState: PetLibraryContentState {
PetLibraryContentState.resolve(
hasLoadedStateSnapshot: store.hasLoadedStateSnapshot,
petCount: store.pets.count,
filteredPetCount: filteredPets.count
)
}
private var showsSearch: Bool {
PetLibraryDensityPolicy.showsSearch(petCount: store.pets.count)
}
private var availableSourceFilters: [PetLibrarySourceFilter] {
[.all] + PetLibrarySourceFilter.allCases.dropFirst().filter { filter in
store.pets.contains(where: filter.includes)
}
}
private var productPresentation: PetLibraryProductPresentation {
let pet = featuredPet
return PetLibraryProductPresentation(
pets: store.pets,
selectedPet: pet,
selectedPetCanBeUsed: pet.map {
store.petAssetWarningIndex[$0.id] == nil
&& !store.petOperationIDs.contains($0.id)
} ?? false
)
}
var body: some View {
searchConfiguredSurface
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
store.importPetpacks()
} label: {
Label(
APCLocalization.text(
store.isImportingPetpack
? .libraryImportInProgress
: .libraryImportAction
),
systemImage: "square.and.arrow.down"
)
.labelStyle(.iconOnly)
}
.help(APCLocalization.text(.libraryImportAction))
.disabled(store.isImportingPetpack)
.accessibilityIdentifier("pet-library.import")
Button {
store.selection = .maker
} label: {
Label(
APCLocalization.text(.libraryMakeAction),
systemImage: "wand.and.stars"
)
.labelStyle(.iconOnly)
}
.help(APCLocalization.text(.libraryMakeAction))
.accessibilityIdentifier("pet-library.make")
}
}
.onAppear {
selectDefaultPetIfNeeded()
}
.onChange(of: store.pets.map(\.id)) { oldIDs, newIDs in
reconcileSelection(oldIDs: oldIDs, newIDs: newIDs)
if sourceFilter != .all,
!store.pets.contains(where: sourceFilter.includes)
{
sourceFilter = .all
}
}
.onChange(of: filteredPets.map(\.id)) { _, visibleIDs in
guard let firstVisibleID = visibleIDs.first else { return }
if selectedPetID.map({ !visibleIDs.contains($0) }) ?? true {
selectedPetID = firstVisibleID
}
}
.confirmationDialog(
pendingDeletePet.map {
APCLocalization.text(
$0.active ? .libraryDeleteCurrentTitle : .libraryDeleteTitle
)
} ?? APCLocalization.text(.libraryDeleteTitle),
isPresented: Binding(
get: { pendingDeletePet != nil },
set: { isPresented in
if !isPresented { pendingDeletePet = nil }
}
),
titleVisibility: .visible,
presenting: pendingDeletePet
) { pet in
Button(
APCLocalization.format(.libraryDeleteActionFormat, pet.name),
role: .destructive
) {
store.deletePet(pet)
pendingDeletePet = nil
}
Button(APCLocalization.text(.commonCancel), role: .cancel) {
pendingDeletePet = nil
}
} message: { pet in
Text(APCLocalization.format(.libraryDeleteMessageFormat, pet.name))
}
.sheet(item: $pendingPetSheet) { request in
PetHistorySheet(
pet: request.pet,
initialMode: request.mode,
maximumInstructionCharacters: Self.maximumEditInstructionCharacters,
onCancel: {
pendingPetSheet = nil
},
onStart: { baselineRevisionID, instruction in
pendingPetSheet = nil
store.startPetEdit(
request.pet,
baselineRevisionID: baselineRevisionID,
instruction: instruction
)
}
)
}
}
@ViewBuilder
private var searchConfiguredSurface: some View {
if showsSearch && shellMode != .allColumns {
libraryPage
.searchable(
text: $searchText,
placement: .toolbar,
prompt: Text(APCLocalization.text(.librarySearchPlaceholder))
)
} else {
libraryPage
}
}
private var libraryPage: some View {
GeometryReader { geometry in
if contentState != .loading,
contentState != .empty,
PetLibraryWorkspacePolicy.usesSplitLayout(
availableWidth: geometry.size.width,
shellMode: shellMode
)
{
workspaceLibraryPage
} else {
stackedLibraryPage
}
}
.accessibilityIdentifier("pet-library.page")
}
private var stackedLibraryPage: some View {
PageScroll {
pageHeaderAndNotices
stackedLibraryContent
}
}
private var workspaceLibraryPage: some View {
VStack(alignment: .leading, spacing: 18) {
pageHeaderAndNotices
workspaceContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, 24)
.padding(.vertical, 22)
}
@ViewBuilder
private var pageHeaderAndNotices: some View {
ProductPageHeader(
identity: ProductComponentIdentity(scope: "pet-library"),
title: APCLocalization.text(.navigationLibrary),
summary: APCLocalization.text(.libraryPageSubtitle)
)
if let progress = store.petpackImportProgress {
PetLibraryImportProgressBanner(progress: progress)
}
if let notice = store.petLibraryNotice {
PetLibraryNoticeBanner(
notice: notice,
retrying: store.isImportingPetpack,
onRetry: { store.importPetpacks() },
onDismiss: { store.dismissPetLibraryNotice() }
)
}
}
private var workspaceContent: some View {
GeometryReader { geometry in
let detailWidth = PetLibraryWorkspacePolicy.detailWidth(
for: geometry.size.width
)
HStack(alignment: .top, spacing: PetLibraryWorkspacePolicy.spacing) {
collectionPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
if let featuredPet {
ScrollView(.vertical) {
petHero(featuredPet, layout: .workspace)
.id(featuredPet.id)
}
.scrollIndicators(.visible)
.frame(width: detailWidth)
.frame(maxHeight: .infinity)
.accessibilityIdentifier("pet-library.detail-scroll")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@ViewBuilder
private var stackedLibraryContent: some View {
switch contentState {
case .loading:
VStack(spacing: 12) {
ProgressView()
.controlSize(.regular)
Text(APCLocalization.text(.libraryLoadingTitle))
.font(.headline)
Text(APCLocalization.text(.libraryLoadingDetail))
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, minHeight: 320)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("pet-library.loading")
case .empty:
EmptyStateAction(
identity: ProductComponentIdentity(scope: "pet-library", instance: "empty"),
status: ProductStatusPresentation(
appearance: .normal,
title: APCLocalization.text(.libraryEmptyTitle)
),
message: APCLocalization.text(.libraryEmptyDetail),
primaryAction: ProductActionPresentation(
action: productPresentation.primaryAction,
title: APCLocalizedPresentation.primaryActionTitle(
productPresentation.primaryAction
) ?? APCLocalization.text(.libraryEmptyAction),
systemImage: "wand.and.stars"
),
onPrimaryAction: { action in
if action == .createPet {
store.selection = .maker
}
}
)
.frame(maxWidth: .infinity, minHeight: 320)
.accessibilityIdentifier("pet-library.empty")
case .searchEmpty:
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, minHeight: 320)
.accessibilityIdentifier("pet-library.search-empty")
case .results:
VStack(alignment: .leading, spacing: 22) {
if let featuredPet {
petHero(featuredPet, layout: .stacked)
.id(featuredPet.id)
}
VStack(alignment: .leading, spacing: 12) {
Text(APCLocalization.format(.libraryAllCountFormat, store.pets.count))
.font(.headline)
.accessibilityIdentifier("pet-library.collection-title")
petGrid
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.grid")
}
}
}
}
private var collectionPane: some View {
ProductCardSurface(padding: 14) {
VStack(alignment: .leading, spacing: 12) {
collectionToolbar
Divider()
ScrollView(.vertical) {
if filteredPets.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, minHeight: 260)
.accessibilityIdentifier("pet-library.search-empty")
} else {
workspacePetGrid
.padding(.bottom, 2)
}
}
.scrollIndicators(.visible)
.accessibilityIdentifier("pet-library.collection-scroll")
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.accessibilityIdentifier("pet-library.collection-pane")
}
private var collectionToolbar: some View {
HStack(spacing: 10) {
Text(collectionCountTitle)
.font(.headline)
.lineLimit(1)
.accessibilityIdentifier("pet-library.collection-title")
Spacer(minLength: 4)
TextField(
APCLocalization.text(.librarySearchPlaceholder),
text: $searchText
)
.textFieldStyle(.roundedBorder)
.frame(minWidth: 118, idealWidth: 160, maxWidth: 190)
.accessibilityIdentifier("pet-library.collection-search")
Picker(
APCLocalization.text(.librarySourceFilterLabel),
selection: $sourceFilter
) {
ForEach(availableSourceFilters) { filter in
Text(filter.title())
.tag(filter)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(maxWidth: 132)
.accessibilityLabel(APCLocalization.text(.librarySourceFilterLabel))
.accessibilityIdentifier("pet-library.collection-source-filter")
}
}
private var collectionCountTitle: String {
let hasActiveFilter = sourceFilter != .all
|| !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if hasActiveFilter {
return APCLocalization.format(
.libraryFilteredCountFormat,
filteredPets.count,
store.pets.count
)
}
return APCLocalization.format(.libraryAllCountFormat, store.pets.count)
}
private var workspacePetGrid: some View {
ViewThatFits(in: .horizontal) {
workspacePetGrid(columnCount: 3)
.frame(minWidth: PetLibraryWorkspacePolicy.minimumCollectionWidth(
for: 3
))
workspacePetGrid(columnCount: 2)
.frame(minWidth: PetLibraryWorkspacePolicy.minimumCollectionWidth(
for: 2
))
workspacePetGrid(columnCount: 1)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.grid")
}
private func workspacePetGrid(columnCount: Int) -> some View {
LazyVGrid(
columns: PetLibraryWorkspacePolicy.collectionColumns(count: columnCount),
alignment: .leading,
spacing: PetLibraryWorkspacePolicy.compactCardSpacing
) {
petCards(layout: .workspace)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var petGrid: some View {
if shellMode == .singleContent {
petGrid(columnCount: 1)
} else {
ViewThatFits(in: .horizontal) {
petGrid(columnCount: 4)
.frame(minWidth: PetLibraryGridPolicy.minimumWidth(for: 4))
petGrid(columnCount: 3)
.frame(minWidth: PetLibraryGridPolicy.minimumWidth(for: 3))
petGrid(columnCount: 2)
.frame(minWidth: PetLibraryGridPolicy.minimumWidth(for: 2))
}
}
}
private func petGrid(columnCount: Int) -> some View {
LazyVGrid(
columns: PetLibraryGridPolicy.columns(count: columnCount),
alignment: .leading,
spacing: PetLibraryGridPolicy.spacing
) {
petCards(layout: .standard)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func petCards(layout: PetCardLayout) -> some View {
ForEach(filteredPets) { pet in
PetCard(
pet: pet,
assetWarning: store.petAssetWarningIndex[pet.id],
selected: selectedPetID == pet.id,
activeEvent: store.activeOverlayEvent,
variantOrdinal: PetLibraryCardIdentityPolicy.variantOrdinal(
for: pet,
in: store.pets
),
layout: layout,
onSelect: { select(pet) }
)
}
}
private func petHero(
_ pet: PetSummary,
layout: PetLibraryHeroLayout
) -> some View {
PetLibraryHero(
pet: pet,
productPresentation: productPresentation,
assetWarning: store.petAssetWarningIndex[pet.id],
assetRepairState: store.petAssetRepairState(for: pet.id),
activeEvent: store.activeOverlayEvent,
isBusy: store.petOperationIDs.contains(pet.id),
generationIsActive: store.generationSession.isActive,
layout: layout,
onActivate: { store.activatePet(pet) },
onRepairAssets: { store.repairPetAssets(pet) },
onOpenDiagnostics: { store.selection = .diagnostics },
onCustomizeCopy: { store.preparePetCustomizationCopy(pet) },
onRequestEdit: { requestEdit(pet) },
onRequestHistory: { requestHistory(pet) },
onExport: { store.exportPet(pet) },
onRequestDelete: { pendingDeletePet = pet }
)
}
private func select(_ pet: PetSummary) {
selectedPetID = pet.id
}
private func selectDefaultPetIfNeeded() {
guard selectedPetID == nil else { return }
selectedPetID = PetLibrarySelectionPolicy.reconciledSelection(
currentID: selectedPetID,
pets: store.pets,
preferredID: store.activePet?.id,
allowsDefaultSelection: true
)
}
private func reconcileSelection(oldIDs: [String], newIDs: [String]) {
let hadMissingSelection = selectedPetID.map { !newIDs.contains($0) } ?? false
let allowsDefault = hadMissingSelection || (oldIDs.isEmpty && !newIDs.isEmpty)
selectedPetID = PetLibrarySelectionPolicy.reconciledSelection(
currentID: selectedPetID,
pets: store.pets,
preferredID: store.activePet?.id,
allowsDefaultSelection: allowsDefault
)
}
private func requestEdit(_ pet: PetSummary) {
guard PetLibraryCapabilities(pet: pet).canModify else { return }
presentPetSheet(PetLibrarySheetRequest(pet: pet, mode: .edit))
}
private func requestHistory(_ pet: PetSummary) {
guard PetLibraryCapabilities(pet: pet).canModify else { return }
presentPetSheet(PetLibrarySheetRequest(pet: pet, mode: .history))
}
private func presentPetSheet(_ request: PetLibrarySheetRequest) {
// Let the originating AXPress finish before the button's window gains
// a modal sheet. Synchronous presentation can make AppKit report a
// failed accessibility action even though SwiftUI accepted the press.
Task { @MainActor in
await Task.yield()
pendingPetSheet = request
}
}
}
private struct PetLibrarySheetRequest: Identifiable {
enum Mode: String {
case edit
case history
}
let pet: PetSummary
let mode: Mode
var id: String { "\(pet.id)-\(mode.rawValue)" }
}
private struct PetLibraryImportProgressBanner: View {
let progress: PetLibraryImportProgress
private var detail: String {
switch progress.phase {
case .importing:
guard let currentFileName = progress.currentFileName else {
return APCLocalization.text(.libraryImportInProgress)
}
return APCLocalization.format(
.libraryImportProgressDetailFormat,
currentFileName
)
case .refreshingLibrary:
return APCLocalization.text(.libraryImportProgressRefreshing)
}
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
ProgressView()
.controlSize(.regular)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(APCLocalization.text(.libraryImportProgressTitle))
.font(.callout.weight(.semibold))
if progress.phase == .importing {
Text(
APCLocalization.format(
.libraryImportProgressFileFormat,
progress.currentOrdinal,
progress.totalCount
)
)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if progress.totalCount > 1 {
ProgressView(value: progress.completedFraction)
.progressViewStyle(.linear)
.accessibilityValue(
"\(progress.completedCount)/\(progress.totalCount)"
)
}
Text(APCLocalization.text(.libraryImportProgressKeepOpen))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer(minLength: 8)
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor.opacity(0.08))
}
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.accentColor.opacity(0.45), lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("pet-library.import-progress")
}
}
private struct PetLibraryNoticeBanner: View {
let notice: PetLibraryNotice
let retrying: Bool
let onRetry: () -> Void
let onDismiss: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: notice.systemImage)
.foregroundStyle(
notice.kind == .importSuccess ? Color.green : Color.orange
)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(notice.title)
.font(.callout.weight(.semibold))
Text(notice.message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 5) {
if notice.kind == .importFailure {
Button(
APCLocalization.text(.libraryNoticeRetryImport),
systemImage: "arrow.clockwise",
action: onRetry
)
.disabled(retrying)
.accessibilityIdentifier("pet-library.notice.retry")
}
Button(
APCLocalization.text(.libraryNoticeDismiss),
systemImage: "xmark",
action: onDismiss
)
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
.accessibilityIdentifier("pet-library.notice.dismiss")
}
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
}
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(
notice.kind == .importSuccess
? Color(nsColor: .systemGreen).opacity(0.72)
: Color(nsColor: .systemOrange).opacity(0.72),
lineWidth: 1
)
.allowsHitTesting(false)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier(
notice.kind == .importSuccess
? "pet-library.notice.import-success"
: "pet-library.notice.import-failure"
)
}
}
private struct PetHistorySheet: View {
@EnvironmentObject private var store: AppStore
let pet: PetSummary
let initialMode: PetLibrarySheetRequest.Mode
let maximumInstructionCharacters: Int
let onCancel: () -> Void
let onStart: (String?, String) -> Void
@State private var history: PetHistorySnapshot?
@State private var loadFailed = false
@State private var selectedRevisionID: String?
@State private var instruction = ""
@State private var isEditing: Bool
init(
pet: PetSummary,
initialMode: PetLibrarySheetRequest.Mode,
maximumInstructionCharacters: Int,
onCancel: @escaping () -> Void,
onStart: @escaping (String?, String) -> Void
) {
self.pet = pet
self.initialMode = initialMode
self.maximumInstructionCharacters = maximumInstructionCharacters
self.onCancel = onCancel
self.onStart = onStart
_isEditing = State(initialValue: initialMode == .edit)
}
private var presentation: PetLibraryPresentation {
PetLibraryPresentation(pet: pet, assetWarning: store.petAssetWarningIndex[pet.id])
}
private var selectedRevision: PetRevisionHistoryRecord? {
guard let selectedRevisionID else { return nil }
return displayedRevisions.first(where: { $0.revisionID == selectedRevisionID })
}
private var displayedRevisions: [PetRevisionHistoryRecord] {
history.map(PetLibraryHistoryBounds.revisions(in:)) ?? []
}
private var displayedJobs: [GenerationJobHistoryRecord] {
history.map(PetLibraryHistoryBounds.jobs(in:)) ?? []
}
private var baselineState: PetHistoryBaselineState {
PetHistoryBaselineState.resolve(
history: history,
loadFailed: loadFailed,
selectedRevision: selectedRevision
)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 5) {
Text(
isEditing
? APCLocalization.format(.libraryEditTitleFormat, pet.name)
: APCLocalization.format(.libraryHistorySheetTitleFormat, pet.name)
)
.font(.title2.weight(.semibold))
Text(
isEditing
? APCLocalization.text(.libraryEditDetail)
: APCLocalization.text(.libraryHistoryReadOnlyDetail)
)
.font(.callout)
.foregroundStyle(.secondary)
}
ScrollView {
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 18) {
baselineColumn
.frame(width: 235)
detailColumn
.frame(minWidth: 330)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.history.layout.wide")
VStack(alignment: .leading, spacing: 16) {
baselineColumn
detailColumn
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.history.layout.compact")
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(minHeight: 300, idealHeight: 470, maxHeight: 540)
if let history, PetLibraryHistoryBounds.isTruncated(history) {
Label(APCLocalization.text(.libraryHistoryTruncated), systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
if store.generationSession.isActive {
Text(APCLocalization.text(.libraryEditActiveWarning))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(
APCLocalization.text(isEditing ? .commonCancel : .libraryHistoryClose),
action: onCancel
)
.accessibilityIdentifier("pet-library.history.cancel")
if isEditing {
Button(APCLocalization.text(.libraryHistoryConfirmBaseline)) {
onStart(
selectedRevision?.validated == true ? selectedRevision?.revisionID : nil,
instruction
)
}
.apcClearGlassButtonStyle(prominent: true)
.disabled(!canStart)
.accessibilityIdentifier("pet-library.edit.confirm")
} else {
Button(APCLocalization.text(.libraryHistoryUseBaseline)) {
isEditing = true
}
.apcClearGlassButtonStyle(prominent: true)
.disabled(
!presentation.canModify
|| store.generationSession.isActive
|| !baselineSelectionIsValid
)
.accessibilityIdentifier("pet-library.history.use-baseline")
}
}
}
.padding(24)
.frame(
minWidth: 520,
idealWidth: 700,
maxWidth: 780,
minHeight: 420,
idealHeight: 600,
maxHeight: 700
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.history.sheet")
.task(id: pet.id) {
await loadHistory()
}
}
private var baselineColumn: some View {
VStack(alignment: .leading, spacing: 14) {
GroupBox(APCLocalization.text(.libraryEditBaseline)) {
VStack(alignment: .leading, spacing: 10) {
RevisionCoverImage(
pet: pet,
revision: selectedRevision,
assetWarning: presentation.assetWarning
)
.frame(maxWidth: .infinity)
.frame(height: 130)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
}
Text(selectedRevision?.revisionID ?? presentation.revisionIDSummary)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
Label(
selectedRevision?.current == false
? APCLocalization.text(.libraryHistoryOlderRevision)
: APCLocalization.text(.libraryHistoryCurrentRevision),
systemImage: selectedRevision?.current == false
? "clock.arrow.circlepath"
: "checkmark.seal.fill"
)
.font(.caption.weight(.semibold))
.foregroundStyle(
selectedRevision?.current == false ? Color.secondary : Color.accentColor
)
}
.padding(.top, 4)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.edit.baseline")
GroupBox(APCLocalization.text(.libraryHistoryRevisionsTitle)) {
revisionList
.frame(height: 160)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private var detailColumn: some View {
VStack(alignment: .leading, spacing: 14) {
GroupBox(APCLocalization.text(.libraryCurrentInfo)) {
VStack(spacing: 0) {
InfoRow(title: APCLocalization.text(.libraryFieldStableID), value: pet.id)
InfoRow(
title: APCLocalization.text(.libraryFieldRevisionID),
value: selectedRevision?.revisionID ?? presentation.revisionIDSummary
)
InfoRow(title: APCLocalization.text(.libraryFieldStates), value: presentation.stateSummary)
InfoRow(
title: APCLocalization.text(.libraryFieldTiming),
value: presentation.timingSummary
)
InfoRow(
title: APCLocalization.text(.libraryFieldDuration),
value: presentation.durationSummary
)
InfoRow(
title: APCLocalization.text(.libraryFieldValidation),
value: selectedRevision.map {
$0.validated
? APCLocalization.text(.libraryHistoryValidated)
: APCLocalization.text(.libraryHistoryNotSelectable)
} ?? presentation.validationTitle
)
}
}
GroupBox(APCLocalization.text(.libraryHistoryJobsTitle)) {
jobList
.frame(height: isEditing ? 120 : 180)
}
if isEditing {
editComposer
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
@ViewBuilder
private var revisionList: some View {
if history != nil, displayedRevisions.isEmpty {
ContentUnavailableView(
APCLocalization.text(.libraryHistoryNoOwnedRevisions),
systemImage: "shippingbox"
)
} else if history != nil {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(displayedRevisions) { revision in
Button {
selectedRevisionID = revision.revisionID
} label: {
HStack(spacing: 8) {
Image(systemName: revision.current ? "checkmark.circle.fill" : "clock")
.foregroundStyle(revision.current ? Color.accentColor : Color.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(revision.current
? APCLocalization.text(.libraryHistoryCurrentRevision)
: APCLocalization.text(.libraryHistoryOlderRevision))
.font(.caption.weight(.semibold))
Text(revision.revisionID)
.font(.caption2.monospaced())
.lineLimit(1)
}
Spacer(minLength: 4)
Image(systemName: revision.validated ? "checkmark.seal" : "exclamationmark.triangle")
.foregroundStyle(revision.validated ? Color.green : Color.orange)
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(
selectedRevisionID == revision.revisionID
? Color.accentColor.opacity(0.14)
: Color.clear
)
}
}
.buttonStyle(.plain)
.disabled(!revision.validated)
.accessibilityIdentifier("pet-library.history.revision.\(revision.revisionID)")
}
}
}
} else if baselineState.canRetry {
ContentUnavailableView {
Label(
APCLocalization.text(.libraryHistoryFailedTitle),
systemImage: "exclamationmark.triangle"
)
} description: {
Text(APCLocalization.text(.libraryHistoryFailedDetail))
} actions: {
Button(APCLocalization.text(.commonRetry)) {
Task { await loadHistory() }
}
.accessibilityIdentifier("pet-library.history.retry")
}
} else {
ProgressView(APCLocalization.text(.libraryHistoryCheckingTitle))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@ViewBuilder
private var jobList: some View {
if history != nil, displayedJobs.isEmpty {
ContentUnavailableView(
APCLocalization.text(.libraryHistoryNoRecords),
systemImage: "clock"
)
.accessibilityIdentifier("pet-library.history.empty")
} else if history != nil {
ScrollView {
LazyVStack(spacing: 7) {
ForEach(displayedJobs) { job in
HStack(alignment: .top, spacing: 9) {
Image(systemName: job.operation == .modify ? "wand.and.stars" : "sparkles")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(operationTitle(job.operation))
.font(.callout.weight(.semibold))
Text(statusTitle(job.status))
.font(.caption)
.foregroundStyle(.secondary)
}
if let revisionID = job.revisionID {
Text(revisionID)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(PetLibraryHistoryPresentation.localizedTimestamp(job.updatedAt))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer(minLength: 4)
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
}
}
}
}
} else if loadFailed {
Text(APCLocalization.text(.libraryHistoryFailedDetail))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var editComposer: some View {
VStack(alignment: .leading, spacing: 7) {
Text(APCLocalization.text(.libraryEditInstruction))
.font(.callout.weight(.semibold))
TextEditor(text: Binding(
get: { instruction },
set: { instruction = GenerationPromptPolicy.truncate($0) }
))
.font(.body)
.frame(minHeight: 92)
.padding(8)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
}
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
}
.accessibilityLabel(APCLocalization.text(.libraryEditInstructionAccessibility))
.accessibilityIdentifier("pet-library.edit.instruction")
HStack {
Text(APCLocalization.text(.libraryHistoryImmutableNotice))
.foregroundStyle(.secondary)
Spacer()
Text(APCLocalization.format(
.commonCharacterCountFormat,
GenerationPromptPolicy.scalarCount(instruction),
maximumInstructionCharacters
))
.foregroundStyle(
GenerationPromptPolicy.scalarCount(instruction) > maximumInstructionCharacters
? Color.red
: Color.secondary
)
}
.font(.caption)
}
}
private var canStart: Bool {
presentation.canModify
&& !instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& GenerationPromptPolicy.scalarCount(instruction) <= maximumInstructionCharacters
&& !store.generationSession.isActive
&& baselineState.canStartEdit
}
private var baselineSelectionIsValid: Bool {
baselineState.canStartEdit
}
@MainActor
private func loadHistory() async {
loadFailed = false
history = nil
selectedRevisionID = nil
do {
let loaded = try await store.fetchPetHistory(
for: pet,
limit: PetLibraryHistoryBounds.requestLimit
)
guard !Task.isCancelled else { return }
history = loaded
let revisions = PetLibraryHistoryBounds.revisions(in: loaded)
selectedRevisionID = revisions.first(where: {
$0.current && $0.validated
})?.revisionID ?? revisions.first(where: \.validated)?.revisionID
} catch {
guard !Task.isCancelled else { return }
loadFailed = true
}
}
private func operationTitle(_ operation: GenerationOperation) -> String {
APCLocalization.text(
operation == .modify ? .libraryHistoryOperationModify : .libraryHistoryOperationCreate
)
}
private func statusTitle(_ status: GenerationJobHistoryStatus) -> String {
switch status {
case .pending: APCLocalization.text(.libraryHistoryStatusPending)
case .running: APCLocalization.text(.libraryHistoryStatusRunning)
case .waitingForUser: APCLocalization.text(.libraryHistoryStatusWaiting)
case .completed: APCLocalization.text(.libraryHistoryStatusCompleted)
case .failed: APCLocalization.text(.libraryHistoryStatusFailed)
case .canceled: APCLocalization.text(.libraryHistoryStatusCancelled)
}
}
}
private struct RevisionCoverImage: View {
let pet: PetSummary
let revision: PetRevisionHistoryRecord?
let assetWarning: PetAssetWarning?
var body: some View {
if let image {
Image(nsImage: image)
.resizable()
.scaledToFit()
.padding(8)
} else if revision != nil {
// Never present the current head's cover as if it belonged to a
// selected historical baseline. A missing old preview remains an
// explicit read-only placeholder.
MissingPetCoverPlaceholder(scale: 0.36)
} else {
PetCoverImage(
pet: pet,
assetWarning: assetWarning,
fallbackScale: 0.36
)
}
}
private var image: NSImage? {
guard let path = revision?.coverPath else { return nil }
return PetCoverImageCache.shared.image(atPath: path)
}
}
private struct PetLibraryHero: View {
let pet: PetSummary
let productPresentation: PetLibraryProductPresentation
let assetWarning: PetAssetWarning?
let assetRepairState: PetAssetRepairState
let activeEvent: AgentEvent?
let isBusy: Bool
let generationIsActive: Bool
let layout: PetLibraryHeroLayout
let onActivate: () -> Void
let onRepairAssets: () -> Void
let onOpenDiagnostics: () -> Void
let onCustomizeCopy: () -> Void
let onRequestEdit: () -> Void
let onRequestHistory: () -> Void
let onExport: () -> Void
let onRequestDelete: () -> Void
@State private var technicalInformationIsExpanded = false
@State private var selectedPreviewAction = PetLibraryPreviewActionPolicy.defaultAction
private var presentation: PetLibraryPresentation {
PetLibraryPresentation(pet: pet, assetWarning: assetWarning)
}
private var status: ProductStatusPresentation? {
if isBusy {
return ProductStatusPresentation(
appearance: .checking,
title: APCLocalization.text(.libraryPetEnabling)
)
}
guard pet.active else { return nil }
return ProductStatusPresentation(
appearance: .normal,
title: APCLocalization.text(.libraryPetActive),
detail: presentation.currentStateSummary(activeEvent: activeEvent)
)
}
private var primaryAction: ProductActionPresentation<PetLibraryPrimaryAction>? {
guard productPresentation.presentsHeroUseAction else {
return nil
}
return ProductActionPresentation(
action: productPresentation.primaryAction,
title: APCLocalizedPresentation.primaryActionTitle(
productPresentation.primaryAction
) ?? APCLocalization.text(.productActionUsePet),
systemImage: "checkmark.circle",
isEnabled: !isBusy && assetWarning == nil
)
}
@ViewBuilder
var body: some View {
switch layout {
case .stacked:
stackedBody
case .workspace:
workspaceBody
}
}
private var stackedBody: some View {
VStack(alignment: .leading, spacing: 14) {
PrimaryExperienceCard(
identity: ProductComponentIdentity(scope: "pet-library", instance: "featured"),
title: pet.name,
summary: presentation.heroSummary,
status: status,
primaryAction: primaryAction,
onPrimaryAction: { action in
if action == .usePet {
onActivate()
}
}
) {
if assetWarning != nil {
PetAssetRecoveryCard(
pet: pet,
state: assetRepairState,
onRepair: onRepairAssets,
onOpenDiagnostics: onOpenDiagnostics
)
} else {
PetPreviewStage(
identity: ProductComponentIdentity(
scope: "pet-library",
instance: "featured"
),
accessibilityLabel: PetLibraryPreviewActionPolicy.accessibilityLabel(
petName: pet.name,
action: selectedPreviewAction
),
minimumHeight: 280
) {
PetLibraryAnimationPreview(
pet: pet,
action: selectedPreviewAction
)
.frame(
maxWidth: .infinity,
minHeight: 280,
maxHeight: 360
)
}
previewActionPicker
}
secondaryActions
}
AdvancedDetailsDisclosure(
identity: ProductComponentIdentity(scope: "pet-library", instance: "technical"),
title: APCLocalization.text(.libraryCurrentInfo),
summary: APCLocalization.text(.libraryTechnicalInformationSummary),
isExpanded: $technicalInformationIsExpanded
) {
VStack(spacing: 0) {
ForEach(presentation.technicalInformation) { item in
InfoRow(title: item.title, value: item.value)
}
}
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.hero")
}
private var workspaceBody: some View {
ProductCardSurface(padding: 16) {
VStack(alignment: .leading, spacing: 16) {
workspaceHeader
if assetWarning != nil {
PetAssetRecoveryCard(
pet: pet,
state: assetRepairState,
onRepair: onRepairAssets,
onOpenDiagnostics: onOpenDiagnostics
)
} else {
PetPreviewStage(
identity: ProductComponentIdentity(
scope: "pet-library",
instance: "featured"
),
accessibilityLabel: PetLibraryPreviewActionPolicy.accessibilityLabel(
petName: pet.name,
action: selectedPreviewAction
),
minimumHeight: 220
) {
PetLibraryAnimationPreview(
pet: pet,
action: selectedPreviewAction
)
.frame(
maxWidth: .infinity,
minHeight: 220,
maxHeight: 260
)
}
workspacePreviewActions
}
workspaceSecondaryActions
Divider()
DisclosureGroup(
APCLocalization.text(.libraryCurrentInfo),
isExpanded: $technicalInformationIsExpanded
) {
VStack(spacing: 0) {
ForEach(presentation.technicalInformation) { item in
InfoRow(title: item.title, value: item.value)
}
}
.padding(.top, 8)
}
.font(.callout.weight(.semibold))
.accessibilityIdentifier("pet-library.hero.technical")
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.hero")
}
private var workspaceHeader: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(pet.name)
.font(.title3.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
PetLibrarySourceBadge(
accessibilityIdentifier: "pet-library.hero.source",
badge: presentation.sourceBadge
)
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
workspaceHeaderAccessory
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
@ViewBuilder
private var workspaceHeaderAccessory: some View {
if let status {
workspaceStatusAccessory(status)
} else if let primaryAction {
workspacePrimaryActionButton(primaryAction)
}
}
private func workspaceStatusAccessory(
_ presentation: ProductStatusPresentation
) -> some View {
ProductStatusIndicator(presentation: presentation)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(APCDesign.panel)
}
.overlay {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(APCDesign.stroke.opacity(0.72), lineWidth: 1)
.allowsHitTesting(false)
}
.fixedSize(horizontal: true, vertical: false)
.accessibilityIdentifier("pet-library.hero.status")
}
private func workspacePrimaryActionButton(
_ presentation: ProductActionPresentation<PetLibraryPrimaryAction>
) -> some View {
Button {
if presentation.action == .usePet {
onActivate()
}
} label: {
Label(
presentation.title,
systemImage: presentation.systemImage ?? "checkmark.circle"
)
.lineLimit(1)
}
.apcClearGlassButtonStyle(prominent: true)
.controlSize(.regular)
.fixedSize(horizontal: true, vertical: false)
.disabled(!presentation.isEnabled)
.accessibilityLabel(presentation.accessibilityLabel)
.accessibilityHint(presentation.accessibilityHint ?? "")
.accessibilityIdentifier("pet-library.hero.activate")
}
private var previewActionPicker: some View {
VStack(alignment: .leading, spacing: 8) {
Text(APCLocalization.text(.libraryAnimationActionPicker))
.font(.callout.weight(.medium))
Picker(
APCLocalization.text(.libraryAnimationActionPicker),
selection: $selectedPreviewAction
) {
ForEach(PetLibraryPreviewActionPolicy.orderedActions, id: \.self) { action in
Text(APCLocalizedPresentation.animationActionTitle(action))
.tag(action)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.accessibilityLabel(APCLocalization.text(.libraryAnimationActionPicker))
.accessibilityIdentifier("pet-library.hero.action-picker")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var workspacePreviewActions: some View {
VStack(alignment: .leading, spacing: 14) {
previewActionGroup(
title: APCLocalization.text(.libraryAnimationAgentGroup),
actions: PetLibraryPreviewActionPolicy.agentActions,
accessibilityIdentifier: "pet-library.hero.agent-actions"
)
previewActionGroup(
title: APCLocalization.text(.libraryAnimationInteractionGroup),
actions: PetLibraryPreviewActionPolicy.interactionActions,
accessibilityIdentifier: "pet-library.hero.interaction-actions"
)
}
.accessibilityIdentifier("pet-library.hero.action-picker")
}
private func previewActionGroup(
title: String,
actions: [PetAnimationAction],
accessibilityIdentifier: String
) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.callout.weight(.semibold))
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: 0), spacing: 7),
count: actions.count
),
spacing: 7
) {
ForEach(actions, id: \.self) { action in
previewActionButton(action)
}
}
}
.accessibilityIdentifier(accessibilityIdentifier)
}
private func previewActionButton(_ action: PetAnimationAction) -> some View {
let isSelected = selectedPreviewAction == action
return Button {
selectedPreviewAction = action
} label: {
Text(previewActionButtonTitle(action))
.font(.caption.weight(.medium))
.lineLimit(1)
.minimumScaleFactor(0.72)
.frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(isSelected ? Color.white : Color.primary)
.background {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(
isSelected
? Color.accentColor
: Color(nsColor: .controlBackgroundColor)
)
}
.overlay {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.stroke(
isSelected
? Color.accentColor
: Color(nsColor: .separatorColor),
lineWidth: 1
)
.allowsHitTesting(false)
}
}
.buttonStyle(.plain)
.accessibilityLabel(APCLocalizedPresentation.animationActionTitle(action))
.accessibilityValue(UIControlSemantics.selectionValue(isSelected: isSelected))
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityIdentifier("pet-library.hero.action.\(action.rawValue)")
}
private func previewActionButtonTitle(_ action: PetAnimationAction) -> String {
let key: APCLocalizationKey? = switch action {
case .idle: .libraryAnimationIdleShort
case .thinking: .libraryAnimationThinkingShort
case .tool: .libraryAnimationToolShort
case .waiting: .libraryAnimationWaitingShort
case .done: .libraryAnimationDoneShort
case .failed: .libraryAnimationFailedShort
case .acknowledge, .dragLeft, .dragRight: nil
}
if let key {
return APCLocalization.text(key)
}
return APCLocalizedPresentation.animationActionTitle(action)
}
private var workspaceSecondaryActions: some View {
VStack(spacing: 9) {
if presentation.canCustomizeAsCopy {
workspaceActionButton(
title: APCLocalization.text(.libraryCustomizeCopy),
systemImage: "doc.on.doc",
prominent: true,
accessibilityIdentifier: "pet-library.hero.customize-copy",
action: onCustomizeCopy
)
.disabled(isBusy || generationIsActive)
}
if presentation.canModify {
workspaceActionButton(
title: APCLocalization.text(.libraryModifyAction),
systemImage: "wand.and.stars",
prominent: true,
accessibilityIdentifier: "pet-library.hero.modify",
action: onRequestEdit
)
.disabled(isBusy || generationIsActive)
}
workspaceActionButton(
title: APCLocalization.text(.libraryExportAction),
systemImage: "square.and.arrow.up",
accessibilityIdentifier: "pet-library.hero.export",
action: onExport
)
.disabled(isBusy)
if presentation.canDelete {
Menu {
Button(
APCLocalization.text(.libraryHistoryAction),
systemImage: "clock.arrow.circlepath",
action: onRequestHistory
)
.disabled(generationIsActive)
.accessibilityIdentifier("pet-library.hero.history")
Divider()
Button(
APCLocalization.text(.libraryDeleteAction),
systemImage: "trash",
role: .destructive,
action: onRequestDelete
)
.accessibilityIdentifier("pet-library.hero.delete")
} label: {
Label(
APCLocalization.text(.appActionMore),
systemImage: "ellipsis.circle"
)
.frame(maxWidth: .infinity)
}
.apcClearGlassButtonStyle()
.disabled(isBusy)
.accessibilityIdentifier("pet-library.hero.more")
}
}
.accessibilityElement(children: .contain)
}
private func workspaceActionButton(
title: String,
systemImage: String,
prominent: Bool = false,
accessibilityIdentifier: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity)
}
.apcClearGlassButtonStyle(prominent: prominent)
.accessibilityIdentifier(accessibilityIdentifier)
}
private var secondaryActions: some View {
HStack(spacing: 8) {
if presentation.canCustomizeAsCopy {
Button(
APCLocalization.text(.libraryCustomizeCopy),
systemImage: "doc.on.doc",
action: onCustomizeCopy
)
.disabled(isBusy || generationIsActive)
.accessibilityIdentifier("pet-library.hero.customize-copy")
}
if presentation.canModify {
Button(
APCLocalization.text(.libraryModifyAction),
systemImage: "wand.and.stars",
action: onRequestEdit
)
.disabled(isBusy || generationIsActive)
.accessibilityIdentifier("pet-library.hero.modify")
}
if presentation.canDelete {
Menu {
Button(
APCLocalization.text(.libraryHistoryAction),
systemImage: "clock.arrow.circlepath",
action: onRequestHistory
)
.disabled(generationIsActive)
.accessibilityIdentifier("pet-library.hero.history")
Button(
APCLocalization.text(.libraryExportAction),
systemImage: "square.and.arrow.up",
action: onExport
)
.accessibilityIdentifier("pet-library.hero.export")
Divider()
Button(
APCLocalization.text(.libraryDeleteAction),
systemImage: "trash",
role: .destructive,
action: onRequestDelete
)
.accessibilityIdentifier("pet-library.hero.delete")
} label: {
Label(
APCLocalization.text(.appActionMore),
systemImage: "ellipsis.circle"
)
}
.disabled(isBusy)
.accessibilityIdentifier("pet-library.hero.more")
} else {
Button(
APCLocalization.text(.libraryExportAction),
systemImage: "square.and.arrow.up",
action: onExport
)
.disabled(isBusy)
.accessibilityIdentifier("pet-library.hero.export")
}
Spacer(minLength: 0)
}
.apcClearGlassButtonStyle()
.accessibilityElement(children: .contain)
}
}
struct PetCard: View {
let pet: PetSummary
let assetWarning: PetAssetWarning?
let selected: Bool
let activeEvent: AgentEvent?
let variantOrdinal: Int?
let layout: PetCardLayout
let onSelect: () -> Void
private var presentation: PetLibraryPresentation {
PetLibraryPresentation(pet: pet, assetWarning: assetWarning)
}
private var accessibilityPresentation: PetCardAccessibilityPresentation {
PetCardAccessibilityPresentation(
name: pet.name,
styleTitle: presentation.styleTitle,
sourceTitle: presentation.sourceTitle,
variantOrdinal: variantOrdinal
)
}
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 10) {
PetCoverImage(
pet: pet,
assetWarning: assetWarning,
fallbackScale: 0.34
)
.frame(maxWidth: .infinity)
.frame(height: layout == .workspace ? 148 : 104)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
}
Text(pet.name)
.font(.headline)
.lineLimit(layout == .workspace ? 2 : 1)
.frame(
minHeight: layout == .workspace ? 38 : nil,
alignment: .topLeading
)
if pet.style != StylePreset.unspecified.rawValue || variantOrdinal != nil {
HStack(spacing: 5) {
if pet.style != StylePreset.unspecified.rawValue {
Text(presentation.styleTitle)
}
if let variantOrdinal {
if pet.style != StylePreset.unspecified.rawValue {
Text("·")
.accessibilityHidden(true)
}
Text(APCLocalization.format(
.libraryCardVariantFormat,
variantOrdinal
))
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
PetLibrarySourceBadge(
accessibilityIdentifier: "pet-library.card.source.\(pet.id)",
badge: presentation.sourceBadge
)
Group {
if pet.active {
HStack(spacing: 5) {
Circle()
.fill(Color.green)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
Text(APCLocalization.text(.libraryPetActive))
.lineLimit(1)
}
.font(.caption.weight(.medium))
.foregroundStyle(Color.green)
} else {
Color.clear
.frame(height: 14)
.accessibilityHidden(true)
}
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
selected
? Color.accentColor.opacity(0.10)
: Color(nsColor: .controlBackgroundColor)
)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(
selected ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: selected ? 2 : 1
)
.allowsHitTesting(false)
}
.accessibilityLabel(accessibilityPresentation.label)
.accessibilityValue(UIControlSemantics.selectionValue(isSelected: selected))
.accessibilityAddTraits(selected ? .isSelected : [])
.accessibilityIdentifier("pet-library.card.\(pet.id)")
}
}
struct PetCardAccessibilityPresentation: Equatable {
let label: String
init(
name: String,
styleTitle: String,
sourceTitle: String,
variantOrdinal: Int?,
localeIdentifier: String = APCLocalization.interfaceLocaleIdentifier
) {
let baseLabel = APCLocalization.format(
.libraryCardAccessibilityFormat,
locale: localeIdentifier,
name,
styleTitle,
sourceTitle
)
if let variantOrdinal {
let variantLabel = APCLocalization.format(
.libraryCardVariantFormat,
locale: localeIdentifier,
variantOrdinal
)
label = "\(baseLabel). \(variantLabel)"
} else {
label = baseLabel
}
}
}
private struct PetLibrarySourceBadge: View {
let accessibilityIdentifier: String
let badge: PetLibrarySourceBadgePresentation
var body: some View {
Label(badge.title, systemImage: badge.systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(toneColor)
.lineLimit(1)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(toneColor.opacity(0.10)))
.overlay {
Capsule()
.stroke(toneColor.opacity(0.30), lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityIdentifier(accessibilityIdentifier)
}
private var toneColor: Color {
switch badge.tone {
case .bundled:
Color.accentColor
case .verified:
Color.green
case .generated:
Color.purple
case .external:
Color.secondary
}
}
}
struct PetCoverImage: View {
var pet: PetSummary
var assetWarning: PetAssetWarning?
var fallbackScale: CGFloat
init(
pet: PetSummary,
assetWarning: PetAssetWarning? = nil,
fallbackScale: CGFloat
) {
self.pet = pet
self.assetWarning = assetWarning
self.fallbackScale = fallbackScale
}
var body: some View {
if let image {
Image(nsImage: image)
.resizable()
.scaledToFit()
.padding(6)
} else {
MissingPetCoverPlaceholder(scale: fallbackScale)
}
}
private var image: NSImage? {
guard let url = PetAssetLocator.coverURL(for: pet) else { return nil }
return PetLibraryPreviewPolicy.loadIfValidated(
assetWarning: assetWarning
) {
PetCoverImageCache.shared.image(at: url)
}
}
}
struct MissingPetCoverPlaceholder: View {
var scale: CGFloat
var body: some View {
VStack(spacing: 6 * scale) {
Image(systemName: "photo.badge.exclamationmark")
.font(.system(size: max(14, 32 * scale), weight: .semibold))
.foregroundStyle(.secondary)
Text(APCLocalization.text(.libraryMissingPreview))
.font(.system(size: max(9, 16 * scale), weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
}
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
.allowsHitTesting(false)
}
}
}
enum InfoRowLayoutMode: Equatable {
case sideBySide
case stacked
}
enum InfoRowLayoutPolicy {
static let minimumSideBySideWidth: CGFloat = 260
static let minimumSideBySideValueWidth: CGFloat = 112
static func mode(for availableWidth: CGFloat) -> InfoRowLayoutMode {
availableWidth >= minimumSideBySideWidth ? .sideBySide : .stacked
}
}
private struct InfoRowResponsiveLayout: Layout {
private let horizontalSpacing: CGFloat = 12
private let verticalSpacing: CGFloat = 4
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard subviews.count == 2 else { return .zero }
let availableWidth = finiteWidth(from: proposal)
switch availableWidth.map(InfoRowLayoutPolicy.mode(for:)) ?? .stacked {
case .sideBySide:
return sideBySideSize(width: availableWidth!, subviews: subviews)
case .stacked:
return stackedSize(width: availableWidth, subviews: subviews)
}
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard subviews.count == 2 else { return }
switch InfoRowLayoutPolicy.mode(for: bounds.width) {
case .sideBySide:
placeSideBySide(in: bounds, subviews: subviews)
case .stacked:
placeStacked(in: bounds, subviews: subviews)
}
}
private func finiteWidth(from proposal: ProposedViewSize) -> CGFloat? {
guard let width = proposal.width, width.isFinite else { return nil }
return max(0, width)
}
private func sideBySideSize(width: CGFloat, subviews: Subviews) -> CGSize {
let titleDimensions = subviews[0].dimensions(in: .unspecified)
let valueWidth = max(
InfoRowLayoutPolicy.minimumSideBySideValueWidth,
width - titleDimensions.width - horizontalSpacing
)
let valueProposal = ProposedViewSize(width: valueWidth, height: nil)
let valueDimensions = subviews[1].dimensions(in: valueProposal)
let baseline = max(
titleDimensions[VerticalAlignment.firstTextBaseline],
valueDimensions[VerticalAlignment.firstTextBaseline]
)
let height = max(
baseline + titleDimensions.height
- titleDimensions[VerticalAlignment.firstTextBaseline],
baseline + valueDimensions.height
- valueDimensions[VerticalAlignment.firstTextBaseline]
)
return CGSize(width: width, height: height)
}
private func stackedSize(width: CGFloat?, subviews: Subviews) -> CGSize {
let childProposal = ProposedViewSize(width: width, height: nil)
let titleSize = subviews[0].sizeThatFits(childProposal)
let valueSize = subviews[1].sizeThatFits(childProposal)
return CGSize(
width: width ?? max(titleSize.width, valueSize.width),
height: titleSize.height + verticalSpacing + valueSize.height
)
}
private func placeSideBySide(in bounds: CGRect, subviews: Subviews) {
let titleSize = subviews[0].sizeThatFits(.unspecified)
let valueWidth = max(
InfoRowLayoutPolicy.minimumSideBySideValueWidth,
bounds.width - titleSize.width - horizontalSpacing
)
let valueProposal = ProposedViewSize(width: valueWidth, height: nil)
let valueSize = subviews[1].sizeThatFits(valueProposal)
let titleDimensions = subviews[0].dimensions(in: .unspecified)
let valueDimensions = subviews[1].dimensions(in: valueProposal)
let baseline = max(
titleDimensions[VerticalAlignment.firstTextBaseline],
valueDimensions[VerticalAlignment.firstTextBaseline]
)
subviews[0].place(
at: CGPoint(
x: bounds.minX,
y: bounds.minY + baseline - titleDimensions[VerticalAlignment.firstTextBaseline]
),
proposal: ProposedViewSize(titleSize)
)
subviews[1].place(
at: CGPoint(
x: bounds.maxX - valueSize.width,
y: bounds.minY + baseline - valueDimensions[VerticalAlignment.firstTextBaseline]
),
proposal: valueProposal
)
}
private func placeStacked(in bounds: CGRect, subviews: Subviews) {
let childProposal = ProposedViewSize(width: bounds.width, height: nil)
let titleSize = subviews[0].sizeThatFits(childProposal)
subviews[0].place(
at: CGPoint(x: bounds.minX, y: bounds.minY),
proposal: childProposal
)
subviews[1].place(
at: CGPoint(x: bounds.minX, y: bounds.minY + titleSize.height + verticalSpacing),
proposal: childProposal
)
}
}
private struct InfoRowWrappingValue: NSViewRepresentable {
let value: String
func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView(frame: .zero)
textView.isEditable = false
textView.isSelectable = false
textView.isRichText = false
textView.drawsBackground = false
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.heightTracksTextView = false
textView.textContainer?.lineBreakMode = .byCharWrapping
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.setAccessibilityRole(.staticText)
update(textView)
return textView
}
func updateNSView(_ textView: NSTextView, context: Context) {
update(textView)
}
func sizeThatFits(
_ proposal: ProposedViewSize,
nsView textView: NSTextView,
context: Context
) -> CGSize? {
guard let proposedWidth = proposal.width, proposedWidth.isFinite else { return nil }
let width = max(0, proposedWidth)
textView.frame.size.width = width
guard let textContainer = textView.textContainer,
let layoutManager = textView.layoutManager
else {
return CGSize(width: width, height: ceil(textView.intrinsicContentSize.height))
}
textContainer.containerSize = NSSize(
width: width,
height: CGFloat.greatestFiniteMagnitude
)
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
return CGSize(width: width, height: ceil(max(usedRect.height, textView.font?.pointSize ?? 0)))
}
private func update(_ textView: NSTextView) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byCharWrapping
paragraphStyle.alignment = .left
let attributedValue = NSAttributedString(
string: value,
attributes: [
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .medium),
.foregroundColor: NSColor.labelColor,
.paragraphStyle: paragraphStyle,
]
)
if !textView.attributedString().isEqual(to: attributedValue) {
textView.textStorage?.setAttributedString(attributedValue)
}
textView.setAccessibilityLabel(value)
}
}
struct InfoRow: View {
var title: String
var value: String
var body: some View {
InfoRowResponsiveLayout {
Text(title)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
InfoRowWrappingValue(value: value)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
.overlay(alignment: .bottom) {
Divider()
}
}
}