#if !os(tvOS)
import SwiftUI
import UniformTypeIdentifiers
import PhysarumEngine
/// The preset a per-card "Export…" is currently writing out (drives `.fileExporter`).
private struct ExportTarget: Identifiable { let id: String; let preset: Preset }
/// The preset library as a dark overlay: search + tag-filter pills + a card grid.
struct LibraryView: View {
@Bindable var state: AppState
@Binding var isPresented: Bool
/// Sheet mode (compact) dismisses the library on pick; sidebar mode keeps it open
/// so the picked preset swaps the living image in place without closing the column.
var dismissOnSelect = true
/// Opens the save flow (set by RootView). Surfaces a "Save current…" button in the header so the
/// left-bar library is one of the two save entry points (topbar is the other).
var onSave: (() -> Void)? = nil
@State private var search = ""
@State private var activeTags: Set<String> = []
@State private var exportTarget: ExportTarget?
@State private var showImporter = false
@State private var showResetConfirm = false
@State private var showKeepPinnedConfirm = false
/// Import-decode/slot-full failures route through the app-wide alert (no inline text).
@Environment(ErrorPresenter.self) private var errorPresenter
#if !os(macOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
#endif
/// Compact (iPhone, iPad-portrait/split) → a single vertical column so cards
/// stack and scroll; regular width keeps the adaptive multi-column grid.
private var columns: [GridItem] {
#if os(macOS)
[GridItem(.adaptive(minimum: 120), spacing: Theme.Space.s3)]
#else
hSizeClass == .compact
? [GridItem(.flexible(), spacing: Theme.Space.s3)]
: [GridItem(.adaptive(minimum: 120), spacing: Theme.Space.s3)]
#endif
}
private var allTags: [String] {
Array(Set(state.presets.flatMap { $0.tags })).sorted()
}
private var isFiltering: Bool { !search.isEmpty || !activeTags.isEmpty }
private var filtered: [Preset] {
state.presets.filter { p in
let matchesSearch = search.isEmpty
|| p.name.localizedCaseInsensitiveContains(search)
|| p.tags.contains { $0.localizedCaseInsensitiveContains(search) }
let matchesTags = activeTags.isEmpty || activeTags.isSubset(of: Set(p.tags))
return matchesSearch && matchesTags
}
}
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.s3) {
HStack {
Text("PICK A PRESET")
.font(Theme.Fonts.eyebrow).tracking(1.6)
.foregroundStyle(Theme.Colors.fgTertiary)
Spacer()
TextField("Search…", text: $search)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 220)
.accessibilityLabel("Search presets")
.accessibilityHint("Filter by name or tag")
if let onSave {
Button { onSave() } label: {
Image(systemName: "tray.and.arrow.down").font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundStyle(Theme.Colors.fgSecondary)
.help("Save current preset…")
.accessibilityLabel("Save current")
.accessibilityHint("Open the save dialog")
}
Button { showImporter = true } label: {
Image(systemName: "square.and.arrow.down").font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundStyle(Theme.Colors.fgSecondary)
.help("Import a preset…")
.accessibilityLabel("Import preset")
.accessibilityHint("Load a preset file")
Menu {
Button("Keep only pinned") { showKeepPinnedConfirm = true }
.disabled(state.pinned.isEmpty)
.accessibilityLabel("Keep only pinned")
.accessibilityHint("Clear unpinned slots")
.accessibilityValue(state.pinned.isEmpty ? "No pinned presets" : "")
Button("Reset all Presets", role: .destructive) { showResetConfirm = true }
.accessibilityLabel("Reset all presets")
.accessibilityHint("Restore factory defaults")
} label: {
Image(systemName: "ellipsis.circle").font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundStyle(Theme.Colors.fgSecondary)
.help("Library actions")
// Sidebar mode (dismissOnSelect == false) is presented with a `.constant(true)`
// binding, so writing `isPresented = false` here would be a silent no-op — the
// column is collapsed via the topbar preset pill (columnVisibility) instead.
// Only the sheet (compact) instance owns a real binding, so show the close glyph there.
if dismissOnSelect {
Button { isPresented = false } label: {
Image(systemName: "xmark").font(.system(size: 12))
}
.buttonStyle(.plain)
.foregroundStyle(Theme.Colors.fgSecondary)
.accessibilityLabel("Close")
.accessibilityHint("Dismiss the library")
}
}
if !allTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Space.s2) {
ForEach(allTags, id: \.self) { tag in
TagPill(label: tag, active: activeTags.contains(tag)) {
if activeTags.contains(tag) { activeTags.remove(tag) } else { activeTags.insert(tag) }
}
}
}
}
}
ScrollView {
LazyVGrid(columns: columns, spacing: Theme.Space.s3) {
ForEach(filtered, id: \.id) { preset in
PresetCard(
preset: preset,
selected: preset.id == state.selectedID,
thumbURL: PresetCatalog.thumbnailURL(for: preset, store: state.presetStore),
reloadToken: state.thumbnailRevision
) {
state.select(id: preset.id)
if dismissOnSelect { isPresented = false }
}
// A tappable pin star (top-right): pinned slots form the ATV lean-back rotation.
.overlay(alignment: .topTrailing) {
Button { state.togglePin(preset.id) } label: {
Image(systemName: state.isPinned(preset.id) ? "star.fill" : "star")
.font(.system(size: 13))
.foregroundStyle(state.isPinned(preset.id) ? Theme.Colors.accent : Theme.Colors.fgSecondary)
.frame(width: 30, height: 30)
.background(.black.opacity(0.35), in: Circle())
.contentShape(Circle()) // whole circle is tappable, not just the glyph
}
.buttonStyle(.plain)
.padding(5)
.help(state.isPinned(preset.id) ? "Unpin (remove from ATV rotation)" : "Pin to the ATV rotation")
.accessibilityLabel("Pin")
.accessibilityHint("Add or remove from Apple TV rotation")
.accessibilityValue(A11y.pinned(state.isPinned(preset.id)))
}
.contextMenu {
Button("Export…") { exportTarget = ExportTarget(id: preset.id, preset: preset) }
.accessibilityLabel("Export preset")
.accessibilityHint("Save preset to a file")
.accessibilityValue(preset.name)
Button(state.isPinned(preset.id) ? "Unpin" : "Pin") { state.togglePin(preset.id) }
Divider()
Button("Clear", role: .destructive) { state.clearSlot(slotID: preset.id) }
.accessibilityLabel("Clear slot")
.accessibilityHint("Empty this library slot")
.accessibilityValue(preset.name)
}
}
}
}
}
.padding(Theme.Space.s5)
// No macOS minWidth: the library is docked as a fixed-width (panelWidth) overlay
// column in RootView, so forcing a 640pt min here centred the wider content inside
// the narrow panel and clipped it at both edges. It lays out to the panel width.
.background(Theme.Colors.surface)
.foregroundStyle(Theme.Colors.fgPrimary)
.tint(Theme.Colors.accent)
// Export + import on ALL platforms (Files app handles iOS; no macOS-only gate).
.fileExporter(
isPresented: Binding(get: { exportTarget != nil }, set: { if !$0 { exportTarget = nil } }),
document: exportTarget.map { PresetDocument(preset: $0.preset) },
contentType: .json,
defaultFilename: exportTarget.map { slug($0.preset.name) }
) { _ in exportTarget = nil }
.fileImporter(isPresented: $showImporter, allowedContentTypes: [.json]) { result in
guard case .success(let url) = result else { return } // user cancelled → silent
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url),
let preset = try? JSONDecoder().decode(Preset.self, from: data) else {
errorPresenter.report(title: "Couldn’t Import Preset",
message: "That file isn’t a valid Physarum preset.")
return
}
if !state.importIntoSlot(preset) {
errorPresenter.report(title: "No Free Slot",
message: "All 24 slots are full. Clear one, then import again.")
}
}
.alert("Reset all Presets", isPresented: $showResetConfirm) {
Button("Reset", role: .destructive) { state.resetAllToFactory() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure? This will reset all your presets to factory settings.")
}
.alert("Keep only pinned", isPresented: $showKeepPinnedConfirm) {
Button("Keep pinned", role: .destructive) { state.keepOnlyPinned() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This clears every slot you haven't pinned, keeping only your pinned presets.")
}
}
/// Filesystem-safe slug for an export filename (mirrors AppState's id slug).
private func slug(_ s: String) -> String {
let mapped = s.lowercased().map { ch -> Character in ch.isLetter || ch.isNumber ? ch : "-" }
let collapsed = String(mapped).split(separator: "-").joined(separator: "-")
return collapsed.isEmpty ? "preset" : collapsed
}
}
#endif