From 529934b1776e2feb17ef418869e997e9610bc382 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:23:56 +0200 Subject: [PATCH] feat: add physical key button style and lamp Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstUI/Controls.swift | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 Sources/SstUI/Controls.swift diff --git a/Sources/SstUI/Controls.swift b/Sources/SstUI/Controls.swift new file mode 100644 index 0000000..72c2241 --- /dev/null +++ b/Sources/SstUI/Controls.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// A physical key. `latched` keeps it looking pressed (REC while recording). +public struct KeyButtonStyle: ButtonStyle { + public enum Kind { case standard, record } + + private let kind: Kind + private let latched: Bool + + public init(kind: Kind = .standard, latched: Bool = false) { + self.kind = kind + self.latched = latched + } + + public func makeBody(configuration: Configuration) -> some View { + KeyBody(configuration: configuration, kind: kind, latched: latched) + } + + private struct KeyBody: View { + let configuration: Configuration + let kind: Kind + let latched: Bool + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var pressed: Bool { configuration.isPressed || latched } + + private var face: AnyShapeStyle { + switch (kind, pressed) { + case (.standard, false): + AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)) + case (.standard, true): + AnyShapeStyle(Palette.surfaceKeyPressed) + case (.record, false): + AnyShapeStyle(Palette.accentRecord) + case (.record, true): + AnyShapeStyle(Palette.accentRecord.opacity(Opacity.pressedRecord)) + } + } + + var body: some View { + configuration.label + .textStyle(Typography.control) + .foregroundStyle(kind == .record ? Palette.inkInverse : Palette.inkPrimary) + .padding(.horizontal, Space.s4) + .frame( + minWidth: kind == .record ? Size.keyRecWidth : Size.keyMinWidth, + minHeight: kind == .record ? Size.keyRecHeight : Size.controlHeight) + .background(PhysicalSurface( + fill: face, cornerRadius: Radius.sm, + shadows: pressed ? Shadows.keyPressed : Shadows.keyRaised)) + .offset(y: pressed ? Shadows.keyPressedOffset : 0) + .opacity(isEnabled ? 1 : Opacity.disabled) + .animation( + reduceMotion ? nil : .timingCurve( + Motion.easingMechanical.0, Motion.easingMechanical.1, Motion.easingMechanical.2, Motion.easingMechanical.3, + duration: pressed ? Motion.keyPress : Motion.keyRelease), + value: pressed) + } + } +} + +/// A round indicator lamp. The record lamp has a small glow; text never does. +public struct LampView: View { + public enum Tint { case record, amber } + + private let isOn: Bool + private let tint: Tint + private let large: Bool + @Environment(\.colorScheme) private var scheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(isOn: Bool, tint: Tint = .record, large: Bool = false) { + self.isOn = isOn + self.tint = tint + self.large = large + } + + private var color: Color { + switch (tint, isOn) { + case (.record, true): Palette.accentRecord + case (.record, false): Palette.accentRecordUnlit + case (.amber, true): Palette.levelAmber + case (.amber, false): Palette.levelAmberUnlit + } + } + + public var body: some View { + let diameter = large ? Size.lampLarge : Size.lampDiameter + let glow = Shadows.lampOn[0] + Circle() + .fill(color) + .overlay( + Circle().fill(RadialGradient( + colors: [Color.white.opacity(isOn ? Opacity.specularOn : Opacity.specularOff), .clear], + center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: diameter * 0.45))) + .overlay(Circle().strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) + .frame(width: diameter, height: diameter) + .shadow( + color: isOn && tint == .record ? glow.color(scheme) : .clear, + radius: glow.blur / 2) + .animation( + reduceMotion ? nil : .easeOut(duration: isOn ? Motion.lampOn : Motion.lampOff), + value: isOn) + .accessibilityHidden(true) + } +}