waterui 0.3.0

A modern UI framework for Rust
import CWaterUI

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

@MainActor
struct WuiNavigationTitle {
  let view: WuiAnyView
  let text: String?
  let isPlainText: Bool
  /// The title's live text, when it is semantic text. The bar item that shows
  /// this title watches the signal instead of freezing the string it happened
  /// to hold at construction — `text!("{unread} unread")` keeps counting.
  let textSignal: WuiComputed<WuiStyledStr>?
}

@MainActor
struct WuiNavigationSearch {
  let text: WuiBinding<WuiStr>
  let prompt: WuiComputed<WuiStyledStr>
  let env: WuiEnvironment
}

@MainActor
final class WuiNavigationSearchCoordinator: NSObject {
  private let search: WuiNavigationSearch
  private var textWatcher: WatcherGuard?
  private var promptWatcher: WatcherGuard?
  private var promptRenderer: WuiStyledStrRenderer?
  private var bodyFontObservation: WuiComputedObservation<WuiResolvedFontValue>?
  private var isSyncing = false

  init(search: WuiNavigationSearch) {
    self.search = search
  }

  #if canImport(UIKit)
    private weak var uiSearchBar: UISearchBar?

    func attach(searchBar: UISearchBar) {
      uiSearchBar = searchBar
      searchBar.delegate = self
      textWatcher = search.text.watch { [weak self] value, _ in
        self?.applyText(value.toString())
      }
      promptWatcher = search.prompt.watch { [weak self] value, _ in
        self?.applyPrompt(value)
      }
      bodyFontObservation = .bodyFont(env: search.env) { [weak self] font in
        self?.uiSearchBar?.searchTextField.font = font.toPlatformFont()
      }
      applyText(search.text.value.toString())
      applyPrompt(search.prompt.value)
    }

    func attach(searchController: UISearchController) {
      searchController.obscuresBackgroundDuringPresentation = false
      searchController.hidesNavigationBarDuringPresentation = false
      attach(searchBar: searchController.searchBar)
    }

    private func applyText(_ text: String) {
      guard let uiSearchBar else { return }
      guard uiSearchBar.text != text else { return }
      isSyncing = true
      uiSearchBar.text = text
      isSyncing = false
    }

    private func applyPrompt(_ prompt: WuiStyledStr) {
      promptRenderer = WuiStyledStrRenderer(
        styled: prompt,
        env: search.env,
        defaultForegroundSlot: WuiColorSlot_MutedForeground
      ) { [weak self] in
        self?.applyRenderedPrompt()
      }
      applyRenderedPrompt()
    }

    private func applyRenderedPrompt() {
      guard let uiSearchBar, let promptRenderer else { return }
      uiSearchBar.searchTextField.attributedPlaceholder = promptRenderer.attributedString()
      uiSearchBar.setNeedsLayout()
      uiSearchBar.superview?.setNeedsLayout()
    }
  #elseif canImport(AppKit)
    private weak var nsSearchField: NSSearchField?

    func attach(searchField: NSSearchField) {
      nsSearchField = searchField
      searchField.sendsSearchStringImmediately = true
      searchField.delegate = self
      textWatcher = search.text.watch { [weak self] value, _ in
        self?.applyText(value.toString())
      }
      promptWatcher = search.prompt.watch { [weak self] value, _ in
        self?.applyPrompt(value)
      }
      bodyFontObservation = .bodyFont(env: search.env) { [weak self] font in
        guard let field = self?.nsSearchField else { return }
        field.font = font.toPlatformFont()
        field.invalidateIntrinsicContentSize()
      }
      applyText(search.text.value.toString())
      applyPrompt(search.prompt.value)
    }

    private func applyText(_ text: String) {
      guard let nsSearchField else { return }
      guard nsSearchField.stringValue != text else { return }
      isSyncing = true
      nsSearchField.stringValue = text
      isSyncing = false
    }

    private func applyPrompt(_ prompt: WuiStyledStr) {
      promptRenderer = WuiStyledStrRenderer(
        styled: prompt,
        env: search.env,
        defaultForegroundSlot: WuiColorSlot_MutedForeground
      ) { [weak self] in
        self?.applyRenderedPrompt()
      }
      applyRenderedPrompt()
    }

    private func applyRenderedPrompt() {
      guard let nsSearchField, let promptRenderer else { return }
      nsSearchField.placeholderAttributedString = promptRenderer.attributedString()
      nsSearchField.invalidateIntrinsicContentSize()
      nsSearchField.superview?.needsLayout = true
    }
  #endif

  fileprivate func updateBinding(_ text: String) {
    guard !isSyncing else { return }
    search.text.set(WuiStr(string: text))
  }
}

#if canImport(UIKit)
  extension WuiNavigationSearchCoordinator: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
      updateBinding(searchText)
    }

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
      searchBar.resignFirstResponder()
    }
  }
#elseif canImport(AppKit)
  extension WuiNavigationSearchCoordinator: NSSearchFieldDelegate {
    func controlTextDidChange(_ notification: Notification) {
      guard let field = notification.object as? NSSearchField else {
        fatalError("Navigation search delegate received unexpected control")
      }
      updateBinding(field.stringValue)
    }
  }
#endif

@MainActor
func makeInlineNavigationSearchView(
  _ search: WuiNavigationSearch
) -> (PlatformView, WuiNavigationSearchCoordinator) {
  let coordinator = WuiNavigationSearchCoordinator(search: search)
  #if canImport(UIKit)
    let searchBar = UISearchBar(frame: .zero)
    searchBar.searchBarStyle = .minimal
    coordinator.attach(searchBar: searchBar)
    return (searchBar, coordinator)
  #elseif canImport(AppKit)
    let searchField = NSSearchField(frame: .zero)
    coordinator.attach(searchField: searchField)
    return (searchField, coordinator)
  #endif
}

#if canImport(UIKit)
  @MainActor
  func makeNavigationSearchController(
    _ search: WuiNavigationSearch
  ) -> (UISearchController, WuiNavigationSearchCoordinator) {
    let controller = UISearchController(searchResultsController: nil)
    let coordinator = WuiNavigationSearchCoordinator(search: search)
    coordinator.attach(searchController: controller)
    return (controller, coordinator)
  }

  /// WaterUI's title display mode as UIKit's.
  ///
  /// UIKit's navigation bar has two title sizes, not three: there is no
  /// counterpart to Material's medium flexible app bar, so a medium title
  /// takes the large one. The asymmetry is real and is not papered over with
  /// a hand-drawn bar.
  func wuiLargeTitleDisplayMode(_ mode: WuiNavigationTitleDisplayMode)
    -> UINavigationItem.LargeTitleDisplayMode
  {
    switch mode {
    case WuiNavigationTitleDisplayMode_Automatic:
      return .automatic
    case WuiNavigationTitleDisplayMode_Inline:
      return .never
    case WuiNavigationTitleDisplayMode_Medium, WuiNavigationTitleDisplayMode_Large:
      return .always
    default:
      fatalError("Unsupported WaterUI navigation title display mode: \(mode.rawValue)")
    }
  }
#endif

@MainActor
struct WuiNavigationBarState {
  let title: WuiNavigationTitle
  let subtitle: WuiNavigationTitle
  let toolbar: [WuiNavigationToolbarItem]
  let search: WuiNavigationSearch?
  let color: WuiComputed<WuiResolvedColor>?
  let hidden: WuiComputed<Bool>?

  var leadingItem: WuiNavigationToolbarItem? {
    toolbar.first {
      $0.placement == WuiNavigationToolbarPlacement_Cancellation
        || $0.placement == WuiNavigationToolbarPlacement_TopBarLeading
    }
  }

  var trailingItem: WuiNavigationToolbarItem? {
    toolbar.first {
      $0.placement == WuiNavigationToolbarPlacement_PrimaryAction
        || $0.placement == WuiNavigationToolbarPlacement_SecondaryAction
        || $0.placement == WuiNavigationToolbarPlacement_Confirmation
        || $0.placement == WuiNavigationToolbarPlacement_TopBarTrailing
    }
  }

  var leading: WuiAnyView? { leadingItem?.view }

  var trailing: WuiAnyView? { trailingItem?.view }
}

@MainActor
struct WuiNavigationToolbarItem {
  let placement: WuiNavigationToolbarPlacement
  let view: WuiAnyView
  /// The item's name, when it was declared as a semantic label.
  ///
  /// A Mac toolbar draws the icon alone and keeps the name for the overflow
  /// menu, the tooltip and assistive technology, so the name is needed apart
  /// from the content view rather than only inside it.
  let title: WuiComputed<WuiStyledStr>?
  /// The item's icon as a symbol the platform already knows.
  let systemIconName: String?
  /// The item's icon as a view, when it is not a platform symbol.
  let iconView: WuiAnyView?
}

@MainActor
func makeNavigationBarState(from bar: CWaterUI.WuiBar, env: WuiEnvironment) -> WuiNavigationBarState
{
  guard let titlePtr = bar.title else {
    fatalError("Navigation bar title pointer is null")
  }

  let title = makeNavigationTitle(from: titlePtr, env: env)
  guard let subtitlePtr = bar.subtitle else {
    fatalError("Navigation bar subtitle pointer is null")
  }
  let subtitle = makeNavigationTitle(from: subtitlePtr, env: env)
  let toolbar = WuiArray<CWaterUI.WuiNavigationToolbarItem>(bar.toolbar).map { item in
    guard let content = item.content else {
      fatalError("Navigation toolbar item content pointer is null")
    }
    let systemIconName = item.system_icon.map { icon in
      WuiStr(waterui_menu_item_take_icon(icon).name).toString()
    }
    return WuiNavigationToolbarItem(
      placement: item.placement,
      view: WuiAnyView(anyview: content, env: env),
      title: item.title.map(WuiComputed<WuiStyledStr>.init),
      systemIconName: systemIconName?.isEmpty == true ? nil : systemIconName,
      iconView: item.icon.map { WuiAnyView(anyview: $0, env: env) }
    )
  }

  let color = bar.color.map(WuiComputed<WuiResolvedColor>.init)

  let hidden: WuiComputed<Bool>?
  if let hiddenPtr = bar.hidden {
    hidden = WuiComputed<Bool>(hiddenPtr)
  } else {
    hidden = nil
  }

  let search: WuiNavigationSearch?
  if bar.search.has_value {
    guard let text = bar.search.value.text else {
      fatalError("Navigation search text binding pointer is null")
    }
    guard let prompt = bar.search.value.prompt else {
      fatalError("Navigation search prompt computed pointer is null")
    }
    search = WuiNavigationSearch(
      text: WuiBinding<WuiStr>(text),
      prompt: WuiComputed<WuiStyledStr>(prompt),
      env: env
    )
  } else {
    search = nil
  }

  return WuiNavigationBarState(
    title: title,
    subtitle: subtitle,
    toolbar: toolbar,
    search: search,
    color: color,
    hidden: hidden
  )
}

@MainActor
private func makeNavigationTitle(from titlePtr: OpaquePointer, env: WuiEnvironment)
  -> WuiNavigationTitle
{
  let titleView = WuiAnyView(anyview: titlePtr, env: env)
  let (text, isPlainText) = extractNavigationTitleText(from: titleView)
  let textSignal = (wuiResolvedPrimaryContent(of: titleView) as? WuiText)?.semanticContent
  return WuiNavigationTitle(
    view: titleView, text: text, isPlainText: isPlainText, textSignal: textSignal)
}

@MainActor
func extractNavigationTitleText(from titleView: PlatformView) -> (String?, Bool) {
  #if canImport(UIKit)
    func findText(in view: UIView) -> String? {
      if let label = view as? UILabel {
        return label.attributedText?.string ?? label.text
      }
      for sub in view.subviews {
        if let t = findText(in: sub) { return t }
      }
      return nil
    }
    if let text = findText(in: titleView) {
      return (text, true)
    }
  #elseif canImport(AppKit)
    func findText(in view: NSView) -> String? {
      if let field = view as? NSTextField {
        let plain = field.stringValue
        if !plain.isEmpty { return plain }
        let attributed = field.attributedStringValue.string
        return attributed.isEmpty ? nil : attributed
      }
      for sub in view.subviews {
        if let t = findText(in: sub) { return t }
      }
      return nil
    }
    if let text = findText(in: titleView) {
      return (text, true)
    }
  #endif

  return (nil, false)
}