#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