Zum Inhalt

apple/PhysarumApp/Sources/Editor/SliderPrecision.swift

#if !os(tvOS)
import SwiftUI

/// The value delta for one fine/coarse precision step. base = baseStep ?? range/100.
/// fine → ×0.1 (Shift), coarse → ×10 (Option). fine takes precedence if both held.
func precisionStep(range: ClosedRange<Double>, baseStep: Double?, fine: Bool, coarse: Bool) -> Double {
    let span = range.upperBound - range.lowerBound
    let base = baseStep ?? (span / 100)
    let mult: Double = fine ? 0.1 : (coarse ? 10 : 1)
    return base * mult
}

// MARK: - macOS precision scroll monitor

#if os(macOS)
import AppKit

/// A zero-size background NSView that installs a local scroll-wheel monitor and
/// nudges when the pointer is over the host view's frame. Placed as a `.background`
/// (behind the Slider) so it NEVER intercepts the slider's clicks/drag.
private struct SliderScrollMonitor: NSViewRepresentable {
    let onTick: (_ up: Bool, _ fine: Bool, _ coarse: Bool, _ newGesture: Bool) -> Void
    func makeNSView(context: Context) -> NSView { MonitorView(onTick: onTick) }
    func updateNSView(_ nsView: NSView, context: Context) { (nsView as? MonitorView)?.onTick = onTick }

    final class MonitorView: NSView {
        var onTick: (Bool, Bool, Bool, Bool) -> Void
        private var monitor: Any?
        private var lastScroll: TimeInterval = 0
        init(onTick: @escaping (Bool, Bool, Bool, Bool) -> Void) { self.onTick = onTick; super.init(frame: .zero) }
        required init?(coder: NSCoder) { nil }
        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            if window == nil { removeMonitor(); return }
            guard monitor == nil else { return }
            monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] e in
                guard let self, let win = self.window, e.window === win else { return e }
                let frameInWin = self.convert(self.bounds, to: nil)
                guard NSPointInRect(e.locationInWindow, frameInWin) else { return e }
                let dy = e.scrollingDeltaY != 0 ? e.scrollingDeltaY : e.deltaY
                if dy == 0 { return e }
                let now = ProcessInfo.processInfo.systemUptime
                let newGesture = (now - self.lastScroll) > 0.3
                self.lastScroll = now
                self.onTick(dy > 0, e.modifierFlags.contains(.shift), e.modifierFlags.contains(.option), newGesture)
                return nil   // consume so the editor list does not also scroll
            }
        }
        private func removeMonitor() { if let m = monitor { NSEvent.removeMonitor(m); monitor = nil } }
        deinit { removeMonitor() }
    }
}
#endif

// MARK: - precisionControls modifier

extension View {
    /// macOS precision input on a Slider: scroll-wheel + arrow keys nudge `value` by a
    /// fine/coarse step (Shift ×0.1, Option ×10); Home/End jump to the range ends.
    /// `onEditStart` fires once per gesture so undo captures the pre-value. iOS: no-op.
    func precisionControls(value: Binding<Double>, range: ClosedRange<Double>, baseStep: Double?,
                           onEditStart: @escaping () -> Void) -> some View {
        #if os(macOS)
        func clamp(_ v: Double) -> Double { min(max(v, range.lowerBound), range.upperBound) }
        return AnyView(self
            .background(SliderScrollMonitor { up, fine, coarse, newGesture in
                if newGesture { onEditStart() }
                let step = precisionStep(range: range, baseStep: baseStep, fine: fine, coarse: coarse)
                value.wrappedValue = clamp(value.wrappedValue + (up ? step : -step))
            })
            .focusable()
            .onKeyPress(keys: [.leftArrow, .rightArrow, .home, .end]) { press in
                let fine = press.modifiers.contains(.shift)
                let coarse = press.modifiers.contains(.option)
                let step = precisionStep(range: range, baseStep: baseStep, fine: fine, coarse: coarse)
                let cur = value.wrappedValue
                let nv: Double
                switch press.key {
                case .leftArrow: nv = cur - step
                case .rightArrow: nv = cur + step
                case .home: nv = range.lowerBound
                case .end: nv = range.upperBound
                default: return .ignored
                }
                onEditStart()
                value.wrappedValue = clamp(nv)
                return .handled
            })
        #else
        return AnyView(self)
        #endif
    }
}
#endif