crepuscularity-cli 0.7.13

crepus CLI — scaffolding and builds for Crepuscularity (UNSTABLE; in active development).
import SwiftUI

public struct ViewIrRootView: View {
    private let ir: ViewIr

    public init(ir: ViewIr) {
        self.ir = ir
    }

    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ForEach(Array(ir.root.enumerated()), id: \.offset) { _, node in
                ViewNodeView(node: node)
            }
        }
        .padding()
    }
}

public struct ViewNodeView: View {
    let node: ViewNode

    public init(node: ViewNode) {
        self.node = node
    }

    public var body: some View {
        switch node {
        case .text(let content, let style):
            Text(content)
                .applyViewStyle(style, isText: true)

        case .stack(let axis, let spacing, let alignItems, let justifyContent, let style, let children):
            let gap = CGFloat(spacing ?? 8)
            let hAlign = horizontalAlignment(from: alignItems)
            Group {
                switch axis {
                case .column:
                    VStack(alignment: hAlign, spacing: gap) {
                        ForEach(Array(children.enumerated()), id: \.offset) { _, child in
                            ViewNodeView(node: child)
                        }
                    }
                case .row:
                    HStack(alignment: rowAlignment(from: alignItems), spacing: gap) {
                        ForEach(Array(children.enumerated()), id: \.offset) { _, child in
                            ViewNodeView(node: child)
                        }
                    }
                }
            }
            .applyViewStyle(style, isText: false)
            .modifier(JustifyOverlay(justify: justifyContent))

        case .button(let label, let onClick, let style):
            Button(action: {
                if let onClick { print("native shell: click \(onClick)") }
            }) {
                Text(label)
            }
            .applyViewStyle(style, isText: false)

        case .image(let src, let alt, let style):
            VStack(alignment: .leading) {
                Text(alt ?? src)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Text("[image: \(src)]")
                    .font(.caption2)
            }
            .applyViewStyle(style, isText: false)

        case .scroll(let axis, let style, let children):
            let gap: CGFloat = 8
            ScrollView {
                switch axis {
                case .column:
                    VStack(alignment: .leading, spacing: gap) {
                        ForEach(Array(children.enumerated()), id: \.offset) { _, child in
                            ViewNodeView(node: child)
                        }
                    }
                case .row:
                    HStack(alignment: .top, spacing: gap) {
                        ForEach(Array(children.enumerated()), id: \.offset) { _, child in
                            ViewNodeView(node: child)
                        }
                    }
                }
            }
            .applyViewStyle(style, isText: false)

        case .slotRotate(let phrases, _, let style):
            // Full rotation needs a timer; show first phrase in the shell demo.
            Text(phrases.first ?? "")
                .applyViewStyle(style, isText: true)
        }
    }

    private func horizontalAlignment(from alignItems: String?) -> HorizontalAlignment {
        switch alignItems {
        case "end": return .trailing
        case "center": return .center
        default: return .leading
        }
    }

    private func rowAlignment(from alignItems: String?) -> VerticalAlignment {
        switch alignItems {
        case "end": return .bottom
        case "center": return .center
        case "stretch": return .center
        default: return .top
        }
    }
}

/// Best-effort `justify-content` for simple stacks (shell demo).
private struct JustifyOverlay: ViewModifier {
    let justify: String?

    func body(content: Content) -> some View {
        switch justify {
        case "center":
            content.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        case "end":
            content.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
        default:
            content
        }
    }
}

extension View {
    @ViewBuilder
    fileprivate func applyViewStyle(_ style: ViewStyle?, isText: Bool) -> some View {
        if let s = style {
            self.modifier(ViewStyleModifier(style: s, isText: isText))
        } else {
            self
        }
    }
}

private struct ViewStyleModifier: ViewModifier {
    let style: ViewStyle
    let isText: Bool

    func body(content: Content) -> some View {
        let pad = EdgeInsets(
            top: CGFloat(style.paddingTop ?? style.paddingVertical ?? style.padding ?? 0),
            leading: CGFloat(style.paddingLeft ?? style.paddingHorizontal ?? style.padding ?? 0),
            bottom: CGFloat(style.paddingBottom ?? style.paddingVertical ?? style.padding ?? 0),
            trailing: CGFloat(style.paddingRight ?? style.paddingHorizontal ?? style.padding ?? 0)
        )
        let hasPadding = style.padding != nil || style.paddingHorizontal != nil
            || style.paddingVertical != nil || style.paddingTop != nil || style.paddingBottom != nil
            || style.paddingLeft != nil || style.paddingRight != nil

        let base = content
            .modifier(ConditionalPadding(pad: pad, enabled: hasPadding))
            .background((style.backgroundColor.flatMap { Color(hex: $0) }) ?? Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: CGFloat(style.cornerRadius ?? 0)))

        Group {
            if isText {
                textDecorations(base)
            } else {
                base
            }
        }
    }

    @ViewBuilder
    private func textDecorations(_ v: some View) -> some View {
        let w = style.fontWeight.map(fontWeightSwiftUI)
        let withFont = v
            .font(style.fontSize.map { .system(size: CGFloat($0)) } ?? .body)
            .fontWeight(w ?? .regular)
            .foregroundStyle((style.foregroundColor.flatMap { Color(hex: $0) }) ?? Color.primary)
            .italic(style.italic == true)
            .underline(style.underline == true)
            .strikethrough(style.strikethrough == true)
        switch style.textAlign {
        case "center":
            withFont.frame(maxWidth: .infinity, alignment: .center)
        case "trailing":
            withFont.frame(maxWidth: .infinity, alignment: .trailing)
        default:
            withFont
        }
    }
}

private struct ConditionalPadding: ViewModifier {
    let pad: EdgeInsets
    let enabled: Bool

    func body(content: Content) -> some View {
        if enabled {
            content.padding(pad)
        } else {
            content
        }
    }
}

private func fontWeightSwiftUI(_ w: UInt16) -> Font.Weight {
    switch w {
    case ..<300: return .thin
    case ..<400: return .light
    case ..<500: return .regular
    case ..<600: return .medium
    case ..<700: return .semibold
    case ..<800: return .bold
    default: return .heavy
    }
}

extension Color {
    init?(hex: String) {
        var t = hex.trimmingCharacters(in: .whitespaces)
        if t.hasPrefix("#") { t.removeFirst() }
        guard t.count == 6 || t.count == 8 else { return nil }
        var n: UInt64 = 0
        guard Scanner(string: t).scanHexInt64(&n) else { return nil }
        let a: Double
        let r: Double
        let g: Double
        let b: Double
        if t.count == 8 {
            a = Double((n & 0xFF00_0000) >> 24) / 255
            r = Double((n & 0x00FF_0000) >> 16) / 255
            g = Double((n & 0x0000_FF00) >> 8) / 255
            b = Double(n & 0x0000_00FF) / 255
        } else {
            a = 1
            r = Double((n & 0xFF0000) >> 16) / 255
            g = Double((n & 0x00FF00) >> 8) / 255
            b = Double(n & 0x0000FF) / 255
        }
        self = Color(.sRGB, red: r, green: g, blue: b, opacity: a)
    }
}

/// Loads bundled fixture and shows the tree (for Xcode Previews or embedding in an app).
public struct FixtureRootView: View {
    @State private var ir: ViewIr?
    @State private var errorText: String?

    private let bundle: Bundle

    public init(bundle: Bundle = NativeShellResources.bundle) {
        self.bundle = bundle
    }

    public var body: some View {
        Group {
            if let ir {
                ViewIrRootView(ir: ir)
            } else if let errorText {
                Text(errorText).foregroundStyle(.red)
            } else {
                ProgressView()
            }
        }
        .task {
            do {
                ir = try ViewIr.loadFixture(bundle: bundle)
            } catch {
                errorText = String(describing: error)
            }
        }
    }
}

#if DEBUG
#Preview("Fixture") {
    FixtureRootView()
}
#endif