waterui 0.3.0

A modern UI framework for Rust
import CWaterUI

#if canImport(UIKit)
  import UIKit
#elseif canImport(AppKit)
  import AppKit
#endif

@MainActor
private protocol WuiNativeToggleControl {
  var view: PlatformView { get }

  func installAction(target: AnyObject, action: Selector)
  func setOn(_ isOn: Bool, metadata: WuiWatcherMetadata?)
  func valueAfterUserInteraction() -> Bool
  func setEnabled(_ enabled: Bool)
}

#if canImport(UIKit)
  @MainActor
  private final class WuiNativeSwitchControl: WuiNativeToggleControl {
    let control = UISwitch()

    var view: PlatformView { control }

    init(isOn: Bool) {
      control.isOn = isOn
    }

    func installAction(target: AnyObject, action: Selector) {
      control.addTarget(target, action: action, for: .valueChanged)
    }

    func setOn(_ isOn: Bool, metadata: WuiWatcherMetadata?) {
      guard control.isOn != isOn else { return }
      guard let metadata else {
        control.isOn = isOn
        return
      }
      control.setOn(
        isOn,
        animated: shouldAnimate(parseAnimation(metadata.getAnimation()))
      )
    }

    func valueAfterUserInteraction() -> Bool {
      control.isOn
    }

    func setEnabled(_ enabled: Bool) {
      control.isEnabled = enabled
    }
  }

  @MainActor
  private final class WuiNativeCheckboxControl: WuiNativeToggleControl {
    let control: UIButton

    var view: PlatformView { control }

    init(isOn: Bool) {
      guard
        let uncheckedImage = UIImage(systemName: "square"),
        let checkedImage = UIImage(systemName: "checkmark.square.fill")
      else {
        fatalError("WaterUI checkbox requires the system square symbols")
      }
      let control = UIButton(type: .system)
      control.setPreferredSymbolConfiguration(
        UIImage.SymbolConfiguration(textStyle: .body),
        forImageIn: .normal
      )
      control.setImage(uncheckedImage, for: .normal)
      control.setImage(checkedImage, for: .selected)
      control.isSelected = isOn
      control.changesSelectionAsPrimaryAction = true
      control.adjustsImageSizeForAccessibilityContentSizeCategory = true
      self.control = control
    }

    func installAction(target: AnyObject, action: Selector) {
      control.addTarget(target, action: action, for: .primaryActionTriggered)
    }

    func setOn(_ isOn: Bool, metadata: WuiWatcherMetadata?) {
      guard control.isSelected != isOn else { return }
      if let metadata {
        withCrossDissolveAnimation(control, metadata) {
          self.control.isSelected = isOn
        }
      } else {
        control.isSelected = isOn
      }
    }

    func valueAfterUserInteraction() -> Bool {
      control.isSelected
    }

    func setEnabled(_ enabled: Bool) {
      control.isEnabled = enabled
    }
  }
#elseif canImport(AppKit)
  @MainActor
  private final class WuiSwitch: NSSwitch {
    override func mouseDown(with event: NSEvent) {
      // Keep the responder entry point on the composed control so AppKit enters
      // NSSwitch's native tracking loop after WaterUI wrapper hit testing.
      super.mouseDown(with: event)
    }
  }

  @MainActor
  private final class WuiNativeSwitchControl: WuiNativeToggleControl {
    let control = WuiSwitch()

    var view: PlatformView { control }

    init(isOn: Bool) {
      control.state = isOn ? .on : .off
    }

    func installAction(target: AnyObject, action: Selector) {
      control.target = target
      control.action = action
    }

    func setOn(_ isOn: Bool, metadata: WuiWatcherMetadata?) {
      let state: NSControl.StateValue = isOn ? .on : .off
      guard control.state != state else { return }
      if let metadata {
        withPlatformAnimation(metadata) {
          self.control.state = state
        }
      } else {
        control.state = state
      }
    }

    func valueAfterUserInteraction() -> Bool {
      control.state == .on
    }

    func setEnabled(_ enabled: Bool) {
      control.isEnabled = enabled
    }
  }

  @MainActor
  private final class WuiNativeCheckboxControl: WuiNativeToggleControl {
    let control: NSButton

    var view: PlatformView { control }

    init(isOn: Bool) {
      let control = NSButton(checkboxWithTitle: "", target: nil, action: nil)
      control.state = isOn ? .on : .off
      self.control = control
    }

    func installAction(target: AnyObject, action: Selector) {
      control.target = target
      control.action = action
    }

    func setOn(_ isOn: Bool, metadata: WuiWatcherMetadata?) {
      let state: NSControl.StateValue = isOn ? .on : .off
      guard control.state != state else { return }
      if let metadata {
        withPlatformAnimation(metadata) {
          self.control.state = state
        }
      } else {
        control.state = state
      }
    }

    func valueAfterUserInteraction() -> Bool {
      control.state == .on
    }

    func setEnabled(_ enabled: Bool) {
      control.isEnabled = enabled
    }
  }
#endif

@MainActor
final class WuiToggle: PlatformView, WuiComponent {
  static var rawId: CWaterUI.WuiTypeId { waterui_toggle_id() }

  private let toggleControl: any WuiNativeToggleControl
  private let binding: WuiBinding<Bool>
  private let labelView: WuiAnyView
  private let disabled: WuiComputed<Bool>
  private var bindingWatcher: WatcherGuard?
  private var disabledWatcher: WatcherGuard?
  private var accessibility: WuiControlAccessibility?
  private let horizontalSpacing: CGFloat = 8

  convenience init(anyview: OpaquePointer, env: WuiEnvironment) {
    let ffiToggle = waterui_force_as_toggle(anyview)
    self.init(
      label: WuiAnyView(anyview: ffiToggle.label.view, env: env),
      binding: WuiBinding(ffiToggle.toggle),
      style: ffiToggle.style,
      disabled: env.disabled,
      semanticLabel: ffiToggle.label
    )
  }

  init(
    label: WuiAnyView,
    binding: WuiBinding<Bool>,
    style: WuiToggleStyle,
    disabled: WuiComputed<Bool>,
    semanticLabel: CWaterUI.WuiLabel
  ) {
    self.toggleControl = Self.makeToggleControl(style: style, isOn: binding.value)
    self.binding = binding
    self.labelView = label
    self.disabled = disabled

    super.init(frame: .zero)

    configureSubviews()
    toggleControl.installAction(target: self, action: #selector(valueChanged))
    startWatchingBinding()
    startWatchingDisabled()
    accessibility = WuiControlAccessibility(
      consuming: semanticLabel,
      target: toggleControl.view,
      visualLabel: label
    )
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  private static func makeToggleControl(
    style: WuiToggleStyle,
    isOn: Bool
  ) -> any WuiNativeToggleControl {
    switch style {
    case WuiToggleStyle_Automatic:
      // What the platform itself reaches for. A Mac writes a boolean setting as
      // a checkbox beside its label — the switch is the phone's answer, and
      // measured against SwiftUI a `Toggle` in a macOS form is a checkbox. Both
      // remain available by asking for them.
      #if canImport(UIKit)
        return WuiNativeSwitchControl(isOn: isOn)
      #elseif canImport(AppKit)
        return WuiNativeCheckboxControl(isOn: isOn)
      #endif
    case WuiToggleStyle_Switch:
      return WuiNativeSwitchControl(isOn: isOn)
    case WuiToggleStyle_Checkbox:
      return WuiNativeCheckboxControl(isOn: isOn)
    default:
      fatalError("Unsupported WaterUI toggle style: \(style.rawValue)")
    }
  }

  func sizeThatFits(_ proposal: WuiProposalSize) -> CGSize {
    let labelSize = labelView.sizeThatFits(WuiProposalSize())
    let controlSize = toggleControl.view.intrinsicContentSize
    let hasLabel = labelSize.width > 0 && labelSize.height > 0
    let intrinsicWidth = controlSize.width + (hasLabel ? horizontalSpacing + labelSize.width : 0)
    let width: CGFloat
    #if canImport(UIKit)
      // A phone's toggle is a row: the label at one end, the switch at the
      // other, filling whatever width it is offered.
      if hasLabel, let proposedWidth = proposal.width {
        width = max(CGFloat(proposedWidth), intrinsicWidth)
      } else {
        width = intrinsicWidth
      }
    #elseif canImport(AppKit)
      // A Mac's is one control: the checkbox and its title side by side, taking
      // their own width. Filling the row is what pushed the box to the far edge
      // of the window, which no Mac does.
      width = intrinsicWidth
    #endif
    return CGSize(width: width, height: max(controlSize.height, labelSize.height))
  }

  private func configureSubviews() {
    let controlView = toggleControl.view
    labelView.translatesAutoresizingMaskIntoConstraints = false
    controlView.translatesAutoresizingMaskIntoConstraints = false
    addSubview(labelView)
    addSubview(controlView)

    labelView.setContentCompressionResistancePriority(.required, for: .horizontal)
    labelView.setContentHuggingPriority(.defaultHigh, for: .horizontal)

    #if canImport(UIKit)
      NSLayoutConstraint.activate([
        labelView.leadingAnchor.constraint(equalTo: leadingAnchor),
        labelView.centerYAnchor.constraint(equalTo: centerYAnchor),
        controlView.trailingAnchor.constraint(equalTo: trailingAnchor),
        controlView.centerYAnchor.constraint(equalTo: centerYAnchor),
        labelView.trailingAnchor.constraint(
          lessThanOrEqualTo: controlView.leadingAnchor,
          constant: -horizontalSpacing
        ),
      ])
    #elseif canImport(AppKit)
      // Checkbox first, then its title — the order a Mac reads them in.
      NSLayoutConstraint.activate([
        controlView.leadingAnchor.constraint(equalTo: leadingAnchor),
        controlView.centerYAnchor.constraint(equalTo: centerYAnchor),
        labelView.leadingAnchor.constraint(
          equalTo: controlView.trailingAnchor,
          constant: horizontalSpacing
        ),
        labelView.centerYAnchor.constraint(equalTo: centerYAnchor),
        labelView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
      ])
    #endif
  }

  private func startWatchingBinding() {
    bindingWatcher = binding.watch { [weak self] isOn, metadata in
      self?.toggleControl.setOn(isOn, metadata: metadata)
    }
    toggleControl.setOn(binding.value, metadata: nil)
  }

  private func startWatchingDisabled() {
    disabledWatcher = disabled.watch { [weak self] isDisabled, _ in
      self?.toggleControl.setEnabled(!isDisabled)
    }
    toggleControl.setEnabled(!disabled.value)
  }

  @objc private func valueChanged() {
    binding.value = toggleControl.valueAfterUserInteraction()
  }
}