spb/focale Public
Swift 100%
1//2// ControlDial.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Horizontal scrub dial for one manual control, with configurable9// haptic detents (e.g. a tick every third of a stop — CLAUDE.md §6).10//1112import SwiftUI1314struct ControlDial: View {15 let control: CaptureControl16 let haptics: HapticProfile17 @Bindable var controls: ManualControls18 var onChanged: () -> Void1920 @State private var dragStartValue: Double?21 @State private var lastDetent: Int = -12223 var body: some View {24 VStack(spacing: 4) {25 Text(controls.displayValue(for: control))26 .font(DesignTokens.controlValueFont)27 .foregroundStyle(.white)28 .contentTransition(.numericText())2930 Text(control.displayName)31 .font(DesignTokens.controlLabelFont)32 .foregroundStyle(DesignTokens.textSecondary)33 }34 .frame(minWidth: 74)35 .padding(.vertical, 8)36 .padding(.horizontal, 10)37 .background(DesignTokens.surfaceRaised, in: RoundedRectangle(38 cornerRadius: DesignTokens.cornerRadius39 ))40 .sensoryFeedback(.selection, trigger: lastDetent)41 .gesture(dial)42 .accessibilityLabel(control.displayName)43 .accessibilityValue(controls.displayValue(for: control))44 }4546 private var dial: some Gesture {47 DragGesture(minimumDistance: 2)48 .onChanged { gesture in49 let start = dragStartValue ?? controls.normalizedValue(for: control)50 if dragStartValue == nil { dragStartValue = start }5152 let sensitivity = 1.0 / 220.0 // full range over ~220 pt53 let value = (start + gesture.translation.width * sensitivity)54 .clamped(to: 0...1)55 controls.setNormalizedValue(value, for: control)5657 if haptics.enabled, haptics.detentCount > 0 {58 let detent = Int(value * Double(haptics.detentCount))59 if detent != lastDetent { lastDetent = detent }60 }61 onChanged()62 }63 .onEnded { _ in dragStartValue = nil }64 }65}66