From 9ac93bc868f0bd43a5f1d380f858de83517eef98 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:23:30 +0200 Subject: [PATCH] feat: add physical surfaces, brushed metal and silkscreen label Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstUI/Surfaces.swift | 175 +++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 Sources/SstUI/Surfaces.swift diff --git a/Sources/SstUI/Surfaces.swift b/Sources/SstUI/Surfaces.swift new file mode 100644 index 0000000..3b03615 --- /dev/null +++ b/Sources/SstUI/Surfaces.swift @@ -0,0 +1,175 @@ +import AppKit +import SwiftUI + +/// A shape with a fill, layered drop shadows, layered inset shadows and a hairline edge: a physical part. +public struct PhysicalSurface: View { + private let fill: AnyShapeStyle + private let cornerRadius: CGFloat + private let shadows: [ShadowLayer] + private let edge: Color? + @Environment(\.colorScheme) private var scheme + + public init(fill: AnyShapeStyle, cornerRadius: CGFloat, shadows: [ShadowLayer], edge: Color? = Palette.lineSeamDark) { + self.fill = fill + self.cornerRadius = cornerRadius + self.shadows = shadows + self.edge = edge + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .circular) + ZStack { + shadows.filter { !$0.inset }.reduce(AnyView(shape.fill(fill))) { view, layer in + AnyView(view.shadow(color: layer.color(scheme), radius: layer.blur / 2, x: layer.x, y: layer.y)) + } + ForEach(Array(shadows.filter(\.inset).enumerated()), id: \.offset) { _, layer in + InsetShadow(layer: layer, cornerRadius: cornerRadius) + } + if let edge { + shape.strokeBorder(edge, lineWidth: Border.hairline) + } + } + } +} + +/// A shadow cast inwards from the edge of a rounded rectangle. +struct InsetShadow: View { + let layer: ShadowLayer + let cornerRadius: CGFloat + @Environment(\.colorScheme) private var scheme + + var body: some View { + Canvas { context, size in + let rect = CGRect(origin: .zero, size: size) + context.clip(to: Path(roundedRect: rect, cornerRadius: cornerRadius)) + context.drawLayer { inner in + inner.addFilter(.blur(radius: layer.blur / 2)) + let hole = rect.insetBy(dx: layer.spread, dy: layer.spread).offsetBy(dx: layer.x, dy: layer.y) + var frame = Path(rect.insetBy(dx: -size.width, dy: -size.height)) + frame.addPath(Path(roundedRect: hole, cornerRadius: max(0, cornerRadius - layer.spread))) + inner.fill(frame, with: .color(layer.color(scheme)), style: FillStyle(eoFill: true)) + } + } + .allowsHitTesting(false) + } +} + +/// Faceplate of matt plastic, sitting above the chassis. +public struct PanelBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePanel), cornerRadius: Radius.none, shadows: Shadows.panel) + } +} + +/// A recessed area (input fields, display windows). +public struct WellBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceWell), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// Dark display glass for the counter. +public struct DisplayBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceDisplay), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// Cassette-label cream, for lists of transcripts and dictionary entries. +public struct PaperBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePaper), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// An engraved groove: a dark line with a light line below it. +public struct SeamDivider: View { + public init() {} + public var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Palette.lineSeamDark).frame(height: Border.hairline) + Rectangle().fill(Palette.lineSeamLight).frame(height: Border.hairline) + } + .accessibilityHidden(true) + } +} + +/// Small capitals silkscreen label. +public struct SilkLabel: View { + private let text: String + private let style: TextStyle + + public init(_ text: String, style: TextStyle = Typography.label) { + self.text = text + self.style = style + } + + public var body: some View { + Text(text).textStyle(style).foregroundStyle(Palette.inkTertiary) + } +} + +/// Brushed aluminium: a vertical gradient with a horizontal grain. +public struct BrushedBackground: View { + @Environment(\.colorScheme) private var scheme + public init() {} + + public var body: some View { + ZStack { + LinearGradient(colors: [Palette.surfaceMetalHigh, Palette.surfaceMetalLow], startPoint: .top, endPoint: .bottom) + Image(nsImage: scheme == .dark ? BrushedTexture.dark : BrushedTexture.light) + .resizable(resizingMode: .tile) + } + } +} + +enum BrushedTexture { + private static let side = 256 + private static let gain = 2.2 // makes the token grain value visible as streak opacity + + static let light = make(grain: Texture.brushedGrainLight) + static let dark = make(grain: Texture.brushedGrainDark) + + private struct SplitMix64: RandomNumberGenerator { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } + + /// A seeded grain: every row has its own brightness and pixels vary only slightly, which gives fine horizontal streaks. + private static func make(grain: Double) -> NSImage { + var rng = SplitMix64(state: 7) + let rows = (0.. 0.5 ? 1.0 : 0.0 + let index = (y * side + x) * 4 + let premultiplied = UInt8(shade * alpha * 255) + pixels[index] = premultiplied + pixels[index + 1] = premultiplied + pixels[index + 2] = premultiplied + pixels[index + 3] = UInt8(alpha * 255) + } + } + let provider = CGDataProvider(data: Data(pixels) as CFData)! + let image = CGImage( + width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)! + return NSImage(cgImage: image, size: NSSize(width: side, height: side)) + } +}