// WuiSlider.swift
// Slider component - merged UIKit and AppKit implementation
//
// # Layout Behavior
// Slider expands horizontally to fill available width, but has fixed intrinsic height.
// Includes optional label at top and min/max value labels beside the track.
// Use frame modifiers to constrain width if needed.
//
// // INTERNAL: Layout Contract for Backend Implementers
// // - stretchAxis: .horizontal (expands width, intrinsic height)
// // - sizeThatFits: Returns proposed width (min 50pt track), intrinsic height
// // - Priority: 0 (default)
import CWaterUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
@MainActor
final class WuiSlider: PlatformView, WuiComponent {
static var rawId: CWaterUI.WuiTypeId { waterui_slider_id() }
private(set) var stretchAxis: WuiStretchAxis
#if canImport(UIKit)
private let slider = UISlider()
#elseif canImport(AppKit)
private let slider = NSSlider()
#endif
private var bindingWatcher: WatcherGuard?
private var labelView: WuiAnyView
private var minLabelView: WuiAnyView
private var maxLabelView: WuiAnyView
private var binding: WuiBinding<Double>
private var range: WuiRange_f64
private let disabled: WuiComputed<Bool>
private var disabledWatcher: WatcherGuard?
private var accessibility: WuiControlAccessibility?
// Layout constants
private let verticalSpacing: CGFloat = 4.0
private let horizontalSpacing: CGFloat = 8.0
// AutoLayout constraints (stored for dynamic updates)
private var activeConstraints: [NSLayoutConstraint] = []
// MARK: - WuiComponent Init
convenience init(anyview: OpaquePointer, env: WuiEnvironment) {
let stretchAxis = WuiStretchAxis(waterui_view_stretch_axis(anyview))
let ffiSlider: CWaterUI.WuiSlider = waterui_force_as_slider(anyview)
let labelView = WuiAnyView(anyview: ffiSlider.label.view, env: env)
let minLabelView = WuiAnyView(anyview: ffiSlider.min_value_label, env: env)
let maxLabelView = WuiAnyView(anyview: ffiSlider.max_value_label, env: env)
let binding = WuiBinding<Double>(ffiSlider.value)
self.init(
stretchAxis: stretchAxis,
label: labelView,
minLabel: minLabelView,
maxLabel: maxLabelView,
range: ffiSlider.range,
binding: binding,
disabled: env.disabled,
semanticLabel: ffiSlider.label
)
}
// MARK: - Designated Init
init(
stretchAxis: WuiStretchAxis,
label: WuiAnyView,
minLabel: WuiAnyView,
maxLabel: WuiAnyView,
range: WuiRange_f64,
binding: WuiBinding<Double>,
disabled: WuiComputed<Bool>,
semanticLabel: CWaterUI.WuiLabel
) {
self.disabled = disabled
self.stretchAxis = stretchAxis
self.labelView = label
self.minLabelView = minLabel
self.maxLabelView = maxLabel
self.range = range
self.binding = binding
super.init(frame: .zero)
configureSubviews()
configureSlider()
updateLabel(label, force: true)
updateMinLabel(minLabel, force: true)
updateMaxLabel(maxLabel, force: true)
updateRange(range)
updateBinding(binding, force: true)
startWatchingDisabled()
accessibility = WuiControlAccessibility(
consuming: semanticLabel,
target: slider,
visualLabel: label
)
}
private func startWatchingDisabled() {
disabledWatcher = disabled.watch { [weak self] isDisabled, _ in
self?.applyDisabled(isDisabled)
}
applyDisabled(disabled.value)
}
private func applyDisabled(_ isDisabled: Bool) {
slider.isEnabled = !isDisabled
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - WuiComponent
func sizeThatFits(_ proposal: WuiProposalSize) -> CGSize {
// Slider is axis-expanding on width per LAYOUT_SPEC.md
// It uses isStretch: true to expand, so here we report MINIMUM usable size
let labelSize = labelView.sizeThatFits(WuiProposalSize())
let minLabelSize = minLabelView.sizeThatFits(WuiProposalSize())
let maxLabelSize = maxLabelView.sizeThatFits(WuiProposalSize())
let sliderHeight = slider.intrinsicContentSize.height
// Slider row height: max of slider and labels
let sliderRowHeight = max(sliderHeight, max(minLabelSize.height, maxLabelSize.height))
// Intrinsic height: label height + spacing + slider row height
let intrinsicHeight = labelSize.height + verticalSpacing + sliderRowHeight
// For width: report MINIMUM usable size
// The minimum width ensures labels fit and slider track is usable (at least 50pt)
let minSliderTrackWidth: CGFloat = 50.0
let minWidth = max(
labelSize.width,
minLabelSize.width + horizontalSpacing + minSliderTrackWidth + horizontalSpacing
+ maxLabelSize.width)
// When width is proposed, use it (but not less than minimum)
// When None, return minimum - isStretch:true will expand it to fill remaining space
let width = proposal.width.map { max(CGFloat($0), minWidth) } ?? minWidth
let height = proposal.height.map { CGFloat($0) } ?? intrinsicHeight
return CGSize(width: width, height: max(height, intrinsicHeight))
}
// MARK: - Layout
#if canImport(AppKit)
nonisolated override var isFlipped: Bool { true }
#endif
private func setupConstraints() {
NSLayoutConstraint.deactivate(activeConstraints)
labelView.translatesAutoresizingMaskIntoConstraints = false
minLabelView.translatesAutoresizingMaskIntoConstraints = false
slider.translatesAutoresizingMaskIntoConstraints = false
maxLabelView.translatesAutoresizingMaskIntoConstraints = false
// Ensure labels don't get compressed - they should show their full content
#if canImport(UIKit)
labelView.setContentCompressionResistancePriority(.required, for: .horizontal)
minLabelView.setContentCompressionResistancePriority(.required, for: .horizontal)
maxLabelView.setContentCompressionResistancePriority(.required, for: .horizontal)
#elseif canImport(AppKit)
labelView.setContentCompressionResistancePriority(.required, for: .horizontal)
minLabelView.setContentCompressionResistancePriority(.required, for: .horizontal)
maxLabelView.setContentCompressionResistancePriority(.required, for: .horizontal)
#endif
// Label at top-leading
var constraints = [NSLayoutConstraint]()
constraints.append(contentsOf: [
labelView.topAnchor.constraint(equalTo: topAnchor),
labelView.leadingAnchor.constraint(equalTo: leadingAnchor),
])
// Slider row: [minLabel] - [slider] - [maxLabel]
// All vertically centered relative to each other, below label
// Min label: leading, below label
constraints.append(contentsOf: [
minLabelView.topAnchor.constraint(equalTo: labelView.bottomAnchor, constant: verticalSpacing),
minLabelView.leadingAnchor.constraint(equalTo: leadingAnchor),
minLabelView.centerYAnchor.constraint(equalTo: slider.centerYAnchor),
])
// Max label: trailing, aligned with slider row
constraints.append(contentsOf: [
maxLabelView.trailingAnchor.constraint(equalTo: trailingAnchor),
maxLabelView.centerYAnchor.constraint(equalTo: slider.centerYAnchor),
])
// Slider: between minLabel and maxLabel, below label
constraints.append(contentsOf: [
slider.topAnchor.constraint(equalTo: labelView.bottomAnchor, constant: verticalSpacing),
slider.leadingAnchor.constraint(
equalTo: minLabelView.trailingAnchor, constant: horizontalSpacing),
slider.trailingAnchor.constraint(
equalTo: maxLabelView.leadingAnchor, constant: -horizontalSpacing),
])
NSLayoutConstraint.activate(constraints)
activeConstraints = constraints
}
// MARK: - Update Methods
func updateLabel(_ newLabel: WuiAnyView, force: Bool = false) {
guard force || newLabel !== labelView else { return }
labelView.removeFromSuperview()
labelView = newLabel
addSubview(labelView)
setupConstraints()
}
func updateMinLabel(_ newLabel: WuiAnyView, force: Bool = false) {
guard force || newLabel !== minLabelView else { return }
minLabelView.removeFromSuperview()
minLabelView = newLabel
addSubview(minLabelView)
setupConstraints()
}
func updateMaxLabel(_ newLabel: WuiAnyView, force: Bool = false) {
guard force || newLabel !== maxLabelView else { return }
maxLabelView.removeFromSuperview()
maxLabelView = newLabel
addSubview(maxLabelView)
setupConstraints()
}
func updateBinding(_ newBinding: WuiBinding<Double>, force: Bool = false) {
guard force || newBinding !== binding else { return }
bindingWatcher = nil
binding = newBinding
startBindingWatcher()
#if canImport(UIKit)
slider.setValue(Float(clampedValue(newBinding.value)), animated: false)
#elseif canImport(AppKit)
slider.doubleValue = clampedValue(newBinding.value)
#endif
}
func updateRange(_ newRange: WuiRange_f64) {
range = newRange
#if canImport(UIKit)
slider.minimumValue = Float(newRange.start)
slider.maximumValue = Float(newRange.end)
slider.setValue(Float(clampedValue(binding.value)), animated: false)
#elseif canImport(AppKit)
slider.minValue = newRange.start
slider.maxValue = newRange.end
slider.doubleValue = clampedValue(binding.value)
#endif
}
// MARK: - Configuration
private func configureSubviews() {
addSubview(labelView)
addSubview(minLabelView)
addSubview(slider)
addSubview(maxLabelView)
setupConstraints()
}
private func configureSlider() {
#if canImport(UIKit)
slider.minimumValue = Float(range.start)
slider.maximumValue = Float(range.end)
slider.value = Float(clampedValue(binding.value))
slider.addTarget(self, action: #selector(valueChanged), for: .valueChanged)
#elseif canImport(AppKit)
slider.minValue = range.start
slider.maxValue = range.end
slider.doubleValue = clampedValue(binding.value)
slider.target = self
slider.action = #selector(valueChanged)
slider.isContinuous = true
#endif
}
private func startBindingWatcher() {
bindingWatcher = binding.watch { [weak self] newValue, metadata in
guard let self else { return }
let clamped = clampedValue(newValue)
#if canImport(UIKit)
let clampedFloat = Float(clamped)
if slider.value == clampedFloat { return }
let animated = shouldAnimate(parseAnimation(metadata.getAnimation()))
slider.setValue(clampedFloat, animated: animated)
#elseif canImport(AppKit)
if slider.doubleValue == clamped { return }
withPlatformAnimation(metadata) {
self.slider.doubleValue = clamped
}
#endif
}
}
private func clampedValue(_ value: Double) -> Double {
min(max(value, range.start), range.end)
}
@objc private func valueChanged() {
#if canImport(UIKit)
binding.value = Double(slider.value)
#elseif canImport(AppKit)
binding.value = slider.doubleValue
#endif
}
}