waterui 0.3.0

A modern UI framework for Rust
import CWaterUI
import Foundation

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

struct MenuShortcutData {
  let keyEquivalent: String
  let command: Bool
  let shift: Bool
  let option: Bool
  let control: Bool

  #if canImport(UIKit)
    var modifierMask: UIKeyModifierFlags {
      var mask: UIKeyModifierFlags = []
      if command { mask.insert(.command) }
      if shift { mask.insert(.shift) }
      if option { mask.insert(.alternate) }
      if control { mask.insert(.control) }
      return mask
    }
  #elseif canImport(AppKit)
    var modifierMask: NSEvent.ModifierFlags {
      var mask: NSEvent.ModifierFlags = []
      if command { mask.insert(.command) }
      if shift { mask.insert(.shift) }
      if option { mask.insert(.option) }
      if control { mask.insert(.control) }
      return mask
    }
  #endif
}

@MainActor
final class WuiMenuCommandNode {
  let action: OpaquePointer
  let iconName: String?
  let shortcut: MenuShortcutData?

  private let labelObservation: WuiSemanticTextObservation
  private let disabledObservation: WuiComputedObservation<Bool>
  private let selectedObservation: WuiComputedObservation<Bool>

  var label: String { labelObservation.string }
  var isDisabled: Bool { disabledObservation.value }
  var isSelected: Bool { selectedObservation.value }

  init(
    consuming item: CWaterUI.WuiMenuItem,
    onChange: @escaping (WuiWatcherMetadata) -> Void
  ) {
    guard let label = item.label else {
      fatalError("Command menu item has no label")
    }
    guard let action = item.action else {
      fatalError("Command menu item has no action")
    }
    guard let disabled = item.disabled else {
      fatalError("Command menu item has no disabled signal")
    }
    guard let selected = item.selected else {
      fatalError("Command menu item has no selected signal")
    }

    self.action = OpaquePointer(UnsafeRawPointer(action))
    self.iconName = item.icon.map { icon in
      WuiStr(waterui_menu_item_take_icon(icon).name).toString()
    }
    self.shortcut = item.shortcut.map { pointer in
      let shortcut = waterui_menu_item_take_shortcut(pointer)
      return MenuShortcutData(
        keyEquivalent: WuiStr(shortcut.key).toString(),
        command: shortcut.modifiers.command,
        shift: shortcut.modifiers.shift,
        option: shortcut.modifiers.option,
        control: shortcut.modifiers.control
      )
    }
    self.labelObservation = WuiSemanticTextObservation(
      consuming: waterui_menu_item_take_label(label),
      onChange: { _, metadata in onChange(metadata) }
    )
    self.disabledObservation = WuiComputedObservation(
      WuiComputed<Bool>(disabled),
      onChange: { _, metadata in onChange(metadata) }
    )
    self.selectedObservation = WuiComputedObservation(
      WuiComputed<Bool>(selected),
      onChange: { _, metadata in onChange(metadata) }
    )
  }

  @MainActor deinit {
    waterui_drop_shared_action(action)
  }
}

@MainActor
final class WuiMenuSubmenuNode {
  let iconName: String?
  let items: WuiMenuTree

  private let labelObservation: WuiSemanticTextObservation

  var label: String { labelObservation.string }

  init(
    consuming item: CWaterUI.WuiMenuItem,
    onChange: @escaping (WuiWatcherMetadata) -> Void
  ) {
    guard let label = item.label else {
      fatalError("Nested menu has no label")
    }
    guard let items = item.items else {
      fatalError("Nested menu has no items collection")
    }

    self.iconName = item.icon.map { icon in
      WuiStr(waterui_menu_item_take_icon(icon).name).toString()
    }
    self.items = WuiMenuTree(consuming: items, onChange: onChange)
    self.labelObservation = WuiSemanticTextObservation(
      consuming: waterui_menu_item_take_label(label),
      onChange: { _, metadata in onChange(metadata) }
    )
  }
}

@MainActor
final class WuiMenuNode {
  enum Kind {
    case command(WuiMenuCommandNode)
    case divider
    case menu(WuiMenuSubmenuNode)
  }

  let id: Int32
  let kind: Kind

  init(
    id: Int32,
    consuming item: CWaterUI.WuiMenuItem,
    onChange: @escaping (WuiWatcherMetadata) -> Void
  ) {
    self.id = id
    switch item.tag {
    case WuiMenuItemTag_Command:
      self.kind = .command(WuiMenuCommandNode(consuming: item, onChange: onChange))
    case WuiMenuItemTag_Divider:
      self.kind = .divider
    case WuiMenuItemTag_Menu:
      self.kind = .menu(WuiMenuSubmenuNode(consuming: item, onChange: onChange))
    default:
      fatalError("Unsupported WuiMenuItemTag: \(item.tag.rawValue)")
    }
  }
}

@MainActor
final class WuiMenuTree {
  private let source: WuiAnyViews
  private let collection = WuiStableSemanticCollection<Int32, WuiMenuNode>()
  private var watcher: WatcherGuard?

  var nodes: [WuiMenuNode] { collection.ordered }

  init(
    consuming source: OpaquePointer,
    onChange: @escaping (WuiWatcherMetadata) -> Void
  ) {
    self.source = WuiAnyViews(source)
    watcher = watchAnyViewsIds(self.source) { [weak self] ids, metadata in
      self?.reconcile(ids: ids, onChange: onChange)
      onChange(metadata)
    }
    reconcile(ids: self.source.allIds(), onChange: onChange)
  }

  private func reconcile(
    ids: [Int32],
    onChange: @escaping (WuiWatcherMetadata) -> Void
  ) {
    collection.reconcile(ids: ids) { [source] index, id in
      let item = waterui_force_as_menu_item(source.takeRawView(at: index))
      return WuiMenuNode(id: id, consuming: item, onChange: onChange)
    }
  }
}

#if canImport(UIKit)
  @MainActor
  func buildUIKitMenuElements(
    from nodes: [WuiMenuNode],
    handler: @escaping (WuiMenuCommandNode) -> Void
  ) -> [UIMenuElement] {
    let groups = splitMenuGroups(nodes)
    guard groups.count > 1 else {
      return groups.first.map { buildUIKitMenuGroup($0, handler: handler) } ?? []
    }

    return groups.map { group in
      UIMenu(
        title: "", options: .displayInline, children: buildUIKitMenuGroup(group, handler: handler))
    }
  }

  @MainActor
  func buildUIKitMenu(
    title: String,
    imageName: String? = nil,
    from nodes: [WuiMenuNode],
    handler: @escaping (WuiMenuCommandNode) -> Void
  ) -> UIMenu {
    UIMenu(
      title: title,
      image: uiMenuImage(named: imageName),
      identifier: nil,
      options: [],
      children: buildUIKitMenuElements(from: nodes, handler: handler)
    )
  }

  @MainActor
  private func buildUIKitMenuGroup(
    _ nodes: [WuiMenuNode],
    handler: @escaping (WuiMenuCommandNode) -> Void
  ) -> [UIMenuElement] {
    nodes.compactMap { node in
      switch node.kind {
      case .command(let command):
        return UIAction(
          title: command.label,
          image: uiMenuImage(named: command.iconName),
          identifier: nil,
          discoverabilityTitle: nil,
          attributes: command.isDisabled ? [.disabled] : [],
          state: command.isSelected ? .on : .off
        ) { _ in
          handler(command)
        }
      case .divider:
        return nil
      case .menu(let menu):
        return buildUIKitMenu(
          title: menu.label,
          imageName: menu.iconName,
          from: menu.items.nodes,
          handler: handler
        )
      }
    }
  }

  private func uiMenuImage(named name: String?) -> UIImage? {
    name.flatMap { UIImage(systemName: $0) }
  }

  @MainActor
  func buildUIKitSystemMenus(
    from nodes: [WuiMenuNode],
    handler: @escaping (WuiMenuCommandNode) -> Void
  ) -> [UIMenu] {
    nodes.map { node in
      guard case .menu(let menu) = node.kind else {
        fatalError("App::menu_bar only accepts top-level Menu values")
      }
      return UIMenu(
        title: menu.label,
        image: uiMenuImage(named: menu.iconName),
        identifier: UIMenu.Identifier("dev.waterui.menu.\(node.id)"),
        options: [],
        children: buildUIKitMenuElements(from: menu.items.nodes, handler: handler)
      )
    }
  }

  private func splitMenuGroups(_ nodes: [WuiMenuNode]) -> [[WuiMenuNode]] {
    var groups: [[WuiMenuNode]] = []
    var current: [WuiMenuNode] = []

    for node in nodes {
      if case .divider = node.kind {
        if !current.isEmpty {
          groups.append(current)
          current.removeAll(keepingCapacity: true)
        }
      } else {
        current.append(node)
      }
    }

    if !current.isEmpty {
      groups.append(current)
    }
    return groups
  }
#endif

#if canImport(AppKit)
  @MainActor
  final class MenuActionRef: NSObject {
    let command: WuiMenuCommandNode

    init(command: WuiMenuCommandNode) {
      self.command = command
    }
  }

  @MainActor
  func appendAppKitMenuItems(
    _ nodes: [WuiMenuNode],
    to menu: NSMenu,
    target: AnyObject,
    action: Selector
  ) {
    for node in nodes {
      switch node.kind {
      case .divider:
        menu.addItem(.separator())
      case .command(let command):
        let item = NSMenuItem(
          title: command.label,
          action: action,
          keyEquivalent: command.shortcut?.keyEquivalent ?? ""
        )
        item.target = target
        item.isEnabled = !command.isDisabled
        item.state = command.isSelected ? .on : .off
        item.keyEquivalentModifierMask = command.shortcut?.modifierMask ?? []
        item.image = appKitMenuImage(named: command.iconName)
        item.representedObject = MenuActionRef(command: command)
        menu.addItem(item)
      case .menu(let submenu):
        let item = NSMenuItem(title: submenu.label, action: nil, keyEquivalent: "")
        item.image = appKitMenuImage(named: submenu.iconName)
        let nested = NSMenu(title: submenu.label)
        appendAppKitMenuItems(submenu.items.nodes, to: nested, target: target, action: action)
        item.submenu = nested
        menu.addItem(item)
      }
    }
  }

  private func appKitMenuImage(named name: String?) -> NSImage? {
    name.flatMap { NSImage(systemSymbolName: $0, accessibilityDescription: nil) }
  }

  @MainActor
  func appendAppKitMenuBarItems(
    _ nodes: [WuiMenuNode],
    to mainMenu: NSMenu,
    target: AnyObject,
    action: Selector
  ) {
    for node in nodes {
      guard case .menu(let menu) = node.kind else {
        fatalError("App::menu_bar only accepts top-level Menu values")
      }
      let item = NSMenuItem(title: menu.label, action: nil, keyEquivalent: "")
      item.image = appKitMenuImage(named: menu.iconName)
      let submenu = NSMenu(title: menu.label)
      appendAppKitMenuItems(menu.items.nodes, to: submenu, target: target, action: action)
      item.submenu = submenu
      mainMenu.addItem(item)
    }
  }
#endif