Zum Inhalt

apple/PhysarumApp/Sources/UI/Components.swift

#if !os(tvOS)
import SwiftUI
import PhysarumEngine

/// A collapsible section with a mono uppercase eyebrow label + chevron — the
/// web editor's section style.
struct PhysSection<Content: View>: View {
    let title: String
    @State private var expanded: Bool
    @ViewBuilder let content: () -> Content

    init(_ title: String, expanded: Bool = false, @ViewBuilder content: @escaping () -> Content) {
        self.title = title
        self._expanded = State(initialValue: expanded)
        self.content = content
    }

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s3) {
            Button {
                withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() }
            } label: {
                HStack {
                    Text(title.uppercased())
                        .font(Theme.Fonts.label)
                        .fontWeight(.semibold)
                        .tracking(1.4)
                        .foregroundStyle(Theme.Colors.fgSecondary)
                    Spacer()
                    Image(systemName: expanded ? "chevron.down" : "chevron.right")
                        .font(.system(size: 12, weight: .bold))
                        .foregroundStyle(Theme.Colors.fgTertiary)
                }
                .padding(.vertical, Theme.Space.s2)   // bigger tap target for expand/collapse
                .contentShape(Rectangle())             // whole row tappable, not just the glyphs
            }
            .buttonStyle(.plain)
            .accessibilityLabel(title)
            .accessibilityHint("Expand or collapse this section")
            .accessibilityValue(expanded ? "Expanded" : "Collapsed")
            if expanded {
                VStack(alignment: .leading, spacing: Theme.Space.s4) { content() }
            }
        }
        .padding(.vertical, Theme.Space.s2)
        .overlay(alignment: .bottom) {
            Rectangle().fill(Theme.Colors.borderSubtle).frame(height: 1)
        }
    }
}

/// Filled or ghost button, mono-caps — accent (phosphor) or ember (momentum/save).
struct PhysButton: View {
    enum Kind { case accent, ember, ghost }
    let title: String
    var kind: Kind = .accent
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(title.uppercased())
                .font(Theme.Fonts.label)
                .tracking(1.2)
                .padding(.horizontal, Theme.Space.s4)
                .padding(.vertical, Theme.Space.s2)
                .frame(maxWidth: .infinity)
                // Without this the empty area of the maxWidth frame has no hit shape,
                // so only the text glyphs are tappable. Make the whole button tappable.
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .foregroundStyle(fg)
        .background(bg)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
        .overlay(RoundedRectangle(cornerRadius: Theme.Radius.md).strokeBorder(stroke, lineWidth: 1))
        .accessibilityLabel(title)
    }

    private var fg: Color {
        switch kind {
        case .accent, .ember: return Theme.Colors.vault
        case .ghost: return Theme.Colors.fgSecondary
        }
    }
    private var bg: Color {
        switch kind {
        case .accent: return Theme.Colors.accent
        case .ember: return Theme.Colors.ember
        case .ghost: return .clear
        }
    }
    private var stroke: Color {
        kind == .ghost ? Theme.Colors.border : .clear
    }
}

/// Dark glass surface for floating panels (topbar, editor, library overlay).
struct GlassPanel: ViewModifier {
    var radius: CGFloat = Theme.Radius.lg
    func body(content: Content) -> some View {
        content
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: radius))
            .background(Theme.Colors.surface.opacity(0.6), in: RoundedRectangle(cornerRadius: radius))
            .overlay(
                RoundedRectangle(cornerRadius: radius)
                    .strokeBorder(Theme.Colors.borderSubtle, lineWidth: 1)
            )
    }
}

extension View {
    func glassPanel(radius: CGFloat = Theme.Radius.lg) -> some View { modifier(GlassPanel(radius: radius)) }

    /// A mono uppercase label in the web editor's style.
    func physLabel() -> some View {
        self.font(Theme.Fonts.label).foregroundStyle(Theme.Colors.fgSecondary)
    }
}

/// A toggleable mono-caps tag chip for the library filter row.
struct TagPill: View {
    let label: String
    let active: Bool
    let action: () -> Void
    var body: some View {
        Button(action: action) {
            Text(label.uppercased())
                .font(Theme.Fonts.eyebrow)
                .tracking(0.8)
                .padding(.horizontal, Theme.Space.s2)
                .padding(.vertical, 4)
                .background(active ? Theme.Colors.accent.opacity(0.16) : Theme.Colors.overlay, in: Capsule())
                .overlay(Capsule().strokeBorder(active ? Theme.Colors.accent : Theme.Colors.borderSubtle, lineWidth: 1))
                .foregroundStyle(active ? Theme.Colors.accent : Theme.Colors.fgSecondary)
        }
        .buttonStyle(.plain)
        .accessibilityLabel("\(label) filter")
        .accessibilityHint("Filter presets by this tag")
        .accessibilityValue(A11y.selected(active))
    }
}

/// A preset thumbnail card for the library grid.
struct PresetCard: View {
    let preset: Preset
    let selected: Bool
    let thumbURL: URL?
    let reloadToken: Int
    let action: () -> Void

    @State private var cachedThumb: Image?

    var body: some View {
        Button(action: action) {
            VStack(alignment: .leading, spacing: Theme.Space.s2) {
                thumbnail
                    .accessibilityHidden(true)
                HStack(spacing: 6) {
                    Circle().fill(Theme.Colors.signal(preset.tagSignal.rawValue)).frame(width: 7, height: 7)
                    Text(preset.name).font(Theme.Fonts.body).foregroundStyle(Theme.Colors.fgPrimary).lineLimit(1)
                    Spacer()
                }
            }
            .padding(Theme.Space.s2)
            .background(Theme.Colors.raised, in: RoundedRectangle(cornerRadius: Theme.Radius.lg))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.lg)
                    .strokeBorder(selected ? Theme.Colors.accent : Theme.Colors.borderSubtle,
                                  lineWidth: selected ? 2 : 1)
            )
            // Make the whole card tappable (the Spacer/padding gaps are otherwise
            // transparent to hit-testing, so taps there register only on retry).
            .contentShape(RoundedRectangle(cornerRadius: Theme.Radius.lg))
        }
        .buttonStyle(.plain)
        .accessibilityLabel(preset.name)
        .accessibilityHint("Select this preset")
        .accessibilityValue(A11y.selected(selected))
        .task(id: "\(thumbURL?.path ?? "none")|\(reloadToken)") {
            cachedThumb = thumbURL.flatMap { loadPlatformImage($0) }
        }
    }

    @ViewBuilder private var thumbnail: some View {
        if let img = cachedThumb {
            img.resizable().aspectRatio(1, contentMode: .fill).frame(height: 96).clipped()
                .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
        } else {
            RoundedRectangle(cornerRadius: Theme.Radius.md).fill(Theme.Colors.overlay).frame(height: 96)
        }
    }
}

/// Platform image loader shared by the library + cards.
func loadPlatformImage(_ url: URL) -> Image? {
#if os(macOS)
    NSImage(contentsOf: url).map { Image(nsImage: $0) }
#else
    (try? Data(contentsOf: url)).flatMap { UIImage(data: $0) }.map { Image(uiImage: $0) }
#endif
}
#endif