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 final class WuiTableColumnNode {
  let id: Int32
  let rows: WuiStableViewCollection
  #if canImport(UIKit)
    let label: WuiText
  #elseif canImport(AppKit)
    // The native header cell renders plain text; the styled label reduces to
    // its string. Paragraph alignment is not representable in an
    // NSTableHeaderCell and is consumed without projection.
    private let labelObservation: WuiComputedObservation<WuiStyledStr>
    var title: String { labelObservation.value.toString() }
  #endif

  init(
    id: Int32,
    consuming column: CWaterUI.WuiTableColumn,
    env: WuiEnvironment,
    onRowsChange: @escaping (WuiWatcherMetadata) -> Void,
    onContentChange: @escaping () -> Void
  ) {
    guard let rows = column.rows else {
      fatalError("Table column has no rows collection")
    }
    guard let labelContent = column.label.content else {
      fatalError("Table column label has no content signal")
    }
    guard let labelAlignment = column.label.paragraph_alignment else {
      fatalError("Table column label has no paragraph-alignment signal")
    }
    self.id = id
    #if canImport(UIKit)
      self.label = WuiText(
        content: WuiComputed<WuiStyledStr>(labelContent),
        paragraphAlignment: WuiComputed<WuiHorizontalAlignment>(labelAlignment),
        env: env
      )
    #elseif canImport(AppKit)
      self.labelObservation = WuiComputedObservation(
        WuiComputed<WuiStyledStr>(labelContent)
      ) { _, _ in
        onContentChange()
      }
      // Consume the alignment signal so its native handle is released.
      _ = WuiComputed<WuiHorizontalAlignment>(labelAlignment)
    #endif
    self.rows = WuiStableViewCollection(
      consuming: rows,
      env: env,
      onChange: onRowsChange
    )
    #if canImport(UIKit)
      self.label.onIntrinsicContentChange = onContentChange
    #endif
  }
}

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

  private let env: WuiEnvironment
  private let source: WuiAnyViews
  private let collection = WuiStableSemanticCollection<Int32, WuiTableColumnNode>()
  private var columnsWatcher: WatcherGuard?

  private let horizontalPadding: CGFloat = 12
  private let verticalPadding: CGFloat = 6
  private let minimumColumnWidth: CGFloat = 80

  #if canImport(UIKit)
    private var attachedViews: [PlatformView] = []
  #elseif canImport(AppKit)
    private let tableView = NSTableView()
    // Hosted manually: NSTableHeaderView renders the native column headers
    // (titles, dividers, bottom hairline) without needing the NSScrollView
    // that normally positions it.
    private let nativeHeader = NSTableHeaderView()
    /// The standard height of the native header band.
    private let nativeHeaderHeight: CGFloat = 24
    private var nativeColumns: [Int32: NSTableColumn] = [:]
    private var appKitRowHeights: [CGFloat] = []
  #endif

  private var columns: [WuiTableColumnNode] { collection.ordered }

  convenience init(anyview: OpaquePointer, env: WuiEnvironment) {
    let table = waterui_force_as_table(anyview)
    guard let columns = table.columns else {
      fatalError("WuiTable.columns is null")
    }
    self.init(columns: columns, env: env)
  }

  private init(columns: OpaquePointer, env: WuiEnvironment) {
    self.env = env
    self.source = WuiAnyViews(columns)
    super.init(frame: .zero)

    #if canImport(UIKit)
      clipsToBounds = true
    #elseif canImport(AppKit)
      tableView.style = .inset
      tableView.usesAlternatingRowBackgroundColors = false
      tableView.gridStyleMask = []
      tableView.intercellSpacing = NSSize(width: 0, height: 0)
      tableView.columnAutoresizingStyle = .noColumnAutoresizing
      tableView.backgroundColor = .clear
      tableView.allowsColumnReordering = false
      tableView.allowsColumnResizing = false
      tableView.allowsColumnSelection = false
      tableView.headerView = nativeHeader
      tableView.dataSource = self
      tableView.delegate = self
      addSubview(tableView)
      addSubview(nativeHeader)
    #endif

    columnsWatcher = watchAnyViewsIds(source) { [weak self] ids, metadata in
      guard let self else { return }
      withPlatformAnimation(metadata) {
        self.reconcileColumns(ids: ids)
      }
    }
    reconcileColumns(ids: source.allIds())
  }

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

  private func reconcileColumns(ids: [Int32]) {
    collection.reconcile(ids: ids) { [source, env] index, id in
      let column = waterui_force_as_table_column(source.takeRawView(at: index))
      return WuiTableColumnNode(
        id: id,
        consuming: column,
        env: env,
        onRowsChange: { [weak self] metadata in
          guard let self else { return }
          withPlatformAnimation(metadata) {
            self.reloadContent()
          }
        },
        onContentChange: { [weak self] in
          self?.reloadContent()
        }
      )
    }
    updateColumns()
  }

  private func updateColumns() {
    #if canImport(UIKit)
      updateAttachedViews()
    #elseif canImport(AppKit)
      let activeIds = Set(columns.lazy.map(\.id))
      nativeColumns = nativeColumns.filter { activeIds.contains($0.key) }
      for column in tableView.tableColumns {
        tableView.removeTableColumn(column)
      }
      for column in columns {
        let native =
          nativeColumns[column.id]
          ?? {
            let native = NSTableColumn(
              identifier: NSUserInterfaceItemIdentifier("waterui.table.\(column.id)")
            )
            native.minWidth = minimumColumnWidth
            nativeColumns[column.id] = native
            return native
          }()
        native.title = column.title
        tableView.addTableColumn(native)
      }
    #endif
    reloadContent()
  }

  private func reloadContent() {
    #if canImport(UIKit)
      updateAttachedViews()
      setNeedsLayout()
    #elseif canImport(AppKit)
      for column in columns {
        nativeColumns[column.id]!.title = column.title
      }
      appKitRowHeights = rowHeights()
      updateNativeColumnWidths(columnWidths())
      tableView.reloadData()
      nativeHeader.needsDisplay = true
      needsLayout = true
    #endif
    invalidateIntrinsicContentSize()
    invalidateCapturedRendering()
  }

  #if canImport(UIKit)
    private func updateAttachedViews() {
      var desired: [PlatformView] = columns.map(\.label)
      for column in columns {
        desired.append(contentsOf: column.rows.ordered)
      }
      let desiredIds = Set(desired.lazy.map(ObjectIdentifier.init))
      for view in attachedViews where !desiredIds.contains(ObjectIdentifier(view)) {
        view.removeFromSuperview()
      }
      for view in desired where view.superview !== self {
        addSubview(view)
      }
      attachedViews = desired
    }
  #elseif canImport(AppKit)
    private func updateNativeColumnWidths(_ widths: [CGFloat]) {
      for (column, width) in zip(columns, widths) {
        nativeColumns[column.id]!.width = width
      }
    }
  #endif

  private func columnWidths() -> [CGFloat] {
    columns.map { column in
      #if canImport(UIKit)
        let headerWidth =
          column.label.sizeThatFits(WuiProposalSize()).width + horizontalPadding * 2
      #elseif canImport(AppKit)
        // The native header cell measures its own title (padding included).
        let headerWidth = nativeColumns[column.id]!.headerCell.cellSize.width
      #endif
      var width = max(minimumColumnWidth, headerWidth)
      for row in column.rows.ordered {
        width = max(
          width,
          row.sizeThatFits(WuiProposalSize()).width + horizontalPadding * 2
        )
      }
      return width
    }
  }

  private func headerHeight() -> CGFloat {
    guard !columns.isEmpty else { return 0 }
    #if canImport(UIKit)
      return max(
        28,
        (columns.map { $0.label.sizeThatFits(WuiProposalSize()).height }.max() ?? 0)
          + verticalPadding * 2
      )
    #elseif canImport(AppKit)
      return nativeHeaderHeight
    #endif
  }

  private func rowHeights() -> [CGFloat] {
    let count = columns.map { $0.rows.ordered.count }.max() ?? 0
    return (0..<count).map { rowIndex in
      max(
        28,
        (columns.compactMap { column in
          column.rows.ordered.indices.contains(rowIndex)
            ? column.rows.ordered[rowIndex].sizeThatFits(WuiProposalSize()).height
            : nil
        }.max() ?? 0) + verticalPadding * 2
      )
    }
  }

  func sizeThatFits(_ proposal: WuiProposalSize) -> CGSize {
    CGSize(
      width: columnWidths().reduce(0, +),
      height: headerHeight() + rowHeights().reduce(0, +)
    )
  }

  #if canImport(UIKit)
    override func layoutSubviews() {
      super.layoutSubviews()
      let widths = columnWidths()
      let headerHeight = headerHeight()
      let rowHeights = rowHeights()
      var x: CGFloat = 0
      for (index, column) in columns.enumerated() {
        column.label.frame = CGRect(x: x, y: 0, width: widths[index], height: headerHeight)
        x += widths[index]
      }

      var y = headerHeight
      for (rowIndex, rowHeight) in rowHeights.enumerated() {
        x = 0
        for (columnIndex, column) in columns.enumerated() {
          if column.rows.ordered.indices.contains(rowIndex) {
            column.rows.ordered[rowIndex].frame = CGRect(
              x: x + horizontalPadding,
              y: y + verticalPadding,
              width: widths[columnIndex] - horizontalPadding * 2,
              height: rowHeight - verticalPadding * 2
            )
          }
          x += widths[columnIndex]
        }
        y += rowHeight
      }
    }
  #elseif canImport(AppKit)
    nonisolated override var isFlipped: Bool { true }

    override func layout() {
      super.layout()
      let widths = columnWidths()
      updateNativeColumnWidths(widths)
      let headerHeight = headerHeight()
      nativeHeader.frame = NSRect(x: 0, y: 0, width: bounds.width, height: headerHeight)
      tableView.frame = NSRect(
        x: 0,
        y: headerHeight,
        width: bounds.width,
        height: max(0, bounds.height - headerHeight)
      )
    }

  #endif
}

#if canImport(AppKit)
  extension WuiTable: NSTableViewDataSource {
    func numberOfRows(in tableView: NSTableView) -> Int {
      columns.map { $0.rows.ordered.count }.max() ?? 0
    }
  }

  extension WuiTable: NSTableViewDelegate {
    func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
      appKitRowHeights[row]
    }

    func tableView(
      _ tableView: NSTableView,
      viewFor tableColumn: NSTableColumn?,
      row: Int
    ) -> NSView? {
      let tableColumn = tableColumn!
      let column = columns.first { nativeColumns[$0.id] === tableColumn }!
      guard column.rows.ordered.indices.contains(row) else { return nil }
      return column.rows.ordered[row]
    }
  }
#endif