diff --git a/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift b/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift index 3e5c4d67..5570126c 100644 --- a/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift +++ b/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift @@ -28,6 +28,8 @@ private func luiOCamlTextChanged(_ node: Int64, _ text: UnsafePointer?) - private func luiOCamlSubmit(_ node: Int64) -> Int32 @_silgen_name("lui_ocaml_dismiss") private func luiOCamlDismiss(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_picked") +private func luiOCamlPicked(_ node: Int64, _ payload: UnsafePointer) -> Int32 @_silgen_name("lui_ocaml_double_press") private func luiOCamlDoublePress(_ node: Int64) -> Int32 @_silgen_name("lui_ocaml_toggle_changed") @@ -73,6 +75,8 @@ private final class GalleryHost { text.withCString { _ = luiOCamlTextChanged(Int64(node), $0) } case let .submit(node): _ = luiOCamlSubmit(Int64(node)) case let .dismiss(node): _ = luiOCamlDismiss(Int64(node)) + case let .picked(node, payload): + payload.withCString { _ = luiOCamlPicked(Int64(node), $0) } case let .doublePress(node): _ = luiOCamlDoublePress(Int64(node)) case let .toggleChanged(node, checked): _ = luiOCamlToggleChanged(Int64(node), checked ? 1 : 0) diff --git a/examples/components/native/components_bridge.ml b/examples/components/native/components_bridge.ml index 046fc129..a9807c09 100644 --- a/examples/components/native/components_bridge.ml +++ b/examples/components/native/components_bridge.ml @@ -80,6 +80,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -104,6 +106,7 @@ let register prefix = register (prefix ^ "_text_changed") text_changed; register (prefix ^ "_submit") submit; register (prefix ^ "_dismiss") dismiss; + register (prefix ^ "_picked") picked; register (prefix ^ "_double_press") double_press; register (prefix ^ "_toggle_changed") toggle_changed; register (prefix ^ "_radio_changed") radio_changed; diff --git a/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift b/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift index 54e92d7f..a4b8305c 100644 --- a/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift +++ b/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift @@ -11,6 +11,8 @@ private typealias StopFunction = @convention(c) () -> Int32 private typealias NodeFunction = @convention(c) (Int64) -> Int32 private typealias TextChangedFunction = @convention(c) (Int64, UnsafePointer?) -> Int32 +private typealias PickedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 private typealias SliderChangedFunction = @convention(c) (Int64, Double) -> Int32 private typealias ExtensionEventFunction = @@ -38,6 +40,7 @@ private final class NativeMarkdownRuntime { private let textChangedFunction: TextChangedFunction private let submitFunction: NodeFunction private let dismissFunction: NodeFunction + private let pickedFunction: PickedFunction private let doublePressFunction: NodeFunction private let toggleChangedFunction: ToggleChangedFunction private let radioChangedFunction: NodeFunction @@ -57,6 +60,7 @@ private final class NativeMarkdownRuntime { textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) submitFunction = try Self.load("lui_ocaml_submit", from: handle) dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + pickedFunction = try Self.load("lui_ocaml_picked", from: handle) doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) @@ -86,6 +90,12 @@ private final class NativeMarkdownRuntime { func submit(node: Int) { _ = submitFunction(Int64(node)) } func dismiss(node: Int) { _ = dismissFunction(Int64(node)) } + + func picked(node: Int, payload: String) { + payload.withCString { source in + _ = pickedFunction(Int64(node), source) + } + } func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } func toggleChanged(node: Int, checked: Bool) { @@ -166,6 +176,8 @@ private final class MarkdownHost: NSObject, NSApplicationDelegate, NSWindowDeleg self?.runtime?.sliderChanged(node: node, value: value) case let .dismiss(node): self?.runtime?.dismiss(node: node) + case let .picked(node, payload): + self?.runtime?.picked(node: node, payload: payload) case let .doublePress(node): self?.runtime?.doublePress(node: node) case let .appear(node): diff --git a/examples/markdown/native/markdown_bridge.ml b/examples/markdown/native/markdown_bridge.ml index 98b8b370..69dfc011 100644 --- a/examples/markdown/native/markdown_bridge.ml +++ b/examples/markdown/native/markdown_bridge.ml @@ -72,6 +72,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -106,6 +108,7 @@ let () = Callback.register "lui_ocaml_text_changed" text_changed; Callback.register "lui_ocaml_submit" submit; Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_double_press" double_press; Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; diff --git a/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift b/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift index bab9ee5d..0fc65660 100644 --- a/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift +++ b/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift @@ -15,6 +15,8 @@ private typealias TextChangedFunction = @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias SubmitFunction = @convention(c) (Int64) -> Int32 private typealias DismissFunction = @convention(c) (Int64) -> Int32 +private typealias PickedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias DoublePressFunction = @convention(c) (Int64) -> Int32 private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 private typealias RadioChangedFunction = @convention(c) (Int64) -> Int32 @@ -83,6 +85,7 @@ private final class NativeSplitRuntime { private let textChangedFunction: TextChangedFunction private let submitFunction: SubmitFunction private let dismissFunction: DismissFunction + private let pickedFunction: PickedFunction private let doublePressFunction: DoublePressFunction private let toggleChangedFunction: ToggleChangedFunction private let radioChangedFunction: RadioChangedFunction @@ -102,6 +105,7 @@ private final class NativeSplitRuntime { textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) submitFunction = try Self.load("lui_ocaml_submit", from: handle) dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + pickedFunction = try Self.load("lui_ocaml_picked", from: handle) doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) @@ -135,6 +139,12 @@ private final class NativeSplitRuntime { func dismiss(node: Int) { _ = dismissFunction(Int64(node)) } + func picked(node: Int, payload: String) { + payload.withCString { source in + _ = pickedFunction(Int64(node), source) + } + } + func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } func toggleChanged(node: Int, checked: Bool) { @@ -237,6 +247,8 @@ private final class SplitDemoHost: NSObject, NSApplicationDelegate, NSWindowDele self?.runtime?.sliderChanged(node: node, value: value) case let .dismiss(node): self?.runtime?.dismiss(node: node) + case let .picked(node, payload): + self?.runtime?.picked(node: node, payload: payload) case let .doublePress(node): self?.runtime?.doublePress(node: node) case let .`extension`(node, identifier, name, values): diff --git a/examples/split/native/split_bridge.ml b/examples/split/native/split_bridge.ml index d18a3c4d..07705c42 100644 --- a/examples/split/native/split_bridge.ml +++ b/examples/split/native/split_bridge.ml @@ -72,6 +72,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -106,6 +108,7 @@ let () = Callback.register "lui_ocaml_text_changed" text_changed; Callback.register "lui_ocaml_submit" submit; Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_double_press" double_press; Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; diff --git a/examples/todos/macos-appkit/Sources/LUITodos/main.swift b/examples/todos/macos-appkit/Sources/LUITodos/main.swift index 1f0ab0c1..287ac712 100644 --- a/examples/todos/macos-appkit/Sources/LUITodos/main.swift +++ b/examples/todos/macos-appkit/Sources/LUITodos/main.swift @@ -14,6 +14,8 @@ private typealias TextChangedFunction = @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias SubmitFunction = @convention(c) (Int64) -> Int32 private typealias DismissFunction = @convention(c) (Int64) -> Int32 +private typealias PickedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias DoublePressFunction = @convention(c) (Int64) -> Int32 private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 private typealias RadioChangedFunction = @convention(c) (Int64) -> Int32 @@ -39,6 +41,7 @@ private final class NativeTodosRuntime { private let textChangedFunction: TextChangedFunction private let submitFunction: SubmitFunction private let dismissFunction: DismissFunction + private let pickedFunction: PickedFunction private let doublePressFunction: DoublePressFunction private let toggleChangedFunction: ToggleChangedFunction private let radioChangedFunction: RadioChangedFunction @@ -56,6 +59,7 @@ private final class NativeTodosRuntime { textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) submitFunction = try Self.load("lui_ocaml_submit", from: handle) dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + pickedFunction = try Self.load("lui_ocaml_picked", from: handle) doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) @@ -94,6 +98,12 @@ private final class NativeTodosRuntime { _ = dismissFunction(Int64(node)) } + func picked(node: Int, payload: String) { + payload.withCString { source in + _ = pickedFunction(Int64(node), source) + } + } + func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } @@ -161,6 +171,8 @@ private final class TodosHost: NSObject, NSApplicationDelegate, NSWindowDelegate self?.runtime?.sliderChanged(node: node, value: value) case let .dismiss(node): self?.runtime?.dismiss(node: node) + case let .picked(node, payload): + self?.runtime?.picked(node: node, payload: payload) case let .doublePress(node): self?.runtime?.doublePress(node: node) } diff --git a/examples/todos/native/todos_bridge.ml b/examples/todos/native/todos_bridge.ml index 7e7eeb50..7639bba5 100644 --- a/examples/todos/native/todos_bridge.ml +++ b/examples/todos/native/todos_bridge.ml @@ -71,6 +71,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -94,6 +96,7 @@ let () = Callback.register "lui_ocaml_text_changed" text_changed; Callback.register "lui_ocaml_submit" submit; Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_double_press" double_press; Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 4739c015..1c0a0ad7 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -164,6 +164,16 @@ final class LUINodeModel: Identifiable { properties[.doublePressEnabled]?.boolValue ?? false } var supportsAppear: Bool { properties[.appearEnabled]?.boolValue ?? false } + var rowKey: String? { properties[.key]?.stringValue } + var separatorVisibility: String? { properties[.separator]?.stringValue } + var listStyle: String? { properties[.style]?.stringValue } + var scrollTarget: String? { properties[.scrollTarget]?.stringValue } + var scrollAnchor: String? { properties[.scrollAnchor]?.stringValue } + var scrollToken: Int? { properties[.scrollToken]?.intValue } + var scrollAnimated: Bool { properties[.scrollAnimated]?.boolValue ?? true } + var tracksVisibleRange: Bool { + properties[.trackVisibleRange]?.boolValue ?? false + } var containerRelativeFrame: String? { properties[.containerRelativeFrame]?.stringValue } @@ -407,6 +417,40 @@ public final class LUIAppleBackend { private var nodeFrames: [Int: CGRect] = [:] private var framesReportScheduled = false + // MARK: - file-picker operations + + /// In-flight and completed file-picker requests keyed by node id. Held + /// here rather than in `LUIFilePickerView`'s @State so a view teardown + /// (tab switch, re-layout) does not release security-scoped files or + /// lose the pending completion handshake for a still-mounted node. + private var filePickerOperations: [Int: LUIFilePickerOperation] = [:] + + func filePickerOperation(node: Int) -> LUIFilePickerOperation? { + filePickerOperations[node] + } + + func setFilePickerOperation(node: Int, _ operation: LUIFilePickerOperation) { + filePickerOperations[node] = operation + } + + /// Drops the operation and releases every file it retained. + func clearFilePickerOperation(node: Int) { + guard let operation = filePickerOperations.removeValue(forKey: node) + else { return } + releaseFilePickerFiles(operation.files) + } + + func releaseFilePickerFiles(_ files: [LUIRetainedFile]) { + for file in files { + if file.securityScoped { + file.url.stopAccessingSecurityScopedResource() + } + if file.temporary { + try? FileManager.default.removeItem(at: file.url) + } + } + } + func reportNodeFrame(_ nodeID: Int, _ rect: CGRect) { if nodeFrames[nodeID] == rect { return } nodeFrames[nodeID] = rect @@ -653,7 +697,7 @@ public final class LUIAppleBackend { (model.kind == .tableCell && model.supportsPress) || model.kind == .select || model.kind == .combobox || model.kind == .menuItem || - model.kind == .listItem || + model.kind == .listItem || model.kind == .swipeAction || (model.kind == .timelineItem && model.supportsPress) || (model.kind == .fileImage && model.supportsPress) || (model.isTreeItem && model.supportsPress), @@ -707,6 +751,14 @@ public final class LUIAppleBackend { emit(.appear(node: node)) } + func performScrollCompleted(node: Int, token: Int, outcome: String) { + emit(.scrollCompleted(node: node, token: token, outcome: outcome)) + } + + func performVisibleRange(node: Int, first: Int, last: Int) { + emit(.visibleRange(node: node, first: first, last: last)) + } + public func performExtensionEvent( node: Int, name: String, @@ -736,9 +788,11 @@ public final class LUIAppleBackend { guard let model = models[node], model.kind == .toggleButton || model.kind == .checkbox || model.kind == .switchControl || model.kind == .toggle || - model.kind == .accordion || model.kind == .drawer || model.isTreeItem, + model.kind == .accordion || model.kind == .drawer || + model.kind == .listItem || model.isTreeItem, model.isEnabled, - (model.kind != .accordion && model.kind != .drawer && !model.isTreeItem) || + (model.kind != .accordion && model.kind != .drawer && + model.kind != .listItem && !model.isTreeItem) || model.supportsToggle else { throw invalid("node \(node) is not an enabled toggle") } @@ -782,12 +836,20 @@ public final class LUIAppleBackend { model.kind == .select || model.kind == .combobox || model.kind == .dropdownMenu || model.kind == .dialog || model.kind == .sheet || model.kind == .toast || - model.kind == .filePreview else { + model.kind == .filePreview || + model.kind == .filePicker else { throw invalid("node \(node) is not dismissible") } emit(.dismiss(node: node)) } + func performPicked(node: Int, payload: String) throws { + guard let model = models[node], model.kind == .filePicker else { + throw invalid("node \(node) is not a file-picker") + } + emit(.picked(node: node, payload: payload)) + } + func performAction(node: Int) throws { guard let model = models[node] else { throw invalid("unknown node") } if model.isTreeItem { @@ -796,7 +858,7 @@ public final class LUIAppleBackend { } switch model.kind { case .button, .select, .combobox, .menuItem, .listItem, .timelineItem, - .fileImage: + .swipeAction, .fileImage: try performPress(node: node) case .toggleButton: try performToggle(node: node, checked: !model.isSelected) @@ -920,13 +982,20 @@ public final class LUIAppleBackend { for id in dropped { models[id] = nil extensionModels[id] = nil + clearFilePickerOperation(node: id) } for id in touched where !dropped.contains(id) { if let state = tree.nodes[id] { - // Kinds are immutable, so context-menu membership in the + // Kinds are immutable, so auxiliary-slot membership in the // child list is fixed until the list itself changes. - let visibleChildren = state.children.filter { - tree.nodes[$0]?.kind != .contextMenu + let visibleChildren = state.children.filter { childID in + switch tree.nodes[childID]?.kind { + case .contextMenu, .swipeActions, .listSectionHeader, + .listSectionFooter: + false + default: + true + } } if let model = models[id] { model.apply(state: state, visibleChildren: visibleChildren) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift index bbcb8b91..d0c2671e 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift @@ -81,6 +81,24 @@ private final class LUIAppleBridge { "".withCString { callback(8, Int32(node), $0) } case let .appear(node): "".withCString { callback(10, Int32(node), $0) } + case let .scrollCompleted(node, token, outcome): + let payload: [String: Any] = [ + "token": token, + "outcome": outcome, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + json.withCString { callback(11, Int32(node), $0) } + case let .visibleRange(node, first, last): + let payload: [String: Any] = [ + "first": first, + "last": last, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + json.withCString { callback(12, Int32(node), $0) } + case let .picked(node, payload): + payload.withCString { callback(13, Int32(node), $0) } case let .extension(node, identifier, name, values): let payload: [String: Any] = [ "identifier": identifier, diff --git a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift new file mode 100644 index 00000000..db2ad3c4 --- /dev/null +++ b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift @@ -0,0 +1,521 @@ +import SwiftUI +import UniformTypeIdentifiers +#if canImport(PhotosUI) +import PhotosUI +#endif + +/// A picked file held until `completion` acknowledges the request token. +struct LUIRetainedFile { + let url: URL + /// True when `startAccessingSecurityScopedResource` succeeded — + /// only those URLs need `stopAccessingSecurityScopedResource`. + let securityScoped: Bool + /// True for temp copies we own (photo/camera captures) — deleted on release. + let temporary: Bool +} + +/// A file-picker request in flight (`presenting`) or picked and awaiting +/// its `completion` (`awaitingCompletion`). Lives on the backend keyed by +/// node id, not in view state: SwiftUI may tear the view down while the +/// node stays mounted (tab switches, re-layout), and picked URLs must keep +/// their security scope until the app answers with `completion`. +struct LUIFilePickerOperation { + enum Phase { + case presenting + case awaitingCompletion + } + + let token: LUIWireValue + var phase: Phase + var files: [LUIRetainedFile] = [] +} + +/// Non-visual `file-picker` node: presents the platform file importer, +/// photo library picker, or camera capture when the `request` property +/// changes to a new token while `enabled` is true. +/// +/// Handshake: a new `request` token presents the picker for `source` +/// (`"files"` default, `"photos"`, `"camera"`). A completed pick emits +/// `picked` with a JSON payload echoing the request token plus a `files` +/// array of `{path, name, content-type}` entries; a cancelled (or failed) +/// presentation emits `dismiss`. Picked URLs are kept accessible — security +/// scope started for document picks, temp copies for photo/camera picks — +/// until the `completion` property echoes the same request token or the +/// node is dropped. Only one request is in flight at a time: a token +/// arriving while another is retained is ignored. +/// +/// `types` is a comma-separated list of UTIs; for `files` it becomes the +/// importer's allowed content types, for `photos`/`camera` it narrows the +/// media to image and/or movie. `camera` is iOS-only — on other platforms +/// (or a camera-less device) the request is answered with `dismiss` +/// without presenting anything. +struct LUIFilePickerView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + /// Whether the current presentation already produced a result — + /// `.fileImporter` on iOS never invokes its completion on cancel, so a + /// presentation ending with `handled == false` means the user cancelled. + @State private var handled = false + @State private var presented = false + #if canImport(PhotosUI) + @State private var photoSelection: [PhotosPickerItem] = [] + #endif + + private var operation: LUIFilePickerOperation? { + backend.filePickerOperation(node: model.id) + } + + var body: some View { + // An empty ForEach (or empty Group) installs no view, so the + // presentation modifiers below never attach when the node has no + // children — render a real 1pt placeholder in that case. + Group { + if model.visibleChildren.isEmpty { + Color.clear.frame(width: 1, height: 1) + } else { + ForEach(model.visibleChildren, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend) + } + } + } + .fileImporter( + isPresented: filesPresented, + allowedContentTypes: allowedTypes, + allowsMultipleSelection: allowsMultiple + ) { result in + handleFileImport(result) + } + #if canImport(PhotosUI) + .modifier( + LUIPhotosPickerModifier( + presented: photosPresented, + selection: $photoSelection, + multiple: allowsMultiple, + filter: photoFilter + ) + ) + .onChange(of: photoSelection) { _, items in + handlePhotoSelection(items) + } + #endif + #if os(iOS) + .sheet(isPresented: cameraPresented) { + LUICameraPicker(mediaTypes: cameraMediaTypes) { outcome in + handleCamera(outcome) + } + .ignoresSafeArea() + } + #endif + .onAppear { + evaluateCompletion() + evaluate() + } + .onChange(of: request) { evaluate() } + .onChange(of: completion) { evaluateCompletion() } + .onChange(of: presented) { _, isPresented in + // Cancel detection for presentations without their own cancel + // callback (iOS `.fileImporter`, `.photosPicker`). The photo + // selection handler marks `handled` before clearing + // `photoSelection`, so reaching this point means no pick is + // being imported. + guard !isPresented, !handled, + operation?.phase == .presenting else { return } + finishCancelled() + } + } + + // MARK: - properties + + private var request: LUIWireValue? { + model.property(.request) + } + + private var completion: LUIWireValue? { + model.property(.completion) + } + + private var source: String { + model.property(.source)?.stringValue ?? "files" + } + + private var allowsMultiple: Bool { + model.property(.multiple)?.boolValue ?? false + } + + private var enabled: Bool { + model.isEnabled + } + + private var typeIdentifiers: [String] { + guard let raw = model.property(.types)?.stringValue else { return [] } + return raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + private var allowedTypes: [UTType] { + let parsed = typeIdentifiers.map { UTType($0) ?? UTType(importedAs: $0) } + return parsed.isEmpty ? [.item] : parsed + } + + #if canImport(PhotosUI) + private var photoFilter: PHPickerFilter { + guard model.property(.types)?.stringValue != nil else { + return .any(of: [.images, .videos]) + } + var filters: [PHPickerFilter] = [] + if allowedTypes.contains(where: { $0.conforms(to: .image) }) { + filters.append(.images) + } + if allowedTypes.contains(where: { $0.conforms(to: .movie) }) { + filters.append(.videos) + } + return filters.isEmpty ? .any(of: [.images, .videos]) : .any(of: filters) + } + #endif + + private var cameraMediaTypes: [UTType] { + guard model.property(.types)?.stringValue != nil else { + return [.image, .movie] + } + var media: [UTType] = [] + if allowedTypes.contains(where: { $0.conforms(to: .image) }) { media.append(.image) } + if allowedTypes.contains(where: { $0.conforms(to: .movie) }) { media.append(.movie) } + return media.isEmpty ? [.image] : media + } + + // MARK: - presentation bindings + + private var filesPresented: Binding { + Binding( + get: { presented && source == "files" }, + set: { if !$0 { presented = false } } + ) + } + + #if canImport(PhotosUI) + private var photosPresented: Binding { + Binding( + get: { presented && source == "photos" }, + set: { if !$0 { presented = false } } + ) + } + #endif + + #if os(iOS) + private var cameraPresented: Binding { + Binding( + get: { presented && source == "camera" }, + set: { if !$0 { presented = false } } + ) + } + #endif + + // MARK: - state machine + + private func evaluate() { + // A token equal to `completion` was already acknowledged — mounting + // it must not re-present. + guard let request, enabled, request != completion else { return } + if let operation { + // One request in flight: the same token re-presents a + // presentation killed by a view teardown; any other token + // waits for completion/cancel of the current one. + guard operation.token == request else { return } + if operation.phase == .presenting && !presented { + present() + } + return + } + backend.setFilePickerOperation( + node: model.id, + LUIFilePickerOperation(token: request, phase: .presenting) + ) + present() + } + + private func present() { + handled = false + if source == "camera" { + #if os(iOS) + // Devices without a camera (simulator) throw an Obj-C + // exception on `sourceType = .camera` — answer with dismiss. + if UIImagePickerController.isSourceTypeAvailable(.camera) { + presented = true + } else { + finishCancelled() + } + #else + finishCancelled() + #endif + } else { + presented = true + } + } + + private func evaluateCompletion() { + guard let completion, let operation, completion == operation.token + else { return } + backend.clearFilePickerOperation(node: model.id) + presented = false + } + + private func finishPicked(files: [LUIRetainedFile]) { + guard var operation else { + // The operation was released (node dropped or completed) before + // the pick landed — drop the files rather than leaking their + // security scope / temp copies. + backend.releaseFilePickerFiles(files) + return + } + handled = true + operation.phase = .awaitingCompletion + operation.files = files + backend.setFilePickerOperation(node: model.id, operation) + emitPicked(token: operation.token, files: files) + presented = false + } + + private func finishCancelled() { + handled = true + presented = false + backend.clearFilePickerOperation(node: model.id) + try? backend.performDismiss(node: model.id) + } + + // MARK: - results + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls) where !urls.isEmpty: + let files = urls.map { url in + LUIRetainedFile( + url: url, + securityScoped: url.startAccessingSecurityScopedResource(), + temporary: false + ) + } + finishPicked(files: files) + default: + // macOS reports cancel here as a failure; on iOS the completion + // is never called on cancel (see the `presented` onChange). + if !handled { finishCancelled() } + } + } + + #if canImport(PhotosUI) + private func handlePhotoSelection(_ items: [PhotosPickerItem]) { + guard operation != nil, source == "photos", !items.isEmpty else { return } + // Mark handled before clearing the selection: `presented` flips to + // false in the same update, and the cancel-detection hook must not + // fire while the async import below is still running. + handled = true + photoSelection = [] + Task { + var files: [LUIRetainedFile] = [] + for item in items { + guard let file = await Self.importPhotoItem(item) else { continue } + files.append(file) + } + if files.isEmpty { + finishCancelled() + } else { + finishPicked(files: files) + } + } + } + + /// Imports a PhotosPickerItem via a file-based representation — assets + /// stream to disk instead of materializing as `Data` in memory. + private static func importPhotoItem(_ item: PhotosPickerItem) async + -> LUIRetainedFile? + { + guard let file = try? await item.loadTransferable( + type: PhotoFile.self + ) else { + return nil + } + return LUIRetainedFile(url: file.url, securityScoped: false, temporary: true) + } + + /// `FileRepresentation` delivers the asset as a temporary file URL; + /// it is valid only inside the importing closure, so copy it into our + /// own temp location there. + private struct PhotoFile: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(importedContentType: .data) { received in + let ext = received.file.pathExtension + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("lui-picked-\(UUID().uuidString)") + .appendingPathExtension(ext.isEmpty ? "bin" : ext) + try FileManager.default.copyItem(at: received.file, to: destination) + return PhotoFile(url: destination) + } + } + } + #endif + + #if os(iOS) + private func handleCamera(_ outcome: LUICameraPicker.Outcome) { + switch outcome { + case let .captured(url): + finishPicked(files: [ + LUIRetainedFile(url: url, securityScoped: false, temporary: true), + ]) + case .cancelled: + finishCancelled() + } + } + #endif + + // MARK: - payload + + private func emitPicked(token: LUIWireValue, files: [LUIRetainedFile]) { + let request: Any = switch token { + case let .string(value): value + case let .int(value): NSNumber(value: value) + case let .bool(value): NSNumber(value: value) + case let .double(value): NSNumber(value: value) + } + let entries = files.map { file -> [String: Any] in + [ + "path": file.url.path, + "name": file.url.lastPathComponent, + "content-type": Self.contentType(of: file.url), + ] + } + let payload: [String: Any] = ["request": request, "files": entries] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + try? backend.performPicked(node: model.id, payload: json) + } + + private static func contentType(of url: URL) -> String { + if let values = try? url.resourceValues(forKeys: [.contentTypeKey]), + let mime = values.contentType?.preferredMIMEType { + return mime + } + if let mime = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType { + return mime + } + return "application/octet-stream" + } +} + +#if canImport(PhotosUI) +/// The array-selection `photosPicker` overloads differ by `maxSelectionCount` +/// (non-optional `Int`), so branching happens inside a modifier. +private struct LUIPhotosPickerModifier: ViewModifier { + let presented: Binding + @Binding var selection: [PhotosPickerItem] + let multiple: Bool + let filter: PHPickerFilter + + func body(content: Content) -> some View { + if multiple { + content.photosPicker( + isPresented: presented, + selection: $selection, + matching: filter + ) + } else { + content.photosPicker( + isPresented: presented, + selection: $selection, + maxSelectionCount: 1, + matching: filter + ) + } + } +} +#endif + +#if os(iOS) +/// `UIImagePickerController` wrapper for `source == "camera"`. Captures land +/// as temp files we own (released on `completion` or node drop), matching +/// the file-pick retention contract. +struct LUICameraPicker: UIViewControllerRepresentable { + enum Outcome { + case captured(URL) + case cancelled + } + + let mediaTypes: [UTType] + let completion: (Outcome) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(completion: completion) + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let controller = UIImagePickerController() + controller.sourceType = .camera + controller.mediaTypes = mediaTypes.map(\.identifier) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController( + _ uiViewController: UIImagePickerController, + context: Context + ) {} + + final class Coordinator: NSObject, UINavigationControllerDelegate, + UIImagePickerControllerDelegate { + let completion: (Outcome) -> Void + + init(completion: @escaping (Outcome) -> Void) { + self.completion = completion + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + completion(.cancelled) + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + if let url = captureURL(info: info) { + completion(.captured(url)) + } else { + completion(.cancelled) + } + } + + private func captureURL( + info: [UIImagePickerController.InfoKey: Any] + ) -> URL? { + if let url = info[.mediaURL] as? URL { + return copy(url, extension: "mov") + } + if let url = info[.imageURL] as? URL { + let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension + return copy(url, extension: ext) + } + if let image = info[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 0.9) { + let url = tempURL(extension: "jpg") + return (try? data.write(to: url)) != nil ? url : nil + } + return nil + } + + private func copy(_ source: URL, extension ext: String) -> URL? { + let destination = tempURL(extension: ext) + return (try? FileManager.default.copyItem(at: source, to: destination)) + != nil ? destination : nil + } + + private func tempURL(extension ext: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("lui-picked-\(UUID().uuidString)") + .appendingPathExtension(ext) + } + } +} +#endif diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 7cb0d62e..32f31dc6 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -532,7 +532,7 @@ enum LUIUnmodifiedNodePolicy { static func bypassesSurface(kind: LUINodeKind) -> Bool { // edge-inset bypasses the container surface: its surface props style // the pinned region instead (LUIEdgeInsetView applies the modifier). - kind == .spacer || kind == .edgeInset + kind == .spacer || kind == .edgeInset || kind == .filePicker } } @@ -755,12 +755,29 @@ private struct LUINodeView: View { return AnyView(LUIAccordionView(model: model, backend: backend)) case .dialog, .sheet: return AnyView(EmptyView()) + case .filePicker: + return AnyView(LUIFilePickerView(model: model, backend: backend)) case .menuItem: return AnyView(LUIMenuItemView(model: model, backend: backend)) case .menuTrigger: return AnyView(LUIMenuTriggerView(model: model, backend: backend)) case .listItem: return AnyView(LUIListItemView(model: model, backend: backend)) + case .listSection, .listSectionHeader, .listSectionFooter: + return AnyView( + VStack( + alignment: .leading, + spacing: CGFloat(model.property(.gap)?.intValue ?? 0) + ) { + ForEach(model.children, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + } + } + ) + case .swipeActions: + return AnyView(EmptyView()) + case .swipeAction: + return AnyView(LUISwipeActionView(model: model, backend: backend)) case .table: return AnyView(LUITableView(model: model, backend: backend)) case .tree: @@ -1291,6 +1308,16 @@ private struct LUIInFusedCapsuleKey: EnvironmentKey { static let defaultValue = false } +/// Mutable row-visibility sink a tracked `list` hands to descendant rows via +/// the environment; only accessed on the main actor. +private final class LUIListRowTracker: @unchecked Sendable { + var onChange: ((Int, Bool) -> Void)? +} + +private struct LUIListRowTrackerKey: EnvironmentKey { + static let defaultValue: LUIListRowTracker? = nil +} + private extension EnvironmentValues { var luiTreeContext: LUITreeContext? { get { self[LUITreeContextKey.self] } @@ -1317,6 +1344,11 @@ private extension EnvironmentValues { get { self[LUIInFusedCapsuleKey.self] } set { self[LUIInFusedCapsuleKey.self] = newValue } } + + var luiListRowTracker: LUIListRowTracker? { + get { self[LUIListRowTrackerKey.self] } + set { self[LUIListRowTrackerKey.self] = newValue } + } } private struct LUITreeView: View { @@ -2769,6 +2801,7 @@ private struct LUINavigationFormRows: View { ).map { section in let headerText = section.headerID.flatMap { backend.model(id: $0)?.text } return LUIListSection( + nodeID: nil, headerID: LUINavigationFormSectionPolicy.visibleHeaderID( section.headerID, text: headerText @@ -4085,7 +4118,24 @@ private struct LUIListItemView: View { var body: some View { Group { - if suppressesPrimaryAction { + if !nestedRowIDs.isEmpty { + // Disclosure rows: nested list-item children render as + // collapsible rows under this row's label. + DisclosureGroup(isExpanded: expansion) { + ForEach(nestedRowIDs, id: \.self) { rowID in + if let row = backend.model(id: rowID) { + LUIListItemView( + model: row, + backend: backend, + isNativeListRow: isNativeListRow + ) + .id(rowID) + } + } + } label: { + rowContent + } + } else if suppressesPrimaryAction { rowContent } else { switch LUIListItemInteractionPolicy.style( @@ -4104,6 +4154,7 @@ private struct LUIListItemView: View { } } } + .modifier(LUIRowVisibilityModifier(rowID: model.id)) .padding(.horizontal, usesSystemListInsets || hasExplicitPadding ? 0 : 12) .padding(.vertical, usesSystemListInsets || hasExplicitPadding ? 0 : verticalPadding) .frame(minHeight: LUIListItemLayoutPolicy.minimumHeight( @@ -4131,7 +4182,11 @@ private struct LUIListItemView: View { } .accessibilityAddTraits(model.isSelected ? .isSelected : []) .disabled(!model.isEnabled) - .modifier(LUIListItemSwipeActionsModifier(menu: swipeMenu, backend: backend)) + .modifier(LUIListItemSwipeActionsModifier( + model: model, + menu: swipeMenu, + backend: backend + )) .modifier(LUINativeListRowAccessibilityModifier( model: model, backend: backend, @@ -4166,7 +4221,7 @@ private struct LUIListItemView: View { : Color.secondary ) } - if visibleChildren.isEmpty { + if contentChildIDs.isEmpty { Text(verbatim: model.text) .font(isNavigationHeading ? .title3 : .body) .fontWeight( @@ -4176,7 +4231,7 @@ private struct LUIListItemView: View { ) .lineLimit(1) } else { - ForEach(visibleChildren, id: \.self) { childID in + ForEach(contentChildIDs, id: \.self) { childID in let child = backend.model(id: childID) LUIAnyNodeView(nodeID: childID, backend: backend).equatable() .environment(\.luiIsNativeListRow, isNativeListRow) @@ -4280,6 +4335,26 @@ private struct LUIListItemView: View { model.visibleChildren } + /// Nested list-item children of a disclosure row. + private var nestedRowIDs: [Int] { + visibleChildren.filter { backend.model(id: $0)?.kind == .listItem } + } + + /// Content children rendered inside the row itself. + private var contentChildIDs: [Int] { + visibleChildren.filter { backend.model(id: $0)?.kind != .listItem } + } + + private var expansion: Binding { + Binding( + get: { model.isExpanded ?? false }, + set: { newValue in + guard model.supportsToggle else { return } + try? backend.performToggle(node: model.id, checked: newValue) + } + ) + } + private var hasInteractiveChildren: Bool { visibleChildren.contains(where: containsInteractiveControl) } @@ -4328,6 +4403,11 @@ private struct LUIListItemView: View { private var swipeMenu: LUINodeModel? { #if os(iOS) guard isNativeListRow, let contextMenu else { return nil } + // An explicit swipe-actions child takes over the edges; the context + // menu is not consumed and stays reachable via the ellipsis button. + guard !model.children.contains(where: { + backend.model(id: $0)?.kind == .swipeActions + }) else { return nil } let items = contextMenu.children.compactMap { backend.model(id: $0) } guard !items.isEmpty, items.allSatisfy({ $0.kind == .menuItem && $0.isEnabled && @@ -4399,12 +4479,29 @@ private struct LUIListItemSupplementaryGesturesModifier: ViewModifier { } private struct LUIListItemSwipeActionsModifier: ViewModifier { + let model: LUINodeModel let menu: LUINodeModel? let backend: LUIAppleBackend @ViewBuilder func body(content: Content) -> some View { - if let menu, menu.children.contains(where: { + if swipeActions != nil { + content + .swipeActions(edge: .leading, allowsFullSwipe: false) { + ForEach(actionIDs(on: .leading), id: \.self) { actionID in + if let action = backend.model(id: actionID) { + LUISwipeActionView(model: action, backend: backend) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + ForEach(actionIDs(on: .trailing), id: \.self) { actionID in + if let action = backend.model(id: actionID) { + LUISwipeActionView(model: action, backend: backend) + } + } + } + } else if let menu, menu.children.contains(where: { backend.model(id: $0)?.kind == .menuItem }) { content.swipeActions(edge: .trailing, allowsFullSwipe: false) { @@ -4414,6 +4511,85 @@ private struct LUIListItemSwipeActionsModifier: ViewModifier { content } } + + private var swipeActions: LUINodeModel? { + model.children.compactMap { backend.model(id: $0) } + .first { $0.kind == .swipeActions } + } + + private enum SwipeEdge { + case leading + case trailing + } + + private func actionIDs(on edge: SwipeEdge) -> [Int] { + guard let swipeActions else { return [] } + return swipeActions.children.filter { actionID in + let action = backend.model(id: actionID) + let isLeading = action?.property(.edge)?.stringValue == "leading" + return edge == .leading ? isLeading : !isLeading + } + } +} + +private struct LUISwipeActionLabel: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + var body: some View { + if model.text.isEmpty { + LUIIconImage( + source: backend.iconSource(for: model.buttonIconName), + bundle: backend.appIconBundle + ) + } else if model.buttonIconName.isEmpty { + Text(verbatim: model.text) + } else { + Label { + Text(verbatim: model.text) + } icon: { + LUIIconImage( + source: backend.iconSource(for: model.buttonIconName), + bundle: backend.appIconBundle + ) + } + } + } +} + +private struct LUISwipeActionView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + @Environment(\.luiSemanticColors) private var semanticColors + + var body: some View { + Button( + role: model.property(.variant)?.stringValue == "destructive" + ? .destructive : nil + ) { + if model.supportsPress { + try? backend.performPress(node: model.id) + } + } label: { + LUISwipeActionLabel(model: model, backend: backend) + } + .disabled(!model.isEnabled) + .tint(LUIThemeColorResolver.color( + model.property(.background)?.stringValue, + semanticColors: semanticColors + )) + } +} + +private struct LUIRowVisibilityModifier: ViewModifier { + let rowID: Int + @Environment(\.luiListRowTracker) private var tracker + + func body(content: Content) -> some View { + content + .onAppear { tracker?.onChange?(rowID, true) } + .onDisappear { tracker?.onChange?(rowID, false) } + } } enum LUIButtonIconPlacementPolicy { @@ -5448,11 +5624,25 @@ private struct LUIColumnPressModifier: ViewModifier { } struct LUIListSection: Equatable, Identifiable { + /// The `list-section` node id when this section is explicit. + let nodeID: Int? let headerID: Int? let childIDs: [Int] let footerID: Int? - var id: Int { headerID ?? childIDs.first ?? footerID ?? Int.min } + init( + nodeID: Int? = nil, + headerID: Int?, + childIDs: [Int], + footerID: Int? + ) { + self.nodeID = nodeID + self.headerID = headerID + self.childIDs = childIDs + self.footerID = footerID + } + + var id: Int { nodeID ?? headerID ?? childIDs.first ?? footerID ?? Int.min } } enum LUIListSurfacePolicy { @@ -5468,6 +5658,53 @@ public struct LUIListSurfacePreferenceKey: PreferenceKey { } enum LUIListSectionPolicy { + /// Explicit `list-section` children each become a section, drawing + /// header/footer from their section-header/-footer children; runs of + /// other children group into anonymous sections. + @MainActor + static func explicitSections( + childIDs: [Int], + model: (Int) -> LUINodeModel?, + isHeading: (Int) -> Bool, + isFooter: (Int) -> Bool + ) -> [LUIListSection] { + var result: [LUIListSection] = [] + var pending: [Int] = [] + + func flush() { + guard !pending.isEmpty else { return } + // Runs of plain children still get heading/footnote inference. + result.append(contentsOf: sections( + childIDs: pending, + isHeading: isHeading, + isFooter: isFooter + )) + pending = [] + } + + for childID in childIDs { + if let node = model(childID), node.kind == .listSection { + flush() + let headerID = node.children.first { + model($0)?.kind == .listSectionHeader + } + let footerID = node.children.first { + model($0)?.kind == .listSectionFooter + } + result.append(LUIListSection( + nodeID: childID, + headerID: headerID, + childIDs: node.visibleChildren, + footerID: footerID + )) + } else { + pending.append(childID) + } + } + flush() + return result + } + static func sections( childIDs: [Int], isHeading: (Int) -> Bool, @@ -5481,6 +5718,7 @@ enum LUIListSectionPolicy { func appendCurrentSection() { guard headerID != nil || !rows.isEmpty || footerID != nil else { return } result.append(LUIListSection( + nodeID: nil, headerID: headerID, childIDs: rows, footerID: footerID @@ -5514,67 +5752,146 @@ private struct LUIListView: View { @Environment(\.luiSemanticColors) private var semanticColors @Environment(\.luiInsideScroll) private var insideScroll + @State private var handledScrollToken: Int? + @State private var pendingScrollToken: Int? + @State private var scrollTask: Task? + @State private var rowTracker = LUIListRowTracker() + @State private var visibleRowIDs = Set() + @State private var lastEmittedRange: (Int, Int)? + @State private var rangeDebounce: Task? + var body: some View { - if insideScroll { - // A nested scrolling List collapses inside an outer ScrollView; - // lay rows out statically instead. - LazyVStack( - alignment: .leading, - spacing: CGFloat(model.property(.gap)?.intValue ?? 0) - ) { - ForEach(sections) { section in - if let headerID = section.headerID, - let header = backend.model(id: headerID) { - Text(verbatim: header.text) - .font(.headline) - } - rows(section.childIDs) - if let footerID = section.footerID, - let footer = backend.model(id: footerID) { - Text(verbatim: footer.text) - .font(.footnote) - .foregroundStyle(.secondary) - } + Group { + if insideScroll { + // A nested scrolling List collapses inside an outer + // ScrollView; lay rows out statically instead. + fallbackList + } else { + ScrollViewReader { proxy in + nativeList + .onChange(of: model.scrollToken, initial: true) { _, _ in + handleScrollRequest(proxy: proxy) + } + .onDisappear(perform: cancelPendingScroll) } } - } else { - nativeList + } + .environment(\.luiListRowTracker, listRowTracker) + .onChange(of: flatRowOrder) { _, _ in + // Row insert/remove/reorder shifts flat positions without any + // appear/disappear — re-emit so the range stays accurate. + emitVisibleRange() } } - private var nativeList: some View { - List { + private var listRowTracker: LUIListRowTracker? { + guard model.tracksVisibleRange else { return nil } + if rowTracker.onChange == nil { + rowTracker.onChange = { [self] rowID, isVisible in + self.rowVisibilityChanged(rowID, isVisible: isVisible) + } + } + return rowTracker + } + + private var fallbackList: some View { + LazyVStack( + alignment: .leading, + spacing: CGFloat(model.property(.gap)?.intValue ?? 0) + ) { ForEach(sections) { section in - if let headerID = section.headerID, - let header = backend.model(id: headerID), - let footerID = section.footerID, - let footer = backend.model(id: footerID) { - Section { - rows(section.childIDs) - } header: { - Text(verbatim: header.text) - } footer: { - Text(verbatim: footer.text) - } - } else if let headerID = section.headerID, - let header = backend.model(id: headerID) { - Section { - rows(section.childIDs) - } header: { - Text(verbatim: header.text) - } - } else if let footerID = section.footerID, - let footer = backend.model(id: footerID) { - Section { + if let headerID = section.headerID { + fallbackSectionSlot(headerID, isHeader: true) + } + rows(section.childIDs) + if let footerID = section.footerID { + fallbackSectionSlot(footerID, isHeader: false) + } + } + } + .onChange(of: model.scrollToken, initial: true) { _, _ in + // No scroll surface of its own to scroll on; only tokens not + // already handled report back. + if let token = model.scrollToken, handledScrollToken != token { + handledScrollToken = token + backend.performScrollCompleted( + node: model.id, + token: token, + outcome: "cancelled" + ) + } + } + } + + @ViewBuilder + private func fallbackSectionSlot(_ id: Int, isHeader: Bool) -> some View { + if let node = backend.model(id: id) { + if node.kind == .listSectionHeader || node.kind == .listSectionFooter { + LUIAnyNodeView(nodeID: id, backend: backend).equatable() + } else if isHeader { + Text(verbatim: node.text) + .font(.headline) + } else { + Text(verbatim: node.text) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private var nativeList: some View { + styledList( + List { + ForEach(sections) { section in + let sectionModel = section.nodeID.flatMap { backend.model(id: $0) } + if let headerID = section.headerID, + let footerID = section.footerID { + Section { + rows(section.childIDs) + } header: { + sectionSlot(headerID, isHeader: true) + } footer: { + sectionSlot(footerID, isHeader: false) + } + .modifier(LUIListSectionSeparatorModifier( + visibility: sectionModel?.separatorVisibility + )) + } else if let headerID = section.headerID { + Section { + rows(section.childIDs) + } header: { + sectionSlot(headerID, isHeader: true) + } + .modifier(LUIListSectionSeparatorModifier( + visibility: sectionModel?.separatorVisibility + )) + } else if let footerID = section.footerID { + Section { + rows(section.childIDs) + } footer: { + sectionSlot(footerID, isHeader: false) + } + .modifier(LUIListSectionSeparatorModifier( + visibility: sectionModel?.separatorVisibility + )) + } else if section.nodeID != nil { + // An explicit headerless section still needs a real + // SwiftUI Section so adjacent groups stay separate. + Section { + rows(section.childIDs) + } + .modifier(LUIListSectionSeparatorModifier( + visibility: sectionModel?.separatorVisibility + )) + } else { rows(section.childIDs) - } footer: { - Text(verbatim: footer.text) + .modifier(LUIListSectionSeparatorModifier( + visibility: sectionModel?.separatorVisibility + )) } - } else { - rows(section.childIDs) } } - } + ) .scrollContentBackground( listBackground == nil ? LUIListSurfacePolicy.scrollContentBackground : .hidden @@ -5586,11 +5903,46 @@ private struct LUIListView: View { value: semanticColors["background"] == nil ) #if os(iOS) - .listStyle(.insetGrouped) .modifier(LUISearchableNodeModifier(model: searchableField, backend: backend)) #endif } + @ViewBuilder + private func styledList(_ content: some View) -> some View { + #if os(iOS) + switch model.listStyle { + case "plain": + content.listStyle(.plain) + case "inset": + content.listStyle(.inset) + default: + content.listStyle(.insetGrouped) + } + #else + switch model.listStyle { + case "plain": + content.listStyle(.plain) + default: + content.listStyle(.inset) + } + #endif + } + + @ViewBuilder + private func sectionSlot(_ id: Int, isHeader: Bool) -> some View { + if let node = backend.model(id: id) { + if node.kind == .listSectionHeader || node.kind == .listSectionFooter { + LUIAnyNodeView(nodeID: id, backend: backend).equatable() + } else if isHeader { + Text(verbatim: node.text) + } else { + Text(verbatim: node.text) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + private var listBackground: Color? { LUIThemeColorResolver.color( model.property(.background)?.stringValue, @@ -5620,30 +5972,219 @@ private struct LUIListView: View { LUIAnyNodeView(nodeID: childID, backend: backend).equatable() } } + .id(childID) + .modifier(LUIRowVisibilityModifier(rowID: childID)) + .modifier(LUIListRowSeparatorModifier( + visibility: backend.model(id: childID)?.separatorVisibility + )) .listRowBackground(semanticColors["surface"]) } } private var sections: [LUIListSection] { - LUIListSectionPolicy.sections( - childIDs: model.visibleChildren.filter { childID in - childID != searchableField?.id - }, - isHeading: { childID in - backend.model(id: childID)?.kind == .heading - }, - isFooter: { childID in - guard let child = backend.model(id: childID), child.kind == .text else { - return false - } - return (child.property(.styleClass)?.stringValue ?? "") - .split(separator: " ") - .contains("footnote") == true + let childIDs = model.visibleChildren.filter { childID in + childID != searchableField?.id + } + if childIDs.contains(where: { + backend.model(id: $0)?.kind == .listSection + }) { + return LUIListSectionPolicy.explicitSections( + childIDs: childIDs, + model: { backend.model(id: $0) }, + isHeading: isHeading, + isFooter: isFooter + ) + } + return LUIListSectionPolicy.sections( + childIDs: childIDs, + isHeading: isHeading, + isFooter: isFooter + ) + } + + private func isHeading(_ childID: Int) -> Bool { + backend.model(id: childID)?.kind == .heading + } + + private func isFooter(_ childID: Int) -> Bool { + guard let child = backend.model(id: childID), child.kind == .text else { + return false + } + return (child.property(.styleClass)?.stringValue ?? "") + .split(separator: " ") + .contains("footnote") == true + } + + /// Row ids in flat payload order across sections; nested rows of + /// expanded disclosure items count in place. + private var flatRowOrder: [Int] { + var order: [Int] = [] + for section in sections { + collectRows(section.childIDs, into: &order) + } + return order + } + + private func collectRows(_ ids: [Int], into order: inout [Int]) { + for id in ids { + guard let node = backend.model(id: id) else { continue } + order.append(id) + if node.kind == .listItem, node.isExpanded == true { + collectRows( + node.visibleChildren.filter { + backend.model(id: $0)?.kind == .listItem + }, + into: &order + ) + } + } + } + + private var scrollAnchorPoint: UnitPoint? { + switch model.scrollAnchor { + case "top": .top + case "center": .center + case "bottom": .bottom + default: nil + } + } + + private func rowID(forKey key: String) -> Int? { + if let row = flatRowOrder.first(where: { + backend.model(id: $0)?.rowKey == key + }) { + return row + } + // A section key targets that section's first row. + for section in sections { + guard let nodeID = section.nodeID, + backend.model(id: nodeID)?.rowKey == key else { continue } + return section.childIDs.first + } + return nil + } + + private func handleScrollRequest(proxy: ScrollViewProxy) { + guard let token = model.scrollToken, handledScrollToken != token else { + return + } + handledScrollToken = token + if let pending = pendingScrollToken { + backend.performScrollCompleted( + node: model.id, + token: pending, + outcome: "superseded" + ) + pendingScrollToken = nil + scrollTask?.cancel() + } + guard let target = model.scrollTarget, !target.isEmpty, + let rowID = rowID(forKey: target) else { + backend.performScrollCompleted( + node: model.id, + token: token, + outcome: "missing-target" + ) + return + } + let anchor = scrollAnchorPoint + pendingScrollToken = token + scrollTask = Task { @MainActor in + if model.scrollAnimated { + withAnimation { proxy.scrollTo(rowID, anchor: anchor) } + } else { + proxy.scrollTo(rowID, anchor: anchor) } + // Let the scroll animation/layout pass settle before reporting. + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + pendingScrollToken = nil + backend.performScrollCompleted( + node: model.id, + token: token, + outcome: "succeeded" + ) + } + } + + private func cancelPendingScroll() { + scrollTask?.cancel() + if let pending = pendingScrollToken { + pendingScrollToken = nil + backend.performScrollCompleted( + node: model.id, + token: pending, + outcome: "cancelled" + ) + } + } + + private func rowVisibilityChanged(_ rowID: Int, isVisible: Bool) { + if isVisible { + visibleRowIDs.insert(rowID) + } else { + visibleRowIDs.remove(rowID) + } + rangeDebounce?.cancel() + rangeDebounce = Task { @MainActor in + try? await Task.sleep(nanoseconds: 80_000_000) + guard !Task.isCancelled else { return } + emitVisibleRange() + } + } + + /// `last` is exclusive, matching the flat position convention. + private func emitVisibleRange() { + var positions: [Int] = [] + for (index, rowID) in flatRowOrder.enumerated() + where visibleRowIDs.contains(rowID) { + positions.append(index) + } + let range = positions.isEmpty + ? (0, 0) + : (positions.min() ?? 0, (positions.max() ?? 0) + 1) + if let previous = lastEmittedRange, previous == range { return } + lastEmittedRange = range + backend.performVisibleRange( + node: model.id, + first: range.0, + last: range.1 ) } } +private struct LUIListRowSeparatorModifier: ViewModifier { + let visibility: String? + + @ViewBuilder + func body(content: Content) -> some View { + switch visibility { + case "hidden": + content.listRowSeparator(.hidden) + case "visible": + content.listRowSeparator(.visible) + default: + content + } + } +} + +private struct LUIListSectionSeparatorModifier: ViewModifier { + let visibility: String? + + @ViewBuilder + func body(content: Content) -> some View { + switch visibility { + case "hidden": + content.listSectionSeparator(.hidden) + case "visible": + content.listSectionSeparator(.visible) + default: + content + } + } +} + private struct LUIVirtualListView: View { let model: LUINodeModel let backend: LUIAppleBackend diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index dc3b5e39..78c8f561 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -15,6 +15,9 @@ public enum LUIEvent: Equatable, Sendable { case dismiss(node: Int) case doublePress(node: Int) case appear(node: Int) + case scrollCompleted(node: Int, token: Int, outcome: String) + case visibleRange(node: Int, first: Int, last: Int) + case picked(node: Int, payload: String) case `extension`( node: Int, identifier: String, @@ -131,14 +134,15 @@ enum LUIWireValue: Decodable, Equatable { return (1...6).contains(value) case .checked, .selected, .autofocus, .submitOnEnter, .longPressEnabled, .changeEnabled, .toggleEnabled, .pressEnabled, .submitEnabled, - .doublePressEnabled, .appearEnabled, .connector, .expanded, .enabled: + .doublePressEnabled, .appearEnabled, .connector, .expanded, .enabled, + .scrollAnimated, .trackVisibleRange: return boolValue != nil case .progressValue: return doubleValue != nil case .resizeDuration, .image, .surface, .active, .columns, .paddingHorizontal, .paddingVertical, .borderWidth, .cornerRadius, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight, - .containerRelativeFrameInset: + .containerRelativeFrameInset, .scrollToken: guard let value = intValue else { return false } return value >= 0 case .resizeEasing: @@ -180,7 +184,7 @@ enum LUIWireValue: Decodable, Equatable { return value == "leading" || value == "trailing" || value == "top" case .title, .description, .meta, .indicator, .foreground, .borderColor, .text, .background, .placeholder, .accessibilityLabel, - .accessibilityIdentifier, .styleClass: + .accessibilityIdentifier, .styleClass, .key, .scrollTarget: return stringValue != nil case .containerRelativeFrame: guard let value = stringValue else { return false } @@ -205,6 +209,15 @@ enum LUIWireValue: Decodable, Equatable { case .textAlignment: guard let value = stringValue else { return false } return Self.textAlignments.contains(value) + case .separator: + guard let value = stringValue else { return false } + return value == "visible" || value == "hidden" + case .style: + guard let value = stringValue else { return false } + return ["plain", "inset", "inset-grouped"].contains(value) + case .scrollAnchor: + guard let value = stringValue else { return false } + return ["top", "center", "bottom"].contains(value) case .role: guard let value = stringValue else { return false } return value == "treeitem" || value == "navigation" || @@ -217,6 +230,15 @@ enum LUIWireValue: Decodable, Equatable { case .themeMode: guard let value = stringValue else { return false } return ["system", "light", "dark"].contains(value) + case .request, .completion: + return stringValue != nil || intValue != nil + case .types: + return stringValue != nil + case .multiple: + return boolValue != nil + case .source: + guard let value = stringValue else { return false } + return ["files", "photos", "camera"].contains(value) case .main: guard let value = stringValue else { return false } return Self.mainAlignments.contains(value) @@ -668,6 +690,29 @@ struct LUIRetainedTree { if parentNode.kind == .menuItem, childNode.kind != .contextMenu { throw invalid("menu-item accepts only context-menu metadata") } + if parentNode.kind == .listSection, + childNode.kind != .listItem, + childNode.kind != .listSectionHeader, + childNode.kind != .listSectionFooter { + throw invalid("list-section accepts only list rows and section header/footer") + } + if childNode.kind == .listSectionHeader || + childNode.kind == .listSectionFooter, + parentNode.kind != .listSection { + throw invalid("list-section header/footer requires a list-section parent") + } + if childNode.kind == .listSection, parentNode.kind != .list { + throw invalid("list-section requires a direct list parent") + } + if childNode.kind == .swipeActions, parentNode.kind != .listItem { + throw invalid("swipe-actions requires a list-item parent") + } + if parentNode.kind == .swipeActions, childNode.kind != .swipeAction { + throw invalid("swipe-actions accepts only swipe-action children") + } + if childNode.kind == .swipeAction, parentNode.kind != .swipeActions { + throw invalid("swipe-action requires a swipe-actions parent") + } if parentNode.kind != .menuItem, Self.isContextMenuLeafHost(parentNode.kind), childNode.kind != .contextMenu { @@ -868,13 +913,19 @@ struct LUIRetainedTree { kind == .tableCell || kind == .bubble || kind == .statusBar case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) + case .key, .separator: kind == .listItem || kind == .listSection + case .style, .scrollTarget, .scrollAnchor, .scrollToken, + .scrollAnimated, .trackVisibleRange: + kind == .list + case .edge: kind == .swipeAction || kind == .edgeInset case .minValue, .maxValue, .stepValue: kind == .numberStepper case .detents, .sizing: kind == .sheet case .path: kind == .fileImage || kind == .filePreview case .url: kind == .link case .maxPixelSize: kind == .fileImage case .active, .title, .description, .meta, .indicator, .connector: false - case .edge, .visible: kind == .edgeInset + case .request, .types, .multiple, .source, .completion: false + case .visible: kind == .edgeInset // `.alignment` is admitted ahead of the restrictive matrix above. case .alignment: kind != .root } @@ -897,7 +948,10 @@ struct LUIRetainedTree { kind == .stepper || kind == .timeline || kind == .inputGroup || kind == .inputGroupActions || kind == .toast || kind == .toolbar || kind == .bottomTabs || kind == .bottomTab || - kind == .menuTrigger || isContextMenuLeafHost(kind) || kind == .link + kind == .menuTrigger || kind == .filePicker || isContextMenuLeafHost(kind) + || kind == .link || kind == .listSection + || kind == .listSectionHeader || kind == .listSectionFooter + || kind == .swipeActions } private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool { @@ -908,7 +962,8 @@ struct LUIRetainedTree { kind == .sheet || kind == .accordion || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || kind == .toast || kind == .toolbar || - kind == .bottomTab + kind == .bottomTab || kind == .listSection || + kind == .listSectionHeader || kind == .listSectionFooter } private static func isModalSurface(_ kind: LUINodeKind) -> Bool { @@ -1137,9 +1192,18 @@ struct LUIRetainedTree { let hasTreeMetadata = node.properties[.role]?.stringValue == "treeitem" || node.properties[.treeLevel] != nil || node.properties[.expanded] != nil if hasTreeMetadata { - guard node.properties[.role]?.stringValue == "treeitem", - hasAncestor(node.parent, kind: .tree) else { - throw invalid("tree row metadata requires a treeitem inside tree") + // A list-item inside a list may carry expansion state as a + // disclosure row without the treeitem role. + let isDisclosureItem = node.kind == .listItem && + node.properties[.role] == nil && + node.properties[.treeLevel] == nil && + node.properties[.changeEnabled] == nil && + hasAncestor(node.parent, kind: .list) + if !isDisclosureItem { + guard node.properties[.role]?.stringValue == "treeitem", + hasAncestor(node.parent, kind: .tree) else { + throw invalid("tree row metadata requires a treeitem inside tree") + } } if node.properties[.expanded] != nil, node.properties[.toggleEnabled]?.boolValue != true { @@ -1155,14 +1219,33 @@ struct LUIRetainedTree { } } if node.kind == .listItem { + let disclosure = node.properties[.expanded] != nil let hasText = !(node.properties[.text]?.stringValue ?? "").isEmpty - let hasChildren = node.children.contains { nodes[$0]?.kind != .contextMenu } + let hasChildren = node.children.contains { childID in + guard let child = nodes[childID] else { return false } + if child.kind == .contextMenu || child.kind == .swipeActions { + return false + } + // Nested list items on a disclosure row are rows, not content. + return !(disclosure && child.kind == .listItem) + } guard hasText || hasChildren else { throw invalid("list-item requires text or children") } guard !(hasText && hasChildren) else { throw invalid("list-item accepts text or children, not both") } + if node.children.contains(where: { nodes[$0]?.kind == .listItem }), + node.properties[.expanded] == nil { + throw invalid("nested list-item children require expanded") + } + } + if node.kind == .swipeAction { + let text = node.properties[.text]?.stringValue ?? "" + let icon = node.properties[.icon]?.stringValue ?? "" + guard !text.isEmpty || !icon.isEmpty else { + throw invalid("swipe-action requires text or icon") + } } if node.kind == .avatar || node.kind == .image { if node.kind == .avatar, diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 380d2db5..fd534e41 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -78,6 +78,12 @@ enum LUINodeKind: String, Decodable, Equatable { case toast = "toast" case toolbar = "toolbar" case statusBar = "status-bar" + case listSection = "list-section" + case listSectionHeader = "list-section-header" + case listSectionFooter = "list-section-footer" + case swipeActions = "swipe-actions" + case swipeAction = "swipe-action" + case filePicker = "file-picker" case link = "link" case fileImage = "file-image" case filePreview = "file-preview" @@ -157,6 +163,20 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case key = "key" + case separator = "separator" + case style = "style" + case scrollTarget = "scroll-target" + case scrollAnchor = "scroll-anchor" + case scrollToken = "scroll-token" + case scrollAnimated = "scroll-animated" + case trackVisibleRange = "track-visible-range" + case edge = "edge" + case request = "request" + case types = "types" + case multiple = "multiple" + case source = "source" + case completion = "completion" case minValue = "min" case maxValue = "max" case stepValue = "step" @@ -165,7 +185,6 @@ enum LUIProperty: String, Decodable, Hashable { case path = "path" case url = "url" case maxPixelSize = "max-pixel-size" - case edge = "edge" case visible = "visible" case alignment = "alignment" } @@ -184,6 +203,10 @@ enum LUISchemaMatrix { .bottomTabs: [.accessibilityLabel, .styleClass, .grow, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight], .bottomTab: [.title, .icon, .selected, .enabled, .pressEnabled], .menuTrigger: [.text, .icon, .accessibilityLabel, .enabled, .foreground, .styleClass], + .listSection: [.key, .separator], + .swipeActions: [], + .swipeAction: [.text, .icon, .variant, .edge, .enabled, .background, .pressEnabled], + .filePicker: [.request, .types, .multiple, .source, .completion, .enabled, .appearEnabled], .filePreview: [.path], ] diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index cd110d6c..f658c8c4 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift @@ -734,3 +734,166 @@ struct LUIBackendParityTests { _ = LUISwiftUIRoot(backend: backend, rootID: 1) } } + +@MainActor +@Suite("LUI list suite", .serialized) +struct LUIListSuiteTests { + @Test("grouped list with sections, disclosure rows, and swipe actions builds") + func groupedListBuilds() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"set-prop","id":1,"property":"style","value":"inset-grouped"}, + {"op":"set-prop","id":1,"property":"scroll-target","value":"row-2"}, + {"op":"set-prop","id":1,"property":"scroll-anchor","value":"center"}, + {"op":"set-prop","id":1,"property":"scroll-token","value":1}, + {"op":"set-prop","id":1,"property":"scroll-animated","value":false}, + {"op":"set-prop","id":1,"property":"track-visible-range","value":true}, + {"op":"create-node","id":2,"kind":"list-section"}, + {"op":"set-prop","id":2,"property":"key","value":"s-1"}, + {"op":"set-prop","id":2,"property":"separator","value":"hidden"}, + {"op":"create-node","id":3,"kind":"list-section-header"}, + {"op":"create-node","id":4,"kind":"text"}, + {"op":"set-prop","id":4,"property":"text","value":"Section one"}, + {"op":"create-node","id":5,"kind":"list-item"}, + {"op":"set-prop","id":5,"property":"key","value":"row-1"}, + {"op":"set-prop","id":5,"property":"expanded","value":true}, + {"op":"set-prop","id":5,"property":"toggle-enabled","value":true}, + {"op":"create-node","id":6,"kind":"text"}, + {"op":"set-prop","id":6,"property":"text","value":"Parent row"}, + {"op":"create-node","id":7,"kind":"list-item"}, + {"op":"set-prop","id":7,"property":"key","value":"row-2"}, + {"op":"create-node","id":8,"kind":"text"}, + {"op":"set-prop","id":8,"property":"text","value":"Nested row"}, + {"op":"create-node","id":9,"kind":"swipe-actions"}, + {"op":"create-node","id":10,"kind":"swipe-action"}, + {"op":"set-prop","id":10,"property":"text","value":"Delete"}, + {"op":"set-prop","id":10,"property":"icon","value":"trash"}, + {"op":"set-prop","id":10,"property":"variant","value":"destructive"}, + {"op":"set-prop","id":10,"property":"edge","value":"trailing"}, + {"op":"create-node","id":11,"kind":"swipe-action"}, + {"op":"set-prop","id":11,"property":"text","value":"Pin"}, + {"op":"set-prop","id":11,"property":"edge","value":"leading"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":2,"child":5,"index":1}, + {"op":"insert-child","parent":3,"child":4,"index":0}, + {"op":"insert-child","parent":5,"child":6,"index":0}, + {"op":"insert-child","parent":5,"child":7,"index":1}, + {"op":"insert-child","parent":5,"child":9,"index":2}, + {"op":"insert-child","parent":7,"child":8,"index":0}, + {"op":"insert-child","parent":9,"child":10,"index":0}, + {"op":"insert-child","parent":9,"child":11,"index":1} + ]} + """) + + let list = try #require(backend.model(id: 1)) + #expect(list.listStyle == "inset-grouped") + #expect(list.scrollTarget == "row-2") + #expect(list.scrollAnchor == "center") + #expect(list.scrollToken == 1) + #expect(list.scrollAnimated == false) + #expect(list.tracksVisibleRange) + #expect(backend.model(id: 2)?.rowKey == "s-1") + #expect(backend.model(id: 2)?.separatorVisibility == "hidden") + #expect(backend.model(id: 5)?.isExpanded == true) + #expect(backend.model(id: 5)?.supportsToggle == true) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + } + + @Test("list suite validation rejects malformed structures") + func listSuiteValidation() throws { + let backend = LUIAppleBackend() + // A swipe action needs text or an icon. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"list-item"}, + {"op":"set-prop","id":2,"property":"text","value":"Row"}, + {"op":"create-node","id":3,"kind":"swipe-actions"}, + {"op":"create-node","id":4,"kind":"swipe-action"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":3,"child":4,"index":0} + ]} + """) + } + // A list-section must sit directly under a list. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"row"}, + {"op":"create-node","id":2,"kind":"list-section"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + } + // swipe-actions belong to a list-item. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"swipe-actions"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + } + // Disclosure rows still need toggle-enabled with expanded. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"list-item"}, + {"op":"set-prop","id":2,"property":"text","value":"Row"}, + {"op":"set-prop","id":2,"property":"expanded","value":true}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + } + // Unknown style vocab is rejected. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"set-prop","id":1,"property":"style","value":"cards"} + ]} + """) + } + } + + @Test("list suite events emit on the wire") + func listSuiteEvents() throws { + let backend = LUIAppleBackend() + var events: [LUIEvent] = [] + backend.onEvent = { events.append($0) } + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"list-item"}, + {"op":"set-prop","id":2,"property":"text","value":"Row"}, + {"op":"set-prop","id":2,"property":"expanded","value":false}, + {"op":"set-prop","id":2,"property":"toggle-enabled","value":true}, + {"op":"create-node","id":3,"kind":"swipe-actions"}, + {"op":"create-node","id":4,"kind":"swipe-action"}, + {"op":"set-prop","id":4,"property":"text","value":"Delete"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":3,"child":4,"index":0} + ]} + """) + + try backend.performPress(node: 4) + try backend.performToggle(node: 2, checked: true) + backend.performScrollCompleted(node: 1, token: 7, outcome: "succeeded") + backend.performVisibleRange(node: 1, first: 0, last: 5) + #expect(events == [ + .press(node: 4), + .toggleChanged(node: 2, checked: true), + .scrollCompleted(node: 1, token: 7, outcome: "succeeded"), + .visibleRange(node: 1, first: 0, last: 5), + ]) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + } +} diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift new file mode 100644 index 00000000..6661a3c1 --- /dev/null +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift @@ -0,0 +1,130 @@ +import Testing +@testable import LUIAppleBackend + +@MainActor +@Suite("LUI file-picker backend") +struct LUIFilePickerTests { + private func makeBackend() throws -> LUIAppleBackend { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"file-picker"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + return backend + } + + @Test("file-picker accepts its property set") + func filePickerAcceptsPickerProperties() throws { + let backend = try makeBackend() + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"request","value":"op-1"}, + {"op":"set-prop","id":2,"property":"types","value":"public.image,public.movie"}, + {"op":"set-prop","id":2,"property":"multiple","value":true}, + {"op":"set-prop","id":2,"property":"source","value":"photos"}, + {"op":"set-prop","id":2,"property":"enabled","value":true}, + {"op":"set-prop","id":2,"property":"appear-enabled","value":true} + ]} + """) + #expect(backend.model(id: 2) != nil) + } + + @Test("request and completion accept string or int tokens") + func requestAndCompletionAcceptStringOrInt() throws { + let backend = try makeBackend() + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"request","value":7} + ]} + """) + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"set-prop","id":2,"property":"completion","value":"op-1"} + ]} + """) + #expect(backend.model(id: 2) != nil) + } + + @Test("file-picker rejects unknown sources and foreign properties") + func filePickerRejectsInvalidProperties() throws { + let backend = try makeBackend() + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"source","value":"screen"} + ]} + """) + } + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"text","value":"nope"} + ]} + """) + } + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"multiple","value":"yes"} + ]} + """) + } + } + + @Test("performPicked emits the payload only for file-picker nodes") + func performPickedEmitsForFilePickerOnly() throws { + let backend = try makeBackend() + var events: [LUIEvent] = [] + backend.onEvent = { events.append($0) } + + try backend.performPicked( + node: 2, + payload: #"{"request":"op-1","files":[{"path":"/tmp/a.png","name":"a.png","content-type":"image/png"}]}"# + ) + #expect(events == [ + .picked( + node: 2, + payload: #"{"request":"op-1","files":[{"path":"/tmp/a.png","name":"a.png","content-type":"image/png"}]}"# + ), + ]) + + #expect(throws: LUIBackendError.self) { + try backend.performPicked(node: 1, payload: "{}") + } + } + + @Test("file-picker presentations report cancellation through dismiss") + func filePickerIsDismissible() throws { + let backend = try makeBackend() + var events: [LUIEvent] = [] + backend.onEvent = { events.append($0) } + + try backend.performDismiss(node: 2) + #expect(events == [.dismiss(node: 2)]) + + #expect(throws: LUIBackendError.self) { + try backend.performPicked(node: 0, payload: "{}") + } + } + + @Test("dropping the node releases its file-picker operation") + func droppingNodeReleasesOperation() throws { + let backend = try makeBackend() + backend.setFilePickerOperation( + node: 2, + LUIFilePickerOperation(token: .string("op-1"), phase: .presenting) + ) + #expect(backend.filePickerOperation(node: 2) != nil) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"remove-child","parent":1,"child":2}, + {"op":"drop-node","id":2} + ]} + """) + #expect(backend.filePickerOperation(node: 2) == nil) + } +} diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index f7e58398..f8cfadeb 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -35,6 +35,10 @@ sealed class LUIEvent { required double value, }) = LUIValueChangedEvent; const factory LUIEvent.dismiss({required int node}) = LUIDismissEvent; + const factory LUIEvent.picked({ + required int node, + required String payload, + }) = LUIPickedEvent; const factory LUIEvent.doublePress({required int node}) = LUIDoublePressEvent; const factory LUIEvent.extension({ required int node, @@ -42,6 +46,16 @@ sealed class LUIEvent { required String name, required Map values, }) = LUIExtensionComponentEvent; + const factory LUIEvent.scrollCompleted({ + required int node, + required int token, + required String outcome, + }) = LUIScrollCompletedEvent; + const factory LUIEvent.visibleRange({ + required int node, + required int first, + required int last, + }) = LUIVisibleRangeEvent; } @immutable @@ -95,6 +109,19 @@ final class LUIDismissEvent extends LUIEvent { int get hashCode => node.hashCode; } +final class LUIPickedEvent extends LUIEvent { + const LUIPickedEvent({required this.node, required this.payload}); + final int node; + final String payload; + + @override + bool operator ==(Object other) => + other is LUIPickedEvent && other.node == node && other.payload == payload; + + @override + int get hashCode => Object.hash(node, payload); +} + final class LUIChangeEvent extends LUIEvent { const LUIChangeEvent({required this.node}); final int node; @@ -182,6 +209,48 @@ final class LUIToggleChangedEvent extends LUIEvent { int get hashCode => Object.hash(node, checked); } +final class LUIScrollCompletedEvent extends LUIEvent { + const LUIScrollCompletedEvent({ + required this.node, + required this.token, + required this.outcome, + }); + final int node; + final int token; + final String outcome; + + @override + bool operator ==(Object other) => + other is LUIScrollCompletedEvent && + other.node == node && + other.token == token && + other.outcome == outcome; + + @override + int get hashCode => Object.hash(node, token, outcome); +} + +final class LUIVisibleRangeEvent extends LUIEvent { + const LUIVisibleRangeEvent({ + required this.node, + required this.first, + required this.last, + }); + final int node; + final int first; + final int last; + + @override + bool operator ==(Object other) => + other is LUIVisibleRangeEvent && + other.node == node && + other.first == first && + other.last == last; + + @override + int get hashCode => Object.hash(node, first, last); +} + final class LUIBackendException implements Exception { const LUIBackendException(this.message); final String message; @@ -360,9 +429,8 @@ final class _LUIThemeScope extends InheritedWidget { final Map tokens; - static Map? maybeTokens(BuildContext context) => context - .dependOnInheritedWidgetOfExactType<_LUIThemeScope>() - ?.tokens; + static Map? maybeTokens(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_LUIThemeScope>()?.tokens; @override bool updateShouldNotify(_LUIThemeScope oldWidget) => @@ -672,8 +740,9 @@ final class LUIFlutterBackend { entry.key: _ExtensionNodeState.copy(entry.value), }; + final filePickerRequests = {}; for (final operation in operations) { - _applyState(next, nextExtensions, operation); + _applyState(next, nextExtensions, operation, filePickerRequests); } _validateStates(next); _validateExtensionStates(next, nextExtensions); @@ -762,6 +831,18 @@ final class LUIFlutterBackend { _handles[id]?.markDependencyChanged(); } _scheduleLayoutSettle(); + // This backend cannot present a picker: every file-picker request the + // batch left unanswered (request set, not matching `completion`) is + // answered with `dismiss`, per the element contract. + for (final id in filePickerRequests) { + final state = _states[id]; + if (state == null || state.kind != _NodeKind.filePicker) continue; + final request = state.properties['request']; + if (request == null || request == state.properties['completion']) { + continue; + } + onEvent?.call(LUIEvent.dismiss(node: id)); + } } // Render objects adopted or moved into a subtree that is already clean @@ -878,7 +959,8 @@ final class LUIFlutterBackend { if (!_containsState(states, extensions, id)) { return; } - final properties = states[id]?.properties ?? + final properties = + states[id]?.properties ?? _requireExtensionStateFrom(extensions, id).properties; final identifier = properties['accessibility-identifier']; if (identifier is String) { @@ -901,7 +983,8 @@ final class LUIFlutterBackend { if (!_containsState(_states, _extensionStates, id)) { return; } - final properties = _states[id]?.properties ?? + final properties = + _states[id]?.properties ?? _requireExtensionStateFrom(_extensionStates, id).properties; final identifier = properties['accessibility-identifier']; if (identifier is String && @@ -947,7 +1030,10 @@ final class LUIFlutterBackend { final present = {}; final roots = [ RendererBinding - .instance.rootPipelineOwner.semanticsOwner?.rootSemanticsNode, + .instance + .rootPipelineOwner + .semanticsOwner + ?.rootSemanticsNode, for (final view in RendererBinding.instance.renderViews) view.owner?.semanticsOwner?.rootSemanticsNode, ]; @@ -984,8 +1070,7 @@ final class LUIFlutterBackend { walk(root); } } - final retainedSet = - _staleSemanticsIdentifiers.intersection(present); + final retainedSet = _staleSemanticsIdentifiers.intersection(present); final mountedIds = _mountedSemanticsIdentifiers(); final missingSet = mountedIds.difference(present); final unresolved = retainedSet.length + missingSet.length; @@ -1491,6 +1576,7 @@ final class LUIFlutterBackend { state.kind != _NodeKind.combobox && state.kind != _NodeKind.dropdownMenu && state.kind != _NodeKind.toast && + state.kind != _NodeKind.filePicker && state.kind != _NodeKind.filePreview && !state.kind.isModalSurface) { throw LUIBackendException('node $node is not dismissible'); @@ -1506,7 +1592,9 @@ final class LUIFlutterBackend { orElse: () => null, ); final children = state.children - .where((child) => child != contextMenuID) + .where((child) => + child != contextMenuID && + _states[child]?.kind != _NodeKind.swipeActions) .map((child) { final childWidget = widget(node: child); final childState = _states[child]; @@ -1855,7 +1943,8 @@ final class LUIFlutterBackend { // `scroll-leading` pins the last child at the trailing edge while the // rest scroll, matching the Apple backend's LUIToolbarLayoutPolicy. Widget horizontalToolbar(List children, double gap) { - final pinTrailing = (state.properties['style-class'] as String? ?? '') + final pinTrailing = + (state.properties['style-class'] as String? ?? '') .split(' ') .contains('scroll-leading') && children.length >= 2; @@ -1886,6 +1975,7 @@ final class LUIFlutterBackend { ], ); } + Widget horizontalGroupFlex() => LUIFlex( direction: Axis.horizontal, expandsForAlignment: state.properties.containsKey('main'), @@ -1921,13 +2011,11 @@ final class LUIFlutterBackend { children: children, ); if (state.properties['press-enabled'] == true) { - return GestureDetector( - onTap: () => performAction(id), - child: body, - ); + return GestureDetector(onTap: () => performAction(id), child: body); } return body; } + Widget stack() { final menuID = state.children.cast().firstWhere( (childID) => @@ -2611,10 +2699,8 @@ final class LUIFlutterBackend { } else if (indicator.isNotEmpty) { marker = Text( indicator, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: variantColor, - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: variantColor, fontWeight: FontWeight.w600), ); } else { marker = Container( @@ -2904,6 +2990,11 @@ final class LUIFlutterBackend { _NodeKind.menuItem => menuItem(), _NodeKind.menuTrigger => menuTrigger(), _NodeKind.listItem => listItem(), + _NodeKind.listSection || + _NodeKind.listSectionHeader || + _NodeKind.listSectionFooter || + _NodeKind.swipeActions => column(), + _NodeKind.swipeAction => Text(text), _NodeKind.table => table(), _NodeKind.tree => tree(), _NodeKind.tableRow => tableRow(), @@ -3023,6 +3114,10 @@ final class LUIFlutterBackend { color: foreground, ), _NodeKind.statusBar => statusBar(), + // Non-visual node: presentation is driven by its properties on + // platforms that implement the file-picker backend; children render + // inline. + _NodeKind.filePicker => Stack(children: children), _NodeKind.link => column(), _NodeKind.fileImage || _NodeKind.filePreview => const SizedBox.shrink(), }; @@ -3280,6 +3375,7 @@ final class LUIFlutterBackend { Map states, Map extensions, Map operation, + Set filePickerRequests, ) { switch (_string(operation['op'], 'op')) { case 'create-node': @@ -3322,7 +3418,8 @@ final class LUIFlutterBackend { throw LUIBackendException('unknown node $id'); } case 'set-prop': - final node = _requireState(states, _integer(operation['id'], 'id')); + final id = _integer(operation['id'], 'id'); + final node = _requireState(states, id); final property = _string(operation['property'], 'property'); final value = operation['value']; if (!_supports(node.kind, property, value)) { @@ -3331,6 +3428,9 @@ final class LUIFlutterBackend { ); } node.properties[property] = value!; + if (node.kind == _NodeKind.filePicker && property == 'request') { + filePickerRequests.add(id); + } case 'remove-prop': final node = _requireState(states, _integer(operation['id'], 'id')); final property = _string(operation['property'], 'property'); @@ -3588,6 +3688,44 @@ final class LUIFlutterBackend { 'toolbar accepts only interactive controls and dividers', ); } + if (parent.kind == _NodeKind.listSection && + child.kind != _NodeKind.listItem && + child.kind != _NodeKind.listSectionHeader && + child.kind != _NodeKind.listSectionFooter) { + throw const LUIBackendException( + 'list-section accepts only list rows and section header/footer', + ); + } + if ((child.kind == _NodeKind.listSectionHeader || + child.kind == _NodeKind.listSectionFooter) && + parent.kind != _NodeKind.listSection) { + throw const LUIBackendException( + 'list-section header/footer requires a list-section parent', + ); + } + if (child.kind == _NodeKind.listSection && parent.kind != _NodeKind.list) { + throw const LUIBackendException( + 'list-section requires a direct list parent', + ); + } + if (child.kind == _NodeKind.swipeActions && + parent.kind != _NodeKind.listItem) { + throw const LUIBackendException( + 'swipe-actions requires a list-item parent', + ); + } + if (parent.kind == _NodeKind.swipeActions && + child.kind != _NodeKind.swipeAction) { + throw const LUIBackendException( + 'swipe-actions accepts only swipe-action children', + ); + } + if (child.kind == _NodeKind.swipeAction && + parent.kind != _NodeKind.swipeActions) { + throw const LUIBackendException( + 'swipe-action requires a swipe-actions parent', + ); + } } /// Direction-aware nine-point anchor; 'leading'/'trailing' follow the @@ -3728,6 +3866,38 @@ final class LUIFlutterBackend { _ => false, }; } + if (kind == _NodeKind.listSection) { + return switch (property) { + 'key' => value is String, + 'separator' => value == 'visible' || value == 'hidden', + _ => false, + }; + } + if (kind == _NodeKind.swipeActions) return false; + if (kind == _NodeKind.swipeAction) { + return switch (property) { + 'text' || 'background' => value is String, + 'icon' => + value is String && + (_iconNames.contains(value) || + _appIconNamePattern.hasMatch(value)), + 'variant' => value is String && _buttonVariants.contains(value), + 'edge' => value == 'leading' || value == 'trailing', + 'enabled' || 'press-enabled' => value is bool, + _ => false, + }; + } + if (kind == _NodeKind.filePicker) { + return switch (property) { + 'request' || 'completion' => value is String || value is int, + 'types' => value is String, + 'multiple' || 'enabled' || 'appear-enabled' => value is bool, + 'source' => + value is String && + const {'files', 'photos', 'camera'}.contains(value), + _ => false, + }; + } // Mirrors OCaml common_property_supported: theme props are admitted on // every non-restrictive container kind. if (property == 'theme') { @@ -4095,6 +4265,25 @@ final class LUIFlutterBackend { 'role' => value == 'treeitem' && value is String && _isTreeRowKind(kind), 'tree-level' => value is int && value > 0 && _isTreeRowKind(kind), 'expanded' => value is bool && _isTreeRowKind(kind), + 'key' => + value is String && + (kind == _NodeKind.listItem || kind == _NodeKind.listSection), + 'separator' => + (value == 'visible' || value == 'hidden') && + (kind == _NodeKind.listItem || kind == _NodeKind.listSection), + 'style' => + const {'plain', 'inset', 'inset-grouped'}.contains(value) && + kind == _NodeKind.list, + 'scroll-target' => value is String && kind == _NodeKind.list, + 'scroll-anchor' => + const {'top', 'center', 'bottom'}.contains(value) && + kind == _NodeKind.list, + 'scroll-token' => value is int && value >= 0 && kind == _NodeKind.list, + 'scroll-animated' || + 'track-visible-range' => value is bool && kind == _NodeKind.list, + 'edge' => + (value == 'leading' || value == 'trailing') && + kind == _NodeKind.swipeAction, _ => false, }; } @@ -4324,8 +4513,17 @@ final class LUIFlutterBackend { state.properties.containsKey('tree-level') || state.properties.containsKey('expanded'); if (hasTreeMetadata) { - if (state.properties['role'] != 'treeitem' || - !_hasAncestor(states, state.parent, _NodeKind.tree)) { + // A list-item inside a list may carry expansion state as a + // disclosure row without the treeitem role. + final isDisclosureItem = + state.kind == _NodeKind.listItem && + !state.properties.containsKey('role') && + !state.properties.containsKey('tree-level') && + !state.properties.containsKey('change-enabled') && + _hasAncestor(states, state.parent, _NodeKind.list); + if (!isDisclosureItem && + (state.properties['role'] != 'treeitem' || + !_hasAncestor(states, state.parent, _NodeKind.tree))) { throw const LUIBackendException( 'tree row metadata requires a treeitem inside tree', ); @@ -4349,10 +4547,18 @@ final class LUIFlutterBackend { } } if (state.kind == _NodeKind.listItem) { + final disclosure = state.properties.containsKey('expanded'); final hasText = (state.properties['text'] as String? ?? '').isNotEmpty; - final hasChildren = state.children.any( - (child) => states[child]?.kind != _NodeKind.contextMenu, - ); + final hasChildren = state.children.any((childID) { + final child = states[childID]; + if (child == null || + child.kind == _NodeKind.contextMenu || + child.kind == _NodeKind.swipeActions) { + return false; + } + // Nested list items on a disclosure row are rows, not content. + return !(disclosure && child.kind == _NodeKind.listItem); + }); if (!hasText && !hasChildren) { throw const LUIBackendException( 'list-item requires text or children', @@ -4363,6 +4569,21 @@ final class LUIFlutterBackend { 'list-item accepts text or children, not both', ); } + if (state.children.any( + (child) => states[child]?.kind == _NodeKind.listItem, + ) && + !state.properties.containsKey('expanded')) { + throw const LUIBackendException( + 'nested list-item children require expanded', + ); + } + } + if (state.kind == _NodeKind.swipeAction) { + final text = state.properties['text'] as String? ?? ''; + final icon = state.properties['icon'] as String? ?? ''; + if (text.isEmpty && icon.isEmpty) { + throw const LUIBackendException('swipe-action requires text or icon'); + } } if (state.kind == _NodeKind.avatar || state.kind == _NodeKind.image) { final text = state.properties['text'] as String? ?? ''; @@ -4571,8 +4792,13 @@ final class LUIFlutterBackend { kind == _NodeKind.toolbar || kind == _NodeKind.bottomTabs || kind == _NodeKind.bottomTab || + kind == _NodeKind.filePicker || kind == _NodeKind.link || _isContextMenuLeafHost(kind) || + kind == _NodeKind.listSection || + kind == _NodeKind.listSectionHeader || + kind == _NodeKind.listSectionFooter || + kind == _NodeKind.swipeActions || kind == _NodeKind.edgeInset || kind == _NodeKind.overlay || kind == _NodeKind.viewThatFits || @@ -4604,7 +4830,10 @@ final class LUIFlutterBackend { kind == _NodeKind.bubble || kind == _NodeKind.toast || kind == _NodeKind.toolbar || - kind == _NodeKind.bottomTab; + kind == _NodeKind.bottomTab || + kind == _NodeKind.listSection || + kind == _NodeKind.listSectionHeader || + kind == _NodeKind.listSectionFooter; static bool _isToolbarChild(_NodeKind kind) => kind == _NodeKind.button || @@ -6386,12 +6615,11 @@ final class _LUIRetainedTooltipState extends State<_LUIRetainedTooltip> } } -typedef _LUIToggleSelectionBuilder = - Widget Function( - BuildContext context, - bool selected, - ValueChanged setSelected, - ); +typedef _LUIToggleSelectionBuilder = Widget Function( + BuildContext context, + bool selected, + ValueChanged setSelected, +); final class _LUIToggleSelection extends StatefulWidget { const _LUIToggleSelection({ diff --git a/platform/flutter/lib/lui_ocaml_bridge.dart b/platform/flutter/lib/lui_ocaml_bridge.dart index 91aa6a46..a13524cc 100644 --- a/platform/flutter/lib/lui_ocaml_bridge.dart +++ b/platform/flutter/lib/lui_ocaml_bridge.dart @@ -28,6 +28,8 @@ typedef _NativeSubmit = Int32 Function(Int64 node); typedef _DartSubmit = int Function(int node); typedef _NativeDismiss = Int32 Function(Int64 node); typedef _DartDismiss = int Function(int node); +typedef _NativePicked = Int32 Function(Int64 node, Pointer payload); +typedef _DartPicked = int Function(int node, Pointer payload); typedef _NativeDoublePress = Int32 Function(Int64 node); typedef _DartDoublePress = int Function(int node); typedef _NativeToggleChanged = Int32 Function(Int64 node, Int32 checked); @@ -65,6 +67,9 @@ final class LUIOcamlBridge { _dismiss = library.lookupFunction<_NativeDismiss, _DartDismiss>( 'lui_ocaml_dismiss', ), + _picked = library.lookupFunction<_NativePicked, _DartPicked>( + 'lui_ocaml_picked', + ), _doublePress = library .lookupFunction<_NativeDoublePress, _DartDoublePress>( 'lui_ocaml_double_press', @@ -104,6 +109,7 @@ final class LUIOcamlBridge { final _DartTextChanged _textChanged; final _DartSubmit _submit; final _DartDismiss _dismiss; + final _DartPicked _picked; final _DartDoublePress _doublePress; final _DartToggleChanged _toggleChanged; final _DartRadioChanged _radioChanged; @@ -180,6 +186,17 @@ final class LUIOcamlBridge { if (_dismiss(node) != 1) throw StateError('OCaml dismiss dispatch failed'); } + void picked(int node, String payload) { + final nativePayload = payload.toNativeUtf8(); + try { + if (_picked(node, nativePayload) != 1) { + throw StateError('OCaml picked dispatch failed'); + } + } finally { + malloc.free(nativePayload); + } + } + void toggleChanged(int node, bool checked) { if (_toggleChanged(node, checked ? 1 : 0) != 1) { throw StateError('OCaml toggle dispatch failed'); diff --git a/platform/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index b3cd760f..db397498 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -78,6 +78,12 @@ enum _NodeKind { toast, toolbar, statusBar, + listSection, + listSectionHeader, + listSectionFooter, + swipeActions, + swipeAction, + filePicker, link, fileImage, filePreview, @@ -164,6 +170,12 @@ _NodeKind _decodeNodeKind(Object? value) { 'toast' => _NodeKind.toast, 'toolbar' => _NodeKind.toolbar, 'status-bar' => _NodeKind.statusBar, + 'list-section' => _NodeKind.listSection, + 'list-section-header' => _NodeKind.listSectionHeader, + 'list-section-footer' => _NodeKind.listSectionFooter, + 'swipe-actions' => _NodeKind.swipeActions, + 'swipe-action' => _NodeKind.swipeAction, + 'file-picker' => _NodeKind.filePicker, 'link' => _NodeKind.link, 'file-image' => _NodeKind.fileImage, 'file-preview' => _NodeKind.filePreview, diff --git a/platform/flutter/native/lui_ocaml_bridge.c b/platform/flutter/native/lui_ocaml_bridge.c index f9428e14..77c76d9a 100644 --- a/platform/flutter/native/lui_ocaml_bridge.c +++ b/platform/flutter/native/lui_ocaml_bridge.c @@ -102,6 +102,19 @@ LUI_EXPORT int32_t lui_ocaml_dismiss(int64_t node) { return emit_patch(caml_callback_exn(*dispatch, Val_long(node))); } +LUI_EXPORT int32_t lui_ocaml_picked(int64_t node, const char *payload) { + CAMLparam0(); + CAMLlocal2(payload_value, result); + const value *dispatch = caml_named_value("lui_flutter_picked"); + if (dispatch == NULL || payload == NULL) { + CAMLreturnT(int32_t, 0); + } + payload_value = caml_copy_string(payload); + result = caml_callback2_exn(*dispatch, Val_long(node), payload_value); + int32_t accepted = emit_patch(result); + CAMLreturnT(int32_t, accepted); +} + LUI_EXPORT int32_t lui_ocaml_double_press(int64_t node) { const value *dispatch = caml_named_value("lui_flutter_double_press"); if (dispatch == NULL) { diff --git a/platform/native/lui_ocaml_bridge.c b/platform/native/lui_ocaml_bridge.c index ed8ced52..310dae7c 100644 --- a/platform/native/lui_ocaml_bridge.c +++ b/platform/native/lui_ocaml_bridge.c @@ -108,6 +108,19 @@ LUI_EXPORT int32_t lui_ocaml_dismiss(int64_t node) { return emit_patch(caml_callback_exn(*dispatch, Val_long(node))); } +LUI_EXPORT int32_t lui_ocaml_picked(int64_t node, const char *payload) { + CAMLparam0(); + CAMLlocal2(payload_value, result); + const value *dispatch = caml_named_value("lui_ocaml_picked"); + if (dispatch == NULL || payload == NULL) { + CAMLreturnT(int32_t, 0); + } + payload_value = caml_copy_string(payload); + result = caml_callback2_exn(*dispatch, Val_long(node), payload_value); + int32_t accepted = emit_patch(result); + CAMLreturnT(int32_t, accepted); +} + LUI_EXPORT int32_t lui_ocaml_double_press(int64_t node) { const value *dispatch = caml_named_value("lui_ocaml_double_press"); if (dispatch == NULL) { diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 741d77de..24b71ea9 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -60,6 +60,7 @@ qt_add_qml_module(lui_qml ../qml/LuiDropdownMenu.qml ../qml/LuiEdgeInset.qml ../qml/LuiFileImage.qml + ../qml/LuiFilePicker.qml ../qml/LuiFilePreview.qml ../qml/LuiGrid.qml ../qml/LuiHeading.qml diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index b6c7750b..bea4b30e 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -1244,7 +1244,8 @@ bool LuiQmlBackend::performDismiss(qint64 node) { if (state->kind != NodeKind::Select && state->kind != NodeKind::Combobox && state->kind != NodeKind::DropdownMenu && state->kind != NodeKind::Toast && - state->kind != NodeKind::FilePreview && !modalSurface(state->kind)) { + state->kind != NodeKind::FilePreview && + state->kind != NodeKind::FilePicker && !modalSurface(state->kind)) { return fail(QStringLiteral("node %1 is not dismissible").arg(node)); } emitEvent(node, QStringLiteral("dismiss")); diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 728a24a5..48370ed6 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -194,7 +194,10 @@ bool canContainChildren(NodeKind kind) { NodeKind::Stepper, NodeKind::Timeline, NodeKind::InputGroup, NodeKind::InputGroupActions, NodeKind::Toast, NodeKind::Toolbar, NodeKind::Alert, NodeKind::Bubble, - NodeKind::BottomTabs, NodeKind::BottomTab, NodeKind::Link}); + NodeKind::BottomTabs, NodeKind::BottomTab, NodeKind::Link, + NodeKind::FilePicker, NodeKind::ListSection, + NodeKind::ListSectionHeader, NodeKind::ListSectionFooter, + NodeKind::SwipeActions}); } bool acceptsExtensionChildren(NodeKind kind) { @@ -207,7 +210,9 @@ bool acceptsExtensionChildren(NodeKind kind) { NodeKind::ListItem, NodeKind::Dialog, NodeKind::Sheet, NodeKind::Accordion, NodeKind::Resizable, NodeKind::Split, NodeKind::Drawer, NodeKind::Alert, NodeKind::Bubble, - NodeKind::Toast, NodeKind::Toolbar, NodeKind::BottomTab}); + NodeKind::Toast, NodeKind::Toolbar, NodeKind::BottomTab, + NodeKind::ListSection, NodeKind::ListSectionHeader, + NodeKind::ListSectionFooter}); } bool childKindSupported(NodeKind parent, NodeKind child) { @@ -240,9 +245,23 @@ bool childKindSupported(NodeKind parent, NodeKind child) { case NodeKind::DropdownMenu: case NodeKind::ContextMenu: return child == NodeKind::MenuItem || child == NodeKind::Divider; + case NodeKind::ListSection: + return child == NodeKind::ListItem || + child == NodeKind::ListSectionHeader || + child == NodeKind::ListSectionFooter; + case NodeKind::SwipeActions: + return child == NodeKind::SwipeAction; default: - return true; + break; + } + if (child == NodeKind::ListSection) return parent == NodeKind::ListContainer; + if (child == NodeKind::ListSectionHeader || + child == NodeKind::ListSectionFooter) { + return parent == NodeKind::ListSection; } + if (child == NodeKind::SwipeActions) return parent == NodeKind::ListItem; + if (child == NodeKind::SwipeAction) return parent == NodeKind::SwipeActions; + return true; } bool eventSupported(NodeKind kind, Event event) { @@ -252,7 +271,8 @@ bool eventSupported(NodeKind kind, Event event) { NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Text, NodeKind::TableCell, NodeKind::TimelineItem, - NodeKind::FileImage, NodeKind::BottomTab}); + NodeKind::FileImage, NodeKind::BottomTab, + NodeKind::SwipeAction}); case Event::LongPress: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -270,7 +290,7 @@ bool eventSupported(NodeKind kind, Event event) { return oneOf(kind, {NodeKind::ToggleButton, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio, NodeKind::Accordion, - NodeKind::Drawer}); + NodeKind::Drawer, NodeKind::ListItem}); case Event::Change: return kind == NodeKind::Radio; case Event::ValueChanged: @@ -280,11 +300,16 @@ bool eventSupported(NodeKind kind, Event event) { return oneOf(kind, {NodeKind::Select, NodeKind::Combobox, NodeKind::DropdownMenu, NodeKind::Toast, NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, - NodeKind::FilePreview}); + NodeKind::FilePreview, NodeKind::FilePicker}); case Event::DoublePress: return kind == NodeKind::ListItem; case Event::Appear: return kind != NodeKind::Root; + case Event::ScrollCompleted: + case Event::VisibleRange: + return kind == NodeKind::ListContainer; + case Event::Picked: + return kind == NodeKind::FilePicker; } return false; } @@ -568,6 +593,18 @@ bool commonPropertySupported(NodeKind kind, Property property) { case Property::ResizeEasing: case Property::ResizeOrigin: return kind == NodeKind::Split; + case Property::KeyValue: + case Property::SeparatorValue: + return kind == NodeKind::ListItem || kind == NodeKind::ListSection; + case Property::StyleValue: + case Property::ScrollTarget: + case Property::ScrollAnchor: + case Property::ScrollToken: + case Property::ScrollAnimated: + case Property::TrackVisibleRange: + return kind == NodeKind::ListContainer; + case Property::EdgeValue: + return kind == NodeKind::SwipeAction; case Property::ThemeValue: case Property::ThemeMode: return canContainChildren(kind); @@ -694,6 +731,24 @@ bool propertySupported(NodeKind kind, Property property) { property == Property::GrowValue; case NodeKind::InputGroupActions: return property == Property::Gap; + case NodeKind::ListSection: + return property == Property::KeyValue || + property == Property::SeparatorValue; + case NodeKind::SwipeActions: + return false; + case NodeKind::SwipeAction: + return oneOf(property, {Property::TextValue, Property::InlineIconName, + Property::VariantValue, Property::EdgeValue, + Property::Enabled, Property::BackgroundValue, + Property::PressEnabled}); + case NodeKind::FilePicker: + return property == Property::PickerRequest || + property == Property::PickerTypes || + property == Property::PickerMultiple || + property == Property::PickerSource || + property == Property::PickerCompletion || + property == Property::Enabled || + property == Property::AppearEnabled; case NodeKind::Dialog: return property == Property::DescriptionValue || commonPropertySupported(kind, property); @@ -853,6 +908,38 @@ bool propertyValueSupported(Property property, const QVariant &value) { case Property::ThemeMode: return isString(value) && inSet(value.toString(), {"system", "light", "dark"}); + case Property::KeyValue: + case Property::ScrollTarget: + return isString(value); + case Property::SeparatorValue: + return isString(value) && + inSet(value.toString(), {"visible", "hidden"}); + case Property::StyleValue: + return isString(value) && + inSet(value.toString(), {"plain", "inset", "inset-grouped"}); + case Property::ScrollAnchor: + return isString(value) && + inSet(value.toString(), {"top", "center", "bottom"}); + case Property::ScrollToken: { + int number = 0; + return isIntValue(value, &number) && number >= 0; + } + case Property::ScrollAnimated: + case Property::TrackVisibleRange: + return isBool(value); + case Property::EdgeValue: + return isString(value) && + inSet(value.toString(), {"leading", "trailing"}); + case Property::PickerRequest: + case Property::PickerCompletion: + return isString(value) || isIntValue(value); + case Property::PickerTypes: + return isString(value); + case Property::PickerMultiple: + return isBool(value); + case Property::PickerSource: + return isString(value) && + inSet(value.toString(), {"files", "photos", "camera"}); case Property::MinValue: case Property::MaxValue: return isNumeric(value); @@ -1042,12 +1129,25 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { properties.contains(QStringLiteral("expanded")) || properties.contains(QStringLiteral("change-enabled")) || properties.contains(QStringLiteral("toggle-enabled")); - if (hasTreeMetadata && !treeitem) return false; + // A list-item may carry expansion state as a disclosure row without + // the treeitem role. + const bool disclosureListItem = + kind == NodeKind::ListItem && !treeitem && + !properties.contains(QStringLiteral("tree-level")) && + !properties.contains(QStringLiteral("change-enabled")) && + (properties.contains(QStringLiteral("expanded")) || + properties.contains(QStringLiteral("toggle-enabled"))); + if (hasTreeMetadata && !treeitem && !disclosureListItem) return false; if (properties.contains(QStringLiteral("expanded")) && !trueProperty(properties, "toggle-enabled")) { return false; } } + if (kind == NodeKind::SwipeAction && + !stringPropertyNonempty(properties, "text") && + !stringPropertyNonempty(properties, "icon")) { + return false; + } return true; } diff --git a/platform/qt/lib/lui_schema.h b/platform/qt/lib/lui_schema.h index f9e8589c..7149633c 100644 --- a/platform/qt/lib/lui_schema.h +++ b/platform/qt/lib/lui_schema.h @@ -21,6 +21,9 @@ enum class Event { Dismiss, DoublePress, Appear, + ScrollCompleted, + VisibleRange, + Picked, }; bool modalSurface(NodeKind kind); diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fc4f8b7e..20901ff7 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -82,6 +82,12 @@ enum class NodeKind { Toast, Toolbar, StatusBar, + ListSection, + ListSectionHeader, + ListSectionFooter, + SwipeActions, + SwipeAction, + FilePicker, Link, FileImage, FilePreview, @@ -161,6 +167,20 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + KeyValue, + SeparatorValue, + StyleValue, + ScrollTarget, + ScrollAnchor, + ScrollToken, + ScrollAnimated, + TrackVisibleRange, + EdgeValue, + PickerRequest, + PickerTypes, + PickerMultiple, + PickerSource, + PickerCompletion, MinValue, MaxValue, StepValue, @@ -169,7 +189,6 @@ enum class Property { PathValue, UrlValue, MaxPixelSize, - EdgeValue, Visible, AlignmentValue, }; @@ -252,6 +271,12 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::Toast: return "toast"; case NodeKind::Toolbar: return "toolbar"; case NodeKind::StatusBar: return "status-bar"; + case NodeKind::ListSection: return "list-section"; + case NodeKind::ListSectionHeader: return "list-section-header"; + case NodeKind::ListSectionFooter: return "list-section-footer"; + case NodeKind::SwipeActions: return "swipe-actions"; + case NodeKind::SwipeAction: return "swipe-action"; + case NodeKind::FilePicker: return "file-picker"; case NodeKind::Link: return "link"; case NodeKind::FileImage: return "file-image"; case NodeKind::FilePreview: return "file-preview"; @@ -336,6 +361,12 @@ inline bool decodeNodeKind(const char *name, NodeKind *kind) { if (std::strcmp(name, "toast") == 0) { *kind = NodeKind::Toast; return true; } if (std::strcmp(name, "toolbar") == 0) { *kind = NodeKind::Toolbar; return true; } if (std::strcmp(name, "status-bar") == 0) { *kind = NodeKind::StatusBar; return true; } + if (std::strcmp(name, "list-section") == 0) { *kind = NodeKind::ListSection; return true; } + if (std::strcmp(name, "list-section-header") == 0) { *kind = NodeKind::ListSectionHeader; return true; } + if (std::strcmp(name, "list-section-footer") == 0) { *kind = NodeKind::ListSectionFooter; return true; } + if (std::strcmp(name, "swipe-actions") == 0) { *kind = NodeKind::SwipeActions; return true; } + if (std::strcmp(name, "swipe-action") == 0) { *kind = NodeKind::SwipeAction; return true; } + if (std::strcmp(name, "file-picker") == 0) { *kind = NodeKind::FilePicker; return true; } if (std::strcmp(name, "link") == 0) { *kind = NodeKind::Link; return true; } if (std::strcmp(name, "file-image") == 0) { *kind = NodeKind::FileImage; return true; } if (std::strcmp(name, "file-preview") == 0) { *kind = NodeKind::FilePreview; return true; } @@ -393,6 +424,11 @@ inline bool containerNodeKind(NodeKind kind) { case NodeKind::Sheet: case NodeKind::Toast: case NodeKind::Toolbar: + case NodeKind::ListSection: + case NodeKind::ListSectionHeader: + case NodeKind::ListSectionFooter: + case NodeKind::SwipeActions: + case NodeKind::FilePicker: case NodeKind::Link: return true; default: @@ -479,6 +515,12 @@ inline const char *nodeKindComponentName(NodeKind kind) { case NodeKind::Toast: return "LuiToast.qml"; case NodeKind::Toolbar: return "LuiToolbar.qml"; case NodeKind::StatusBar: return "LuiStatusBar.qml"; + case NodeKind::ListSection: return "LuiListSection.qml"; + case NodeKind::ListSectionHeader: return "LuiListSectionHeader.qml"; + case NodeKind::ListSectionFooter: return "LuiListSectionFooter.qml"; + case NodeKind::SwipeActions: return "LuiSwipeActions.qml"; + case NodeKind::SwipeAction: return "LuiSwipeAction.qml"; + case NodeKind::FilePicker: return "LuiFilePicker.qml"; case NodeKind::Link: return "LuiLink.qml"; case NodeKind::FileImage: return "LuiFileImage.qml"; case NodeKind::FilePreview: return "LuiFilePreview.qml"; @@ -561,6 +603,20 @@ inline const char *propertyWireName(Property property) { case Property::ResizeOrigin: return "resize-origin"; case Property::ThemeValue: return "theme"; case Property::ThemeMode: return "theme-mode"; + case Property::KeyValue: return "key"; + case Property::SeparatorValue: return "separator"; + case Property::StyleValue: return "style"; + case Property::ScrollTarget: return "scroll-target"; + case Property::ScrollAnchor: return "scroll-anchor"; + case Property::ScrollToken: return "scroll-token"; + case Property::ScrollAnimated: return "scroll-animated"; + case Property::TrackVisibleRange: return "track-visible-range"; + case Property::EdgeValue: return "edge"; + case Property::PickerRequest: return "request"; + case Property::PickerTypes: return "types"; + case Property::PickerMultiple: return "multiple"; + case Property::PickerSource: return "source"; + case Property::PickerCompletion: return "completion"; case Property::MinValue: return "min"; case Property::MaxValue: return "max"; case Property::StepValue: return "step"; @@ -569,7 +625,6 @@ inline const char *propertyWireName(Property property) { case Property::PathValue: return "path"; case Property::UrlValue: return "url"; case Property::MaxPixelSize: return "max-pixel-size"; - case Property::EdgeValue: return "edge"; case Property::Visible: return "visible"; case Property::AlignmentValue: return "alignment"; } @@ -650,6 +705,20 @@ inline bool decodePropertyWireName(const char *name, Property *property) { if (std::strcmp(name, "resize-origin") == 0) { *property = Property::ResizeOrigin; return true; } if (std::strcmp(name, "theme") == 0) { *property = Property::ThemeValue; return true; } if (std::strcmp(name, "theme-mode") == 0) { *property = Property::ThemeMode; return true; } + if (std::strcmp(name, "key") == 0) { *property = Property::KeyValue; return true; } + if (std::strcmp(name, "separator") == 0) { *property = Property::SeparatorValue; return true; } + if (std::strcmp(name, "style") == 0) { *property = Property::StyleValue; return true; } + if (std::strcmp(name, "scroll-target") == 0) { *property = Property::ScrollTarget; return true; } + if (std::strcmp(name, "scroll-anchor") == 0) { *property = Property::ScrollAnchor; return true; } + if (std::strcmp(name, "scroll-token") == 0) { *property = Property::ScrollToken; return true; } + if (std::strcmp(name, "scroll-animated") == 0) { *property = Property::ScrollAnimated; return true; } + if (std::strcmp(name, "track-visible-range") == 0) { *property = Property::TrackVisibleRange; return true; } + if (std::strcmp(name, "edge") == 0) { *property = Property::EdgeValue; return true; } + if (std::strcmp(name, "request") == 0) { *property = Property::PickerRequest; return true; } + if (std::strcmp(name, "types") == 0) { *property = Property::PickerTypes; return true; } + if (std::strcmp(name, "multiple") == 0) { *property = Property::PickerMultiple; return true; } + if (std::strcmp(name, "source") == 0) { *property = Property::PickerSource; return true; } + if (std::strcmp(name, "completion") == 0) { *property = Property::PickerCompletion; return true; } if (std::strcmp(name, "min") == 0) { *property = Property::MinValue; return true; } if (std::strcmp(name, "max") == 0) { *property = Property::MaxValue; return true; } if (std::strcmp(name, "step") == 0) { *property = Property::StepValue; return true; } @@ -658,7 +727,6 @@ inline bool decodePropertyWireName(const char *name, Property *property) { if (std::strcmp(name, "path") == 0) { *property = Property::PathValue; return true; } if (std::strcmp(name, "url") == 0) { *property = Property::UrlValue; return true; } if (std::strcmp(name, "max-pixel-size") == 0) { *property = Property::MaxPixelSize; return true; } - if (std::strcmp(name, "edge") == 0) { *property = Property::EdgeValue; return true; } if (std::strcmp(name, "visible") == 0) { *property = Property::Visible; return true; } if (std::strcmp(name, "alignment") == 0) { *property = Property::AlignmentValue; return true; } return false; diff --git a/platform/qt/qml/LuiFilePicker.qml b/platform/qt/qml/LuiFilePicker.qml new file mode 100644 index 00000000..acbe94ad --- /dev/null +++ b/platform/qt/qml/LuiFilePicker.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts + +// Wire kind: file-picker — non-visual element; the Qt backend does not +// present a native file dialog. Per the element contract a request the +// backend cannot serve is answered with `dismiss`. Children (if any) +// render inline. +Item { + id: picker + required property var node + readonly property var props: node ? node.properties : ({}) + + // Re-evaluates on every node change; `onPendingRequestChanged` fires + // only when the request token itself changes (a re-sent identical + // token is not a new request). + readonly property var pendingRequest: props["request"] + readonly property var completionToken: props["completion"] + + function reportUnserved() { + if (!node) return + if (pendingRequest === undefined || pendingRequest === null) return + // A token equal to `completion` was already acknowledged. + if (pendingRequest === completionToken) return + node.dismiss() + } + + onPendingRequestChanged: reportUnserved() + Component.onCompleted: reportUnserved() + + implicitWidth: col.implicitWidth + implicitHeight: col.implicitHeight + + ColumnLayout { + id: col + spacing: 0 + Repeater { + model: picker.node ? picker.node.children : [] + delegate: LuiNodeView { + required property var modelData + node: modelData + } + } + } +} diff --git a/platform/web/melange/core/lui_web_store.ml b/platform/web/melange/core/lui_web_store.ml index 2b89e4fe..3358e08e 100644 --- a/platform/web/melange/core/lui_web_store.ml +++ b/platform/web/melange/core/lui_web_store.ml @@ -319,22 +319,35 @@ let node_properties_error current = else if kind = Icon then "icon requires a valid name" else "node properties conflict" -let context_menu_child nodes child_id = +let metadata_child nodes child_id = match Hashtbl.find_opt nodes child_id with - | Some current -> standard_kind_is current ContextMenu + | Some current -> + standard_kind_is current ContextMenu + || standard_kind_is current SwipeActions + | None -> false + +let list_item_row_child nodes child_id = + match Hashtbl.find_opt nodes child_id with + | Some current -> standard_kind_is current ListItem | None -> false let validate_list_item_content nodes current = if standard_kind_is current ListItem then begin - let text = string_property_of current.retained_properties TextValue in - let visible_children = + let properties = current.retained_properties in + let text = string_property_of properties TextValue in + (* swipe-actions/context-menu children are metadata, not content; a + disclosure row's nested list-items don't count as content either. *) + let disclosure = Property_map.mem Expanded properties in + let content_children = List.filter - (fun child -> not (context_menu_child nodes child)) + (fun child -> + not (metadata_child nodes child) + && not (disclosure && list_item_row_child nodes child)) current.retained_children in - if text <> "" && visible_children <> [] then + if text <> "" && content_children <> [] then invalid_arg "list-item accepts text or children, not both"; - if text = "" && visible_children = [] then + if text = "" && content_children = [] then invalid_arg "list-item requires text or children" end @@ -382,7 +395,12 @@ let validate_context_menu_child nodes child = let validate_context_menu nodes current = let context_children = - List.filter (context_menu_child nodes) current.retained_children + List.filter + (fun child_id -> + match Hashtbl.find_opt nodes child_id with + | Some current -> standard_kind_is current ContextMenu + | None -> false) + current.retained_children in if List.length context_children > 1 then invalid_arg "host accepts at most one context-menu"; diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index cb823b08..0fb137eb 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -90,6 +90,12 @@ let base_class_name kind = | Split -> "lui-split" | Drawer -> "lui-drawer" | StatusBar -> "lui-status-bar" + | ListSection -> "lui-list-section" + | ListSectionHeader -> "lui-list-section-header" + | ListSectionFooter -> "lui-list-section-footer" + | SwipeActions -> "lui-swipe-actions" + | SwipeAction -> "lui-swipe-action" + | FilePicker -> "lui-file-picker" | Link -> "lui-link" | FileImage -> "lui-file-image" | FilePreview -> "lui-file-preview" diff --git a/platform/web/melange/shell/lui_web_apply.ml b/platform/web/melange/shell/lui_web_apply.ml index fecfc4ac..1b017cda 100644 --- a/platform/web/melange/shell/lui_web_apply.ml +++ b/platform/web/melange/shell/lui_web_apply.ml @@ -231,6 +231,7 @@ let insert_child_dom renderer previous_nodes parent child index = | Some ContextMenu -> W.Element.appendChild (W.Element.asNode child_dom) (Util.document_body renderer) + | Some (SwipeActions | SwipeAction) -> () | _ -> let container = dom_child_container renderer parent parent_dom @@ -344,6 +345,10 @@ let apply_move_child renderer previous_nodes parent child index = let tooltip = prev_anchored_tooltip previous_nodes child in let toast = prev_kind_is previous_nodes child Toast in let metadata = prev_kind_is previous_nodes child ContextMenu in + let swipe_meta = + prev_kind_is previous_nodes child SwipeActions + || prev_kind_is previous_nodes child SwipeAction + in let bottom_tab = match Store.node renderer.web_store child with | Some current -> Store.standard_kind_is current BottomTab @@ -370,7 +375,8 @@ let apply_move_child renderer previous_nodes parent child index = (W.Element.removeChild (W.Element.asNode child_node) actual_parent) | None -> ()); - if dropdown || modal || tooltip || toast || metadata then + if swipe_meta then () + else if dropdown || modal || tooltip || toast || metadata then W.Element.appendChild (W.Element.asNode child_node) parent_node else Util.insert_dom_child parent_node child_node diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index 796daa06..ea7d05df 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -168,10 +168,12 @@ public void ApplyJson(string source) JsonElement operations = ObjectList(batchRoot.GetProperty("ops"), "ops"); + var filePickerRequests = new HashSet(); foreach (JsonElement operation in operations.EnumerateArray()) { ApplyState( - next, nextExtensions, ObjectMap(operation, "operation")); + next, nextExtensions, ObjectMap(operation, "operation"), + filePickerRequests); } ValidateStates(next); ValidateExtensionStates(next, nextExtensions); @@ -262,12 +264,38 @@ public void ApplyJson(string source) Dependent = dependent, Removed = removed, }); + // This backend cannot present a picker: every file-picker + // request the batch left unanswered (request set, not matching + // `completion`) is answered with `dismiss`, per the element + // contract. + foreach (long id in filePickerRequests) + { + if (!_states.TryGetValue(id, out LUINodeState? state) || + state.Kind != LUINodeKind.FilePicker) + { + continue; + } + if (!state.Properties.TryGetValue( + LUIProperty.PickerRequest, out LUIWireValue? request)) + { + continue; + } + if (state.Properties.TryGetValue( + LUIProperty.PickerCompletion, + out LUIWireValue? completion) && + completion.Equals(request)) + { + continue; + } + OnEvent?.Invoke(new LUIEvent.Dismiss(id)); + } } void ApplyState( Dictionary states, Dictionary extensions, - JsonElement operation) + JsonElement operation, + HashSet filePickerRequests) { switch (StringProperty(operation, "op")) { @@ -377,6 +405,11 @@ void ApplyState( $"{propertyName}"); } node.Properties[property] = value; + if (node.Kind == LUINodeKind.FilePicker && + property == LUIProperty.PickerRequest) + { + filePickerRequests.Add(id); + } break; } case "remove-prop": @@ -1518,6 +1551,7 @@ public void PerformDismiss(long node) state.Kind != LUINodeKind.Combobox && state.Kind != LUINodeKind.DropdownMenu && state.Kind != LUINodeKind.Toast && + state.Kind != LUINodeKind.FilePicker && state.Kind != LUINodeKind.FilePreview && !LUISchema.ModalSurface(state.Kind)) { @@ -1526,6 +1560,17 @@ public void PerformDismiss(long node) OnEvent?.Invoke(new LUIEvent.Dismiss(node)); } + public void PerformPicked(long node, string payload) + { + LUINodeState state = RequireState(node); + if (state.Kind != LUINodeKind.FilePicker) + { + throw new LUIBackendException( + $"node {node} is not a file-picker"); + } + OnEvent?.Invoke(new LUIEvent.Picked(node, payload)); + } + public void PerformTextChanged(long node, string text) { LUINodeState state = RequireState(node); diff --git a/platform/winui/LUI.Core/LUINodeState.cs b/platform/winui/LUI.Core/LUINodeState.cs index e101476c..5f81ffc1 100644 --- a/platform/winui/LUI.Core/LUINodeState.cs +++ b/platform/winui/LUI.Core/LUINodeState.cs @@ -109,6 +109,7 @@ public sealed record LongPress(long Node) : LUIEvent; public sealed record DoublePress(long Node) : LUIEvent; public sealed record Submit(long Node) : LUIEvent; public sealed record Dismiss(long Node) : LUIEvent; + public sealed record Picked(long Node, string Payload) : LUIEvent; public sealed record Change(long Node) : LUIEvent; public sealed record TextChanged(long Node, string Text) : LUIEvent; public sealed record ToggleChanged(long Node, bool Checked) : LUIEvent; diff --git a/platform/winui/LUI.Core/LUIOcamlBridge.cs b/platform/winui/LUI.Core/LUIOcamlBridge.cs index f58a9df6..a609724c 100644 --- a/platform/winui/LUI.Core/LUIOcamlBridge.cs +++ b/platform/winui/LUI.Core/LUIOcamlBridge.cs @@ -54,6 +54,10 @@ static extern int lui_ocaml_text_changed( [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] static extern int lui_ocaml_dismiss(long node); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + static extern int lui_ocaml_picked( + long node, [MarshalAs(UnmanagedType.LPUTF8Str)] string payload); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] static extern int lui_ocaml_double_press(long node); @@ -132,6 +136,9 @@ void HandleBackendEvent(LUIEvent payload) case LUIEvent.Dismiss dismiss: lui_ocaml_dismiss(dismiss.Node); break; + case LUIEvent.Picked picked: + lui_ocaml_picked(picked.Node, picked.Payload); + break; case LUIEvent.Change change: lui_ocaml_radio_changed(change.Node); break; diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 51e0ef63..33c203a7 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -18,7 +18,10 @@ public enum LUIEventKind Dismiss, DoublePress, Appear, + Picked, Extension, + ScrollCompleted, + VisibleRange, } public static class LUISchema @@ -159,6 +162,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.TimelineItem: case LUINodeKind.FileImage: case LUINodeKind.BottomTab: + case LUINodeKind.SwipeAction: return true; default: return false; @@ -199,6 +203,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Radio: case LUINodeKind.Accordion: case LUINodeKind.Drawer: + case LUINodeKind.ListItem: return true; default: return false; @@ -219,6 +224,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Dialog: case LUINodeKind.Drawer: case LUINodeKind.Sheet: + case LUINodeKind.FilePicker: case LUINodeKind.FilePreview: return true; default: @@ -226,8 +232,13 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) } case LUIEventKind.DoublePress: return kind == LUINodeKind.ListItem; + case LUIEventKind.Picked: + return kind == LUINodeKind.FilePicker; case LUIEventKind.Appear: return kind != LUINodeKind.Root; + case LUIEventKind.ScrollCompleted: + case LUIEventKind.VisibleRange: + return kind == LUINodeKind.ListContainer; case LUIEventKind.Extension: return false; default: @@ -683,6 +694,20 @@ public static bool CommonPropertySupported( case LUIProperty.ResizeEasing: case LUIProperty.ResizeOrigin: return kind == LUINodeKind.Split; + case LUIProperty.KeyValue: + case LUIProperty.SeparatorValue: + return kind == LUINodeKind.ListItem || + kind == LUINodeKind.ListSection; + case LUIProperty.StyleValue: + case LUIProperty.ScrollTarget: + case LUIProperty.ScrollAnchor: + case LUIProperty.ScrollToken: + case LUIProperty.ScrollAnimated: + case LUIProperty.TrackVisibleRange: + return kind == LUINodeKind.ListContainer; + case LUIProperty.EdgeValue: + return kind == LUINodeKind.SwipeAction || + kind == LUINodeKind.EdgeInset; case LUIProperty.MinValue: case LUIProperty.MaxValue: case LUIProperty.StepValue: @@ -693,7 +718,6 @@ public static bool CommonPropertySupported( case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); - case LUIProperty.EdgeValue: case LUIProperty.Visible: return kind == LUINodeKind.EdgeInset; // AlignmentValue is admitted ahead of the restrictive matrix @@ -1140,6 +1164,57 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + case LUIProperty.KeyValue: + case LUIProperty.ScrollTarget: + { + return value is LUIWireValue.String; + } + case LUIProperty.SeparatorValue: + { + return value is LUIWireValue.String text && + (text.Value == "visible" || text.Value == "hidden"); + } + case LUIProperty.StyleValue: + { + return value is LUIWireValue.String text && + (text.Value == "plain" || text.Value == "inset" || + text.Value == "inset-grouped"); + } + case LUIProperty.ScrollAnchor: + { + return value is LUIWireValue.String text && + (text.Value == "top" || text.Value == "center" || + text.Value == "bottom"); + } + case LUIProperty.ScrollToken: + { + return value is LUIWireValue.Int { Value: >= 0 }; + } + case LUIProperty.ScrollAnimated: + case LUIProperty.TrackVisibleRange: + { + return value is LUIWireValue.Bool; + } + case LUIProperty.PickerRequest: + case LUIProperty.PickerCompletion: + { + return value is LUIWireValue.String || + value is LUIWireValue.Int; + } + case LUIProperty.PickerTypes: + { + return value is LUIWireValue.String; + } + case LUIProperty.PickerMultiple: + { + return value is LUIWireValue.Bool; + } + case LUIProperty.PickerSource: + { + return value is LUIWireValue.String text && + (text.Value == "files" || text.Value == "photos" || + text.Value == "camera"); + } case LUIProperty.MinValue: case LUIProperty.MaxValue: { @@ -1507,13 +1582,30 @@ maximum is LUIWireValue.Float maxFloat properties.ContainsKey(LUIProperty.Expanded) || properties.ContainsKey(LUIProperty.ChangeEnabled) || properties.ContainsKey(LUIProperty.ToggleEnabled); - if (hasTreeMetadata && !treeitem) return false; + // A list-item may carry expansion state as a disclosure row + // without the treeitem role. + bool disclosureListItem = + kind == LUINodeKind.ListItem && !treeitem && + !properties.ContainsKey(LUIProperty.TreeLevel) && + !properties.ContainsKey(LUIProperty.ChangeEnabled) && + (properties.ContainsKey(LUIProperty.Expanded) || + properties.ContainsKey(LUIProperty.ToggleEnabled)); + if (hasTreeMetadata && !treeitem && !disclosureListItem) + { + return false; + } if (properties.ContainsKey(LUIProperty.Expanded) && !TrueProperty(properties, LUIProperty.ToggleEnabled)) { return false; } } + if (kind == LUINodeKind.SwipeAction && + !StringPropertyNonempty(properties, LUIProperty.TextValue) && + !StringPropertyNonempty(properties, LUIProperty.InlineIconName)) + { + return false; + } return true; } @@ -1562,6 +1654,11 @@ public static bool CanContainChildren(LUINodeKind kind) case LUINodeKind.Bubble: case LUINodeKind.BottomTabs: case LUINodeKind.BottomTab: + case LUINodeKind.ListSection: + case LUINodeKind.ListSectionHeader: + case LUINodeKind.ListSectionFooter: + case LUINodeKind.SwipeActions: + case LUINodeKind.FilePicker: case LUINodeKind.Link: return true; default: @@ -1609,9 +1706,33 @@ public static bool ChildKindSupported( case LUINodeKind.ContextMenu: return childKind == LUINodeKind.MenuItem || childKind == LUINodeKind.Divider; + case LUINodeKind.ListSection: + return childKind == LUINodeKind.ListItem || + childKind == LUINodeKind.ListSectionHeader || + childKind == LUINodeKind.ListSectionFooter; + case LUINodeKind.SwipeActions: + return childKind == LUINodeKind.SwipeAction; default: - return true; + break; + } + if (childKind == LUINodeKind.ListSection) + { + return parentKind == LUINodeKind.ListContainer; + } + if (childKind == LUINodeKind.ListSectionHeader || + childKind == LUINodeKind.ListSectionFooter) + { + return parentKind == LUINodeKind.ListSection; + } + if (childKind == LUINodeKind.SwipeActions) + { + return parentKind == LUINodeKind.ListItem; } + if (childKind == LUINodeKind.SwipeAction) + { + return parentKind == LUINodeKind.SwipeActions; + } + return true; } } } diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 0273ed64..7e9b5732 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -84,6 +84,12 @@ public enum LUINodeKind Toast, Toolbar, StatusBar, + ListSection, + ListSectionHeader, + ListSectionFooter, + SwipeActions, + SwipeAction, + FilePicker, Link, FileImage, FilePreview, @@ -164,6 +170,20 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + KeyValue, + SeparatorValue, + StyleValue, + ScrollTarget, + ScrollAnchor, + ScrollToken, + ScrollAnimated, + TrackVisibleRange, + EdgeValue, + PickerRequest, + PickerTypes, + PickerMultiple, + PickerSource, + PickerCompletion, MinValue, MaxValue, StepValue, @@ -172,7 +192,6 @@ public enum LUIProperty PathValue, UrlValue, MaxPixelSize, - EdgeValue, Visible, AlignmentValue, } @@ -258,6 +277,12 @@ public static class LUIWireSchema { "toast", LUINodeKind.Toast }, { "toolbar", LUINodeKind.Toolbar }, { "status-bar", LUINodeKind.StatusBar }, + { "list-section", LUINodeKind.ListSection }, + { "list-section-header", LUINodeKind.ListSectionHeader }, + { "list-section-footer", LUINodeKind.ListSectionFooter }, + { "swipe-actions", LUINodeKind.SwipeActions }, + { "swipe-action", LUINodeKind.SwipeAction }, + { "file-picker", LUINodeKind.FilePicker }, { "link", LUINodeKind.Link }, { "file-image", LUINodeKind.FileImage }, { "file-preview", LUINodeKind.FilePreview }, @@ -339,6 +364,20 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "key", LUIProperty.KeyValue }, + { "separator", LUIProperty.SeparatorValue }, + { "style", LUIProperty.StyleValue }, + { "scroll-target", LUIProperty.ScrollTarget }, + { "scroll-anchor", LUIProperty.ScrollAnchor }, + { "scroll-token", LUIProperty.ScrollToken }, + { "scroll-animated", LUIProperty.ScrollAnimated }, + { "track-visible-range", LUIProperty.TrackVisibleRange }, + { "edge", LUIProperty.EdgeValue }, + { "request", LUIProperty.PickerRequest }, + { "types", LUIProperty.PickerTypes }, + { "multiple", LUIProperty.PickerMultiple }, + { "source", LUIProperty.PickerSource }, + { "completion", LUIProperty.PickerCompletion }, { "min", LUIProperty.MinValue }, { "max", LUIProperty.MaxValue }, { "step", LUIProperty.StepValue }, @@ -347,7 +386,6 @@ public static class LUIWireSchema { "path", LUIProperty.PathValue }, { "url", LUIProperty.UrlValue }, { "max-pixel-size", LUIProperty.MaxPixelSize }, - { "edge", LUIProperty.EdgeValue }, { "visible", LUIProperty.Visible }, { "alignment", LUIProperty.AlignmentValue }, }; @@ -430,6 +468,12 @@ public static class LUIWireSchema LUINodeKind.Toast => "toast", LUINodeKind.Toolbar => "toolbar", LUINodeKind.StatusBar => "status-bar", + LUINodeKind.ListSection => "list-section", + LUINodeKind.ListSectionHeader => "list-section-header", + LUINodeKind.ListSectionFooter => "list-section-footer", + LUINodeKind.SwipeActions => "swipe-actions", + LUINodeKind.SwipeAction => "swipe-action", + LUINodeKind.FilePicker => "file-picker", LUINodeKind.Link => "link", LUINodeKind.FileImage => "file-image", LUINodeKind.FilePreview => "file-preview", @@ -488,6 +532,11 @@ LUINodeKind.Drawer or LUINodeKind.Sheet or LUINodeKind.Toast or LUINodeKind.Toolbar or + LUINodeKind.ListSection or + LUINodeKind.ListSectionHeader or + LUINodeKind.ListSectionFooter or + LUINodeKind.SwipeActions or + LUINodeKind.FilePicker or LUINodeKind.Link => true, _ => false, }; @@ -567,6 +616,20 @@ LUINodeKind.Toolbar or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.KeyValue => "key", + LUIProperty.SeparatorValue => "separator", + LUIProperty.StyleValue => "style", + LUIProperty.ScrollTarget => "scroll-target", + LUIProperty.ScrollAnchor => "scroll-anchor", + LUIProperty.ScrollToken => "scroll-token", + LUIProperty.ScrollAnimated => "scroll-animated", + LUIProperty.TrackVisibleRange => "track-visible-range", + LUIProperty.EdgeValue => "edge", + LUIProperty.PickerRequest => "request", + LUIProperty.PickerTypes => "types", + LUIProperty.PickerMultiple => "multiple", + LUIProperty.PickerSource => "source", + LUIProperty.PickerCompletion => "completion", LUIProperty.MinValue => "min", LUIProperty.MaxValue => "max", LUIProperty.StepValue => "step", @@ -575,7 +638,6 @@ LUINodeKind.Toolbar or LUIProperty.PathValue => "path", LUIProperty.UrlValue => "url", LUIProperty.MaxPixelSize => "max-pixel-size", - LUIProperty.EdgeValue => "edge", LUIProperty.Visible => "visible", LUIProperty.AlignmentValue => "alignment", _ => "unknown", @@ -602,6 +664,10 @@ private static HashSet Set(params LUIProperty[] values) => { LUINodeKind.BottomTabs, Set(LUIProperty.AccessibilityLabel, LUIProperty.StyleClass, LUIProperty.GrowValue, LUIProperty.WidthValue, LUIProperty.HeightValue, LUIProperty.MinWidth, LUIProperty.MaxWidth, LUIProperty.MinHeight, LUIProperty.MaxHeight) }, { LUINodeKind.BottomTab, Set(LUIProperty.TitleValue, LUIProperty.InlineIconName, LUIProperty.Selected, LUIProperty.Enabled, LUIProperty.PressEnabled) }, { LUINodeKind.MenuTrigger, Set(LUIProperty.TextValue, LUIProperty.InlineIconName, LUIProperty.AccessibilityLabel, LUIProperty.Enabled, LUIProperty.ForegroundValue, LUIProperty.StyleClass) }, + { LUINodeKind.ListSection, Set(LUIProperty.KeyValue, LUIProperty.SeparatorValue) }, + { LUINodeKind.SwipeActions, Set() }, + { LUINodeKind.SwipeAction, Set(LUIProperty.TextValue, LUIProperty.InlineIconName, LUIProperty.VariantValue, LUIProperty.EdgeValue, LUIProperty.Enabled, LUIProperty.BackgroundValue, LUIProperty.PressEnabled) }, + { LUINodeKind.FilePicker, Set(LUIProperty.PickerRequest, LUIProperty.PickerTypes, LUIProperty.PickerMultiple, LUIProperty.PickerSource, LUIProperty.PickerCompletion, LUIProperty.Enabled, LUIProperty.AppearEnabled) }, { LUINodeKind.FilePreview, Set(LUIProperty.PathValue) }, }; diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index 84c1a6aa..5c9498b9 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -176,6 +176,11 @@ void SyncKind(LUINodeState state, LUISyncContext context) break; case LUINodeKind.MenuItem: break; + case LUINodeKind.FilePicker: + // Non-visual node: the WinUI backend does not present a + // picker; children sync inline. + SyncStackLike(state, context); + break; case LUINodeKind.BottomTabs: SyncBottomTabs(state, context); break; diff --git a/schema/components.json b/schema/components.json index 79276f45..b22487ab 100644 --- a/schema/components.json +++ b/schema/components.json @@ -137,6 +137,18 @@ "name": "list-item", "status": "supported" }, + { + "name": "list-section", + "status": "supported" + }, + { + "name": "swipe-actions", + "status": "supported" + }, + { + "name": "swipe-action", + "status": "supported" + }, { "name": "alert", "status": "supported" @@ -309,6 +321,10 @@ "name": "input-group-actions", "status": "supported" }, + { + "name": "file-picker", + "status": "supported" + }, { "name": "link", "status": "supported" @@ -855,6 +871,48 @@ "swift": "statusBar", "container": false }, + { + "lg": "ListSection", + "wire": "list-section", + "dart": "listSection", + "swift": "listSection", + "container": true + }, + { + "lg": "ListSectionHeader", + "wire": "list-section-header", + "dart": "listSectionHeader", + "swift": "listSectionHeader", + "container": true + }, + { + "lg": "ListSectionFooter", + "wire": "list-section-footer", + "dart": "listSectionFooter", + "swift": "listSectionFooter", + "container": true + }, + { + "lg": "SwipeActions", + "wire": "swipe-actions", + "dart": "swipeActions", + "swift": "swipeActions", + "container": true + }, + { + "lg": "SwipeAction", + "wire": "swipe-action", + "dart": "swipeAction", + "swift": "swipeAction", + "container": false + }, + { + "lg": "FilePicker", + "wire": "file-picker", + "dart": "filePicker", + "swift": "filePicker", + "container": true + }, { "lg": "Link", "wire": "link", @@ -1243,6 +1301,76 @@ "wire": "theme-mode", "swift": "themeMode" }, + { + "lg": "KeyValue", + "wire": "key", + "swift": "key" + }, + { + "lg": "SeparatorValue", + "wire": "separator", + "swift": "separator" + }, + { + "lg": "StyleValue", + "wire": "style", + "swift": "style" + }, + { + "lg": "ScrollTarget", + "wire": "scroll-target", + "swift": "scrollTarget" + }, + { + "lg": "ScrollAnchor", + "wire": "scroll-anchor", + "swift": "scrollAnchor" + }, + { + "lg": "ScrollToken", + "wire": "scroll-token", + "swift": "scrollToken" + }, + { + "lg": "ScrollAnimated", + "wire": "scroll-animated", + "swift": "scrollAnimated" + }, + { + "lg": "TrackVisibleRange", + "wire": "track-visible-range", + "swift": "trackVisibleRange" + }, + { + "lg": "EdgeValue", + "wire": "edge", + "swift": "edge" + }, + { + "lg": "PickerRequest", + "wire": "request", + "swift": "request" + }, + { + "lg": "PickerTypes", + "wire": "types", + "swift": "types" + }, + { + "lg": "PickerMultiple", + "wire": "multiple", + "swift": "multiple" + }, + { + "lg": "PickerSource", + "wire": "source", + "swift": "source" + }, + { + "lg": "PickerCompletion", + "wire": "completion", + "swift": "completion" + }, { "lg": "MinValue", "wire": "min", @@ -1283,11 +1411,6 @@ "wire": "max-pixel-size", "swift": "maxPixelSize" }, - { - "lg": "EdgeValue", - "wire": "edge", - "swift": "edge" - }, { "lg": "Visible", "wire": "visible", @@ -1362,6 +1485,29 @@ "fields": [ ":int" ] + }, + { + "lg": "ScrollCompleted", + "fields": [ + ":int", + ":int", + ":string" + ] + }, + { + "lg": "VisibleRange", + "fields": [ + ":int", + ":int", + ":int" + ] + }, + { + "lg": "Picked", + "fields": [ + ":int", + ":string" + ] } ], "kindProperties": { @@ -1442,6 +1588,29 @@ "foreground", "style-class" ], + "list-section": [ + "key", + "separator" + ], + "swipe-actions": [], + "swipe-action": [ + "text", + "icon", + "variant", + "edge", + "enabled", + "background", + "press-enabled" + ], + "file-picker": [ + "request", + "types", + "multiple", + "source", + "completion", + "enabled", + "appear-enabled" + ], "file-preview": [ "path" ] diff --git a/src/lui_elements.ml b/src/lui_elements.ml index 907284d0..1f3eed0b 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -36,6 +36,20 @@ type frame_axes = type resize_easing = [ `linear | `standard | `emphasized | `spring ] type role = [ `treeitem | `navigation | `navigation_heading ] +type list_style = [ `plain | `inset | `inset_grouped ] +type scroll_anchor = [ `top | `center | `bottom ] +type separator_visibility = [ `visible | `hidden ] +type swipe_edge = [ `leading | `trailing ] + +(** [~source] of [file_picker]: [`files] presents a document importer, + [`photos] the photo library, [`camera] live capture (iOS only — + other platforms answer the request with a cancel event). *) +type file_picker_source = [ `files | `photos | `camera ] + +(** [~request] / [~completion] of [file_picker]: an opaque token echoed back + in [picked] payloads. String tokens cover UUID-style identifiers; int + tokens cover counters. *) +type file_picker_token = [ `String of string | `Int of int ] type icon = [ `alert | `archive | `arrow_down | `arrow_right | `arrow_up | `check | `check_circle | `chevron_down | `chevron_left | `chevron_right | `chevron_up | `circle_dot | `clock | `copy | `download | `edit | `ellipsis | `external_link | `eye | `file_text | `folder | `folder_open | `git_branch | `git_merge | `git_pull_request | `info | `menu | `mic | `moon | `music | `panel_left | `panel_right | `pause | `play | `plus | `refresh_cw | `repeat | `save | `search | `send | `settings | `shuffle | `skip_back | `skip_forward | `sun | `terminal | `trash | `volume | `wrench | `x | `x_circle @@ -53,6 +67,7 @@ type table_row_el = t type table_cell_el = t type radio_el = t type input_group_actions_el = t +type swipe_action_el = t (* Children slot of leaf elements: the only inhabitant is [[]], so any real child fails [dune build] instead of being rejected by the schema at emit. *) @@ -153,6 +168,35 @@ let role_value : role -> string = function | `navigation -> "navigation" | `navigation_heading -> "navigation-heading" +let list_style_value : list_style -> string = function + | `plain -> "plain" + | `inset -> "inset" + | `inset_grouped -> "inset-grouped" + +let scroll_anchor_value : scroll_anchor -> string = function + | `top -> "top" + | `center -> "center" + | `bottom -> "bottom" + +let separator_value : separator_visibility -> string = function + | `visible -> "visible" + | `hidden -> "hidden" + +let swipe_edge_value : swipe_edge -> string = function + | `leading -> "leading" + | `trailing -> "trailing" + +let file_picker_source_value : file_picker_source -> string = function + | `files -> "files" + | `photos -> "photos" + | `camera -> "camera" + +let file_picker_token_wire_value + : file_picker_token -> Lui_protocol.wire_value + = function + | `String value -> Lui_protocol.StringValue value + | `Int value -> Lui_protocol.IntValue value + let icon_value : icon -> string = function | `app name -> "app:" ^ name | `alert -> "alert" @@ -281,6 +325,7 @@ let keyed_bottom_tab = keyed let keyed_table_row = keyed let keyed_table_cell = keyed let keyed_radio = keyed +let keyed_swipe_action = keyed let press send action _event = ignore (send action) @@ -301,8 +346,11 @@ let is_input = function TextChanged _ -> true | _ -> false let is_submit = function Submit _ -> true | _ -> false let is_toggle = function ToggleChanged _ -> true | _ -> false let is_dismiss = function Dismiss _ -> true | _ -> false +let is_picked = function Picked _ -> true | _ -> false let is_appear = function Appear _ -> true | _ -> false let is_resize = function ValueChanged _ -> true | _ -> false +let is_scroll_completed = function ScrollCompleted _ -> true | _ -> false +let is_visible_range = function VisibleRange _ -> true | _ -> false let register_press context node handler = ignore (on_event context node is_press handler) @@ -328,6 +376,9 @@ let register_toggle context node handler = let register_dismiss context node handler = ignore (on_event context node is_dismiss handler) +let register_picked context node handler = + ignore (on_event context node is_picked handler) + let appear_handler context node handler = enable context node AppearEnabled; ignore (on_event context node is_appear handler) @@ -335,6 +386,12 @@ let appear_handler context node handler = let register_resize context node handler = enable context node ChangeEnabled; ignore (on_event context node is_resize handler) + +let register_scroll_completed context node handler = + ignore (on_event context node is_scroll_completed handler) + +let register_visible_range context node handler = + ignore (on_event context node is_visible_range handler) let apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~on_appear = Option.iter (Lui_ui.key context node) key; Option.iter (Lui_ui.int_property context node Gap) gap; @@ -494,10 +551,22 @@ let scroll ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?p mount_children context node children; node -let list ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear (children : t list) : t = +let list ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?style ?scroll_target ?scroll_anchor ?scroll_token ?scroll_animated ?track_visible_range ?on_scroll_completed ?on_visible_range (children : t list) : t = fun context parent -> let node = Lui_ui.list context in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~on_appear; + Option.iter (Lui_ui.string_property context node StyleValue) + (Option.map list_style_value style); + Option.iter (Lui_ui.string_property context node ScrollTarget) scroll_target; + Option.iter (Lui_ui.string_property context node ScrollAnchor) + (Option.map scroll_anchor_value scroll_anchor); + Option.iter (Lui_ui.int_property context node ScrollToken) scroll_token; + Option.iter (Lui_ui.bool_property context node ScrollAnimated) + scroll_animated; + Option.iter (Lui_ui.bool_property context node TrackVisibleRange) + track_visible_range; + Option.iter (register_scroll_completed context node) on_scroll_completed; + Option.iter (register_visible_range context node) on_visible_range; attach context parent node; mount_children context node children; node @@ -1332,6 +1401,47 @@ let toast ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?pa mount_children context node children; node +let file_picker ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?source ?request ?request_signal ?types ?types_signal ?multiple ?multiple_signal ?disabled ?disabled_signal ?completion ?completion_signal ?on_picked ?on_dismiss (children : t list) : t = + fun context parent -> + let node = Lui_ui.file_picker context in + apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~on_appear; + Option.iter (Lui_ui.string_property context node PickerSource) (Option.map file_picker_source_value source); + Option.iter + (fun token -> + Lui_ui.value_property context node PickerRequest + (file_picker_token_wire_value token)) + request; + Option.iter + (fun source -> + Lui_ui.value_property_signal context node PickerRequest + (Signal.map + (fun token -> file_picker_token_wire_value token) + source)) + request_signal; + Option.iter (Lui_ui.string_property context node PickerTypes) types; + Option.iter (Lui_ui.string_property_signal context node PickerTypes) types_signal; + Option.iter (Lui_ui.bool_property context node PickerMultiple) multiple; + Option.iter (Lui_ui.bool_property_signal context node PickerMultiple) multiple_signal; + Option.iter (Lui_ui.disabled context node) disabled; + Option.iter (Lui_ui.disabled_signal context node) disabled_signal; + Option.iter + (fun token -> + Lui_ui.value_property context node PickerCompletion + (file_picker_token_wire_value token)) + completion; + Option.iter + (fun source -> + Lui_ui.value_property_signal context node PickerCompletion + (Signal.map + (fun token -> file_picker_token_wire_value token) + source)) + completion_signal; + Option.iter (register_picked context node) on_picked; + Option.iter (register_dismiss context node) on_dismiss; + attach context parent node; + mount_children context node children; + node + let toolbar ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?orientation ?label ?toolbar_gap ?toolbar_class ?placement (children : t list) : t = fun context parent -> let node = Lui_ui.toolbar context in @@ -1443,10 +1553,11 @@ let menu ?key ?accessibility_identifier ?foreground ?style_class ?text let submenu ?key ?text ?icon ?label ?disabled ?disabled_signal ?on_dismiss entries : t = menu ?key ?text ?icon ?label ?disabled ?disabled_signal ?on_dismiss entries -let list_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?text ?text_signal ?icon ?icon_signal ?icon_placement ?role ?tree_level ?expanded ?expanded_signal ?selected ?selected_signal ?disabled ?disabled_signal ?on_press ?on_long_press ?on_double_press ?on_submit ?on_input ?on_toggle (children : t list) : t = +let list_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?text ?text_signal ?icon ?icon_signal ?icon_placement ?role ?tree_level ?expanded ?expanded_signal ?selected ?selected_signal ?disabled ?disabled_signal ?on_press ?on_long_press ?on_double_press ?on_submit ?on_input ?on_toggle ?separator ?swipe_actions (children : t list) : t = fun context parent -> let node = Lui_ui.list_item context in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~on_appear; + Option.iter (Lui_ui.string_property context node KeyValue) key; Option.iter (Lui_ui.string_property context node TextValue) text; Option.iter (Lui_ui.string_property_signal context node TextValue) text_signal; Option.iter (Lui_ui.string_property context node InlineIconName) (Option.map icon_value icon); @@ -1460,6 +1571,8 @@ let list_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal Option.iter (Lui_ui.bool_property_signal context node Selected) selected_signal; Option.iter (Lui_ui.disabled context node) disabled; Option.iter (Lui_ui.disabled_signal context node) disabled_signal; + Option.iter (Lui_ui.string_property context node SeparatorValue) + (Option.map separator_value separator); (match on_press with | Some handler -> enable context node PressEnabled; @@ -1492,6 +1605,84 @@ let list_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal | None -> ()); attach context parent node; mount_children context node children; + Option.iter + (fun actions -> + let container = Lui_ui.swipe_actions context in + attach context (Some node) container; + mount_children context container actions) + swipe_actions; + node + +let list_section ?key ?accessibility_identifier ?separator ?header ?footer + (children : t list) : t = + fun context parent -> + let node = Lui_ui.list_section context in + Option.iter (Lui_ui.key context node) key; + Option.iter (Lui_ui.string_property context node KeyValue) key; + Option.iter + (Lui_ui.string_property context node AccessibilityIdentifier) + accessibility_identifier; + Option.iter (Lui_ui.string_property context node SeparatorValue) + (Option.map separator_value separator); + attach context parent node; + Option.iter + (fun content -> + let slot = Lui_ui.list_section_header context in + attach context (Some node) slot; + ignore (content context (Some slot))) + header; + mount_children context node children; + Option.iter + (fun content -> + let slot = Lui_ui.list_section_footer context in + attach context (Some node) slot; + ignore (content context (Some slot))) + footer; + node + +let swipe_actions ?key ?accessibility_identifier + (children : swipe_action_el list) : t = + fun context parent -> + let node = Lui_ui.swipe_actions context in + Option.iter (Lui_ui.key context node) key; + Option.iter + (Lui_ui.string_property context node AccessibilityIdentifier) + accessibility_identifier; + attach context parent node; + mount_children context node children; + node + +let swipe_action ?key ?accessibility_identifier ?text ?text_signal ?icon + ?icon_signal ?variant ?edge ?background ?disabled ?on_press + (_children : nothing list) : swipe_action_el = + fun context parent -> + let node = Lui_ui.swipe_action context in + Option.iter (Lui_ui.key context node) key; + Option.iter + (Lui_ui.string_property context node AccessibilityIdentifier) + accessibility_identifier; + Option.iter (Lui_ui.string_property context node TextValue) text; + Option.iter (Lui_ui.string_property_signal context node TextValue) + text_signal; + Option.iter (Lui_ui.string_property context node InlineIconName) + (Option.map icon_value icon); + Option.iter + (fun signal -> + Lui_ui.string_property_signal context node InlineIconName + (Signal.map icon_value signal)) + icon_signal; + Option.iter (Lui_ui.string_property context node VariantValue) + (Option.map variant_value variant); + Option.iter (Lui_ui.string_property context node EdgeValue) + (Option.map swipe_edge_value edge); + Option.iter (Lui_ui.string_property context node BackgroundValue) background; + Option.iter (Lui_ui.disabled context node) disabled; + (match on_press with + | Some handler -> + enable context node PressEnabled; + register_press context node handler + | None -> ()); + attach context parent node; node let avatar ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?text ?text_signal ?image ?image_signal ?source_x ?source_y ?source_width ?source_height ?label (_children : nothing list) : t = diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 95751c90..fe085254 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -42,6 +42,20 @@ type frame_axes = type resize_easing = [ `linear | `standard | `emphasized | `spring ] type role = [ `treeitem | `navigation | `navigation_heading ] +type list_style = [ `plain | `inset | `inset_grouped ] +type scroll_anchor = [ `top | `center | `bottom ] +type separator_visibility = [ `visible | `hidden ] +type swipe_edge = [ `leading | `trailing ] + +(** [~source] of {!file_picker}: [`files] presents a document importer, + [`photos] the photo library, [`camera] live capture (iOS only — + other platforms answer the request with a dismiss event). *) +type file_picker_source = [ `files | `photos | `camera ] + +(** [~request] / [~completion] of {!file_picker}: an opaque token echoed + back in [picked] payloads. String tokens cover UUID-style identifiers; + int tokens cover counters. *) +type file_picker_token = [ `String of string | `Int of int ] (** [~icon]/[~name] values: the schema icon names, or [`app "name"] for an application-registered icon ([app:name] on the wire). *) @@ -63,6 +77,7 @@ type table_row_el type table_cell_el type radio_el type input_group_actions_el +type swipe_action_el (** Empty type: [leaf] constructors take a [nothing list] children slot, so [] compiles and any real child is a type error. *) @@ -144,6 +159,11 @@ val keyed_radio : key:('a -> 'b) -> cmp:('b -> 'b -> int) -> mount:('a Signal.signal -> radio_el) -> radio_el +val keyed_swipe_action : + source:'a list Signal.signal -> + key:('a -> 'b) -> + cmp:('b -> 'b -> int) -> + mount:('a Signal.signal -> swipe_action_el) -> swipe_action_el (** Event-handler builders: element [~on_*] parameters take an [event -> unit] callback and imply their enable flags automatically @@ -662,7 +682,15 @@ val list : ?foreground_signal:string Signal.signal -> ?background_signal:string Signal.signal -> ?style_class:string -> - ?on_appear:(Lui_protocol.event -> unit) -> t list -> t + ?on_appear:(Lui_protocol.event -> unit) -> + ?style:list_style -> + ?scroll_target:string -> + ?scroll_anchor:scroll_anchor -> + ?scroll_token:int -> + ?scroll_animated:bool -> + ?track_visible_range:bool -> + ?on_scroll_completed:(Lui_protocol.event -> unit) -> + ?on_visible_range:(Lui_protocol.event -> unit) -> t list -> t val virtual_list : ?key:string -> ?gap:int -> @@ -2383,6 +2411,58 @@ val toast : ?label:string -> ?toast_class:string -> ?on_dismiss:(Lui_protocol.event -> unit) -> t list -> t + +(** Non-visual element that presents the platform file/photo/camera picker + when [~request] changes to a new token. [~on_picked] receives a JSON + payload echoing the request token plus a [files] array of + [{path, name, content-type}]; [~on_dismiss] fires when the picker is + cancelled. The backend retains security-scoped file access for picked + URLs until [~completion] echoes the request token (or the node is + dropped). [~types] is a comma-separated list of UTIs; [~source] + selects files (default), photo library, or camera; [~multiple] allows + multi-selection; [~disabled] gates presentation. *) +val file_picker : + ?key:string -> + ?gap:int -> + ?main:main_alignment -> + ?cross:cross_alignment -> + ?grow:float -> + ?columns:int -> + ?padding:int -> + ?padding_horizontal:int -> + ?padding_vertical:int -> + ?background:string -> + ?foreground:string -> + ?border_color:string -> + ?border_width:int -> + ?corner_radius:int -> + ?width:int -> + ?height:int -> + ?min_width:int -> + ?max_width:int -> + ?min_height:int -> + ?max_height:int -> + ?container_relative_frame:frame_axes -> + ?container_relative_frame_inset:int -> + ?accessibility_identifier:string -> + ?accessibility_identifier_signal:string Signal.signal -> + ?foreground_signal:string Signal.signal -> + ?background_signal:string Signal.signal -> + ?style_class:string -> + ?on_appear:(Lui_protocol.event -> unit) -> + ?source:file_picker_source -> + ?request:file_picker_token -> + ?request_signal:file_picker_token Signal.signal -> + ?types:string -> + ?types_signal:string Signal.signal -> + ?multiple:bool -> + ?multiple_signal:bool Signal.signal -> + ?disabled:bool -> + ?disabled_signal:bool Signal.signal -> + ?completion:file_picker_token -> + ?completion_signal:file_picker_token Signal.signal -> + ?on_picked:(Lui_protocol.event -> unit) -> + ?on_dismiss:(Lui_protocol.event -> unit) -> t list -> t val toolbar : ?key:string -> ?gap:int -> @@ -2544,7 +2624,33 @@ val list_item : ?on_double_press:(Lui_protocol.event -> unit) -> ?on_submit:(Lui_protocol.event -> unit) -> ?on_input:(Lui_protocol.event -> unit) -> - ?on_toggle:(Lui_protocol.event -> unit) -> t list -> t + ?on_toggle:(Lui_protocol.event -> unit) -> + ?separator:separator_visibility -> + ?swipe_actions:swipe_action_el list -> t list -> t + +(** A named group of rows inside a {!list}: [~header]/[~footer] take any + element and mount it in a dedicated slot node, [~key] sets both the + reload key and the wire [key] identity used by scroll targeting. *) +val list_section : + ?key:string -> + ?accessibility_identifier:string -> + ?separator:separator_visibility -> + ?header:t -> ?footer:t -> t list -> t +val swipe_actions : + ?key:string -> + ?accessibility_identifier:string -> swipe_action_el list -> t +val swipe_action : + ?key:string -> + ?accessibility_identifier:string -> + ?text:string -> + ?text_signal:string Signal.signal -> + ?icon:icon -> + ?icon_signal:icon Signal.signal -> + ?variant:variant -> + ?edge:swipe_edge -> + ?background:string -> + ?disabled:bool -> + ?on_press:(Lui_protocol.event -> unit) -> nothing list -> swipe_action_el val avatar : ?key:string -> ?gap:int -> diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 3a7a7133..5726ee46 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -80,6 +80,12 @@ type node_kind = | Toast | Toolbar | StatusBar + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions + | SwipeAction + | FilePicker | Link | FileImage | FilePreview @@ -180,6 +186,20 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | KeyValue + | SeparatorValue + | StyleValue + | ScrollTarget + | ScrollAnchor + | ScrollToken + | ScrollAnimated + | TrackVisibleRange + | EdgeValue + | PickerRequest + | PickerTypes + | PickerMultiple + | PickerSource + | PickerCompletion | MinValue | MaxValue | StepValue @@ -188,7 +208,6 @@ type property = | PathValue | UrlValue | MaxPixelSize - | EdgeValue | Visible | AlignmentValue @@ -217,6 +236,9 @@ type event = | Dismiss of int | DoublePress of int | Appear of int + | ScrollCompleted of int * int * string + | VisibleRange of int * int * int + | Picked of int * string | ExtensionEvent of int * string * string * wire_value String_map.t type patch_op = @@ -257,6 +279,9 @@ let event_node event = | Dismiss node | DoublePress node | Appear node + | ScrollCompleted (node, _, _) + | VisibleRange (node, _, _) + | Picked (node, _) | ExtensionEvent (node, _, _, _) -> node let modal_surface kind = kind = Dialog || kind = Drawer || kind = Sheet @@ -326,7 +351,8 @@ let event_supported kind event = | TableCell | TimelineItem | FileImage - | BottomTab -> true + | BottomTab + | SwipeAction -> true | _ -> false) | LongPress _ -> (match kind with @@ -361,7 +387,8 @@ let event_supported kind event = | Toggle | Radio | Accordion - | Drawer -> true + | Drawer + | ListItem -> true | _ -> false) | Change _ -> kind = Radio | ValueChanged _ -> @@ -379,10 +406,13 @@ let event_supported kind event = | Dialog | Drawer | Sheet - | FilePreview -> true + | FilePreview + | FilePicker -> true | _ -> false) | DoublePress _ -> kind = ListItem | Appear _ -> kind <> Root + | ScrollCompleted _ | VisibleRange _ -> kind = ListContainer + | Picked _ -> kind = FilePicker | ExtensionEvent _ -> false let true_property properties property = @@ -539,6 +569,9 @@ let icon_name_supported value = let theme_mode_supported value = value = "system" || value = "light" || value = "dark" +let picker_source_supported value = + value = "files" || value = "photos" || value = "camera" + let sizing_supported value = value = "form" || value = "fitted" || value = "page" @@ -600,7 +633,12 @@ let can_contain_children kind = | Bubble | BottomTabs | BottomTab - | Link -> true + | Link + | FilePicker + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions -> true | _ -> false let common_property_supported kind property = @@ -762,6 +800,14 @@ let common_property_supported kind property = || kind = StatusBar | RoleValue -> tree_row_kind kind || kind = ListItem | TreeLevel | Expanded -> tree_row_kind kind + | KeyValue | SeparatorValue -> kind = ListItem || kind = ListSection + | StyleValue + | ScrollTarget + | ScrollAnchor + | ScrollToken + | ScrollAnimated + | TrackVisibleRange -> kind = ListContainer + | EdgeValue -> kind = SwipeAction || kind = EdgeInset | ResizeDuration | ResizeEasing | ResizeOrigin -> kind = Split | MinValue | MaxValue | StepValue -> kind = NumberStepper | Detents | Sizing -> kind = Sheet @@ -825,8 +871,9 @@ let common_property_supported kind property = | PathValue -> kind = FileImage || kind = FilePreview | UrlValue -> kind = Link | MaxPixelSize -> kind = FileImage - | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector -> - false + | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector + | PickerRequest | PickerTypes | PickerMultiple | PickerSource + | PickerCompletion -> false | TitleValue -> kind = BottomTab | Gap -> kind = Row @@ -844,7 +891,7 @@ let common_property_supported kind property = || kind = Split || kind = EdgeInset || horizontal_container kind - | EdgeValue | Visible -> kind = EdgeInset + | Visible -> kind = EdgeInset let property_supported kind property = if property = AccessibilityIdentifier then true @@ -876,6 +923,11 @@ let property_supported kind property = property = TextValue || property = InlineIconName || property = AccessibilityLabel || property = Enabled || property = ForegroundValue || property = StyleClass + | FilePicker -> + property = PickerRequest || property = PickerTypes + || property = PickerMultiple || property = PickerSource + || property = PickerCompletion || property = Enabled + || property = AppearEnabled | Accordion -> property = TextValue || property = Selected || property = ToggleEnabled || property = HeightValue @@ -892,6 +944,19 @@ let property_supported kind property = property = AccessibilityLabel || property = WidthValue || property = HeightValue || property = MinWidth || property = GrowValue | InputGroupActions -> property = Gap + | ListSection -> property = KeyValue || property = SeparatorValue + | SwipeActions -> false + | SwipeAction -> + List.mem + property + [ TextValue + ; InlineIconName + ; VariantValue + ; EdgeValue + ; Enabled + ; BackgroundValue + ; PressEnabled + ] | Dialog -> property = DescriptionValue || common_property_supported kind property | FilePreview -> property = PathValue @@ -983,6 +1048,25 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_supported value + | KeyValue, StringValue _ -> true + | SeparatorValue, StringValue value -> + value = "visible" || value = "hidden" + | StyleValue, StringValue value -> + value = "plain" || value = "inset" || value = "inset-grouped" + | ScrollTarget, StringValue _ -> true + | ScrollAnchor, StringValue value -> + value = "top" || value = "center" || value = "bottom" + | ScrollToken, IntValue value -> value >= 0 + | ScrollAnimated, BoolValue _ -> true + | TrackVisibleRange, BoolValue _ -> true + | EdgeValue, StringValue value -> + value = "top" || value = "bottom" || value = "leading" + || value = "trailing" + | PickerRequest, (StringValue _ | IntValue _) + | PickerCompletion, (StringValue _ | IntValue _) -> true + | PickerTypes, StringValue _ -> true + | PickerMultiple, BoolValue _ -> true + | PickerSource, StringValue value -> picker_source_supported value | MinValue, FloatValue value | MaxValue, FloatValue value -> is_finite value | StepValue, FloatValue value -> is_finite value && value > 0.0 @@ -990,9 +1074,6 @@ let property_value_supported property value = | Sizing, StringValue value -> sizing_supported value | PathValue, StringValue _ | UrlValue, StringValue _ -> true | MaxPixelSize, IntValue value -> value > 0 - | EdgeValue, StringValue value -> - value = "top" || value = "bottom" || value = "leading" - || value = "trailing" | Visible, BoolValue _ -> true | AlignmentValue, StringValue value -> alignment_supported value @@ -1073,6 +1154,10 @@ let node_properties_supported kind properties = && (if kind = MenuItem || kind = Accordion then string_property_nonempty properties TextValue else true) + && (if kind = SwipeAction then + string_property_nonempty properties TextValue + || string_property_nonempty properties InlineIconName + else true) && (if kind = MenuTrigger then let text = string_property_or properties TextValue "" in let icon = string_property_or properties InlineIconName "" in @@ -1177,7 +1262,17 @@ let node_properties_supported kind properties = || Property_map.mem ChangeEnabled properties || Property_map.mem ToggleEnabled properties in - ((not has_tree_metadata) || treeitem) + (* A plain list item may carry expansion state for disclosure rows + without the treeitem role; tree level / change events stay tree-only. *) + let disclosure_list_item = + kind = ListItem + && (not treeitem) + && (not (Property_map.mem TreeLevel properties)) + && (not (Property_map.mem ChangeEnabled properties)) + && (Property_map.mem Expanded properties + || Property_map.mem ToggleEnabled properties) + in + ((not has_tree_metadata) || treeitem || disclosure_list_item) && (if Property_map.mem Expanded properties then true_property properties ToggleEnabled else true) @@ -1189,8 +1284,15 @@ let child_kind_supported parent_kind child_kind = else if parent_kind = MenuItem then child_kind = ContextMenu else if parent_kind = MenuTrigger then child_kind = DropdownMenu else if context_menu_leaf_host_kind parent_kind then child_kind = ContextMenu + else if child_kind = ListSection then parent_kind = ListContainer + else if child_kind = ListSectionHeader || child_kind = ListSectionFooter + then parent_kind = ListSection + else if child_kind = SwipeActions then parent_kind = ListItem + else if child_kind = SwipeAction then parent_kind = SwipeActions else match parent_kind with + | ListSection -> child_kind = ListItem + | SwipeActions -> child_kind = SwipeAction | Table -> child_kind = TableRow | TableRow -> child_kind = TableCell | BottomTabs -> child_kind = BottomTab diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 418182ea..bf8b7e05 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -78,6 +78,12 @@ type node_kind = | Toast | Toolbar | StatusBar + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions + | SwipeAction + | FilePicker | Link | FileImage | FilePreview @@ -178,6 +184,20 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | KeyValue + | SeparatorValue + | StyleValue + | ScrollTarget + | ScrollAnchor + | ScrollToken + | ScrollAnimated + | TrackVisibleRange + | EdgeValue + | PickerRequest + | PickerTypes + | PickerMultiple + | PickerSource + | PickerCompletion | MinValue | MaxValue | StepValue @@ -186,7 +206,6 @@ type property = | PathValue | UrlValue | MaxPixelSize - | EdgeValue | Visible | AlignmentValue @@ -212,6 +231,9 @@ type event = | Dismiss of int | DoublePress of int | Appear of int + | ScrollCompleted of int * int * string + | VisibleRange of int * int * int + | Picked of int * string | ExtensionEvent of int * string * string * wire_value String_map.t type patch_op = diff --git a/src/lui_ui.ml b/src/lui_ui.ml index a85c838a..9a159a32 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -136,6 +136,11 @@ let accordion context = create context Accordion let menu_item context = create context MenuItem let menu_trigger context = create context MenuTrigger let list_item context = create context ListItem +let list_section context = create context ListSection +let list_section_header context = create context ListSectionHeader +let list_section_footer context = create context ListSectionFooter +let swipe_actions context = create context SwipeActions +let swipe_action context = create context SwipeAction let avatar context = create context Avatar let image context = create context Image let media_surface context = create context MediaSurface @@ -148,6 +153,7 @@ let timeline context = create context Timeline let timeline_item context = create context TimelineItem let input_group context = create context InputGroup let input_group_actions context = create context InputGroupActions +let file_picker context = create context FilePicker let button context = create context Button let toggle_button context = create context ToggleButton let toggle context = create context Toggle @@ -314,6 +320,14 @@ let int_property context node property value = Lui_runtime.set_prop context.ui_application node property (IntValue value) +let value_property context node property value = + Lui_runtime.set_prop context.ui_application node property value + +let value_property_signal context node property source = + ignore + (Lui_runtime.bind_prop context.ui_scope context.ui_application node + property (Signal.own_signal context.ui_scope source)) + let disabled context node disabled = bool_property context node Enabled (not disabled) diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 37e9fbd2..4fecabf5 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -92,6 +92,11 @@ val accordion : ui_context -> int val menu_item : ui_context -> int val menu_trigger : ui_context -> int val list_item : ui_context -> int +val list_section : ui_context -> int +val list_section_header : ui_context -> int +val list_section_footer : ui_context -> int +val swipe_actions : ui_context -> int +val swipe_action : ui_context -> int val avatar : ui_context -> int val image : ui_context -> int val media_surface : ui_context -> int @@ -104,6 +109,7 @@ val timeline : ui_context -> int val timeline_item : ui_context -> int val input_group : ui_context -> int val input_group_actions : ui_context -> int +val file_picker : ui_context -> int val button : ui_context -> int val toggle_button : ui_context -> int val toggle : ui_context -> int @@ -156,6 +162,13 @@ val int_property_signal : int -> Lui_protocol.Property_map.key -> int Signal.signal -> unit val int_property : ui_context -> int -> Lui_protocol.Property_map.key -> int -> unit +val value_property : + ui_context -> + int -> Lui_protocol.Property_map.key -> Lui_protocol.wire_value -> unit +val value_property_signal : + ui_context -> + int -> + Lui_protocol.Property_map.key -> Lui_protocol.wire_value Signal.signal -> unit val disabled : ui_context -> int -> bool -> unit val disabled_signal : ui_context -> int -> bool Signal.signal -> unit val checked_signal : ui_context -> int -> bool Signal.signal -> unit diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index da80a3a1..36148614 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -81,6 +81,12 @@ let node_kind_name kind = | Toast -> "toast" | Toolbar -> "toolbar" | StatusBar -> "status-bar" + | ListSection -> "list-section" + | ListSectionHeader -> "list-section-header" + | ListSectionFooter -> "list-section-footer" + | SwipeActions -> "swipe-actions" + | SwipeAction -> "swipe-action" + | FilePicker -> "file-picker" | Link -> "link" | FileImage -> "file-image" | FilePreview -> "file-preview" @@ -163,6 +169,12 @@ let standard_node_name name = | "toast" -> true | "toolbar" -> true | "status-bar" -> true + | "list-section" -> true + | "list-section-header" -> true + | "list-section-footer" -> true + | "swipe-actions" -> true + | "swipe-action" -> true + | "file-picker" -> true | "link" -> true | "file-image" -> true | "file-preview" -> true @@ -243,6 +255,20 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | KeyValue -> "key" + | SeparatorValue -> "separator" + | StyleValue -> "style" + | ScrollTarget -> "scroll-target" + | ScrollAnchor -> "scroll-anchor" + | ScrollToken -> "scroll-token" + | ScrollAnimated -> "scroll-animated" + | TrackVisibleRange -> "track-visible-range" + | EdgeValue -> "edge" + | PickerRequest -> "request" + | PickerTypes -> "types" + | PickerMultiple -> "multiple" + | PickerSource -> "source" + | PickerCompletion -> "completion" | MinValue -> "min" | MaxValue -> "max" | StepValue -> "step" @@ -251,7 +277,6 @@ let property_name property = | PathValue -> "path" | UrlValue -> "url" | MaxPixelSize -> "max-pixel-size" - | EdgeValue -> "edge" | Visible -> "visible" | AlignmentValue -> "alignment" @@ -269,6 +294,10 @@ let kind_property_matrix kind = | BottomTabs -> Some [ AccessibilityLabel; StyleClass; GrowValue; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight ] | BottomTab -> Some [ TitleValue; InlineIconName; Selected; Enabled; PressEnabled ] | MenuTrigger -> Some [ TextValue; InlineIconName; AccessibilityLabel; Enabled; ForegroundValue; StyleClass ] + | ListSection -> Some [ KeyValue; SeparatorValue ] + | SwipeActions -> Some [ ] + | SwipeAction -> Some [ TextValue; InlineIconName; VariantValue; EdgeValue; Enabled; BackgroundValue; PressEnabled ] + | FilePicker -> Some [ PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; Enabled; AppearEnabled ] | FilePreview -> Some [ PathValue ] | _ -> None @@ -277,6 +306,6 @@ let kind_extra_properties kind = | Dialog -> [ DescriptionValue ] | _ -> [] -let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; Link; FileImage; FilePreview ] +let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; ListSection; ListSectionHeader; ListSectionFooter; SwipeActions; SwipeAction; FilePicker; Link; FileImage; FilePreview ] -let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; EdgeValue; Visible; AlignmentValue ] +let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; Visible; AlignmentValue ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 4a54cc32..d34bf66a 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,211 @@ let test_menu_trigger_rules () = (node_properties_supported MenuTrigger (props [ (TextValue, StringValue "More") ])) +let test_list_suite_rules () = + let open Lui_protocol in + (* explicit sections *) + Alcotest.(check bool) "list hosts section" true + (child_kind_supported ListContainer ListSection); + Alcotest.(check bool) "section rejects list parent" false + (child_kind_supported ListSection ListSection); + Alcotest.(check bool) "section outside list rejected" false + (child_kind_supported Row ListSection); + Alcotest.(check bool) "section hosts row" true + (child_kind_supported ListSection ListItem); + Alcotest.(check bool) "section rejects text row" false + (child_kind_supported ListSection Text); + Alcotest.(check bool) "section hosts header" true + (child_kind_supported ListSection ListSectionHeader); + Alcotest.(check bool) "section hosts footer" true + (child_kind_supported ListSection ListSectionFooter); + Alcotest.(check bool) "header needs section parent" false + (child_kind_supported ListContainer ListSectionHeader); + (* swipe actions *) + Alcotest.(check bool) "row hosts swipe-actions" true + (child_kind_supported ListItem SwipeActions); + Alcotest.(check bool) "swipe-actions needs list-item" false + (child_kind_supported ListSection SwipeActions); + Alcotest.(check bool) "swipe-actions hosts action" true + (child_kind_supported SwipeActions SwipeAction); + Alcotest.(check bool) "action needs swipe-actions" false + (child_kind_supported ListItem SwipeAction); + Alcotest.(check bool) "swipe-actions rejects row" false + (child_kind_supported SwipeActions ListItem); + (* props *) + Alcotest.(check bool) "section key" true + (property_supported ListSection KeyValue); + Alcotest.(check bool) "section separator" true + (property_supported ListSection SeparatorValue); + Alcotest.(check bool) "section drops text" false + (property_supported ListSection TextValue); + Alcotest.(check bool) "row key" true + (property_supported ListItem KeyValue); + Alcotest.(check bool) "row separator" true + (property_supported ListItem SeparatorValue); + Alcotest.(check bool) "key rejected elsewhere" false + (property_supported Button KeyValue); + Alcotest.(check bool) "list style" true + (property_supported ListContainer StyleValue); + Alcotest.(check bool) "list scroll token" true + (property_supported ListContainer ScrollToken); + Alcotest.(check bool) "list tracks visible range" true + (property_supported ListContainer TrackVisibleRange); + Alcotest.(check bool) "scroll props list-only" false + (property_supported ListItem ScrollToken); + Alcotest.(check bool) "action edge" true + (property_supported SwipeAction EdgeValue); + Alcotest.(check bool) "action icon" true + (property_supported SwipeAction InlineIconName); + Alcotest.(check bool) "action drops width" false + (property_supported SwipeAction WidthValue); + Alcotest.(check bool) "swipe-actions container bare" false + (property_supported SwipeActions TextValue); + (* prop value vocab *) + Alcotest.(check bool) "style vocab" true + (property_value_supported StyleValue (StringValue "inset-grouped")); + Alcotest.(check bool) "style rejects junk" false + (property_value_supported StyleValue (StringValue "cards")); + Alcotest.(check bool) "anchor vocab" true + (property_value_supported ScrollAnchor (StringValue "center")); + Alcotest.(check bool) "separator vocab" true + (property_value_supported SeparatorValue (StringValue "hidden")); + Alcotest.(check bool) "edge vocab" true + (property_value_supported EdgeValue (StringValue "leading")); + Alcotest.(check bool) "token is int" true + (property_value_supported ScrollToken (IntValue 3)); + Alcotest.(check bool) "token rejects string" false + (property_value_supported ScrollToken (StringValue "3")); + (* events *) + Alcotest.(check bool) "list scroll-completed" true + (event_supported ListContainer (ScrollCompleted (0, 0, ""))); + Alcotest.(check bool) "list visible-range" true + (event_supported ListContainer (VisibleRange (0, 0, 0))); + Alcotest.(check bool) "scroll events list-only" false + (event_supported ListItem (ScrollCompleted (0, 0, ""))); + Alcotest.(check bool) "action press" true + (event_supported SwipeAction (Press 0)); + Alcotest.(check bool) "row toggle" true + (event_supported ListItem (ToggleChanged (0, true))); + (* disclosure rows *) + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "disclosure row ok" true + (node_properties_supported ListItem + (props [ (Expanded, BoolValue true); (ToggleEnabled, BoolValue true) ])); + Alcotest.(check bool) "expanded needs toggle-enabled" false + (node_properties_supported ListItem + (props [ (Expanded, BoolValue true) ])); + Alcotest.(check bool) "tree level still needs treeitem" false + (node_properties_supported ListItem + (props [ (TreeLevel, IntValue 1); (Expanded, BoolValue true); + (ToggleEnabled, BoolValue true) ])); + (* swipe-action needs a label or icon *) + Alcotest.(check bool) "action text ok" true + (node_properties_supported SwipeAction + (props [ (TextValue, StringValue "Archive") ])); + Alcotest.(check bool) "action icon ok" true + (node_properties_supported SwipeAction + (props [ (InlineIconName, StringValue "trash") ])); + Alcotest.(check bool) "bare action rejected" false + (node_properties_supported SwipeAction (props [])) + +let test_file_picker () = + let open Lui_protocol in + Alcotest.(check bool) "file-picker hosts children" true + (can_contain_children FilePicker); + Alcotest.(check bool) "picked is a file-picker event" true + (event_supported_for_properties FilePicker Property_map.empty + (Picked (0, "{}"))); + Alcotest.(check bool) "dismiss is a file-picker event" true + (event_supported_for_properties FilePicker Property_map.empty + (Dismiss 0)); + Alcotest.(check bool) "picked is not a button event" false + (event_supported_for_properties Button Property_map.empty + (Picked (0, "{}"))); + Alcotest.(check bool) "request prop allowed" true + (property_supported FilePicker PickerRequest); + Alcotest.(check bool) "types prop allowed" true + (property_supported FilePicker PickerTypes); + Alcotest.(check bool) "multiple prop allowed" true + (property_supported FilePicker PickerMultiple); + Alcotest.(check bool) "source prop allowed" true + (property_supported FilePicker PickerSource); + Alcotest.(check bool) "completion prop allowed" true + (property_supported FilePicker PickerCompletion); + Alcotest.(check bool) "text prop rejected" false + (property_supported FilePicker TextValue); + Alcotest.(check bool) "request accepts a string" true + (property_value_supported PickerRequest (StringValue "op-1")); + Alcotest.(check bool) "request accepts an int" true + (property_value_supported PickerRequest (IntValue 7)); + Alcotest.(check bool) "multiple accepts bool" true + (property_value_supported PickerMultiple (BoolValue true)); + Alcotest.(check bool) "source accepts camera" true + (property_value_supported PickerSource (StringValue "camera")); + Alcotest.(check bool) "source rejects junk" false + (property_value_supported PickerSource (StringValue "screen")); + let picked_payloads = ref [] in + let dismissed = ref 0 in + let picker_node = ref 0 in + let int_picker_node = ref 0 in + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + (fun _context _model_source _send -> + Lui_elements.column + [ capture_node picker_node + (Lui_elements.file_picker ~source:`photos + ~request:(`String "op-1") ~types:"public.image" ~multiple:true + ~completion:(`String "") + ~on_picked:(fun event -> + match event with + | Picked (_, payload) -> + picked_payloads := payload :: !picked_payloads + | _ -> ()) + ~on_dismiss:(fun _ -> incr dismissed) + []); + capture_node int_picker_node + (Lui_elements.file_picker ~request:(`Int 7) []) ]) + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + let kind_of node = + List.find_map + (function + | CreateNode (id, kind) when id = node -> Some kind + | _ -> None) + ops + in + let prop_value node property = + List.find_map + (function + | SetProp (id, key, value) when id = node && key = property -> + Some value + | _ -> None) + ops + in + Alcotest.(check bool) "node is a file-picker" true + (kind_of !picker_node = Some FilePicker); + Alcotest.(check bool) "request wired" true + (prop_value !picker_node PickerRequest = Some (StringValue "op-1")); + Alcotest.(check bool) "int request wired" true + (prop_value !int_picker_node PickerRequest = Some (IntValue 7)); + Alcotest.(check bool) "types wired" true + (prop_value !picker_node PickerTypes = Some (StringValue "public.image")); + Alcotest.(check bool) "multiple wired" true + (prop_value !picker_node PickerMultiple = Some (BoolValue true)); + Alcotest.(check bool) "source wired" true + (prop_value !picker_node PickerSource = Some (StringValue "photos")); + ignore + (Lui_app.dispatch_event app + (Picked (!picker_node, {|{"request":"op-1","files":[]}|}))); + ignore (Lui_app.dispatch_event app (Dismiss !picker_node)); + flush_app app; + Alcotest.(check (list string)) "picked delivered" + [ {|{"request":"op-1","files":[]}|} ] !picked_payloads; + Alcotest.(check int) "dismiss delivered" 1 !dismissed; + ignore (Lui_app.dispose app) + let test_media_file_rules () = let open Lui_protocol in Alcotest.(check bool) "link is container" true @@ -1814,6 +2019,9 @@ let () = Alcotest.test_case "helpers" `Quick test_protocol_helpers; Alcotest.test_case "menu-trigger rules" `Quick test_menu_trigger_rules; + Alcotest.test_case "list suite rules" `Quick + test_list_suite_rules; + Alcotest.test_case "file-picker rules" `Quick test_file_picker; Alcotest.test_case "media file rules" `Quick test_media_file_rules; Alcotest.test_case "media file mount" `Quick test_media_file_mount; Alcotest.test_case "edge/overlay/fit rules" `Quick