diff --git a/.agents/skills/testing-lui-demos/SKILL.md b/.agents/skills/testing-lui-demos/SKILL.md index 27f94b82..7d8d4fb0 100644 --- a/.agents/skills/testing-lui-demos/SKILL.md +++ b/.agents/skills/testing-lui-demos/SKILL.md @@ -128,3 +128,18 @@ Gotchas: edge drops, but chip/strip targets are more reliable with an explicit `left_mouse_down` → several `mouse_move` steps → pause (`wait` ~0.7s, pane highlight/ghost visible) → `left_mouse_up` sequence. + +### Exercising new/ordinary element kinds end-to-end + +The split demo is the fastest vehicle even for non-split elements: scratch-edit +`examples/split/model.ml` + `view.ml` (uncommitted) so `view` returns a plain +`column` of the elements under test, add model fields/actions for any state you +need to observe (e.g. a press counter rendered via `dyn`/`text` so event +delivery is proven by an OCaml-rendered value, not just pixels), then rerun +`build-app.sh` — it rebuilds `liblui_split.dylib` and the bundle. Revert with +`git checkout` afterwards. `if_ ~test:(model_source >|= ...)` is the way to +mount/unmount presence-driven kinds (dialog/sheet/file-preview). + +QuickLook panels (`file-preview`, `.quickLookPreview`) on macOS: the panel's +close affordance is small/hover-only; **Escape** reliably closes it and drives +the Dismiss binding. Clicking the panel title bar does nothing. 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/gallery/view.ml b/examples/gallery/view.ml index 79280380..f1a03132 100644 --- a/examples/gallery/view.ml +++ b/examples/gallery/view.ml @@ -455,6 +455,69 @@ let card_section model_source : t = ] ] +let inset_rows n = + List.init n (fun i -> + card ~padding:12 + [ text ~value:(Printf.sprintf "Inset demo row %d" (i + 1)) [] ]) + +let edge_inset_section model_source send : t = + let checked = model_source >|= Model.checked in + section "Edge Inset" + [ switch_ ~text:"Show pinned bar" ~checked:(reactive checked) + ~on_toggle:(on_toggle send (fun v -> Model.SetChecked v)) [] + ; edge_inset ~edge:`top ~background:"bar" ~max_width:560 ~height:240 + ~visible:(reactive checked) + [ scroll ~height:200 [ list ~gap:8 ~cross:`stretch (inset_rows 12) ] + ; row ~padding:8 ~gap:8 ~cross:`center + [ text ~value:"Pinned top bar" [] + ; spacer [] + ; text ~value:"edge-inset · top" ~foreground:"muted-foreground" [] + ] + ] + ; edge_inset ~edge:`bottom ~background:"bar" ~max_width:560 ~height:180 + [ scroll ~height:150 [ list ~gap:8 ~cross:`stretch (inset_rows 10) ] + ; row ~padding:8 ~gap:8 ~cross:`center + [ text ~value:"Pinned bottom bar" [] + ; spacer [] + ; text ~value:"edge-inset · bottom" ~foreground:"muted-foreground" [] + ] + ] + ] + +let overlay_section : t = + section "Overlay" + [ overlay ~max_width:560 + [ card ~width:560 ~height:160 ~padding:16 + [ column ~gap:8 + [ text ~value:"Base card — this content sizes the overlay" [] + ; paragraph + ~value:"Floating children do not shift this layout." [] + ] + ] + ; align `top_trailing + (card ~padding:8 [ text ~value:"Float · top-trailing" [] ]) + ; align `bottom_leading + (card ~padding:8 [ text ~value:"Float · bottom-leading" [] ]) + ] + ] + +let view_that_fits_section : t = + section "View That Fits" + [ paragraph + ~value:"Widen the window for the expanded layout; shrink it below ~700pt wide for the compact fallback." + [] + ; view_that_fits + [ card ~width:640 ~padding:16 + [ row ~gap:8 ~cross:`center + [ text ~value:"EXPANDED — wide layout" [] + ; spacer [] + ; text ~value:"640pt" ~foreground:"muted-foreground" [] + ] + ] + ; text ~value:"COMPACT — narrow fallback" ~foreground:"accent" [] + ] + ] + let alert_section : t = section "Alert" [ alert ~text:"Sync paused" ~variant:`secondary @@ -1577,6 +1640,9 @@ let view context model_source send : t = ; row_section ; column_section ; grid_section + ; edge_inset_section model_source send + ; overlay_section + ; view_that_fits_section ; text_section ; spacer_section ; toggle_button_section model_source send 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 22fe3d89..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 } @@ -171,6 +181,16 @@ final class LUINodeModel: Identifiable { properties[.containerRelativeFrameInset]?.intValue ?? 0 } var sliderValue: Double { properties[.progressValue]?.doubleValue ?? 0.0 } + var stepperValue: Double { properties[.progressValue]?.doubleValue ?? 0.0 } + var stepperRange: ClosedRange { + let lower = properties[.minValue]?.doubleValue ?? 0.0 + let upper = properties[.maxValue]?.doubleValue ?? .greatestFiniteMagnitude + return lower...max(lower, upper) + } + var stepperStep: Double { + let step = properties[.stepValue]?.doubleValue ?? 1.0 + return step > 0 ? step : 1.0 + } var splitFraction: Double { properties[.progressValue]?.doubleValue ?? 0.0 } var splitGap: Int { properties[.gap]?.intValue ?? 9 } var splitResizeDuration: Int { properties[.resizeDuration]?.intValue ?? 0 } @@ -397,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 @@ -440,6 +494,7 @@ public final class LUIAppleBackend { private let extensionRegistry: LUIAppleExtensionRegistry let tooltipSession = LUITooltipSession() let modalPresentation = LUIModalPresentationStore() + let filePreviewPresentation = LUIFilePreviewStore() public init( appIcons: [String: LUIAppleIconSource] = [:], @@ -642,8 +697,9 @@ 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), model.isEnabled else { throw invalid("node \(node) is not an enabled pressable control") @@ -695,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, @@ -724,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") } @@ -749,24 +815,41 @@ public final class LUIAppleBackend { func performValueChange(node: Int, value: Double) throws { guard let model = models[node], - model.kind == .slider || model.kind == .split, + model.kind == .slider || model.kind == .split || + model.kind == .numberStepper, model.isEnabled, value.isFinite else { throw invalid("node \(node) is not an enabled value control") } - emit(.valueChanged(node: node, value: min(max(value, 0.0), 1.0))) + if model.kind == .numberStepper { + emit(.valueChanged( + node: node, + value: min(max(value, model.stepperRange.lowerBound), model.stepperRange.upperBound) + )) + } else { + emit(.valueChanged(node: node, value: min(max(value, 0.0), 1.0))) + } } func performDismiss(node: Int) throws { guard let model = models[node], model.kind == .select || model.kind == .combobox || model.kind == .dropdownMenu || model.kind == .dialog || - model.kind == .sheet || model.kind == .toast else { + model.kind == .sheet || model.kind == .toast || + 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 { @@ -774,7 +857,8 @@ public final class LUIAppleBackend { return } switch model.kind { - case .button, .select, .combobox, .menuItem, .listItem, .timelineItem: + case .button, .select, .combobox, .menuItem, .listItem, .timelineItem, + .swipeAction, .fileImage: try performPress(node: node) case .toggleButton: try performToggle(node: node, checked: !model.isSelected) @@ -898,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) @@ -952,11 +1043,22 @@ public final class LUIAppleBackend { private func syncModalPresentation() { var presentation: LUIModalPresentation? var nestedSheets: [Int: LUIModalPresentation] = [:] + var filePreview: LUIFilePreviewPresentation? var visited = Set() func visit(_ nodeID: Int, rootID: Int, dialogAnchorID: Int?, parentSheetID: Int?) { guard visited.insert(nodeID).inserted else { return } if let model = models[nodeID] { + if model.kind == .filePreview, + let url = LUIFilePath.url( + model.property(.path)?.stringValue ?? "" + ) { + filePreview = LUIFilePreviewPresentation( + nodeID: nodeID, + rootID: rootID, + url: url + ) + } if model.kind == .dialog || model.kind == .sheet { let item = LUIModalPresentation( model: model, @@ -994,6 +1096,7 @@ public final class LUIAppleBackend { } modalPresentation.nestedSheets = nestedSheets modalPresentation.synchronize(with: presentation) + filePreviewPresentation.synchronize(with: filePreview) } private func invalidateAvatars(imageID: Int) { 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/LUIFileImageLoader.swift b/platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift new file mode 100644 index 00000000..4b7cc69b --- /dev/null +++ b/platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift @@ -0,0 +1,81 @@ +import CoreGraphics +import Foundation +import ImageIO + +/// Resolves the `path` prop shared by `file-image` and `file-preview` nodes: +/// either an absolute filesystem path or a `file://` URL string. Non-file +/// schemes return nil — remote URLs belong to `link` nodes. +enum LUIFilePath { + static func url(_ path: String) -> URL? { + guard !path.isEmpty else { return nil } + if let url = URL(string: path), url.scheme != nil { + return url.isFileURL ? url : nil + } + return URL(fileURLWithPath: path) + } +} + +/// Loads `file-image` pixels. Decoding happens off the main actor through +/// `CGImageSourceCreateThumbnailAtIndex` — the file is never rasterized at +/// native size — and thumbnails are cached by "|" in an +/// NSCache (~32MB cost limit, 32 items). +enum LUIFileImageLoader { + static let defaultMaxPixelSize = 1024 + + private actor Cache { + private let images = NSCache() + + init() { + images.totalCostLimit = 32 * 1024 * 1024 + images.countLimit = 32 + } + + func image(for key: String) -> CGImage? { + images.object(forKey: key as NSString) + } + + func store(_ image: CGImage, for key: String) { + images.setObject( + image, + forKey: key as NSString, + cost: image.bytesPerRow * image.height + ) + } + } + + private static let cache = Cache() + + static func thumbnail(path: String, maxPixelSize: Int) async -> CGImage? { + guard let url = LUIFilePath.url(path) else { return nil } + let key = "\(maxPixelSize)|\(path)" + if let cached = await cache.image(for: key) { return cached } + let decoded = await Task.detached(priority: .userInitiated) { + decodeThumbnail(url: url, maxPixelSize: maxPixelSize) + }.value + if let decoded { + await cache.store(decoded, for: key) + } + return decoded + } + + private static func decodeThumbnail(url: URL, maxPixelSize: Int) -> CGImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL( + url as CFURL, + sourceOptions + ) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + ] + return CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) + } +} 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 266d5e07..348dfc08 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import QuickLook import SwiftUI #if os(macOS) import AppKit @@ -68,6 +69,37 @@ final class LUIModalPresentationStore { } } +struct LUIFilePreviewPresentation: Identifiable { + let nodeID: Int + let rootID: Int + let url: URL + + var id: Int { nodeID } +} + +/// Like `LUIModalPresentationStore`, an interactively closed preview stays +/// mounted on the wire until the reducer consumes the Dismiss event, so a +/// re-sync in that window must not reassert it. +@Observable +@MainActor +final class LUIFilePreviewStore { + private(set) var item: LUIFilePreviewPresentation? + private var pendingDismissalID: Int? + + func synchronize(with item: LUIFilePreviewPresentation?) { + if let pendingDismissalID, item?.id == pendingDismissalID { return } + pendingDismissalID = nil + self.item = item + } + + func dismissFromPresentation() -> Int? { + guard let presentedID = item?.id else { return nil } + pendingDismissalID = presentedID + item = nil + return presentedID + } +} + public struct LUISwiftUIRoot: View { private let backend: LUIAppleBackend private let rootID: Int @@ -94,6 +126,7 @@ public struct LUISwiftUIRoot: View { LUIModalSurfaceContent(model: presentation.model, backend: backend) .luiSheetScope(semanticColors: semanticColors, colorScheme: colorScheme) } + .quickLookPreview(filePreviewBinding) .modifier(LUIDialogPresentationModifier(anchorID: rootID, backend: backend)) } @@ -115,6 +148,23 @@ public struct LUISwiftUIRoot: View { } try? backend.performDismiss(node: nodeID) } + + private var filePreviewBinding: Binding { + Binding( + get: { + guard let item = backend.filePreviewPresentation.item, + item.rootID == rootID else { return nil } + return item.url + }, + set: { url in + guard url == nil, + backend.filePreviewPresentation.item?.rootID == rootID, + let nodeID = backend.filePreviewPresentation + .dismissFromPresentation() else { return } + try? backend.performDismiss(node: nodeID) + } + ) + } } /// Hosts modal presentations (dialogs and sheets) for a traversal root on an @@ -140,9 +190,27 @@ public struct LUIModalHostModifier: ViewModifier { LUIModalSurfaceContent(model: presentation.model, backend: backend) .luiSheetScope(semanticColors: semanticColors, colorScheme: colorScheme) } + .quickLookPreview(filePreviewBinding) .modifier(LUIDialogPresentationModifier(anchorID: rootID, backend: backend)) } + private var filePreviewBinding: Binding { + Binding( + get: { + guard let item = backend.filePreviewPresentation.item, + item.rootID == rootID else { return nil } + return item.url + }, + set: { url in + guard url == nil, + backend.filePreviewPresentation.item?.rootID == rootID, + let nodeID = backend.filePreviewPresentation + .dismissFromPresentation() else { return } + try? backend.performDismiss(node: nodeID) + } + ) + } + private var sheetBinding: Binding { Binding( get: { @@ -462,7 +530,9 @@ enum LUIDirectRevisionObservationPolicy { enum LUIUnmodifiedNodePolicy { static func bypassesSurface(kind: LUINodeKind) -> Bool { - kind == .spacer + // edge-inset bypasses the container surface: its surface props style + // the pinned region instead (LUIEdgeInsetView applies the modifier). + kind == .spacer || kind == .edgeInset || kind == .filePicker } } @@ -548,7 +618,7 @@ private struct LUINodeView: View { content .modifier(LUIAccessibilityModifier(model: model, backend: backend)) .modifier(LUIAppearModifier(model: model, backend: backend)) - } else if model.kind.isModalSurface { + } else if model.kind.isModalSurface || model.kind == .filePreview { content } else if model.kind == .resizable { content @@ -619,6 +689,12 @@ private struct LUINodeView: View { return AnyView(LUIGridView(model: model, backend: backend)) case .stack, .panel, .card: return AnyView(LUIStackView(model: model, backend: backend)) + case .edgeInset: + return AnyView(LUIEdgeInsetView(model: model, backend: backend)) + case .overlay: + return AnyView(LUIOverlayView(model: model, backend: backend)) + case .viewThatFits: + return AnyView(LUIViewThatFitsView(model: model, backend: backend)) case .alert: return AnyView(LUIAlertView(model: model, backend: backend)) case .bubble: @@ -679,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: @@ -710,6 +803,12 @@ private struct LUINodeView: View { return AnyView(LUIImageView(model: model, backend: backend)) case .mediaSurface: return AnyView(LUIMediaSurfaceView(model: model, backend: backend)) + case .link: + return AnyView(LUILinkView(model: model, backend: backend)) + case .fileImage: + return AnyView(LUIFileImageView(model: model, backend: backend)) + case .filePreview: + return AnyView(EmptyView()) case .stepper: return AnyView(LUIStepperView(model: model, backend: backend)) case .step: @@ -765,6 +864,8 @@ private struct LUINodeView: View { .disabled(!model.isEnabled) .frame(minHeight: minimumTouchHeight) ) + case .numberStepper: + return AnyView(LUINumberStepperView(model: model, backend: backend)) case .progress: return AnyView( ProgressView(value: model.progressFraction) @@ -1207,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] } @@ -1233,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 { @@ -1687,6 +1803,156 @@ private struct LUIStackView: View { } } +/// Pins every child after the first to a screen edge via `.safeAreaInset` +/// while the first child fills the view and scrolls beneath. The node's +/// surface props style the pinned region (the container bypasses the surface +/// modifier), so e.g. background "bar" gives a material chrome bar. +private struct LUIEdgeInsetView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + private var edgeName: String { + model.property(.edge)?.stringValue ?? "top" + } + + private var isSideEdge: Bool { + edgeName == "leading" || edgeName == "trailing" + } + + private var isVisible: Bool { + model.property(.visible)?.boolValue ?? true + } + + private var spacing: CGFloat { + CGFloat(model.property(.gap)?.intValue ?? 0) + } + + var body: some View { + Group { + if isSideEdge { + content + .safeAreaInset( + edge: edgeName == "trailing" + ? HorizontalEdge.trailing : .leading, + spacing: spacing + ) { + pinnedRegion + } + } else { + content + .safeAreaInset( + edge: edgeName == "bottom" ? VerticalEdge.bottom : .top, + spacing: spacing + ) { + pinnedRegion + } + } + } + .animation(.default, value: isVisible) + } + + @ViewBuilder + private var content: some View { + if let contentID = model.children.first { + LUIAnyNodeView(nodeID: contentID, backend: backend).equatable() + } + } + + @ViewBuilder + private var pinnedRegion: some View { + // `visible` toggles the pinned region in place so the safe-area + // insertion animates instead of snapping. + if isVisible { + if isSideEdge { + HStack(spacing: 0) { + pinnedViews(Array(model.children.dropFirst())) + } + .frame(maxHeight: .infinity) + .modifier(LUISurfaceModifier(model: model, backend: backend)) + } else { + VStack(spacing: 0) { + pinnedViews(Array(model.children.dropFirst())) + } + .frame(maxWidth: .infinity) + .modifier(LUISurfaceModifier(model: model, backend: backend)) + } + } + } + + private func pinnedViews(_ ids: [Int]) -> some View { + ForEach(ids, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + } + } +} + +/// Renders the children after the first floating over the first child +/// (`base`) without affecting its layout. Each overlay child positions +/// itself by its own `alignment` property, falling back to the overlay's +/// `alignment` (default `.center`). +private struct LUIOverlayView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + private func alignment(of childID: Int) -> Alignment { + Self.alignment( + backend.model(id: childID)?.property(.alignment)?.stringValue + ?? model.property(.alignment)?.stringValue + ) + } + + private static func alignment(_ name: String?) -> Alignment { + switch name { + case "top-leading": .topLeading + case "top": .top + case "top-trailing": .topTrailing + case "leading": .leading + case "trailing": .trailing + case "bottom-leading": .bottomLeading + case "bottom": .bottom + case "bottom-trailing": .bottomTrailing + default: .center + } + } + + var body: some View { + let base: AnyView + if let baseID = model.children.first { + base = AnyView(LUIAnyNodeView(nodeID: baseID, backend: backend).equatable()) + } else { + base = AnyView(EmptyView()) + } + return model.children.dropFirst().reduce(base) { content, childID in + AnyView( + content.overlay(alignment: alignment(of: childID)) { + LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + } + ) + } + } +} + +/// Renders the first child that fits along `orientation` +/// (`ViewThatFits(in:)`; default horizontal). +private struct LUIViewThatFitsView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + private var axis: Axis.Set { + model.property(.orientation)?.stringValue == "vertical" + ? .vertical + : .horizontal + } + + var body: some View { + ViewThatFits(in: axis) { + ForEach(model.children, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + } + } + } +} + private struct LUIAnchoredComboboxMenuHost: View { let model: LUINodeModel? let backend: LUIAppleBackend @@ -2208,22 +2474,76 @@ private struct LUITooltipHost: View { } private struct LUIModalPresentationStyle: ViewModifier { - let kind: LUINodeKind + let model: LUINodeModel @ViewBuilder func body(content: Content) -> some View { - if LUIModalPresentationPolicy.showsDragIndicator(kind: kind) { - content.presentationDragIndicator(.visible) + let withDetents = content.presentationDetents( + LUIModalPresentationPolicy.detents(for: model) + ) + if #available(iOS 18.0, macOS 15.0, *) { + // `sizing` is a best-effort prop: absent or unrecognized values + // keep the platform default. + switch LUIModalPresentationPolicy.sizing(for: model) { + case .form: + withDetents.presentationSizing(.form) + case .fitted: + withDetents.presentationSizing(.fitted) + case .page: + withDetents.presentationSizing(.page) + case nil: + withDetents + } } else { - content + withDetents } } } +/// `sizing` prop values for `sheet`, mirroring `PresentationSizing` cases. +enum LUISheetSizing: Equatable { + case form + case fitted + case page +} + +@MainActor enum LUIModalPresentationPolicy { static func showsDragIndicator(kind: LUINodeKind) -> Bool { false } + + /// `detents` is a comma-separated list of `medium`, `large`, or a + /// fractional height in (0, 1]. Unknown tokens are ignored; an absent or + /// fully-unrecognized value falls back to the default `.large` detent. + static func detents(for model: LUINodeModel) -> Set { + guard let raw = model.property(.detents)?.stringValue else { + return [.large] + } + let parsed = raw.split(separator: ",").compactMap(detent) + return parsed.isEmpty ? [.large] : Set(parsed) + } + + private static func detent(_ token: Substring) -> PresentationDetent? { + let trimmed = token.trimmingCharacters(in: .whitespaces) + switch trimmed { + case "medium": return .medium + case "large": return .large + default: + guard let fraction = Double(trimmed), + fraction > 0, fraction <= 1 else { return nil } + return .fraction(CGFloat(fraction)) + } + } + + static func sizing(for model: LUINodeModel) -> LUISheetSizing? { + switch model.property(.sizing)?.stringValue { + case "form": return .form + case "fitted": return .fitted + case "page": return .page + default: return nil + } + } } private struct LUIModalSurfaceContent: View { @@ -2233,7 +2553,9 @@ private struct LUIModalSurfaceContent: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - surfaceContent.sheet(item: Binding( + surfaceContent + .modifier(LUIModalPresentationStyle(model: model)) + .sheet(item: Binding( get: { backend.modalPresentation.nestedSheets[model.id] }, set: { value in if value == nil, @@ -2475,6 +2797,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 @@ -2927,6 +3250,141 @@ private struct LUIImageView: View { } } +private struct LUILinkView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + @Environment(\.luiSemanticColors) private var semanticColors + + private var urlString: String { + model.property(.url)?.stringValue ?? "" + } + + private var tint: Color? { + LUIThemeColorResolver.color( + model.property(.foreground)?.stringValue, + semanticColors: semanticColors + ) + } + + var body: some View { + if let url = URL(string: urlString) { + Link(destination: url) { label } + .tint(tint) + .disabled(!model.isEnabled) + } else { + label + } + } + + @ViewBuilder + private var label: some View { + if model.visibleChildren.isEmpty { + labelContent + } else { + ForEach(model.visibleChildren, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + } + } + } + + @ViewBuilder + private var labelContent: some View { + let iconName = model.buttonIconName + let text = model.text.isEmpty ? urlString : model.text + if iconName.isEmpty { + Text(verbatim: text) + } else if LUIButtonIconPlacementPolicy.usesVerticalLayout( + model.buttonIconPlacement + ) { + VStack(spacing: 2) { + linkIcon(iconName) + Text(verbatim: text) + } + } else if model.buttonIconPlacement == "trailing" { + HStack(spacing: 4) { + Text(verbatim: text) + linkIcon(iconName) + } + } else { + HStack(spacing: 4) { + linkIcon(iconName) + Text(verbatim: text) + } + } + } + + private func linkIcon(_ name: String) -> some View { + LUIIconImage(source: backend.iconSource(for: name), bundle: backend.appIconBundle) + .scaledToFit() + .frame(width: 14, height: 14) + } +} + +private struct LUIFileImageView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + @State private var image: CGImage? + @State private var failed = false + + private var path: String { + model.property(.path)?.stringValue ?? "" + } + + private var maxPixelSize: Int { + model.property(.maxPixelSize)?.intValue + ?? LUIFileImageLoader.defaultMaxPixelSize + } + + @ViewBuilder + var body: some View { + content + .task(id: "\(maxPixelSize)|\(path)") { + if let decoded = await LUIFileImageLoader.thumbnail( + path: path, + maxPixelSize: maxPixelSize + ) { + image = decoded + failed = false + } else { + image = nil + failed = true + } + } + } + + @ViewBuilder + private var content: some View { + if model.supportsPress { + Button { + try? backend.performPress(node: model.id) + } label: { + inner + } + .buttonStyle(.plain) + } else { + inner + } + } + + @ViewBuilder + private var inner: some View { + if let image { + Image(decorative: image, scale: 1) + .resizable() + } else if failed { + LUIIconImage( + source: .systemName("photo"), + bundle: backend.appIconBundle + ) + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + } + } +} + private struct LUIMediaSurfaceView: View { let model: LUINodeModel let backend: LUIAppleBackend @@ -2949,6 +3407,29 @@ private struct LUIMediaSurfaceView: View { } } +private struct LUINumberStepperView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + var body: some View { + Stepper( + model.text, + value: Binding( + get: { + min( + max(model.stepperValue, model.stepperRange.lowerBound), + model.stepperRange.upperBound + ) + }, + set: { try? backend.performValueChange(node: model.id, value: $0) } + ), + in: model.stepperRange, + step: model.stepperStep + ) + .disabled(!model.isEnabled) + } +} + private struct LUIStepperView: View { let model: LUINodeModel let backend: LUIAppleBackend @@ -3633,7 +4114,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( @@ -3652,6 +4150,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( @@ -3679,7 +4178,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, @@ -3714,7 +4217,7 @@ private struct LUIListItemView: View { : Color.secondary ) } - if visibleChildren.isEmpty { + if contentChildIDs.isEmpty { Text(verbatim: model.text) .font(isNavigationHeading ? .title3 : .body) .fontWeight( @@ -3724,7 +4227,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) @@ -3828,6 +4331,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) } @@ -3876,6 +4399,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 && @@ -3947,12 +4475,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) { @@ -3962,6 +4507,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 { @@ -4996,11 +5620,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 { @@ -5016,6 +5654,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, @@ -5029,6 +5714,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 @@ -5062,67 +5748,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 @@ -5134,11 +5899,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, @@ -5168,30 +5968,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 @@ -5754,7 +6743,11 @@ private struct LUIBackgroundStyleModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { let glassShape: AnyShape = isPill ? AnyShape(Capsule()) : shape - if name == "glass", !suppressesOwnGlass { + if name == "bar" { + // System chrome material (`.background(.bar)`), for edge-pinned + // bars that should pick up the platform bar treatment. + content.background(.bar, in: shape) + } else if name == "glass", !suppressesOwnGlass { if #available(iOS 26.0, macOS 26.0, *) { content .modifier(LUIOptionalClipModifier( diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..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: @@ -148,6 +152,17 @@ enum LUIWireValue: Decodable, Equatable { .anchorOffset: guard let value = doubleValue else { return false } return value.isFinite + case .minValue, .maxValue: + guard let value = doubleValue else { return false } + return value.isFinite + case .stepValue: + guard let value = doubleValue else { return false } + return value.isFinite && value > 0 + case .detents: + return stringValue != nil + case .sizing: + guard let value = stringValue else { return false } + return ["form", "fitted", "page"].contains(value) case .orientation: guard let value = stringValue else { return false } return value == "horizontal" || value == "vertical" @@ -169,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 } @@ -186,9 +201,23 @@ enum LUIWireValue: Decodable, Equatable { case .tooltipDelay, .duration: guard let value = intValue else { return false } return (0...Int(Int32.max)).contains(value) + case .path, .url: + return stringValue != nil + case .maxPixelSize: + guard let value = intValue else { return false } + return value > 0 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" || @@ -201,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) @@ -210,6 +248,14 @@ enum LUIWireValue: Decodable, Equatable { case .grow: guard let value = doubleValue else { return false } return value.isFinite && value >= 0 + case .edge: + guard let value = stringValue else { return false } + return Self.edges.contains(value) + case .visible: + return boolValue != nil + case .alignment: + guard let value = stringValue else { return false } + return Self.overlayAlignments.contains(value) case .gap, .padding: return intValue != nil } @@ -232,6 +278,16 @@ enum LUIWireValue: Decodable, Equatable { private static let textAlignments: Set = ["start", "center", "end"] + private static let edges: Set = [ + "top", "bottom", "leading", "trailing", + ] + + private static let overlayAlignments: Set = [ + "top-leading", "top", "top-trailing", + "leading", "center", "trailing", + "bottom-leading", "bottom", "bottom-trailing", + ] + private static let toolbarPlacements: Set = [ "automatic", "bottom", "navigation", "principal", "primary-action", "secondary-action", "status", "confirmation-action", "cancellation-action", @@ -331,7 +387,8 @@ struct LUIRetainedTree { // `dropNode` erases the state, so its kind must be read first. if case let .dropNode(id) = operation, let kind = nodes[id]?.kind, - kind == .dialog || kind == .sheet || kind == .list { + kind == .dialog || kind == .sheet || kind == .list || + kind == .filePreview { effects.modalRelevant.insert(id) } try apply(operation, extensionRegistry: extensionRegistry) @@ -362,7 +419,8 @@ struct LUIRetainedTree { } effects.modalRelevant.formUnion(effects.touched.filter { let kind = nodes[$0]?.kind - return kind == .dialog || kind == .sheet || kind == .list + return kind == .dialog || kind == .sheet || kind == .list || + kind == .filePreview }) // Revalidate every check whose inputs could have changed: the // touched node itself, its parent (a changed child list or parent @@ -632,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 { @@ -680,6 +761,10 @@ struct LUIRetainedTree { private static func supports(_ property: LUIProperty, on kind: LUINodeKind) -> Bool { if property == .accessibilityIdentifier { return true } + // Position hint honored on overlay children and the overlay itself; + // admitted before the restrictive matrix so e.g. an aligned + // menu-trigger child still carries it. Inert elsewhere. + if property == .alignment { return kind != .root } // Restrictive per-kind allow-lists and additive extras come from // schema/components.json via LUISchemaMatrix; kinds absent from the // restrictive table fall back to the shared structural rules below. @@ -717,14 +802,18 @@ struct LUIRetainedTree { kind == .row || kind == .column || kind == .grid || kind == .box || kind == .panel || kind == .card || kind == .stack || kind == .scroll || kind == .avatar || + kind == .edgeInset || kind == .overlay || + kind == .viewThatFits || kind == .text || kind == .heading || kind == .paragraph || kind == .label || kind == .button || kind == .toggleButton || isTextEntry(kind) || kind == .checkbox || kind == .toggle || - kind == .radio || kind == .slider || kind == .spinner || kind == .icon + kind == .radio || kind == .slider || kind == .numberStepper || + kind == .spinner || kind == .icon || kind == .select || kind == .combobox || kind == .dropdownMenu || kind == .menuItem || kind == .listItem || kind == .tableCell || kind == .resizable || kind == .split || - kind == .alert || kind == .bubble || kind == .statusBar + kind == .alert || kind == .bubble || kind == .statusBar || + kind == .link || kind == .fileImage case .text: kind == .text || kind == .heading || kind == .paragraph || kind == .label || kind == .button || kind == .toggleButton || isTextEntry(kind) || @@ -733,19 +822,21 @@ struct LUIRetainedTree { kind == .tooltip || kind == .tableCell || kind == .alert || kind == .bubble || kind == .statusBar || - isModalSurface(kind) || kind == .drawer + isModalSurface(kind) || kind == .drawer || kind == .numberStepper || + kind == .link case .enabled: kind == .button || kind == .toggleButton || isTextEntry(kind) || kind == .checkbox || kind == .switchControl || kind == .toggle || - kind == .radio || kind == .slider || kind == .select || + kind == .radio || kind == .slider || kind == .numberStepper || + kind == .select || kind == .combobox || kind == .menuItem || kind == .listItem || - kind == .drawer + kind == .drawer || kind == .link case .gap: kind == .row || kind == .column || kind == .grid || kind == .list || kind == .virtualList || kind == .scroll || kind == .card || kind == .panel || kind == .box || kind == .dropdownMenu || isHorizontalGroup(kind) || kind == .split - || kind == .tableRow || kind == .tree + || kind == .tableRow || kind == .tree || kind == .edgeInset case .placeholder: isTextEntry(kind) || kind == .select case .accessibilityIdentifier: @@ -761,14 +852,19 @@ struct LUIRetainedTree { kind == .mediaSurface || kind == .tree || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || - kind == .listItem || + kind == .listItem || kind == .numberStepper || + kind == .link || kind == .fileImage || isTreeRow(kind) case .headingLevel: kind == .heading case .checked: kind == .checkbox || kind == .switchControl || kind == .toggle || kind == .radio - case .progressValue: kind == .progress || kind == .slider || kind == .split + case .progressValue: + kind == .progress || kind == .slider || kind == .numberStepper || + kind == .split case .resizeDuration, .resizeEasing, .resizeOrigin: kind == .split - case .orientation: kind == .divider || kind == .tabs || kind == .scroll + case .orientation: + kind == .divider || kind == .tabs || kind == .scroll || + kind == .viewThatFits case .placement: kind == .toolbar case .size: kind == .button || kind == .toggleButton || kind == .spinner || @@ -779,12 +875,13 @@ struct LUIRetainedTree { kind == .button || kind == .toggleButton || kind == .menuItem || kind == .alert || kind == .bubble case .iconPlacement: - kind == .button || kind == .toggleButton || kind == .listItem + kind == .button || kind == .toggleButton || kind == .listItem || + kind == .link case .longPressEnabled: kind == .button || kind == .toggleButton || kind == .listItem case .icon: kind == .button || kind == .toggleButton || kind == .menuItem || - kind == .listItem + kind == .listItem || kind == .link case .selected: kind == .button || kind == .toggleButton || kind == .menuItem || kind == .listItem || kind == .tableRow || kind == .drawer || @@ -797,7 +894,7 @@ struct LUIRetainedTree { case .pressEnabled: kind == .text || kind == .column || kind == .radio || kind == .select || kind == .combobox || kind == .menuItem || kind == .listItem - || kind == .tableCell || isTreeRow(kind) + || kind == .tableCell || kind == .fileImage || isTreeRow(kind) case .submitEnabled: kind == .combobox || kind == .listItem case .doublePressEnabled: kind == .listItem case .appearEnabled: kind != .root @@ -816,7 +913,21 @@ 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 .request, .types, .multiple, .source, .completion: false + case .visible: kind == .edgeInset + // `.alignment` is admitted ahead of the restrictive matrix above. + case .alignment: kind != .root } } @@ -827,6 +938,7 @@ struct LUIRetainedTree { private static func canContainChildren(_ kind: LUINodeKind) -> Bool { kind == .root || kind == .row || kind == .column || kind == .grid || kind == .stack || + kind == .edgeInset || kind == .overlay || kind == .viewThatFits || kind == .panel || kind == .card || kind == .box || kind == .scroll || kind == .list || kind == .virtualList || isHorizontalGroup(kind) || kind == .radioGroup || kind == .dropdownMenu || kind == .contextMenu || kind == .listItem || isModalSurface(kind) @@ -836,17 +948,22 @@ struct LUIRetainedTree { kind == .stepper || kind == .timeline || kind == .inputGroup || kind == .inputGroupActions || kind == .toast || kind == .toolbar || kind == .bottomTabs || kind == .bottomTab || - kind == .menuTrigger || isContextMenuLeafHost(kind) + kind == .menuTrigger || kind == .filePicker || isContextMenuLeafHost(kind) + || kind == .link || kind == .listSection + || kind == .listSectionHeader || kind == .listSectionFooter + || kind == .swipeActions } private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool { kind == .root || kind == .row || kind == .column || kind == .grid || kind == .stack || + kind == .edgeInset || kind == .overlay || kind == .viewThatFits || kind == .panel || kind == .card || kind == .box || kind == .scroll || kind == .list || kind == .virtualList || kind == .listItem || kind == .dialog || 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 { @@ -900,6 +1017,9 @@ struct LUIRetainedTree { if node.kind == .icon, node.properties[.name] == nil { throw invalid("icon requires name") } + if node.kind == .edgeInset, node.properties[.edge] == nil { + throw invalid("edge-inset requires edge") + } if node.kind == .button || node.kind == .toggleButton || node.kind == .toggle || node.kind == .radio { let text = node.properties[.text]?.stringValue ?? "" @@ -917,6 +1037,25 @@ struct LUIRetainedTree { throw invalid("value control requires an accessibility label") } } + if node.kind == .numberStepper { + let text = node.properties[.text]?.stringValue ?? "" + let label = node.properties[.accessibilityLabel]?.stringValue ?? "" + guard !text.isEmpty || !label.isEmpty else { + throw invalid("number-stepper requires a label") + } + guard case let .double(value)? = node.properties[.progressValue], + value.isFinite else { + throw invalid("number-stepper requires a finite value") + } + // min defaults to 0 and max is unbounded; compare the + // effective endpoints so a lone negative max still fails. + let minimum = node.properties[.minValue]?.doubleValue ?? 0.0 + let maximum = node.properties[.maxValue]?.doubleValue + ?? .greatestFiniteMagnitude + guard minimum <= maximum else { + throw invalid("number-stepper min must not exceed max") + } + } if node.kind == .slider || node.kind == .progress { guard case let .double(value)? = node.properties[.progressValue], value.isFinite else { throw invalid("value control requires a finite fractional value") @@ -1053,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 { @@ -1071,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, @@ -1116,6 +1283,14 @@ struct LUIRetainedTree { if node.kind == .mediaSurface, node.properties[.surface]?.intValue == nil { throw invalid("media-surface requires surface") } + if node.kind == .fileImage || node.kind == .filePreview, + (node.properties[.path]?.stringValue ?? "").isEmpty { + throw invalid("file node requires path") + } + if node.kind == .link, + (node.properties[.url]?.stringValue ?? "").isEmpty { + throw invalid("link requires url") + } if node.kind == .stepper, node.properties[.active]?.intValue == nil { throw invalid("stepper requires active") } @@ -1218,7 +1393,9 @@ extension LUIWireValue { func normalized(for property: LUIProperty) -> LUIWireValue { if property == .grow || property == .anchorOffset || property == .sourceX || property == .sourceY || - property == .sourceWidth || property == .sourceHeight, + property == .sourceWidth || property == .sourceHeight || + property == .progressValue || property == .minValue || + property == .maxValue || property == .stepValue, case let .int(value) = self { return .double(Double(value)) } diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e5505..fd534e41 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -7,6 +7,9 @@ enum LUINodeKind: String, Decodable, Equatable { case column = "column" case grid = "grid" case stack = "stack" + case edgeInset = "edge-inset" + case overlay = "overlay" + case viewThatFits = "view-that-fits" case panel = "panel" case card = "card" case alert = "alert" @@ -22,6 +25,7 @@ enum LUINodeKind: String, Decodable, Equatable { case radioGroup = "radio-group" case radio = "radio" case slider = "slider" + case numberStepper = "number-stepper" case textField = "text-field" case secureField = "secure-field" case input = "input" @@ -74,6 +78,15 @@ 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" } enum LUIProperty: String, Decodable, Hashable { @@ -150,6 +163,30 @@ 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" + case detents = "detents" + case sizing = "sizing" + case path = "path" + case url = "url" + case maxPixelSize = "max-pixel-size" + case visible = "visible" + case alignment = "alignment" } enum LUISchemaMatrix { @@ -166,6 +203,11 @@ 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], ] static let extra: [LUINodeKind: Set] = [ diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 6f99514d..bd2763e8 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -2293,7 +2293,7 @@ struct LUISwiftUIBackendTests { #expect(throws: LUIBackendError.self) { try backend.apply(json: """ {"generation":3,"ops":[ - {"op":"set-prop","id":1,"property":"value","value":1} + {"op":"set-prop","id":1,"property":"value","value":"x"} ]} """) } @@ -3414,6 +3414,120 @@ struct LUISwiftUIBackendTests { } } + @Test("maps a number-stepper to a clamped value control") + func mapsNumberStepper() 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":"column"}, + {"op":"create-node","id":2,"kind":"number-stepper"}, + {"op":"set-prop","id":2,"property":"text","value":"Days"}, + {"op":"set-prop","id":2,"property":"value","value":5.0}, + {"op":"set-prop","id":2,"property":"min","value":0.0}, + {"op":"set-prop","id":2,"property":"max","value":10.0}, + {"op":"set-prop","id":2,"property":"step","value":1.0}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + + let stepper = try #require(backend.model(id: 2)) + #expect(stepper.kind == .numberStepper) + #expect(stepper.stepperRange == 0.0...10.0) + #expect(stepper.stepperStep == 1.0) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + try backend.performValueChange(node: 2, value: 99.0) + #expect(events == [.valueChanged(node: 2, value: 10.0)]) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"step","value":0.0} + ]} + """) + } + #expect(backend.generation == 1) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"create-node","id":3,"kind":"number-stepper"}, + {"op":"set-prop","id":3,"property":"value","value":2.0} + ]} + """) + } + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"create-node","id":4,"kind":"number-stepper"}, + {"op":"set-prop","id":4,"property":"text","value":"Days"}, + {"op":"set-prop","id":4,"property":"value","value":2.0}, + {"op":"set-prop","id":4,"property":"min","value":10.0}, + {"op":"set-prop","id":4,"property":"max","value":0.0} + ]} + """) + } + #expect(backend.generation == 1) + } + + @Test("parses sheet detents and sizing props") + func sheetPresentationProps() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"sheet"}, + {"op":"create-node","id":3,"kind":"input"}, + {"op":"set-prop","id":2,"property":"text","value":"Settings"}, + {"op":"set-prop","id":2,"property":"detents","value":"medium,0.4,large"}, + {"op":"set-prop","id":2,"property":"sizing","value":"form"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0} + ]} + """) + + let sheet = try #require(backend.model(id: 2)) + #expect(LUIModalPresentationPolicy.detents(for: sheet) == [ + .medium, .fraction(0.4), .large, + ]) + #expect(LUIModalPresentationPolicy.sizing(for: sheet) == .form) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"detents","value":"bogus,0.25"} + ]} + """) + #expect(LUIModalPresentationPolicy.detents(for: sheet) == [ + .fraction(0.25), + ]) + + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"set-prop","id":2,"property":"detents","value":"bogus"} + ]} + """) + #expect(LUIModalPresentationPolicy.detents(for: sheet) == [.large]) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":4,"ops":[ + {"op":"set-prop","id":2,"property":"sizing","value":"huge"} + ]} + """) + } + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":4,"ops":[ + {"op":"set-prop","id":1,"property":"detents","value":"large"} + ]} + """) + } + #expect(backend.generation == 3) + } + @Test("maps ContextMenu metadata to native SwiftUI actions") func mapsContextMenu() throws { let backend = LUIAppleBackend() @@ -3792,6 +3906,151 @@ struct LUISwiftUIBackendTests { #expect(capturedAppleEvent == CapturedAppleEvent(kind: 0, node: 1, text: "")) } + @Test("maps Link to a retained node with url") + func mapsLink() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"link"}, + {"op":"create-node","id":3,"kind":"text"}, + {"op":"set-prop","id":2,"property":"url","value":"https://example.com/docs"}, + {"op":"set-prop","id":2,"property":"text","value":"Docs"}, + {"op":"set-prop","id":2,"property":"icon","value":"external-link"}, + {"op":"set-prop","id":3,"property":"text","value":"child"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0} + ]} + """) + + let link = try #require(backend.model(id: 2)) + #expect(link.kind == .link) + #expect(link.property(.url) == .string("https://example.com/docs")) + #expect(link.children == [3]) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"create-node","id":4,"kind":"link"}, + {"op":"insert-child","parent":1,"child":4,"index":1} + ]} + """) + } + #expect(backend.generation == 1) + } + + @Test("maps FileImage with thumbnail, sizing, and press support") + func mapsFileImage() 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":"column"}, + {"op":"create-node","id":2,"kind":"file-image"}, + {"op":"set-prop","id":2,"property":"path","value":"/tmp/pic.png"}, + {"op":"set-prop","id":2,"property":"max-pixel-size","value":512}, + {"op":"set-prop","id":2,"property":"press-enabled","value":true}, + {"op":"set-prop","id":2,"property":"width","value":120}, + {"op":"set-prop","id":2,"property":"height","value":80}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + + let image = try #require(backend.model(id: 2)) + #expect(image.kind == .fileImage) + #expect(image.property(.path) == .string("/tmp/pic.png")) + #expect(image.property(.maxPixelSize) == .int(512)) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + try backend.performPress(node: 2) + #expect(events == [.press(node: 2)]) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"remove-prop","id":2,"property":"press-enabled"} + ]} + """) + #expect(throws: LUIBackendError.self) { + try backend.performPress(node: 2) + } + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"create-node","id":5,"kind":"file-image"}, + {"op":"insert-child","parent":1,"child":5,"index":1} + ]} + """) + } + #expect(backend.generation == 2) + } + + @Test("file-preview presents through the store and dismiss emits an event") + func mapsFilePreview() 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":"column"}, + {"op":"create-node","id":2,"kind":"file-preview"}, + {"op":"set-prop","id":2,"property":"path","value":"/tmp/doc.pdf"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + + #expect(backend.filePreviewPresentation.item?.nodeID == 2) + #expect( + backend.filePreviewPresentation.item?.url + == URL(fileURLWithPath: "/tmp/doc.pdf") + ) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + // Interactive close suppresses re-assert until the wire drops the node. + #expect(backend.filePreviewPresentation.dismissFromPresentation() == 2) + try backend.performDismiss(node: 2) + #expect(events == [.dismiss(node: 2)]) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"path","value":"/tmp/other.pdf"} + ]} + """) + #expect(backend.filePreviewPresentation.item == nil) + + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"remove-child","parent":1,"child":2}, + {"op":"drop-node","id":2} + ]} + """) + #expect(backend.filePreviewPresentation.item == nil) + + try backend.apply(json: """ + {"generation":4,"ops":[ + {"op":"create-node","id":3,"kind":"file-preview"}, + {"op":"set-prop","id":3,"property":"path","value":"file:///tmp/a.pdf"}, + {"op":"insert-child","parent":1,"child":3,"index":0} + ]} + """) + #expect( + backend.filePreviewPresentation.item?.url + == URL(string: "file:///tmp/a.pdf") + ) + + // The kind is restrictive: only `path` is admitted. + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":5,"ops":[ + {"op":"set-prop","id":3,"property":"text","value":"x"} + ]} + """) + } + #expect(backend.generation == 4) + } + private static let initialBatch = """ {"generation":1,"ops":[ {"op":"create-node","id":1,"kind":"row"}, diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index 1fc6a1f5..f658c8c4 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift @@ -636,4 +636,264 @@ struct LUIBackendParityTests { #expect(events == [.textChanged(node: 1, text: "secret")]) _ = LUISwiftUIRoot(backend: backend, rootID: 1) } + + @Test("edge-inset requires a valid edge and toggles visibility") + func edgeInsetValidatesEdgeAndVisibility() throws { + let backend = LUIAppleBackend() + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"edge-inset"} + ]} + """) + } + + let badEdge = LUIAppleBackend() + #expect(throws: LUIBackendError.self) { + try badEdge.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"edge-inset"}, + {"op":"set-prop","id":1,"property":"edge","value":"left"} + ]} + """) + } + + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"edge-inset"}, + {"op":"set-prop","id":1,"property":"edge","value":"top"}, + {"op":"set-prop","id":1,"property":"visible","value":false}, + {"op":"set-prop","id":1,"property":"gap","value":4}, + {"op":"set-prop","id":1,"property":"background","value":"bar"}, + {"op":"create-node","id":2,"kind":"text"}, + {"op":"set-prop","id":2,"property":"text","value":"scrolling"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"create-node","id":3,"kind":"row"}, + {"op":"insert-child","parent":1,"child":3,"index":1} + ]} + """) + + let model = try #require(backend.model(id: 1)) + #expect(model.kind == .edgeInset) + #expect(model.property(.edge)?.stringValue == "top") + #expect(model.property(.visible)?.boolValue == false) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + } + + @Test("overlay sizes to its base child and floats aligned overlays") + func overlayFloatsAlignedChildren() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"overlay"}, + {"op":"create-node","id":2,"kind":"list"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"create-node","id":3,"kind":"row"}, + {"op":"set-prop","id":3,"property":"alignment","value":"top-trailing"}, + {"op":"insert-child","parent":1,"child":3,"index":1} + ]} + """) + + #expect(backend.model(id: 1)?.kind == .overlay) + #expect( + backend.model(id: 3)?.property(.alignment)?.stringValue + == "top-trailing" + ) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + let badAlignment = LUIAppleBackend() + #expect(throws: LUIBackendError.self) { + try badAlignment.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"overlay"}, + {"op":"set-prop","id":1,"property":"alignment","value":"above"} + ]} + """) + } + } + + @Test("view-that-fits mounts its candidate children") + func viewThatFitsMountsChildren() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"view-that-fits"}, + {"op":"set-prop","id":1,"property":"orientation","value":"vertical"}, + {"op":"create-node","id":2,"kind":"text"}, + {"op":"set-prop","id":2,"property":"text","value":"expanded"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"create-node","id":3,"kind":"text"}, + {"op":"set-prop","id":3,"property":"text","value":"compact"}, + {"op":"insert-child","parent":1,"child":3,"index":1} + ]} + """) + + let model = try #require(backend.model(id: 1)) + #expect(model.kind == .viewThatFits) + #expect(model.children == [2, 3]) + _ = 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 06a72c3a..380c7141 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; @@ -34,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, @@ -41,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 @@ -94,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; @@ -181,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; @@ -359,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) => @@ -671,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); @@ -761,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 @@ -877,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) { @@ -900,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 && @@ -946,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, ]; @@ -983,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; @@ -1197,6 +1283,8 @@ final class LUIFlutterBackend { (state.kind == _NodeKind.column && state.properties['press-enabled'] == true) || (state.kind == _NodeKind.text && + state.properties['press-enabled'] == true) || + (state.kind == _NodeKind.fileImage && state.properties['press-enabled'] == true); if (!pressable || state.properties['enabled'] == false) { throw LUIBackendException( @@ -1461,12 +1549,22 @@ final class LUIFlutterBackend { void performValueChange(int node, double value) { final state = _requireState(_states, node); - if ((state.kind != _NodeKind.slider && state.kind != _NodeKind.split) || + if ((state.kind != _NodeKind.slider && + state.kind != _NodeKind.split && + state.kind != _NodeKind.numberStepper) || state.properties['enabled'] == false || !value.isFinite) { throw LUIBackendException('node $node is not an enabled value control'); } - onEvent?.call(LUIEvent.valueChanged(node: node, value: value.clamp(0, 1))); + var emitted = value.clamp(0.0, 1.0); + if (state.kind == _NodeKind.numberStepper) { + final minimum = + (state.properties['min'] as num?)?.toDouble() ?? 0.0; + final maximum = + (state.properties['max'] as num?)?.toDouble() ?? double.maxFinite; + emitted = value.clamp(minimum, maximum < minimum ? minimum : maximum); + } + onEvent?.call(LUIEvent.valueChanged(node: node, value: emitted.toDouble())); } void performDismiss(int node) { @@ -1478,6 +1576,8 @@ 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'); } @@ -1492,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]; @@ -1580,6 +1682,39 @@ final class LUIFlutterBackend { state.kind == _NodeKind.button && state.parent != null && _states[state.parent]?.kind == _NodeKind.tabs; + Widget numberStepperView(_NodeState stepperState, bool stepperEnabled, int id) { + final step = + (stepperState.properties['step'] as num?)?.toDouble() ?? 1.0; + final current = + (stepperState.properties['value'] as num?)?.toDouble() ?? 0.0; + final text = stepperState.properties['text'] as String? ?? ''; + final a11yLabel = + stepperState.properties['accessibility-label'] as String? ?? text; + return Semantics( + label: a11yLabel, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(text)), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Decrease', + onPressed: stepperEnabled + ? () => performValueChange(id, current - step) + : null, + ), + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Increase', + onPressed: stepperEnabled + ? () => performValueChange(id, current + step) + : null, + ), + ], + ), + ); + } + Widget textControl({required _NodeKind kind}) { final multiline = kind == _NodeKind.textarea; final combobox = kind == _NodeKind.combobox; @@ -1808,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; @@ -1839,6 +1975,7 @@ final class LUIFlutterBackend { ], ); } + Widget horizontalGroupFlex() => LUIFlex( direction: Axis.horizontal, expandsForAlignment: state.properties.containsKey('main'), @@ -1874,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) => @@ -1949,6 +2084,79 @@ final class LUIFlutterBackend { ); } + /// Pins the children after the first to `edge` while the first child + /// fills the view beneath them. Pinned children group into a row across + /// side edges and a column under top/bottom; `gap` insets the group + /// from the pinned edge. + Widget edgeInset() { + final visible = state.properties['visible'] as bool? ?? true; + final gap = (state.properties['gap'] as num?)?.toDouble() ?? 0; + final edge = state.properties['edge'] as String? ?? 'top'; + final sideEdge = edge == 'leading' || edge == 'trailing'; + final pinned = children.skip(1).toList(growable: false); + return Stack( + children: [ + if (children.isNotEmpty) children.first, + if (visible && pinned.isNotEmpty) + Positioned.fill( + child: Padding( + padding: switch (edge) { + 'bottom' => EdgeInsetsDirectional.only(bottom: gap), + 'leading' => EdgeInsetsDirectional.only(start: gap), + 'trailing' => EdgeInsetsDirectional.only(end: gap), + _ => EdgeInsetsDirectional.only(top: gap), + }, + child: Align( + alignment: switch (edge) { + 'bottom' => AlignmentDirectional.bottomCenter, + 'leading' => AlignmentDirectional.centerStart, + 'trailing' => AlignmentDirectional.centerEnd, + _ => AlignmentDirectional.topCenter, + }, + child: sideEdge + ? Row(mainAxisSize: MainAxisSize.min, children: pinned) + : Column(mainAxisSize: MainAxisSize.min, children: pinned), + ), + ), + ), + ], + ); + } + + /// Renders the children after the first floating over the first child + /// without affecting its layout; each overlay child positions by its own + /// 'alignment' prop, falling back to the overlay's (default center). + Widget overlay() { + final fallback = + _overlayAlignment(state.properties['alignment'] as String?) ?? + AlignmentDirectional.center; + // `children` skips the context-menu child, so index the visible ids. + final overlayIDs = state.children + .where((child) => child != contextMenuID) + .toList(growable: false); + return Stack( + children: [ + if (children.isNotEmpty) children.first, + for (var index = 1; index < children.length; index += 1) + Positioned.fill( + child: Align( + alignment: + _overlayAlignment( + _states[overlayIDs[index]]?.properties['alignment'] + as String?, + ) ?? + fallback, + child: children[index], + ), + ), + ], + ); + } + + /// No native ViewThatFits equivalent; render the first candidate. + Widget viewThatFits() => + children.isEmpty ? const SizedBox.shrink() : children.first; + Widget select() { final colors = Theme.of(context).colorScheme; final title = accessibilityLabel ?? placeholder ?? ''; @@ -2491,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( @@ -2681,6 +2887,9 @@ final class LUIFlutterBackend { ), _NodeKind.grid => grid(), _NodeKind.stack => stack(), + _NodeKind.edgeInset => edgeInset(), + _NodeKind.overlay => overlay(), + _NodeKind.viewThatFits => viewThatFits(), _NodeKind.panel || _NodeKind.card || _NodeKind.resizable => Stack(children: children), @@ -2781,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(), @@ -2838,6 +3052,7 @@ final class LUIFlutterBackend { ), onChanged: enabled ? (value) => performValueChange(id, value) : null, ), + _NodeKind.numberStepper => numberStepperView(state, enabled, id), _NodeKind.textField || _NodeKind.secureField || _NodeKind.input || @@ -2899,6 +3114,12 @@ 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(), }; if (state.kind == _NodeKind.root || state.kind.isModalSurface) { @@ -3154,6 +3375,7 @@ final class LUIFlutterBackend { Map states, Map extensions, Map operation, + Set filePickerRequests, ) { switch (_string(operation['op'], 'op')) { case 'create-node': @@ -3196,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)) { @@ -3205,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'); @@ -3462,8 +3688,60 @@ 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 + /// ambient Directionality (matching SwiftUI's semantic edges). + static AlignmentGeometry? _overlayAlignment(String? name) => switch (name) { + 'top-leading' => AlignmentDirectional.topStart, + 'top' => AlignmentDirectional.topCenter, + 'top-trailing' => AlignmentDirectional.topEnd, + 'leading' => AlignmentDirectional.centerStart, + 'trailing' => AlignmentDirectional.centerEnd, + 'bottom-leading' => AlignmentDirectional.bottomStart, + 'bottom' => AlignmentDirectional.bottomCenter, + 'bottom-trailing' => AlignmentDirectional.bottomEnd, + _ => null, + }; + static bool _supports(_NodeKind kind, String property, Object? value) { if (property == 'accessibility-identifier') return value is String; if (kind == _NodeKind.root) { @@ -3473,6 +3751,16 @@ final class LUIFlutterBackend { _themeModes.contains(value)); } if (kind == _NodeKind.contextMenu) return false; + if (kind == _NodeKind.filePreview) { + return property == 'path' && value is String; + } + // Position hint honored on overlay children and the overlay itself; + // admitted before the restrictive kinds below. Inert elsewhere. + if (property == 'alignment') { + return kind != _NodeKind.root && + value is String && + _overlayAlignment(value) != null; + } if (kind == _NodeKind.accordion) { return switch (property) { 'text' => value is String, @@ -3578,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') { @@ -3625,6 +3945,7 @@ final class LUIFlutterBackend { kind == _NodeKind.switchControl || kind == _NodeKind.toggle || kind == _NodeKind.radio || + kind == _NodeKind.numberStepper || kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.menuTrigger || @@ -3636,6 +3957,7 @@ final class LUIFlutterBackend { kind == _NodeKind.bubble || kind == _NodeKind.statusBar || kind == _NodeKind.drawer || + kind == _NodeKind.link || kind.isModalSurface), 'enabled' => value is bool && @@ -3646,16 +3968,31 @@ final class LUIFlutterBackend { kind == _NodeKind.toggle || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.listItem || - kind == _NodeKind.drawer), + kind == _NodeKind.drawer || + kind == _NodeKind.link), 'value' => value is double && value.isFinite && (kind == _NodeKind.progress || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.split), + 'min' || 'max' => + value is num && value.isFinite && kind == _NodeKind.numberStepper, + 'step' => + value is num && + value.isFinite && + value > 0 && + kind == _NodeKind.numberStepper, + 'detents' => value is String && kind == _NodeKind.sheet, + 'sizing' => + value is String && + const {'form', 'fitted', 'page'}.contains(value) && + kind == _NodeKind.sheet, 'resize-duration' => value is int && value >= 0 && kind == _NodeKind.split, 'resize-easing' => @@ -3674,7 +4011,8 @@ final class LUIFlutterBackend { (value == 'horizontal' || value == 'vertical') && (kind == _NodeKind.divider || kind == _NodeKind.tabs || - kind == _NodeKind.scroll), + kind == _NodeKind.scroll || + kind == _NodeKind.viewThatFits), 'size' => value is String && (_controlSizes.contains(value) || @@ -3703,11 +4041,12 @@ final class LUIFlutterBackend { (_isButtonKind(kind) || kind == _NodeKind.menuItem || kind == _NodeKind.menuTrigger || - kind == _NodeKind.listItem), + kind == _NodeKind.listItem || + kind == _NodeKind.link), 'icon-placement' => value is String && - (value == 'leading' || value == 'trailing') && - _isButtonKind(kind), + (value == 'leading' || value == 'trailing' || value == 'top') && + (_isButtonKind(kind) || kind == _NodeKind.link), 'selected' => value is bool && (_isButtonKind(kind) || @@ -3738,6 +4077,7 @@ final class LUIFlutterBackend { kind == _NodeKind.menuItem || kind == _NodeKind.listItem || kind == _NodeKind.tableCell || + kind == _NodeKind.fileImage || _isTreeRowKind(kind)), 'submit-enabled' => value is bool && @@ -3753,6 +4093,10 @@ final class LUIFlutterBackend { value is num && value.isFinite && (kind == _NodeKind.avatar || kind == _NodeKind.image), + 'path' => value is String && kind == _NodeKind.fileImage, + 'url' => value is String && kind == _NodeKind.link, + 'max-pixel-size' => + value is int && value > 0 && kind == _NodeKind.fileImage, 'anchor' => value is String && const {'above', 'below', 'left', 'right'}.contains(value) && @@ -3765,6 +4109,14 @@ final class LUIFlutterBackend { value is num && value.isFinite && (kind == _NodeKind.dropdownMenu || kind == _NodeKind.tooltip), + 'edge' => + value is String && + const {'top', 'bottom', 'leading', 'trailing'}.contains(value) && + kind == _NodeKind.edgeInset, + 'visible' => value is bool && kind == _NodeKind.edgeInset, + // 'alignment' is admitted ahead of the restrictive kinds above. + 'alignment' => + value is String && _overlayAlignment(value) != null, 'tooltip-delay' => value is int && value >= 0 && @@ -3782,6 +4134,7 @@ final class LUIFlutterBackend { kind == _NodeKind.tableRow || kind == _NodeKind.tree || kind == _NodeKind.split || + kind == _NodeKind.edgeInset || _isHorizontalGroupKind(kind)), 'padding' => value is int && kind != _NodeKind.avatar && kind != _NodeKind.tooltip, @@ -3799,7 +4152,10 @@ final class LUIFlutterBackend { !kind.isModalSurface, 'foreground' => value is String && - (kind == _NodeKind.text || + (kind == _NodeKind.edgeInset || + kind == _NodeKind.overlay || + kind == _NodeKind.viewThatFits || + kind == _NodeKind.text || kind == _NodeKind.heading || kind == _NodeKind.paragraph || kind == _NodeKind.label || @@ -3809,6 +4165,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toggle || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.spinner || kind == _NodeKind.icon || kind == _NodeKind.select || @@ -3821,7 +4178,9 @@ final class LUIFlutterBackend { kind == _NodeKind.split || kind == _NodeKind.alert || kind == _NodeKind.bubble || - kind == _NodeKind.statusBar), + kind == _NodeKind.statusBar || + kind == _NodeKind.link || + kind == _NodeKind.fileImage), 'border-color' => value is String && kind != _NodeKind.avatar && @@ -3880,6 +4239,7 @@ final class LUIFlutterBackend { kind == _NodeKind.pagination || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.avatar || kind == _NodeKind.image || kind == _NodeKind.mediaSurface || @@ -3891,6 +4251,8 @@ final class LUIFlutterBackend { kind == _NodeKind.bubble || kind == _NodeKind.select || kind == _NodeKind.menuTrigger || + kind == _NodeKind.link || + kind == _NodeKind.fileImage || _isTreeRowKind(kind)), 'text-alignment' => value is String && @@ -3903,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, }; } @@ -3925,6 +4306,10 @@ final class LUIFlutterBackend { !state.properties.containsKey('name')) { throw const LUIBackendException('icon requires name'); } + if (state.kind == _NodeKind.edgeInset && + !state.properties.containsKey('edge')) { + throw const LUIBackendException('edge-inset requires edge'); + } if (_isButtonKind(state.kind)) { final text = state.properties['text'] as String? ?? ''; final label = state.properties['accessibility-label'] as String? ?? ''; @@ -3954,6 +4339,30 @@ final class LUIFlutterBackend { ); } } + if (state.kind == _NodeKind.numberStepper) { + final value = state.properties['value']; + if (value is! double || !value.isFinite) { + throw const LUIBackendException( + 'number-stepper requires a finite value', + ); + } + final text = state.properties['text'] as String? ?? ''; + final label = + state.properties['accessibility-label'] as String? ?? ''; + if (text.isEmpty && label.isEmpty) { + throw const LUIBackendException('number-stepper requires a label'); + } + // min defaults to 0 and max is unbounded; compare the effective + // endpoints so a lone negative max still fails. + final minimum = (state.properties['min'] as num?)?.toDouble() ?? 0.0; + final maximum = + (state.properties['max'] as num?)?.toDouble() ?? double.maxFinite; + if (minimum > maximum) { + throw const LUIBackendException( + 'number-stepper min must not exceed max', + ); + } + } if (state.kind == _NodeKind.split) { if (state.children.length != 2) { throw const LUIBackendException( @@ -4104,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', ); @@ -4129,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', @@ -4143,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? ?? ''; @@ -4194,6 +4635,17 @@ final class LUIFlutterBackend { !state.properties.containsKey('surface')) { throw const LUIBackendException('media-surface requires surface'); } + if ((state.kind == _NodeKind.fileImage || + state.kind == _NodeKind.filePreview) && + (state.properties['path'] as String? ?? '').isEmpty) { + throw LUIBackendException( + '${state.kind == _NodeKind.fileImage ? 'file-image' : 'file-preview'} requires a non-empty path', + ); + } + if (state.kind == _NodeKind.link && + (state.properties['url'] as String? ?? '').isEmpty) { + throw const LUIBackendException('link requires a non-empty url'); + } if (state.kind == _NodeKind.stepper && !state.properties.containsKey('active')) { throw const LUIBackendException('stepper requires active'); @@ -4340,7 +4792,16 @@ 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 || kind.isModalSurface; static bool _acceptsExtensionChildren(_NodeKind kind) => @@ -4349,6 +4810,9 @@ final class LUIFlutterBackend { kind == _NodeKind.column || kind == _NodeKind.grid || kind == _NodeKind.stack || + kind == _NodeKind.edgeInset || + kind == _NodeKind.overlay || + kind == _NodeKind.viewThatFits || kind == _NodeKind.panel || kind == _NodeKind.card || kind == _NodeKind.box || @@ -4366,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 || @@ -4396,6 +4863,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, @@ -4419,6 +4887,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, @@ -4697,6 +5166,8 @@ final class LUIFlutterBackend { 'autocomplete-row-background' => colors.surfaceContainerHigh, 'glass' => colors.surface.withValues(alpha: 0.75), 'glass-fallback' => colors.surface.withValues(alpha: 0.9), + // Apple Material.bar approximation: mostly-opaque theme surface. + 'bar' => colors.surface.withValues(alpha: 0.85), 'primary' => colors.primary, 'primary-foreground' => colors.onPrimary, 'accent' => foreground ? colors.primary : colors.secondaryContainer, @@ -4748,6 +5219,27 @@ final class LUIFlutterBackend { }; } + // Parses the `detents` prop into viewport-height fractions + // (`medium` = 0.5, `large` = 1.0, or a literal fraction in (0, 1]). + // Unknown tokens are ignored; returns null when nothing parses. + static List? _sheetDetentFractions(String? detents) { + if (detents == null) return null; + final fractions = detents + .split(',') + .map((token) { + final trimmed = token.trim(); + if (trimmed == 'medium') return 0.5; + if (trimmed == 'large') return 1.0; + final fraction = double.tryParse(trimmed); + return fraction != null && fraction > 0 && fraction <= 1 + ? fraction + : null; + }) + .nonNulls + .toList(growable: false); + return fractions.isEmpty ? null : fractions; + } + Widget _modalSurface(BuildContext context, int node) { final state = _requireState(_states, node); final width = (state.properties['width'] as int?)?.toDouble(); @@ -4761,12 +5253,44 @@ final class LUIFlutterBackend { child: _modalSurfaceBody(context, state), ), ), - _NodeKind.sheet => SizedBox( - key: ValueKey('lui-sheet-surface-$node'), - width: width ?? double.infinity, - height: height, - child: SafeArea(child: _modalSurfaceBody(context, state)), - ), + _NodeKind.sheet => () { + // Approximate iOS presentation options: detents cap/choose the + // sheet height fraction (medium 0.5, large 1.0, or a literal + // fraction); sizing constrains width (form/fitted ~560px, page + // full width). The bottom sheet always opens at the first detent. + final size = MediaQuery.of(context).size; + final fractions = _sheetDetentFractions( + state.properties['detents'] as String?, + ); + final detentMaxHeight = fractions == null + ? null + : size.height * fractions.reduce(math.max); + final sizingMaxWidth = switch (state.properties['sizing']) { + 'form' || 'fitted' => 560.0, + _ => null, + }; + Widget surface = SizedBox( + key: ValueKey('lui-sheet-surface-$node'), + width: width ?? double.infinity, + height: height ?? + (fractions == null ? null : size.height * fractions.first), + child: SafeArea(child: _modalSurfaceBody(context, state)), + ); + if (detentMaxHeight != null || sizingMaxWidth != null) { + surface = Align( + alignment: Alignment.bottomCenter, + heightFactor: 1.0, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: detentMaxHeight ?? double.infinity, + maxWidth: sizingMaxWidth ?? double.infinity, + ), + child: surface, + ), + ); + } + return surface; + }(), _ => throw const LUIBackendException('node is not a modal surface'), }; } @@ -6091,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 2dd74cff..db397498 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -7,6 +7,9 @@ enum _NodeKind { column, grid, stack, + edgeInset, + overlay, + viewThatFits, panel, card, alert, @@ -22,6 +25,7 @@ enum _NodeKind { radioGroup, radio, slider, + numberStepper, textField, secureField, input, @@ -74,6 +78,15 @@ enum _NodeKind { toast, toolbar, statusBar, + listSection, + listSectionHeader, + listSectionFooter, + swipeActions, + swipeAction, + filePicker, + link, + fileImage, + filePreview, } _NodeKind _decodeNodeKind(Object? value) { @@ -86,6 +99,9 @@ _NodeKind _decodeNodeKind(Object? value) { 'column' => _NodeKind.column, 'grid' => _NodeKind.grid, 'stack' => _NodeKind.stack, + 'edge-inset' => _NodeKind.edgeInset, + 'overlay' => _NodeKind.overlay, + 'view-that-fits' => _NodeKind.viewThatFits, 'panel' => _NodeKind.panel, 'card' => _NodeKind.card, 'alert' => _NodeKind.alert, @@ -101,6 +117,7 @@ _NodeKind _decodeNodeKind(Object? value) { 'radio-group' => _NodeKind.radioGroup, 'radio' => _NodeKind.radio, 'slider' => _NodeKind.slider, + 'number-stepper' => _NodeKind.numberStepper, 'text-field' => _NodeKind.textField, 'secure-field' => _NodeKind.secureField, 'input' => _NodeKind.input, @@ -153,6 +170,15 @@ _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, _ => throw const LUIBackendException('unknown node kind'), }; } 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 9ca673f2..eb7bda2d 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -58,6 +58,10 @@ qt_add_qml_module(lui_qml ../qml/LuiDivider.qml ../qml/LuiDrawer.qml ../qml/LuiDropdownMenu.qml + ../qml/LuiFilePicker.qml + ../qml/LuiFileImage.qml + ../qml/LuiFilePreview.qml + ../qml/LuiEdgeInset.qml ../qml/LuiGrid.qml ../qml/LuiHeading.qml ../qml/LuiIcon.qml @@ -66,11 +70,14 @@ qt_add_qml_module(lui_qml ../qml/LuiInputGroup.qml ../qml/LuiInputGroupActions.qml ../qml/LuiLabel.qml + ../qml/LuiLink.qml ../qml/LuiList.qml ../qml/LuiListItem.qml ../qml/LuiMediaSurface.qml ../qml/LuiMenuItem.qml ../qml/LuiNodeView.qml + ../qml/LuiNumberStepper.qml + ../qml/LuiOverlay.qml ../qml/LuiPagination.qml ../qml/LuiPanel.qml ../qml/LuiParagraph.qml @@ -115,6 +122,7 @@ qt_add_qml_module(lui_qml ../qml/LuiToolbar.qml ../qml/LuiTooltip.qml ../qml/LuiTree.qml + ../qml/LuiViewThatFits.qml ../qml/LuiVirtualList.qml RESOURCES ../qml/LuiStyle.js diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8e92d688..bea4b30e 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include namespace LUI { @@ -1179,6 +1180,8 @@ bool LuiQmlBackend::performPress(qint64 node) { (state->kind == NodeKind::Column && isTrue(state->properties.value(QStringLiteral("press-enabled")))) || (state->kind == NodeKind::Text && + isTrue(state->properties.value(QStringLiteral("press-enabled")))) || + (state->kind == NodeKind::FileImage && isTrue(state->properties.value(QStringLiteral("press-enabled")))); if (!pressable || isFalse(state->properties.value(QStringLiteral("enabled")))) { return fail(QStringLiteral("node %1 is not an enabled pressable control") @@ -1240,7 +1243,9 @@ bool LuiQmlBackend::performDismiss(qint64 node) { if (state == nullptr) return staleNode(node); if (state->kind != NodeKind::Select && state->kind != NodeKind::Combobox && state->kind != NodeKind::DropdownMenu && - state->kind != NodeKind::Toast && !modalSurface(state->kind)) { + state->kind != NodeKind::Toast && + 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")); @@ -1298,14 +1303,25 @@ bool LuiQmlBackend::performChange(qint64 node) { bool LuiQmlBackend::performValueChanged(qint64 node, double value) { const NodeState *state = standardState(m_states, node); if (state == nullptr) return staleNode(node); - if ((state->kind != NodeKind::Slider && state->kind != NodeKind::Split) || + if ((state->kind != NodeKind::Slider && state->kind != NodeKind::Split && + state->kind != NodeKind::NumberStepper) || isFalse(state->properties.value(QStringLiteral("enabled"))) || !std::isfinite(value)) { return fail(QStringLiteral("node %1 is not an enabled value control") .arg(node)); } + double emitted = std::clamp(value, 0.0, 1.0); + if (state->kind == NodeKind::NumberStepper) { + const double minimum = + state->properties.value(QStringLiteral("min")).toDouble(); + const double maximum = + state->properties.contains(QStringLiteral("max")) + ? state->properties.value(QStringLiteral("max")).toDouble() + : std::numeric_limits::max(); + emitted = std::clamp(value, minimum, std::max(minimum, maximum)); + } emitEvent(node, QStringLiteral("value-changed"), - {{QStringLiteral("value"), std::clamp(value, 0.0, 1.0)}}); + {{QStringLiteral("value"), emitted}}); return true; } diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c0..48370ed6 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace LUI { @@ -116,7 +117,8 @@ bool treeRowKind(NodeKind kind) { bool contextMenuHostKind(NodeKind kind) { return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::Toggle, - NodeKind::Radio, NodeKind::Slider, NodeKind::TextField, + NodeKind::Radio, NodeKind::Slider, NodeKind::NumberStepper, + NodeKind::TextField, NodeKind::SecureField, NodeKind::Input, NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, @@ -127,7 +129,8 @@ bool contextMenuHostKind(NodeKind kind) { bool contextMenuLeafHostKind(NodeKind kind) { return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::Toggle, - NodeKind::Radio, NodeKind::Slider, NodeKind::TextField, + NodeKind::Radio, NodeKind::Slider, NodeKind::NumberStepper, + NodeKind::TextField, NodeKind::SecureField, NodeKind::Input, NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, @@ -179,7 +182,8 @@ bool canContainChildren(NodeKind kind) { if (horizontalContainer(kind) || contextMenuLeafHostKind(kind)) return true; return oneOf(kind, {NodeKind::Root, NodeKind::Row, NodeKind::Column, - NodeKind::Grid, NodeKind::Stack, NodeKind::Panel, + NodeKind::Grid, NodeKind::Stack, NodeKind::EdgeInset, + NodeKind::Overlay, NodeKind::ViewThatFits, NodeKind::Panel, NodeKind::Card, NodeKind::Box, NodeKind::Scroll, NodeKind::ListContainer, NodeKind::VirtualList, NodeKind::RadioGroup, NodeKind::DropdownMenu, @@ -190,19 +194,25 @@ 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::BottomTabs, NodeKind::BottomTab, NodeKind::Link, + NodeKind::FilePicker, NodeKind::ListSection, + NodeKind::ListSectionHeader, NodeKind::ListSectionFooter, + NodeKind::SwipeActions}); } bool acceptsExtensionChildren(NodeKind kind) { return oneOf(kind, {NodeKind::Root, NodeKind::Row, NodeKind::Column, - NodeKind::Grid, NodeKind::Stack, NodeKind::Panel, + NodeKind::Grid, NodeKind::Stack, NodeKind::EdgeInset, + NodeKind::Overlay, NodeKind::ViewThatFits, NodeKind::Panel, NodeKind::Card, NodeKind::Box, NodeKind::Scroll, NodeKind::ListContainer, NodeKind::VirtualList, 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) { @@ -235,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) { @@ -247,7 +271,8 @@ bool eventSupported(NodeKind kind, Event event) { NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Text, NodeKind::TableCell, NodeKind::TimelineItem, - NodeKind::BottomTab}); + NodeKind::FileImage, NodeKind::BottomTab, + NodeKind::SwipeAction}); case Event::LongPress: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -265,19 +290,26 @@ 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: - return kind == NodeKind::Slider || kind == NodeKind::Split; + return kind == NodeKind::Slider || kind == NodeKind::NumberStepper || + kind == NodeKind::Split; case Event::Dismiss: return oneOf(kind, {NodeKind::Select, NodeKind::Combobox, NodeKind::DropdownMenu, NodeKind::Toast, - NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet}); + NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, + 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; } @@ -413,17 +445,21 @@ bool commonPropertySupported(NodeKind kind, Property property) { {NodeKind::Row, NodeKind::Column, NodeKind::Grid, NodeKind::Box, NodeKind::Panel, NodeKind::Card, NodeKind::Stack, NodeKind::Scroll, NodeKind::Avatar, + NodeKind::EdgeInset, NodeKind::Overlay, + NodeKind::ViewThatFits, NodeKind::Text, NodeKind::Heading, NodeKind::Paragraph, NodeKind::Label, NodeKind::Button, NodeKind::ToggleButton, NodeKind::TextField, NodeKind::SecureField, NodeKind::Input, NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::Toggle, NodeKind::Radio, - NodeKind::Slider, NodeKind::Spinner, NodeKind::Icon, + NodeKind::Slider, NodeKind::NumberStepper, + NodeKind::Spinner, NodeKind::Icon, NodeKind::Select, NodeKind::Combobox, NodeKind::DropdownMenu, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::TableCell, NodeKind::Resizable, NodeKind::Split, NodeKind::Alert, - NodeKind::Bubble, NodeKind::StatusBar}); + NodeKind::Bubble, NodeKind::StatusBar, NodeKind::Link, + NodeKind::FileImage}); case Property::WidthValue: case Property::HeightValue: return kind != NodeKind::Tooltip; @@ -444,11 +480,13 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::RadioGroup, - NodeKind::Radio, NodeKind::Slider, NodeKind::Avatar, + NodeKind::Radio, NodeKind::Slider, + NodeKind::NumberStepper, NodeKind::Avatar, NodeKind::Image, NodeKind::MediaSurface, NodeKind::Tree, NodeKind::Resizable, NodeKind::Split, NodeKind::Drawer, NodeKind::Alert, NodeKind::Bubble, - NodeKind::ListItem}) || + NodeKind::ListItem, NodeKind::Link, + NodeKind::FileImage}) || horizontalContainer(kind) || treeRowKind(kind); case Property::AccessibilityIdentifier: return true; @@ -463,11 +501,12 @@ bool commonPropertySupported(NodeKind kind, Property property) { return oneOf(kind, {NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio}); case Property::ProgressValue: - return oneOf(kind, - {NodeKind::Progress, NodeKind::Slider, NodeKind::Split}); + return oneOf(kind, {NodeKind::Progress, NodeKind::Slider, + NodeKind::NumberStepper, NodeKind::Split}); case Property::OrientationValue: return oneOf(kind, - {NodeKind::Divider, NodeKind::Tabs, NodeKind::Scroll}); + {NodeKind::Divider, NodeKind::Tabs, NodeKind::Scroll, + NodeKind::ViewThatFits}); case Property::SizeValue: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::Spinner, NodeKind::Icon, @@ -481,11 +520,11 @@ bool commonPropertySupported(NodeKind kind, Property property) { case Property::InlineIconName: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::MenuItem, NodeKind::ListItem, - NodeKind::BottomTab}); + NodeKind::BottomTab, NodeKind::Link}); case Property::IconPlacementValue: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, - NodeKind::ListItem}); + NodeKind::ListItem, NodeKind::Link}); case Property::Selected: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::MenuItem, NodeKind::ListItem, @@ -511,7 +550,7 @@ bool commonPropertySupported(NodeKind kind, Property property) { return oneOf(kind, {NodeKind::Text, NodeKind::Column, NodeKind::Radio, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::TableCell, - NodeKind::BottomTab}) || + NodeKind::BottomTab, NodeKind::FileImage}) || treeRowKind(kind); case Property::SubmitEnabled: return kind == NodeKind::Combobox || kind == NodeKind::ListItem; @@ -527,6 +566,12 @@ bool commonPropertySupported(NodeKind kind, Property property) { return kind == NodeKind::Avatar || kind == NodeKind::Image; case Property::SurfaceIdValue: return kind == NodeKind::MediaSurface; + case Property::PathValue: + return kind == NodeKind::FileImage || kind == NodeKind::FilePreview; + case Property::UrlValue: + return kind == NodeKind::Link; + case Property::MaxPixelSize: + return kind == NodeKind::FileImage; case Property::AnchorValue: case Property::AnchorAlignmentValue: case Property::AnchorOffset: @@ -548,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); @@ -562,7 +619,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Avatar, NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, NodeKind::Tooltip, NodeKind::TableCell, - NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar}); + NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar, + NodeKind::NumberStepper, NodeKind::Link}); case Property::Enabled: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -570,8 +628,10 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio, NodeKind::Slider, - NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, - NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab}); + NodeKind::NumberStepper, NodeKind::Select, + NodeKind::Combobox, NodeKind::MenuItem, + NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab, + NodeKind::Link}); case Property::ActiveIndex: case Property::DescriptionValue: case Property::MetaValue: @@ -580,13 +640,28 @@ bool commonPropertySupported(NodeKind kind, Property property) { return false; case Property::TitleValue: return kind == NodeKind::BottomTab; + case Property::MinValue: + case Property::MaxValue: + case Property::StepValue: + return kind == NodeKind::NumberStepper; + case Property::Detents: + case Property::Sizing: + return kind == NodeKind::Sheet; case Property::Gap: return oneOf(kind, {NodeKind::Row, NodeKind::Column, NodeKind::Grid, NodeKind::ListContainer, NodeKind::VirtualList, NodeKind::DropdownMenu, NodeKind::TableRow, NodeKind::Tree, NodeKind::Scroll, NodeKind::Card, - NodeKind::Panel, NodeKind::Box, NodeKind::Split}) || + NodeKind::Panel, NodeKind::Box, NodeKind::Split, + NodeKind::EdgeInset}) || horizontalContainer(kind); + case Property::EdgeValue: + case Property::Visible: + return kind == NodeKind::EdgeInset; + // `alignment` is admitted ahead of the restrictive arms in + // propertySupported below. + case Property::AlignmentValue: + return kind != NodeKind::Root; } return false; } @@ -595,6 +670,10 @@ bool commonPropertySupported(NodeKind kind, Property property) { bool propertySupported(NodeKind kind, Property property) { if (property == Property::AccessibilityIdentifier) return true; + // Position hint honored on overlay children and the overlay itself; + // admitted before the restrictive arms so e.g. an aligned menu-trigger + // child still carries it. Inert elsewhere. + if (property == Property::AlignmentValue) return kind != NodeKind::Root; // The restrictive arms below mirror schema/components.json kindProperties. switch (kind) { case NodeKind::Root: @@ -652,9 +731,29 @@ 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); + case NodeKind::FilePreview: + return property == Property::PathValue; default: return commonPropertySupported(kind, property); } @@ -750,10 +849,16 @@ bool propertyValueSupported(Property property, const QVariant &value) { int number = 0; return isIntValue(value, &number) && number >= 0; } + case Property::MaxPixelSize: { + int number = 0; + return isIntValue(value, &number) && number > 0; + } case Property::TitleValue: case Property::DescriptionValue: case Property::MetaValue: case Property::IndicatorValue: + case Property::PathValue: + case Property::UrlValue: return isString(value); case Property::Connector: return isBool(value); @@ -803,6 +908,59 @@ 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); + case Property::StepValue: + return isNumeric(value) && value.toDouble() > 0.0; + case Property::Detents: + return isString(value); + case Property::Sizing: + return isString(value) && + inSet(value.toString(), {"form", "fitted", "page"}); + case Property::EdgeValue: + return isString(value) && + inSet(value.toString(), {"top", "bottom", "leading", "trailing"}); + case Property::Visible: + return isBool(value); + case Property::AlignmentValue: + return isString(value) && + inSet(value.toString(), + {"top-leading", "top", "top-trailing", "leading", "center", + "trailing", "bottom-leading", "bottom", + "bottom-trailing"}); } return false; } @@ -833,6 +991,10 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { return false; } } + if (kind == NodeKind::EdgeInset && + !properties.contains(QStringLiteral("edge"))) { + return false; + } if (buttonKind(kind) || kind == NodeKind::Toggle || kind == NodeKind::Radio) { const QString text = stringPropertyOr(properties, "text", QString()); const QString label = @@ -902,6 +1064,14 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { !properties.contains(QStringLiteral("surface"))) { return false; } + if ((kind == NodeKind::FileImage || kind == NodeKind::FilePreview) && + !stringPropertyNonempty(properties, "path")) { + return false; + } + if (kind == NodeKind::Link && + !stringPropertyNonempty(properties, "url")) { + return false; + } if (kind == NodeKind::Stepper && !properties.contains(QStringLiteral("active"))) { return false; @@ -922,6 +1092,22 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { const QVariant value = properties.value(QStringLiteral("value")); if (!isNumeric(value)) return false; } + if (kind == NodeKind::NumberStepper) { + if (!isNumeric(properties.value(QStringLiteral("value")))) return false; + if (!stringPropertyNonempty(properties, "text") && + !stringPropertyNonempty(properties, "accessibility-label")) { + return false; + } + // min defaults to 0 and max is unbounded; compare the effective + // endpoints so a lone negative max still fails. + const QVariant minimum = properties.value(QStringLiteral("min")); + const QVariant maximum = properties.value(QStringLiteral("max")); + const double minimumValue = + isNumeric(minimum) ? minimum.toDouble() : 0.0; + const double maximumValue = isNumeric(maximum) + ? maximum.toDouble() : std::numeric_limits::max(); + if (minimumValue > maximumValue) return false; + } if (kind == NodeKind::Tree || kind == NodeKind::Toolbar) { if (!stringPropertyNonempty(properties, "accessibility-label")) { return false; @@ -943,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 fd11ca9b..20901ff7 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -11,6 +11,9 @@ enum class NodeKind { Column, Grid, Stack, + EdgeInset, + Overlay, + ViewThatFits, Panel, Card, Alert, @@ -26,6 +29,7 @@ enum class NodeKind { RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -78,6 +82,15 @@ enum class NodeKind { Toast, Toolbar, StatusBar, + ListSection, + ListSectionHeader, + ListSectionFooter, + SwipeActions, + SwipeAction, + FilePicker, + Link, + FileImage, + FilePreview, }; enum class Property { @@ -154,6 +167,30 @@ enum class Property { 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, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -163,6 +200,9 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::Column: return "column"; case NodeKind::Grid: return "grid"; case NodeKind::Stack: return "stack"; + case NodeKind::EdgeInset: return "edge-inset"; + case NodeKind::Overlay: return "overlay"; + case NodeKind::ViewThatFits: return "view-that-fits"; case NodeKind::Panel: return "panel"; case NodeKind::Card: return "card"; case NodeKind::Alert: return "alert"; @@ -178,6 +218,7 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::RadioGroup: return "radio-group"; case NodeKind::Radio: return "radio"; case NodeKind::Slider: return "slider"; + case NodeKind::NumberStepper: return "number-stepper"; case NodeKind::TextField: return "text-field"; case NodeKind::SecureField: return "secure-field"; case NodeKind::Input: return "input"; @@ -230,6 +271,15 @@ 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"; } return "unknown"; } @@ -240,6 +290,9 @@ inline bool decodeNodeKind(const char *name, NodeKind *kind) { if (std::strcmp(name, "column") == 0) { *kind = NodeKind::Column; return true; } if (std::strcmp(name, "grid") == 0) { *kind = NodeKind::Grid; return true; } if (std::strcmp(name, "stack") == 0) { *kind = NodeKind::Stack; return true; } + if (std::strcmp(name, "edge-inset") == 0) { *kind = NodeKind::EdgeInset; return true; } + if (std::strcmp(name, "overlay") == 0) { *kind = NodeKind::Overlay; return true; } + if (std::strcmp(name, "view-that-fits") == 0) { *kind = NodeKind::ViewThatFits; return true; } if (std::strcmp(name, "panel") == 0) { *kind = NodeKind::Panel; return true; } if (std::strcmp(name, "card") == 0) { *kind = NodeKind::Card; return true; } if (std::strcmp(name, "alert") == 0) { *kind = NodeKind::Alert; return true; } @@ -255,6 +308,7 @@ inline bool decodeNodeKind(const char *name, NodeKind *kind) { if (std::strcmp(name, "radio-group") == 0) { *kind = NodeKind::RadioGroup; return true; } if (std::strcmp(name, "radio") == 0) { *kind = NodeKind::Radio; return true; } if (std::strcmp(name, "slider") == 0) { *kind = NodeKind::Slider; return true; } + if (std::strcmp(name, "number-stepper") == 0) { *kind = NodeKind::NumberStepper; return true; } if (std::strcmp(name, "text-field") == 0) { *kind = NodeKind::TextField; return true; } if (std::strcmp(name, "secure-field") == 0) { *kind = NodeKind::SecureField; return true; } if (std::strcmp(name, "input") == 0) { *kind = NodeKind::Input; return true; } @@ -307,6 +361,15 @@ 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; } return false; } @@ -322,6 +385,9 @@ inline bool containerNodeKind(NodeKind kind) { case NodeKind::Column: case NodeKind::Grid: case NodeKind::Stack: + case NodeKind::EdgeInset: + case NodeKind::Overlay: + case NodeKind::ViewThatFits: case NodeKind::Panel: case NodeKind::Card: case NodeKind::Alert: @@ -358,6 +424,12 @@ 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: return false; @@ -372,6 +444,9 @@ inline const char *nodeKindComponentName(NodeKind kind) { case NodeKind::Column: return "LuiColumn.qml"; case NodeKind::Grid: return "LuiGrid.qml"; case NodeKind::Stack: return "LuiStack.qml"; + case NodeKind::EdgeInset: return "LuiEdgeInset.qml"; + case NodeKind::Overlay: return "LuiOverlay.qml"; + case NodeKind::ViewThatFits: return "LuiViewThatFits.qml"; case NodeKind::Panel: return "LuiPanel.qml"; case NodeKind::Card: return "LuiCard.qml"; case NodeKind::Alert: return "LuiAlert.qml"; @@ -387,6 +462,7 @@ inline const char *nodeKindComponentName(NodeKind kind) { case NodeKind::RadioGroup: return "LuiRadioGroup.qml"; case NodeKind::Radio: return "LuiRadio.qml"; case NodeKind::Slider: return "LuiSlider.qml"; + case NodeKind::NumberStepper: return "LuiNumberStepper.qml"; case NodeKind::TextField: return "LuiTextField.qml"; case NodeKind::SecureField: return "LuiSecureField.qml"; case NodeKind::Input: return "LuiInput.qml"; @@ -439,6 +515,15 @@ 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"; } return "LuiBox.qml"; } @@ -518,6 +603,30 @@ 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"; + case Property::Detents: return "detents"; + case Property::Sizing: return "sizing"; + case Property::PathValue: return "path"; + case Property::UrlValue: return "url"; + case Property::MaxPixelSize: return "max-pixel-size"; + case Property::Visible: return "visible"; + case Property::AlignmentValue: return "alignment"; } return "unknown"; } @@ -596,6 +705,30 @@ 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; } + if (std::strcmp(name, "detents") == 0) { *property = Property::Detents; return true; } + if (std::strcmp(name, "sizing") == 0) { *property = Property::Sizing; return true; } + 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, "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/LuiEdgeInset.qml b/platform/qt/qml/LuiEdgeInset.qml new file mode 100644 index 00000000..dd7fbe0f --- /dev/null +++ b/platform/qt/qml/LuiEdgeInset.qml @@ -0,0 +1,64 @@ +import QtQuick + +// Wire kind: edge-inset — children after the first are pinned to `edge` +// while the first child fills the view beneath them. `visible` hides the +// pinned region in place; `gap` insets it from the pinned edge. +Item { + id: edgeInset + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property string edge: props.edge || "top" + readonly property bool pinnedVisible: props.visible !== false + readonly property bool sideEdge: edge === "leading" || edge === "trailing" + readonly property real gap: props.gap || 0 + readonly property var pinnedChildren: node ? node.children.slice(1) : [] + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + LuiNodeView { + id: content + anchors.fill: parent + node: edgeInset.node && edgeInset.node.children.length > 0 + ? edgeInset.node.children[0] : null + } + + // Pinned children group into one region so they stack across the edge + // instead of overlapping (column under top/bottom, row along the sides, + // matching the Apple backend). + Item { + id: pinnedRegion + visible: edgeInset.pinnedVisible + implicitWidth: pinnedFlow.implicitWidth + implicitHeight: pinnedFlow.implicitHeight + + // Bound (not imperative) anchors so an `edge` change re-anchors; + // `undefined` clears the anchor. + anchors.top: edgeInset.edge === "top" || edgeInset.sideEdge + ? parent.top : undefined + anchors.bottom: edgeInset.edge === "bottom" || edgeInset.sideEdge + ? parent.bottom : undefined + anchors.left: !edgeInset.sideEdge || edgeInset.edge === "leading" + ? parent.left : undefined + anchors.right: !edgeInset.sideEdge || edgeInset.edge === "trailing" + ? parent.right : undefined + anchors.topMargin: edgeInset.edge === "top" ? edgeInset.gap : 0 + anchors.bottomMargin: edgeInset.edge === "bottom" ? edgeInset.gap : 0 + anchors.leftMargin: edgeInset.edge === "leading" ? edgeInset.gap : 0 + anchors.rightMargin: edgeInset.edge === "trailing" ? edgeInset.gap : 0 + + Grid { + id: pinnedFlow + flow: edgeInset.sideEdge ? Grid.LeftToRight : Grid.TopToBottom + columns: edgeInset.sideEdge ? edgeInset.pinnedChildren.length : 1 + Repeater { + model: edgeInset.pinnedChildren + delegate: LuiNodeView { + required property var modelData + node: modelData + } + } + } + } +} diff --git a/platform/qt/qml/LuiFileImage.qml b/platform/qt/qml/LuiFileImage.qml new file mode 100644 index 00000000..ff8eb0f4 --- /dev/null +++ b/platform/qt/qml/LuiFileImage.qml @@ -0,0 +1,35 @@ +import QtQuick +import "LuiStyle.js" as Style + +// Wire kind: file-image — loads a local file path (or file:// URL) through +// Qt's image pipeline, downsampled to "max-pixel-size" (default 1024). +Item { + id: wrapper + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property string path: Style.str(props, "path", "") + readonly property int maxPixelSize: + Style.num(props, "max-pixel-size", 1024) + + implicitWidth: Style.num(props, "width", 48) + implicitHeight: Style.num(props, "height", 48) + + Image { + anchors.fill: parent + source: wrapper.path.indexOf("file://") === 0 + ? wrapper.path : "file://" + wrapper.path + sourceSize.width: wrapper.maxPixelSize + sourceSize.height: wrapper.maxPixelSize + fillMode: Image.PreserveAspectFit + asynchronous: true + } + + TapHandler { + enabled: wrapper.props["press-enabled"] === true + onTapped: { + if (wrapper.node && wrapper.node.backend) + wrapper.node.backend.performPress(wrapper.node.id) + } + } +} 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/qt/qml/LuiFilePreview.qml b/platform/qt/qml/LuiFilePreview.qml new file mode 100644 index 00000000..5249541c --- /dev/null +++ b/platform/qt/qml/LuiFilePreview.qml @@ -0,0 +1,11 @@ +import QtQuick + +// Wire kind: file-preview — non-rendering node; the owning backend presents a +// native preview of the "path" prop while the node is mounted. +Item { + required property var node + readonly property var props: node ? node.properties : ({}) + visible: false + implicitWidth: 0 + implicitHeight: 0 +} diff --git a/platform/qt/qml/LuiLink.qml b/platform/qt/qml/LuiLink.qml new file mode 100644 index 00000000..6c24bcd4 --- /dev/null +++ b/platform/qt/qml/LuiLink.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Layouts +import "LuiStyle.js" as Style + +// Wire kind: link — opens the "url" prop with the system handler +// (Qt.openUrlExternally). Renders children when present, else "text" or the +// raw url. +Item { + id: wrapper + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property string url: Style.str(props, "url", "") + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + RowLayout { + id: content + spacing: Style.num(wrapper.props, "gap", 4) + + Repeater { + model: wrapper.node ? wrapper.node.children : [] + delegate: LuiNodeView { + required property var modelData + node: modelData + } + } + + Image { + visible: Style.str(wrapper.props, "icon", "") !== "" + source: Style.iconSource(Style.str(wrapper.props, "icon", "")) + sourceSize.width: 16 + sourceSize.height: 16 + } + + Text { + visible: (wrapper.node ? wrapper.node.children.length : 0) === 0 + text: Style.str(wrapper.props, "text", wrapper.url) + color: Style.nodeColor(wrapper.node, + wrapper.props["foreground"], "blue") + font.underline: true + } + } + + TapHandler { + enabled: wrapper.props["enabled"] !== false && wrapper.url !== "" + onTapped: Qt.openUrlExternally(wrapper.url) + } +} diff --git a/platform/qt/qml/LuiNumberStepper.qml b/platform/qt/qml/LuiNumberStepper.qml new file mode 100644 index 00000000..0aba843f --- /dev/null +++ b/platform/qt/qml/LuiNumberStepper.qml @@ -0,0 +1,51 @@ +import QtQuick +import QtQuick.Controls +import "LuiStyle.js" as Style + +// Wire kind: number-stepper — bounded numeric value with -/+ buttons. +// from/step/to come from the min/step/max props; max defaults unbounded. +Row { + id: stepper + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property real minimum: Style.num(props, "min", 0) + readonly property real maximum: + props["max"] !== undefined ? Number(props["max"]) : Number.MAX_VALUE + readonly property real stepSize: Math.max(Style.num(props, "step", 1), 0) + readonly property real value: Style.num(props, "value", 0) + readonly property string label: props["text"] !== undefined ? String(props["text"]) : "" + + enabled: props["enabled"] !== false + spacing: 4 + + function applyStep(delta) { + var next = Math.min(Math.max(value + delta, minimum), maximum); + if (next !== value && node) node.valueChanged(next); + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: stepper.label + visible: stepper.label !== "" + } + + Button { + anchors.verticalCenter: parent.verticalCenter + text: "−" + enabled: stepper.enabled && stepper.value > stepper.minimum + onClicked: stepper.applyStep(-stepper.stepSize) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: stepper.value + } + + Button { + anchors.verticalCenter: parent.verticalCenter + text: "+" + enabled: stepper.enabled && stepper.value < stepper.maximum + onClicked: stepper.applyStep(stepper.stepSize) + } +} diff --git a/platform/qt/qml/LuiOverlay.qml b/platform/qt/qml/LuiOverlay.qml new file mode 100644 index 00000000..64516b56 --- /dev/null +++ b/platform/qt/qml/LuiOverlay.qml @@ -0,0 +1,54 @@ +import QtQuick + +// Wire kind: overlay — children after the first float over the first child +// without affecting its layout. Each overlay child positions itself by its +// own `alignment` property, falling back to the overlay's (default center). +Item { + id: overlay + required property var node + readonly property var props: node ? node.properties : ({}) + + implicitWidth: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 + implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 + + Repeater { + id: rep + model: overlay.node ? overlay.node.children : [] + delegate: LuiNodeView { + required property var modelData + required property int index + node: modelData + + readonly property bool _isBase: index === 0 + // Own `alignment` hint, falling back to the overlay's. + readonly property string _align: { + var p = node ? node.properties : null + return (p && p.alignment) || overlay.props.alignment || + "center" + } + readonly property bool _top: !_isBase && + (_align === "top-leading" || _align === "top" || + _align === "top-trailing") + readonly property bool _bottom: !_isBase && + (_align === "bottom-leading" || _align === "bottom" || + _align === "bottom-trailing") + readonly property bool _leading: !_isBase && + (_align === "top-leading" || _align === "leading" || + _align === "bottom-leading") + readonly property bool _trailing: !_isBase && + (_align === "top-trailing" || _align === "trailing" || + _align === "bottom-trailing") + + // Bound anchors re-evaluate when `alignment` changes; + // `undefined` clears the anchor. The base child fills. + anchors.top: _isBase || _top ? overlay.top : undefined + anchors.bottom: _isBase || _bottom ? overlay.bottom : undefined + anchors.left: _isBase || _leading ? overlay.left : undefined + anchors.right: _isBase || _trailing ? overlay.right : undefined + anchors.horizontalCenter: !_isBase && !_leading && !_trailing + ? overlay.horizontalCenter : undefined + anchors.verticalCenter: !_isBase && !_top && !_bottom + ? overlay.verticalCenter : undefined + } + } +} diff --git a/platform/qt/qml/LuiSheet.qml b/platform/qt/qml/LuiSheet.qml index dd2f0c67..01b54c66 100644 --- a/platform/qt/qml/LuiSheet.qml +++ b/platform/qt/qml/LuiSheet.qml @@ -12,15 +12,44 @@ Item { implicitWidth: 0 implicitHeight: 0 + // detents prop: comma-separated medium|large|fraction — the sheet opens + // at the first detent and caps at the largest (fractions of the overlay + // height). sizing prop: form|fitted narrow the sheet to a centered + // column; page keeps full width. + readonly property var detentFractions: { + var raw = props["detents"]; + if (raw === undefined || raw === null || raw === "") return null; + var parts = String(raw).split(","); + var out = []; + for (var i = 0; i < parts.length; i++) { + var token = parts[i].trim(); + var fraction = token === "medium" ? 0.5 + : token === "large" ? 1.0 + : parseFloat(token); + if (fraction > 0 && fraction <= 1) out.push(fraction); + } + return out.length > 0 ? out : null; + } + readonly property real detentRest: + detentFractions ? detentFractions[0] : 0.9 + readonly property bool sizingNarrow: + Style.str(props, "sizing", "") !== "" && + Style.str(props, "sizing", "") !== "page" + Drawer { id: sheet edge: Qt.BottomEdge modal: true interactive: true - width: Overlay.overlay ? Overlay.overlay.width : parent.width - height: Math.min( - contentColumn.implicitHeight + 32, - Overlay.overlay ? Overlay.overlay.height * 0.9 : 400) + width: host.sizingNarrow && Overlay.overlay + ? Math.min(560, Overlay.overlay.width) + : Overlay.overlay ? Overlay.overlay.width : parent.width + x: host.sizingNarrow && Overlay.overlay + ? Math.round((Overlay.overlay.width - width) / 2) : 0 + height: host.detentFractions && Overlay.overlay + ? Overlay.overlay.height * host.detentRest + : Math.min(contentColumn.implicitHeight + 32, + Overlay.overlay ? Overlay.overlay.height * 0.9 : 400) onClosed: if (host.node) host.node.dismiss() ColumnLayout { diff --git a/platform/qt/qml/LuiStyle.js b/platform/qt/qml/LuiStyle.js index 33685806..9fd539f6 100644 --- a/platform/qt/qml/LuiStyle.js +++ b/platform/qt/qml/LuiStyle.js @@ -10,6 +10,7 @@ var palette = { "secondary": "#f4f4f5", "secondary-foreground": "#18181b", "glass": "#bfffffff", + "bar": "#d9ffffff", "success": "#dcfce7", "success-foreground": "#166534", "warning": "#fef9c3", diff --git a/platform/qt/qml/LuiViewThatFits.qml b/platform/qt/qml/LuiViewThatFits.qml new file mode 100644 index 00000000..0b503ab5 --- /dev/null +++ b/platform/qt/qml/LuiViewThatFits.qml @@ -0,0 +1,26 @@ +import QtQuick + +// Wire kind: view-that-fits — renders the first child that fits along +// `orientation` (default horizontal). QML has no intrinsic fit-or-fallback +// container; renders the first candidate. +Item { + id: viewThatFits + required property var node + readonly property var props: node ? node.properties : ({}) + + implicitWidth: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 + implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 + + Repeater { + id: rep + model: { + var children = viewThatFits.node ? viewThatFits.node.children : [] + return children.length > 0 ? [children[0]] : [] + } + delegate: LuiNodeView { + required property var modelData + node: modelData + anchors.fill: viewThatFits + } + } +} diff --git a/platform/web/melange/core/lui_web_store.ml b/platform/web/melange/core/lui_web_store.ml index ebaeb70c..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"; @@ -450,6 +468,9 @@ let validate_progress_structure current = | Some TimelineItem -> if not (Property_map.mem TitleValue current.retained_properties) then invalid_arg "timeline-item requires title" + | Some NumberStepper -> + if not (Property_map.mem ProgressValue current.retained_properties) then + invalid_arg "number-stepper requires value" | _ -> () let child_kind_of nodes child_id = diff --git a/platform/web/melange/core/lui_web_util.ml b/platform/web/melange/core/lui_web_util.ml index 4843c911..e9e93be3 100644 --- a/platform/web/melange/core/lui_web_util.ml +++ b/platform/web/melange/core/lui_web_util.ml @@ -140,6 +140,10 @@ let semantic_color_names = let web_color_value color = if color = "transparent" then "transparent" + (* Material names (Apple glass/bar surfaces) have no CSS equivalent; + approximate with a translucent theme surface. *) + else if color = "glass" || color = "glass-container" || color = "bar" then + "color-mix(in srgb, var(--color-background) 80%, transparent)" else if List.mem color semantic_color_names then "var(--color-" ^ color ^ ")" else color @@ -149,6 +153,7 @@ let web_color_value color = let content_container kind dom_node = match kind with | Lui_protocol.DropdownMenu | Lui_protocol.Split -> child_element dom_node 0 + | Lui_protocol.Link -> child_element dom_node 1 | Lui_protocol.Alert -> child_element dom_node 1 | Lui_protocol.Bubble -> child_element dom_node 0 | Lui_protocol.Accordion -> accordion_panel_node dom_node diff --git a/platform/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index eb1b2bac..e9082551 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -192,6 +192,26 @@ let attach_slider_event renderer node dom_node = (Util.text_control_node dom_node)))) dom_node +let attach_number_stepper_event renderer node dom_node = + let input = Util.child_element dom_node 0 in + W.Element.addEventListener "input" + (fun _event -> + let raw = + W.HtmlInputElement.valueAsNumber (Util.text_control_node dom_node) + in + (* Clearing the field reports NaN; only finite values in range emit. *) + if Float.is_finite raw then begin + let minimum = Store.float_property renderer node MinValue 0.0 in + let maximum = + Store.float_property renderer node MaxValue Float.max_float + in + emit renderer + (ValueChanged + (node, + Float.min (Float.max raw minimum) (Float.max minimum maximum))) + end) + input + let attach_accordion_event renderer node dom_node = W.Element.addEventListener "click" (fun event -> @@ -422,7 +442,7 @@ let attach_events renderer node kind dom_node = if kind = Toolbar then ignore (Lui_web_focus.attach_toolbar_events renderer node dom_node); match kind with - | Text | TableCell | TimelineItem -> + | Text | TableCell | TimelineItem | FileImage -> attach_pressable_text_events renderer node dom_node | Button | ToggleButton | Toggle -> attach_button_events renderer node kind dom_node @@ -459,6 +479,7 @@ let attach_events renderer node kind dom_node = attach_toggle_event renderer node kind dom_node | Radio -> attach_radio_event renderer node dom_node | Slider -> attach_slider_event renderer node dom_node + | NumberStepper -> attach_number_stepper_event renderer node dom_node | Split -> ignore (Lui_web_split.attach_split_events renderer node dom_node) | Tabs | ButtonGroup | ToggleGroup | Breadcrumb | Pagination -> ignore (Lui_web_focus.attach_horizontal_focus renderer node kind dom_node) @@ -470,6 +491,7 @@ let attach_text_events_bang = attach_text_events let attach_toggle_event_bang = attach_toggle_event let attach_radio_event_bang = attach_radio_event let attach_slider_event_bang = attach_slider_event +let attach_number_stepper_event_bang = attach_number_stepper_event let attach_list_item_events_bang = attach_list_item_events let attach_pressable_text_events_bang = attach_pressable_text_events let attach_accordion_event_bang = attach_accordion_event diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..0fb137eb 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -16,6 +16,9 @@ let base_class_name kind = | Column -> "lui-column" | Grid -> "lui-grid" | Stack -> "lui-stack" + | EdgeInset -> "lui-edge-inset" + | Overlay -> "lui-overlay" + | ViewThatFits -> "lui-view-that-fits" | Panel -> "lui-panel" | Card -> "lui-card" | Alert -> "lui-alert" @@ -31,6 +34,7 @@ let base_class_name kind = | RadioGroup -> "lui-radio-group" | Radio -> "lui-radio" | Slider -> "lui-slider" + | NumberStepper -> "lui-number-stepper" | TextField -> "lui-text-field" | SecureField -> "lui-text-field" | Input -> "lui-input" @@ -86,6 +90,15 @@ 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" let create_split_node renderer = let document = renderer.web_document in @@ -147,6 +160,13 @@ let create_combobox_node renderer = [ ("type", "button"); ("aria-label", "Open menu") ] [] ] +let create_number_stepper_node renderer = + let document = renderer.web_document in + Util.element document "label" (base_class_name NumberStepper) [] + [ Util.element document "input" "lui-number-stepper-control" + [ ("type", "number") ] []; + Util.element document "span" "lui-number-stepper-label" [] [] ] + let create_select_node renderer = let document = renderer.web_document in Util.element document "button" "lui-select" @@ -156,6 +176,14 @@ let create_select_node renderer = ("aria-expanded", "false") ] [ Util.element document "span" "lui-select-value" [] [] ] +let create_link_node renderer = + let document = renderer.web_document in + Util.element document "a" "lui-link" + [ ("data-icon-placement", "leading"); ("rel", "noopener noreferrer") ] + [ Util.element document "span" "lui-link-icon lui-icon" + [ ("aria-hidden", "true") ] []; + Util.element document "span" "lui-link-content" [] [] ] + let create_menu_item_node renderer = let document = renderer.web_document in let hidden = [ ("aria-hidden", "true") ] in @@ -306,6 +334,7 @@ let simple_node_attributes kind = ("data-state", "open") ] | Toolbar -> [ ("role", "toolbar"); ("aria-orientation", "horizontal") ] | StatusBar -> [ ("role", "status") ] + | FilePreview -> [ ("hidden", "") ] | _ -> [] let create_simple_node renderer kind = @@ -368,12 +397,14 @@ let platform_node renderer kind = match kind with | Button | ToggleButton | Toggle -> create_button_node renderer kind | Checkbox | SwitchControl | Radio -> create_direct_toggle_node renderer kind + | NumberStepper -> create_number_stepper_node renderer | Select -> create_select_node renderer | Combobox -> create_combobox_node renderer | DropdownMenu -> create_dropdown_node renderer | MenuItem | MenuTrigger -> create_menu_item_node renderer | Avatar -> create_avatar_node renderer | Image | MediaSurface -> create_media_node renderer kind + | Link -> create_link_node renderer | Step -> create_step_node renderer | TimelineItem -> create_timeline_item_node renderer | BottomTabs -> create_bottom_tabs_node renderer diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 19c5c818..9ccb1e2e 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -204,6 +204,8 @@ let apply_text_value renderer node kind dom_node text = | Dialog | Sheet -> W.Element.setTextContent (Util.child_element (Util.child_element dom_node 0) 0) text + | NumberStepper -> + W.Element.setTextContent (Util.toggle_label_node dom_node) text | Avatar -> W.Element.setTextContent (Util.child_element dom_node 1) text; Widgets.update_avatar renderer node dom_node @@ -211,6 +213,8 @@ let apply_text_value renderer node kind dom_node text = W.Element.setTextContent (Util.child_element dom_node 0) (select_display_text renderer node) + | Link -> + W.Element.setTextContent (Util.child_element dom_node 1) text | TextField | SecureField | Input | SearchField | Textarea | Combobox -> set_text_control_value dom_node text | _ -> set_visible_text kind dom_node text @@ -239,7 +243,8 @@ let apply_enabled renderer node kind dom_node enabled = end else begin let control_node = - if Store.direct_toggle kind then Util.child_element dom_node 0 + if Store.direct_toggle kind || kind = NumberStepper then + Util.child_element dom_node 0 else if kind = Combobox then Util.child_element dom_node 0 else dom_node in @@ -298,7 +303,8 @@ let apply_accessibility_label kind dom_node label = (Util.child_element dom_node 1) else W.Element.setAttribute "aria-label" label - (if Store.direct_toggle kind then Util.child_element dom_node 0 + (if Store.direct_toggle kind || kind = NumberStepper then + Util.child_element dom_node 0 else dom_node) let apply_checked kind dom_node checked = @@ -534,11 +540,60 @@ let rec apply_property renderer node kind dom_node property value = W.Element.setAttribute "aria-level" (string_of_int level) dom_node | _ -> apply_secondary_property renderer node kind dom_node property value +(* Sheets honour `detents` (medium|large|fraction list — the largest caps + the surface height as a viewport fraction) and `sizing` (form|fitted + narrow the surface to a centered column; page stretches full width). *) +and sheet_detent_max value = + String.split_on_char ',' value + |> List.fold_left + (fun cap token -> + match String.trim token with + | "medium" -> max cap 0.5 + | "large" -> max cap 1.0 + | trimmed -> + (match float_of_string_opt trimmed with + | Some fraction when fraction > 0.0 && fraction <= 1.0 -> + max cap fraction + | _ -> cap)) + 0.0 + +and apply_sheet_detents dom_node value = + let cap = sheet_detent_max value in + if cap > 0.0 then + set_style dom_node "max-height" + (Printf.sprintf "%gdvh" (cap *. 100.0)) + else + set_style dom_node "max-height" "" + +and apply_sheet_sizing dom_node value = + match value with + | "form" | "fitted" -> + set_style dom_node "max-width" "560px"; + set_style dom_node "margin-inline" "auto" + | _ -> + set_style dom_node "max-width" ""; + set_style dom_node "margin-inline" "" + and apply_secondary_property renderer node kind dom_node property value = match (property, value) with | Checked, BoolValue checked -> apply_checked kind dom_node checked | ProgressValue, FloatValue value -> apply_progress_value renderer node kind dom_node value + | MinValue, FloatValue value -> + W.Element.setAttribute "min" (Js.Float.toString value) + (Util.child_element dom_node 0) + | MaxValue, FloatValue value -> + W.Element.setAttribute "max" (Js.Float.toString value) + (Util.child_element dom_node 0) + | StepValue, FloatValue value -> + W.Element.setAttribute "step" (Js.Float.toString value) + (Util.child_element dom_node 0) + | Detents, StringValue value -> + W.Element.setAttribute "data-detents" value dom_node; + if kind = Sheet then apply_sheet_detents dom_node value + | Sizing, StringValue value -> + W.Element.setAttribute "data-sizing" value dom_node; + if kind = Sheet then apply_sheet_sizing dom_node value | ResizeDuration, IntValue _duration -> Lui_web_split.update_split renderer node | ResizeEasing, StringValue _easing -> @@ -621,6 +676,14 @@ and apply_secondary_property renderer node kind dom_node property value = W.Element.setAttribute "data-anchor-alignment" alignment dom_node | AnchorOffset, FloatValue offset -> apply_anchor_offset kind dom_node offset + | UrlValue, StringValue url -> + W.Element.setAttribute "href" url dom_node; + W.Element.setAttribute "target" "_blank" dom_node + | PathValue, StringValue path -> + W.Element.setAttribute "data-path" path dom_node + | MaxPixelSize, IntValue size -> + W.Element.setAttribute "data-max-pixel-size" (string_of_int size) + dom_node | TooltipDelay, IntValue delay -> W.Element.setAttribute "data-tooltip-delay" (string_of_int delay) dom_node @@ -635,6 +698,12 @@ and apply_secondary_property renderer node kind dom_node property value = apply_frame_axes dom_node axes | ContainerRelativeFrameInset, IntValue inset -> apply_frame_inset dom_node inset + | EdgeValue, StringValue edge -> + W.Element.setAttribute "data-edge" edge dom_node + | Visible, BoolValue visible -> + Util.set_state_attribute dom_node "data-pinned-hidden" (not visible) + | AlignmentValue, StringValue alignment -> + W.Element.setAttribute "data-alignment" alignment dom_node | _ -> invalid_arg ("invalid DOM property value: " ^ Lui_wire_schema.property_name property) @@ -683,13 +752,39 @@ let remove_property renderer node kind dom_node property = set_style dom_node "min-height" "" | ContainerRelativeFrameInset -> W.Element.removeAttribute "data-lui-frame-inset" dom_node + | EdgeValue -> W.Element.removeAttribute "data-edge" dom_node + | Visible -> W.Element.removeAttribute "data-pinned-hidden" dom_node + | AlignmentValue -> + W.Element.removeAttribute "data-alignment" dom_node | PlaceholderValue -> W.HtmlInputElement.setPlaceholder (Util.text_control_node dom_node) "" | AccessibilityLabel -> W.Element.removeAttribute "aria-label" - (if Store.direct_toggle kind then Util.child_element dom_node 0 + (if Store.direct_toggle kind || kind = NumberStepper then + Util.child_element dom_node 0 else dom_node) + | MinValue -> + W.Element.removeAttribute "min" (Util.child_element dom_node 0) + | MaxValue -> + W.Element.removeAttribute "max" (Util.child_element dom_node 0) + | StepValue -> + W.Element.removeAttribute "step" (Util.child_element dom_node 0) + | Detents -> + W.Element.removeAttribute "data-detents" dom_node; + if kind = Sheet then set_style dom_node "max-height" "" + | Sizing -> + W.Element.removeAttribute "data-sizing" dom_node; + if kind = Sheet then begin + set_style dom_node "max-width" ""; + set_style dom_node "margin-inline" "" + end + | UrlValue -> + W.Element.removeAttribute "href" dom_node; + W.Element.removeAttribute "target" dom_node + | PathValue -> W.Element.removeAttribute "data-path" dom_node + | MaxPixelSize -> + W.Element.removeAttribute "data-max-pixel-size" dom_node | AccessibilityIdentifier -> W.Element.removeAttribute "id" dom_node | OrientationValue -> if kind = Tabs then begin diff --git a/platform/web/melange/shell/lui_web_apply.ml b/platform/web/melange/shell/lui_web_apply.ml index 812b5703..74ba5be9 100644 --- a/platform/web/melange/shell/lui_web_apply.ml +++ b/platform/web/melange/shell/lui_web_apply.ml @@ -70,7 +70,8 @@ let child_hidden_in_parent renderer child = match Store.node renderer.web_store child with | Some child_node -> ( match Store.standard_kind child_node with - | Some (ContextMenu | DropdownMenu | Toast) -> true + | Some (ContextMenu | DropdownMenu | Toast | SwipeActions | SwipeAction) -> + true | Some kind -> modal_surface kind | None -> Store.anchored_tooltip child_node) | None -> true @@ -221,6 +222,7 @@ let insert_child_dom renderer parent child index = | Some ContextMenu -> W.Element.appendChild (W.Element.asNode child_dom) (Util.document_body renderer) + | Some (SwipeActions | SwipeAction) -> () | _ -> Util.insert_dom_child (dom_child_container renderer parent parent_dom) child_dom @@ -332,6 +334,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 @@ -358,7 +364,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/web/src/lui.css b/platform/web/src/lui.css index ecb4bf85..5f171936 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -397,6 +397,105 @@ body { @apply relative; } + /* edge-inset: first child is the scrollable content; pinned children + group at the named edge (flex order, no per-child overlap). */ + .lui-edge-inset { + @apply flex flex-col; + } + + .lui-edge-inset > :first-child { + @apply min-h-0 min-w-0 flex-1; + } + + .lui-edge-inset > :not(:first-child) { + order: -1; + } + + .lui-edge-inset[data-edge='bottom'] > :not(:first-child) { + order: 0; + } + + .lui-edge-inset[data-edge='leading'], + .lui-edge-inset[data-edge='trailing'] { + @apply flex-row; + } + + .lui-edge-inset[data-edge='trailing'] > :not(:first-child) { + order: 0; + } + + .lui-edge-inset[data-pinned-hidden] > :not(:first-child) { + @apply hidden; + } + + /* overlay: the first child sizes the view; overlay children are + absolutely positioned so they never contribute intrinsic size. */ + .lui-overlay { + @apply relative inline-grid; + } + + .lui-overlay > :not(:first-child) { + @apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='top-leading'], + .lui-overlay[data-alignment='top-leading'] > :not(:first-child):not([data-alignment]) { + @apply left-0 top-0 translate-x-0 translate-y-0; + } + + .lui-overlay > [data-alignment='top'], + .lui-overlay[data-alignment='top'] > :not(:first-child):not([data-alignment]) { + @apply left-1/2 top-0 -translate-x-1/2 translate-y-0; + } + + .lui-overlay > [data-alignment='top-trailing'], + .lui-overlay[data-alignment='top-trailing'] > :not(:first-child):not([data-alignment]) { + @apply right-0 top-0 translate-x-0 translate-y-0; + } + + .lui-overlay > [data-alignment='leading'], + .lui-overlay[data-alignment='leading'] > :not(:first-child):not([data-alignment]) { + @apply left-0 top-1/2 translate-x-0 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='center'], + .lui-overlay[data-alignment='center'] > :not(:first-child):not([data-alignment]) { + @apply left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='trailing'], + .lui-overlay[data-alignment='trailing'] > :not(:first-child):not([data-alignment]) { + @apply right-0 top-1/2 translate-x-0 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='bottom-leading'], + .lui-overlay[data-alignment='bottom-leading'] > :not(:first-child):not([data-alignment]) { + @apply bottom-0 left-0 translate-x-0 translate-y-0; + } + + .lui-overlay > [data-alignment='bottom'], + .lui-overlay[data-alignment='bottom'] > :not(:first-child):not([data-alignment]) { + @apply bottom-0 left-1/2 -translate-x-1/2 translate-y-0; + } + + .lui-overlay > [data-alignment='bottom-trailing'], + .lui-overlay[data-alignment='bottom-trailing'] > :not(:first-child):not([data-alignment]) { + @apply bottom-0 right-0 translate-x-0 translate-y-0; + } + + .lui-view-that-fits { + @apply grid; + } + + .lui-view-that-fits > * { + grid-area: 1 / 1; + } + + /* No CSS fit probing; keep only the first candidate. */ + .lui-view-that-fits > :not(:first-child) { + @apply hidden; + } + .lui-tooltip { @apply w-fit rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground shadow-md; } @@ -1161,6 +1260,26 @@ body { @apply order-1; } + .lui-link { + @apply inline-flex items-center gap-1 text-primary underline underline-offset-4; + } + + .lui-link-icon { + @apply pointer-events-none size-4 shrink-0; + } + + .lui-link-icon:not([data-name]) { + @apply hidden; + } + + .lui-link[data-icon-placement="trailing"] .lui-link-icon { + @apply order-1; + } + + .lui-link[data-icon-placement="top"] { + @apply flex-col; + } + .lui-button[data-variant="default"] { @apply bg-background text-foreground; } diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b2..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")) { @@ -353,6 +381,20 @@ void ApplyState( } LUIWireValue value = WireValue( operation.GetProperty("value"), "value"); + if (value is LUIWireValue.Int intValue && + (property == LUIProperty.GrowValue || + property == LUIProperty.AnchorOffset || + property == LUIProperty.SourceX || + property == LUIProperty.SourceY || + property == LUIProperty.SourceWidth || + property == LUIProperty.SourceHeight || + property == LUIProperty.ProgressValue || + property == LUIProperty.MinValue || + property == LUIProperty.MaxValue || + property == LUIProperty.StepValue)) + { + value = new LUIWireValue.Float(intValue.Value); + } if (!LUISchema.PropertySupported(node.Kind, property) || !LUISchema.PropertyValueSupportedForKind( node.Kind, property, value)) @@ -363,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": @@ -682,6 +729,9 @@ static bool AcceptsExtensionChildren(LUINodeKind kind) case LUINodeKind.Column: case LUINodeKind.Grid: case LUINodeKind.Stack: + case LUINodeKind.EdgeInset: + case LUINodeKind.Overlay: + case LUINodeKind.ViewThatFits: case LUINodeKind.Panel: case LUINodeKind.Card: case LUINodeKind.Box: @@ -737,6 +787,11 @@ static void ValidateStates(Dictionary states) { throw new LUIBackendException("icon requires name"); } + if (state.Kind == LUINodeKind.EdgeInset && + !state.Properties.ContainsKey(LUIProperty.EdgeValue)) + { + throw new LUIBackendException("edge-inset requires edge"); + } if (LUISchema.ButtonKind(state.Kind)) { string text = TextProperty(state, LUIProperty.TextValue); @@ -1351,6 +1406,9 @@ public void PerformAction(long node) LUISchema.TrueProperty( state.Properties, LUIProperty.PressEnabled)) || (state.Kind == LUINodeKind.Text && + LUISchema.TrueProperty( + state.Properties, LUIProperty.PressEnabled)) || + (state.Kind == LUINodeKind.FileImage && LUISchema.TrueProperty( state.Properties, LUIProperty.PressEnabled)); if (!pressable || IsDisabled(state)) @@ -1462,16 +1520,28 @@ public void PerformValueChange(long node, double value) { LUINodeState state = RequireState(node); if ((state.Kind != LUINodeKind.Slider && - state.Kind != LUINodeKind.Split) || + state.Kind != LUINodeKind.Split && + state.Kind != LUINodeKind.NumberStepper) || IsDisabled(state) || double.IsNaN(value) || double.IsInfinity(value)) { throw new LUIBackendException( $"node {node} is not an enabled value control"); } - OnEvent?.Invoke( - new LUIEvent.ValueChanged( - node, System.Math.Clamp(value, 0.0, 1.0))); + double emitted = System.Math.Clamp(value, 0.0, 1.0); + if (state.Kind == LUINodeKind.NumberStepper) + { + double minimum = FloatProperty( + state, LUIProperty.MinValue, 0.0); + double maximum = state.Properties.TryGetValue( + LUIProperty.MaxValue, out LUIWireValue? maxValue) && + maxValue is LUIWireValue.Float maxFloat + ? maxFloat.Value + : double.MaxValue; + emitted = System.Math.Clamp( + value, minimum, System.Math.Max(minimum, maximum)); + } + OnEvent?.Invoke(new LUIEvent.ValueChanged(node, emitted)); } public void PerformDismiss(long node) @@ -1481,6 +1551,8 @@ 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)) { throw new LUIBackendException($"node {node} is not dismissible"); @@ -1488,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 69449b55..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 @@ -45,6 +48,7 @@ public static bool ContextMenuHostKind(LUINodeKind kind) case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.TextField: case LUINodeKind.SecureField: case LUINodeKind.Input: @@ -74,6 +78,7 @@ public static bool ContextMenuLeafHostKind(LUINodeKind kind) case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.TextField: case LUINodeKind.SecureField: case LUINodeKind.Input: @@ -155,7 +160,9 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Text: case LUINodeKind.TableCell: case LUINodeKind.TimelineItem: + case LUINodeKind.FileImage: case LUINodeKind.BottomTab: + case LUINodeKind.SwipeAction: return true; default: return false; @@ -196,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; @@ -203,7 +211,9 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUIEventKind.Change: return kind == LUINodeKind.Radio; case LUIEventKind.ValueChanged: - return kind == LUINodeKind.Slider || kind == LUINodeKind.Split; + return kind == LUINodeKind.Slider || + kind == LUINodeKind.NumberStepper || + kind == LUINodeKind.Split; case LUIEventKind.Dismiss: switch (kind) { @@ -214,14 +224,21 @@ 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: return false; } 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: @@ -451,6 +468,9 @@ public static bool CommonPropertySupported( case LUINodeKind.Stack: case LUINodeKind.Scroll: case LUINodeKind.Avatar: + case LUINodeKind.EdgeInset: + case LUINodeKind.Overlay: + case LUINodeKind.ViewThatFits: case LUINodeKind.Text: case LUINodeKind.Heading: case LUINodeKind.Paragraph: @@ -466,6 +486,7 @@ public static bool CommonPropertySupported( case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.Spinner: case LUINodeKind.Icon: case LUINodeKind.Select: @@ -479,6 +500,8 @@ public static bool CommonPropertySupported( case LUINodeKind.Alert: case LUINodeKind.Bubble: case LUINodeKind.StatusBar: + case LUINodeKind.Link: + case LUINodeKind.FileImage: return true; default: return false; @@ -511,6 +534,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.RadioGroup || kind == LUINodeKind.Radio || kind == LUINodeKind.Slider || + kind == LUINodeKind.NumberStepper || HorizontalContainer(kind) || kind == LUINodeKind.Avatar || kind == LUINodeKind.Image || @@ -522,6 +546,8 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Alert || kind == LUINodeKind.Bubble || kind == LUINodeKind.ListItem || + kind == LUINodeKind.Link || + kind == LUINodeKind.FileImage || TreeRowKind(kind); case LUIProperty.AccessibilityIdentifier: return true; @@ -543,11 +569,13 @@ public static bool CommonPropertySupported( case LUIProperty.ProgressValue: return kind == LUINodeKind.Progress || kind == LUINodeKind.Slider || + kind == LUINodeKind.NumberStepper || kind == LUINodeKind.Split; case LUIProperty.OrientationValue: return kind == LUINodeKind.Divider || kind == LUINodeKind.Tabs || - kind == LUINodeKind.Scroll; + kind == LUINodeKind.Scroll || + kind == LUINodeKind.ViewThatFits; case LUIProperty.PlacementValue: return kind == LUINodeKind.Toolbar; case LUIProperty.SizeValue: @@ -570,11 +598,13 @@ public static bool CommonPropertySupported( kind == LUINodeKind.ToggleButton || kind == LUINodeKind.MenuItem || kind == LUINodeKind.ListItem || - kind == LUINodeKind.BottomTab; + kind == LUINodeKind.BottomTab || + kind == LUINodeKind.Link; case LUIProperty.IconPlacementValue: return kind == LUINodeKind.Button || kind == LUINodeKind.ToggleButton || - kind == LUINodeKind.ListItem; + kind == LUINodeKind.ListItem || + kind == LUINodeKind.Link; case LUIProperty.Selected: return kind == LUINodeKind.Button || kind == LUINodeKind.ToggleButton || @@ -614,6 +644,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.ListItem || kind == LUINodeKind.TableCell || kind == LUINodeKind.BottomTab || + kind == LUINodeKind.FileImage || TreeRowKind(kind); case LUIProperty.SubmitEnabled: return kind == LUINodeKind.Combobox || @@ -631,6 +662,13 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Image; case LUIProperty.SurfaceIdValue: return kind == LUINodeKind.MediaSurface; + case LUIProperty.PathValue: + return kind == LUINodeKind.FileImage || + kind == LUINodeKind.FilePreview; + case LUIProperty.UrlValue: + return kind == LUINodeKind.Link; + case LUIProperty.MaxPixelSize: + return kind == LUINodeKind.FileImage; case LUIProperty.AnchorValue: case LUIProperty.AnchorAlignmentValue: case LUIProperty.AnchorOffset: @@ -656,9 +694,36 @@ 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: + return kind == LUINodeKind.NumberStepper; + case LUIProperty.Detents: + case LUIProperty.Sizing: + return kind == LUINodeKind.Sheet; case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); + case LUIProperty.Visible: + return kind == LUINodeKind.EdgeInset; + // AlignmentValue is admitted ahead of the restrictive matrix + // in PropertySupported. + case LUIProperty.AlignmentValue: + return kind != LUINodeKind.Root; case LUIProperty.TextValue: switch (kind) { @@ -686,10 +751,12 @@ public static bool CommonPropertySupported( case LUINodeKind.Drawer: case LUINodeKind.Sheet: case LUINodeKind.Tooltip: + case LUINodeKind.NumberStepper: case LUINodeKind.TableCell: case LUINodeKind.Alert: case LUINodeKind.Bubble: case LUINodeKind.StatusBar: + case LUINodeKind.Link: return true; default: return false; @@ -709,12 +776,14 @@ public static bool CommonPropertySupported( case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.Select: case LUINodeKind.Combobox: case LUINodeKind.MenuItem: case LUINodeKind.ListItem: case LUINodeKind.Drawer: case LUINodeKind.BottomTab: + case LUINodeKind.Link: return true; default: return false; @@ -741,6 +810,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Panel || kind == LUINodeKind.Box || kind == LUINodeKind.Split || + kind == LUINodeKind.EdgeInset || HorizontalContainer(kind); default: return false; @@ -751,6 +821,11 @@ public static bool PropertySupported( LUINodeKind kind, LUIProperty property) { if (property == LUIProperty.AccessibilityIdentifier) return true; + // Position hint honored on overlay children and the overlay + // itself; admitted before the restrictive matrix so e.g. an + // aligned menu-trigger child still carries it. Inert elsewhere. + if (property == LUIProperty.AlignmentValue) + return kind != LUINodeKind.Root; // The restrictive arms mirror schema/components.json // kindProperties; test/property_matrix asserts the two stay in // sync. LUIWireSchema.RestrictiveMatrix is generated from it. @@ -1089,6 +1164,108 @@ 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: + { + return value is LUIWireValue.Float number && + IsFinite(number.Value); + } + case LUIProperty.StepValue: + { + return value is LUIWireValue.Float stepValue && + IsFinite(stepValue.Value) && stepValue.Value > 0.0; + } + case LUIProperty.Detents: + { + return value is LUIWireValue.String; + } + case LUIProperty.Sizing: + { + return value is LUIWireValue.String sizing && + (sizing.Value == "form" || sizing.Value == "fitted" || + sizing.Value == "page"); + } + case LUIProperty.PathValue: + case LUIProperty.UrlValue: + { + return value is LUIWireValue.String; + } + case LUIProperty.MaxPixelSize: + { + return value is LUIWireValue.Int { Value: > 0 }; + } + case LUIProperty.EdgeValue: + { + return value is LUIWireValue.String text && + (text.Value == "top" || text.Value == "bottom" || + text.Value == "leading" || text.Value == "trailing"); + } + case LUIProperty.Visible: + { + return value is LUIWireValue.Bool; + } + case LUIProperty.AlignmentValue: + { + return value is LUIWireValue.String text && + (text.Value == "top-leading" || text.Value == "top" || + text.Value == "top-trailing" || + text.Value == "leading" || text.Value == "center" || + text.Value == "trailing" || + text.Value == "bottom-leading" || + text.Value == "bottom" || + text.Value == "bottom-trailing"); + } default: { return false; @@ -1187,6 +1364,11 @@ public static bool NodePropertiesSupported( return false; } } + if (kind == LUINodeKind.EdgeInset && + !properties.ContainsKey(LUIProperty.EdgeValue)) + { + return false; + } if (kind == LUINodeKind.Button || kind == LUINodeKind.ToggleButton || kind == LUINodeKind.Toggle || kind == LUINodeKind.Radio) { @@ -1292,6 +1474,17 @@ public static bool NodePropertiesSupported( { return false; } + if ((kind == LUINodeKind.FileImage || + kind == LUINodeKind.FilePreview) && + !StringPropertyNonempty(properties, LUIProperty.PathValue)) + { + return false; + } + if (kind == LUINodeKind.Link && + !StringPropertyNonempty(properties, LUIProperty.UrlValue)) + { + return false; + } if (kind == LUINodeKind.Stepper && !properties.ContainsKey(LUIProperty.ActiveIndex)) { @@ -1327,6 +1520,37 @@ public static bool NodePropertiesSupported( return false; } } + if (kind == LUINodeKind.NumberStepper) + { + if (!properties.TryGetValue( + LUIProperty.ProgressValue, + out LUIWireValue? stepperValue) || + stepperValue is not LUIWireValue.Float) + { + return false; + } + if (!StringPropertyNonempty( + properties, LUIProperty.TextValue) && + !StringPropertyNonempty( + properties, LUIProperty.AccessibilityLabel)) + { + return false; + } + // min defaults to 0.0 and max is unbounded; compare the + // effective endpoints so a lone negative max still fails. + double stepperMinimum = properties.TryGetValue( + LUIProperty.MinValue, out LUIWireValue? minimum) && + minimum is LUIWireValue.Float minFloat + ? minFloat.Value : 0.0; + double stepperMaximum = properties.TryGetValue( + LUIProperty.MaxValue, out LUIWireValue? maximum) && + maximum is LUIWireValue.Float maxFloat + ? maxFloat.Value : double.MaxValue; + if (stepperMinimum > stepperMaximum) + { + return false; + } + } if (kind == LUINodeKind.Tree || kind == LUINodeKind.Toolbar) { if (!StringPropertyNonempty( @@ -1358,13 +1582,30 @@ public static bool NodePropertiesSupported( 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; } @@ -1381,6 +1622,9 @@ public static bool CanContainChildren(LUINodeKind kind) case LUINodeKind.Column: case LUINodeKind.Grid: case LUINodeKind.Stack: + case LUINodeKind.EdgeInset: + case LUINodeKind.Overlay: + case LUINodeKind.ViewThatFits: case LUINodeKind.Panel: case LUINodeKind.Card: case LUINodeKind.Box: @@ -1410,6 +1654,12 @@ 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: return false; @@ -1456,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 513775f4..7e9b5732 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -13,6 +13,9 @@ public enum LUINodeKind Column, Grid, Stack, + EdgeInset, + Overlay, + ViewThatFits, Panel, Card, Alert, @@ -28,6 +31,7 @@ public enum LUINodeKind RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -80,6 +84,15 @@ public enum LUINodeKind Toast, Toolbar, StatusBar, + ListSection, + ListSectionHeader, + ListSectionFooter, + SwipeActions, + SwipeAction, + FilePicker, + Link, + FileImage, + FilePreview, } public enum LUIProperty @@ -157,6 +170,30 @@ public enum LUIProperty 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, } public static class LUIWireSchema @@ -169,6 +206,9 @@ public static class LUIWireSchema { "column", LUINodeKind.Column }, { "grid", LUINodeKind.Grid }, { "stack", LUINodeKind.Stack }, + { "edge-inset", LUINodeKind.EdgeInset }, + { "overlay", LUINodeKind.Overlay }, + { "view-that-fits", LUINodeKind.ViewThatFits }, { "panel", LUINodeKind.Panel }, { "card", LUINodeKind.Card }, { "alert", LUINodeKind.Alert }, @@ -184,6 +224,7 @@ public static class LUIWireSchema { "radio-group", LUINodeKind.RadioGroup }, { "radio", LUINodeKind.Radio }, { "slider", LUINodeKind.Slider }, + { "number-stepper", LUINodeKind.NumberStepper }, { "text-field", LUINodeKind.TextField }, { "secure-field", LUINodeKind.SecureField }, { "input", LUINodeKind.Input }, @@ -236,6 +277,15 @@ 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 }, }; private static readonly Dictionary PropertiesByWireName = @@ -314,6 +364,30 @@ 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 }, + { "detents", LUIProperty.Detents }, + { "sizing", LUIProperty.Sizing }, + { "path", LUIProperty.PathValue }, + { "url", LUIProperty.UrlValue }, + { "max-pixel-size", LUIProperty.MaxPixelSize }, + { "visible", LUIProperty.Visible }, + { "alignment", LUIProperty.AlignmentValue }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -323,6 +397,9 @@ public static class LUIWireSchema LUINodeKind.Column => "column", LUINodeKind.Grid => "grid", LUINodeKind.Stack => "stack", + LUINodeKind.EdgeInset => "edge-inset", + LUINodeKind.Overlay => "overlay", + LUINodeKind.ViewThatFits => "view-that-fits", LUINodeKind.Panel => "panel", LUINodeKind.Card => "card", LUINodeKind.Alert => "alert", @@ -338,6 +415,7 @@ public static class LUIWireSchema LUINodeKind.RadioGroup => "radio-group", LUINodeKind.Radio => "radio", LUINodeKind.Slider => "slider", + LUINodeKind.NumberStepper => "number-stepper", LUINodeKind.TextField => "text-field", LUINodeKind.SecureField => "secure-field", LUINodeKind.Input => "input", @@ -390,6 +468,15 @@ 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", _ => "unknown", }; @@ -406,6 +493,9 @@ LUINodeKind.Row or LUINodeKind.Column or LUINodeKind.Grid or LUINodeKind.Stack or + LUINodeKind.EdgeInset or + LUINodeKind.Overlay or + LUINodeKind.ViewThatFits or LUINodeKind.Panel or LUINodeKind.Card or LUINodeKind.Alert or @@ -441,7 +531,13 @@ LUINodeKind.Dialog or LUINodeKind.Drawer or LUINodeKind.Sheet or LUINodeKind.Toast or - LUINodeKind.Toolbar => true, + LUINodeKind.Toolbar or + LUINodeKind.ListSection or + LUINodeKind.ListSectionHeader or + LUINodeKind.ListSectionFooter or + LUINodeKind.SwipeActions or + LUINodeKind.FilePicker or + LUINodeKind.Link => true, _ => false, }; @@ -520,6 +616,30 @@ LUINodeKind.Toast 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", + LUIProperty.Detents => "detents", + LUIProperty.Sizing => "sizing", + LUIProperty.PathValue => "path", + LUIProperty.UrlValue => "url", + LUIProperty.MaxPixelSize => "max-pixel-size", + LUIProperty.Visible => "visible", + LUIProperty.AlignmentValue => "alignment", _ => "unknown", }; @@ -544,6 +664,11 @@ 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) }, }; public static readonly IReadOnlyDictionary> ExtraMatrix = diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index 28d01ada..5c9498b9 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -22,6 +22,11 @@ void SyncKind(LUINodeState state, LUISyncContext context) SyncPlainContainer(state, context); break; case LUINodeKind.Stack: + // edge-inset/overlay/view-that-fits approximate as stacked + // grid children (no WinUI pinned/fit primitive). + case LUINodeKind.EdgeInset: + case LUINodeKind.Overlay: + case LUINodeKind.ViewThatFits: SyncStackLike(state, context); break; case LUINodeKind.Row: @@ -86,6 +91,9 @@ void SyncKind(LUINodeState state, LUISyncContext context) case LUINodeKind.Slider: SyncSlider(state, context); break; + case LUINodeKind.NumberStepper: + SyncNumberStepper(state, context); + break; case LUINodeKind.TextField: case LUINodeKind.Input: case LUINodeKind.Textarea: @@ -168,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; @@ -382,6 +395,37 @@ void OnSliderChanged( } } + void SyncNumberStepper(LUINodeState state, LUISyncContext context) + { + if (Control is not NumberBox box) return; + box.Header = LUIPropertyApplier.Text(state); + box.Minimum = LUIPropertyApplier.Prop( + state, LUIProperty.MinValue)?.AsFloat ?? 0.0; + box.Maximum = LUIPropertyApplier.Prop( + state, LUIProperty.MaxValue)?.AsFloat ?? double.MaxValue; + box.SmallChange = LUIPropertyApplier.Prop( + state, LUIProperty.StepValue)?.AsFloat ?? 1.0; + box.ValueChanged -= OnNumberStepperChanged; + double? value = LUIPropertyApplier.Prop( + state, LUIProperty.ProgressValue)?.AsFloat; + if (value != null && box.Value != value.Value) + { + box.Value = value.Value; + } + box.ValueChanged += OnNumberStepperChanged; + } + + void OnNumberStepperChanged(NumberBox sender, + NumberBoxValueChangedEventArgs args) + { + LUISyncContext? context = Context; + if (context != null && !double.IsNaN(args.NewValue)) + { + Gate(() => + context.Backend.PerformValueChange(Id, args.NewValue)); + } + } + // ---- inputs ------------------------------------------------------- void SyncTextBox(LUINodeState state, LUISyncContext context) diff --git a/platform/winui/LUI.WinUI/LUIElementFactory.cs b/platform/winui/LUI.WinUI/LUIElementFactory.cs index 5e2e3d50..8251faf5 100644 --- a/platform/winui/LUI.WinUI/LUIElementFactory.cs +++ b/platform/winui/LUI.WinUI/LUIElementFactory.cs @@ -17,6 +17,12 @@ public static FrameworkElement Create(LUINodeKind kind) { case LUINodeKind.Stack: return new Grid(); + case LUINodeKind.EdgeInset: + case LUINodeKind.Overlay: + case LUINodeKind.ViewThatFits: + // No WinUI pinned/overlay/fit primitive; children stack + // in a grid (overlay-like approximation). + return new LUIGrid(); case LUINodeKind.Root: case LUINodeKind.Resizable: case LUINodeKind.Split: @@ -65,6 +71,8 @@ public static FrameworkElement Create(LUINodeKind kind) return new RadioButton(); case LUINodeKind.Slider: return new Slider { Minimum = 0, Maximum = 1 }; + case LUINodeKind.NumberStepper: + return new NumberBox { SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; case LUINodeKind.TextField: case LUINodeKind.Input: return new TextBox(); diff --git a/platform/winui/LUI.WinUI/LUIModalPresenter.cs b/platform/winui/LUI.WinUI/LUIModalPresenter.cs index 4041efa6..e7cfacdf 100644 --- a/platform/winui/LUI.WinUI/LUIModalPresenter.cs +++ b/platform/winui/LUI.WinUI/LUIModalPresenter.cs @@ -86,7 +86,7 @@ internal void Sync(IReadOnlyList surfaceIds) : Visibility.Visible; } - static void Position(FrameworkElement control, LUINodeState state) + void Position(FrameworkElement control, LUINodeState state) { switch (state.Kind) { @@ -97,11 +97,67 @@ static void Position(FrameworkElement control, LUINodeState state) control.Margin = new Thickness(24); break; case LUINodeKind.Sheet: - control.HorizontalAlignment = - HorizontalAlignment.Stretch; + { control.VerticalAlignment = VerticalAlignment.Bottom; - control.MaxHeight = 480; + // detents: comma-separated medium|large|fraction — + // the sheet opens at the first detent and can grow to + // the largest (medium = 0.5, large = 1.0 of the + // overlay). sizing: form/fitted center the sheet at a + // fixed width; page stretches edge to edge. + double overlayHeight = _overlay.ActualHeight > 0 + ? _overlay.ActualHeight : 480; + double restFraction = 0.0, maxFraction = 0.0; + string? detents = LUIPropertyApplier.Prop( + state, LUIProperty.Detents)?.AsString; + if (detents != null) + { + bool first = true; + foreach (string token in detents.Split(',')) + { + string trimmed = token.Trim(); + double fraction = + trimmed == "medium" ? 0.5 : + trimmed == "large" ? 1.0 : + (double.TryParse( + trimmed, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo + .InvariantCulture, + out double parsed) && + parsed > 0.0 && parsed <= 1.0 + ? parsed : -1.0); + if (fraction > 0.0) + { + if (first) + { + restFraction = fraction; + first = false; + } + if (fraction > maxFraction) + { + maxFraction = fraction; + } + } + } + } + string? sizing = LUIPropertyApplier.Prop( + state, LUIProperty.Sizing)?.AsString; + if (sizing == "form" || sizing == "fitted") + { + control.HorizontalAlignment = + HorizontalAlignment.Center; + control.MaxWidth = 640; + } + else + { + control.HorizontalAlignment = + HorizontalAlignment.Stretch; + } + control.MinHeight = overlayHeight * restFraction; + control.MaxHeight = maxFraction > 0.0 + ? overlayHeight * maxFraction : 480; break; + } case LUINodeKind.Drawer: control.HorizontalAlignment = HorizontalAlignment.Right; control.VerticalAlignment = VerticalAlignment.Stretch; diff --git a/platform/winui/LUI.WinUI/LUIThemeColors.cs b/platform/winui/LUI.WinUI/LUIThemeColors.cs index 331ce47f..bf9505ac 100644 --- a/platform/winui/LUI.WinUI/LUIThemeColors.cs +++ b/platform/winui/LUI.WinUI/LUIThemeColors.cs @@ -85,6 +85,9 @@ is string token && case "glass": return Resource("AcrylicBackgroundFillColorDefaultBrush", Color.FromArgb(0xCC, 0xF9, 0xF9, 0xF9)); + case "bar": + return Resource("AcrylicBackgroundFillColorDefaultBrush", + Color.FromArgb(0xD9, 0xF9, 0xF9, 0xF9)); case "secondary-foreground": return Resource("TextFillColorSecondaryBrush", Color.FromArgb(0x9E, 0x1B, 0x1B, 0x1B)); diff --git a/schema/components.json b/schema/components.json index 5890108b..b22487ab 100644 --- a/schema/components.json +++ b/schema/components.json @@ -25,6 +25,18 @@ "name": "stack", "status": "supported" }, + { + "name": "edge-inset", + "status": "supported" + }, + { + "name": "overlay", + "status": "supported" + }, + { + "name": "view-that-fits", + "status": "supported" + }, { "name": "panel", "status": "supported" @@ -125,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" @@ -225,6 +249,10 @@ "name": "slider", "status": "supported" }, + { + "name": "number-stepper", + "status": "supported" + }, { "name": "progress", "status": "supported" @@ -292,6 +320,22 @@ { "name": "input-group-actions", "status": "supported" + }, + { + "name": "file-picker", + "status": "supported" + }, + { + "name": "link", + "status": "supported" + }, + { + "name": "file-image", + "status": "supported" + }, + { + "name": "file-preview", + "status": "supported" } ], "nodeKinds": [ @@ -330,6 +374,27 @@ "swift": "stack", "container": true }, + { + "lg": "EdgeInset", + "wire": "edge-inset", + "dart": "edgeInset", + "swift": "edgeInset", + "container": true + }, + { + "lg": "Overlay", + "wire": "overlay", + "dart": "overlay", + "swift": "overlay", + "container": true + }, + { + "lg": "ViewThatFits", + "wire": "view-that-fits", + "dart": "viewThatFits", + "swift": "viewThatFits", + "container": true + }, { "lg": "Panel", "wire": "panel", @@ -435,6 +500,13 @@ "swift": "slider", "container": false }, + { + "lg": "NumberStepper", + "wire": "number-stepper", + "dart": "numberStepper", + "swift": "numberStepper", + "container": false + }, { "lg": "TextField", "wire": "text-field", @@ -798,6 +870,69 @@ "dart": "statusBar", "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", + "dart": "link", + "swift": "link", + "container": true + }, + { + "lg": "FileImage", + "wire": "file-image", + "dart": "fileImage", + "swift": "fileImage", + "container": false + }, + { + "lg": "FilePreview", + "wire": "file-preview", + "dart": "filePreview", + "swift": "filePreview", + "container": false } ], "properties": [ @@ -1165,6 +1300,126 @@ "lg": "ThemeMode", "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", + "swift": "minValue" + }, + { + "lg": "MaxValue", + "wire": "max", + "swift": "maxValue" + }, + { + "lg": "StepValue", + "wire": "step", + "swift": "stepValue" + }, + { + "lg": "Detents", + "wire": "detents", + "swift": "detents" + }, + { + "lg": "Sizing", + "wire": "sizing", + "swift": "sizing" + }, + { + "lg": "PathValue", + "wire": "path", + "swift": "path" + }, + { + "lg": "UrlValue", + "wire": "url", + "swift": "url" + }, + { + "lg": "MaxPixelSize", + "wire": "max-pixel-size", + "swift": "maxPixelSize" + }, + { + "lg": "Visible", + "wire": "visible", + "swift": "visible" + }, + { + "lg": "AlignmentValue", + "wire": "alignment", + "swift": "alignment" } ], "events": [ @@ -1230,6 +1485,29 @@ "fields": [ ":int" ] + }, + { + "lg": "ScrollCompleted", + "fields": [ + ":int", + ":int", + ":string" + ] + }, + { + "lg": "VisibleRange", + "fields": [ + ":int", + ":int", + ":int" + ] + }, + { + "lg": "Picked", + "fields": [ + ":int", + ":string" + ] } ], "kindProperties": { @@ -1309,6 +1587,32 @@ "enabled", "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" ] }, "kindExtraProperties": { diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..1f3eed0b 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -23,6 +23,12 @@ type orientation = [ `horizontal | `vertical ] type icon_placement = [ `leading | `trailing | `top ] type anchor = [ `above | `below | `left | `right ] type anchor_alignment = [ `start | `end_ | `stretch ] +type edge = [ `top | `bottom | `leading | `trailing ] + +type alignment = + [ `top_leading | `top | `top_trailing + | `leading | `center | `trailing + | `bottom_leading | `bottom | `bottom_trailing ] type frame_axes = [ `horizontal | `vertical | `both @@ -30,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 @@ -47,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. *) @@ -111,6 +132,23 @@ let anchor_alignment_value : anchor_alignment -> string = function | `end_ -> "end" | `stretch -> "stretch" +let edge_value : edge -> string = function + | `top -> "top" + | `bottom -> "bottom" + | `leading -> "leading" + | `trailing -> "trailing" + +let alignment_value : alignment -> string = function + | `top_leading -> "top-leading" + | `top -> "top" + | `top_trailing -> "top-trailing" + | `leading -> "leading" + | `center -> "center" + | `trailing -> "trailing" + | `bottom_leading -> "bottom-leading" + | `bottom -> "bottom" + | `bottom_trailing -> "bottom-trailing" + let frame_axes_value : frame_axes -> string = function | `horizontal -> "horizontal" | `vertical -> "vertical" @@ -130,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" @@ -258,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) @@ -278,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) @@ -305,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) @@ -312,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; @@ -376,6 +456,43 @@ let stack ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?pa mount_children context node children; node +let edge_inset ?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 ~edge ?visible ?visible_signal (children : t list) : t = + fun context parent -> + let node = Lui_ui.edge_inset 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; + Lui_ui.string_property context node EdgeValue (edge_value edge); + Option.iter (Lui_ui.bool_property context node Visible) visible; + Option.iter (Lui_ui.bool_property_signal context node Visible) visible_signal; + attach context parent node; + mount_children context node children; + node + +let overlay ?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 ?alignment (children : t list) : t = + fun context parent -> + let node = Lui_ui.overlay 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 AlignmentValue) (Option.map alignment_value alignment); + attach context parent node; + mount_children context node children; + node + +let view_that_fits ?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 (children : t list) : t = + fun context parent -> + let node = Lui_ui.view_that_fits 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 OrientationValue) (Option.map orientation_value orientation); + attach context parent node; + mount_children context node children; + node + +(* Sets an [alignment] hint on [child]'s node; honored when [child] is an + [overlay] child (children past the first), inert elsewhere. *) +let align alignment (child : t) : t = + fun context parent -> + let node = child context parent in + Lui_ui.string_property context node AlignmentValue (alignment_value alignment); + node + let panel ?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 = fun context parent -> let node = Lui_ui.panel context in @@ -434,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 @@ -913,6 +1042,27 @@ let slider ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?p mount_children context node children; node +let number_stepper ?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 ?value ?value_signal ?min ?max ?step ?text ?text_signal ?label ?enabled ?enabled_signal ?on_value_changed (children : t list) : t = + fun context parent -> + let node = Lui_ui.create context NumberStepper 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.float_property context node ProgressValue) value; + Option.iter (Lui_ui.float_property_signal context node ProgressValue) value_signal; + Option.iter (Lui_ui.float_property context node MinValue) min; + Option.iter (Lui_ui.float_property context node MaxValue) max; + Option.iter (Lui_ui.float_property context node StepValue) step; + 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 AccessibilityLabel) label; + Option.iter (Lui_ui.bool_property context node Enabled) enabled; + Option.iter (Lui_ui.bool_property_signal context node Enabled) enabled_signal; + (match on_value_changed with + | Some handler -> register_resize context node handler + | None -> ()); + attach context parent node; + mount_children context node children; + node + let progress ?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 ?value ?value_signal (_children : nothing list) : t = fun context parent -> let node = Lui_ui.create context Progress in @@ -1206,12 +1356,16 @@ let dialog ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?p mount_children context node children; node -let sheet ?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 ?on_dismiss (children : t list) : t = +let sheet ?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 ?detents ?detents_signal ?sizing ?sizing_signal ?on_dismiss (children : t list) : t = fun context parent -> let node = Lui_ui.sheet 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 TextValue) text; Option.iter (Lui_ui.string_property_signal context node TextValue) text_signal; + Option.iter (Lui_ui.string_property context node Detents) detents; + Option.iter (Lui_ui.string_property_signal context node Detents) detents_signal; + Option.iter (Lui_ui.string_property context node Sizing) sizing; + Option.iter (Lui_ui.string_property_signal context node Sizing) sizing_signal; (match on_dismiss with | Some handler -> register_dismiss context node handler | None -> ()); @@ -1247,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 @@ -1358,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); @@ -1375,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; @@ -1407,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 = @@ -1452,6 +1728,56 @@ let media_surface ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizo node +let link ?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 ?url ?url_signal ?text ?text_signal ?icon ?icon_signal ?icon_placement ?label ?disabled ?disabled_signal (children : t list) : t = + fun context parent -> + let node = Lui_ui.create context Link 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 UrlValue) url; + Option.iter (Lui_ui.string_property_signal context node UrlValue) url_signal; + 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 IconPlacementValue) (Option.map icon_placement_value icon_placement); + Option.iter (Lui_ui.string_property context node AccessibilityLabel) label; + Option.iter (Lui_ui.disabled context node) disabled; + Option.iter (Lui_ui.disabled_signal context node) disabled_signal; + attach context parent node; + mount_children context node children; + node + +let file_image ?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 ?path ?path_signal ?max_pixel_size ?label ?on_press (_children : nothing list) : t = + fun context parent -> + let node = Lui_ui.create context FileImage 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 PathValue) path; + Option.iter (Lui_ui.string_property_signal context node PathValue) path_signal; + Option.iter (Lui_ui.int_property context node MaxPixelSize) max_pixel_size; + Option.iter (Lui_ui.string_property context node AccessibilityLabel) label; + (match on_press with + | Some handler -> + enable context node PressEnabled; + register_press context node handler + | None -> ()); + attach context parent node; + + node + +let file_preview ?key ?path ?path_signal ?accessibility_identifier ?accessibility_identifier_signal ?on_dismiss (_children : nothing list) : t = + fun context parent -> + let node = Lui_ui.create context FilePreview in + Option.iter (Lui_ui.key context node) key; + Option.iter (Lui_ui.string_property context node PathValue) path; + Option.iter (Lui_ui.string_property_signal context node PathValue) path_signal; + Option.iter (Lui_ui.string_property context node AccessibilityIdentifier) accessibility_identifier; + Option.iter (Lui_ui.string_property_signal context node AccessibilityIdentifier) accessibility_identifier_signal; + (match on_dismiss with + | Some handler -> register_dismiss context node handler + | None -> ()); + attach context parent node; + + node + let stepper ?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 ?active ?active_signal ?label (children : step_el list) : t = fun context parent -> let node = Lui_ui.create context Stepper in diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1c..fe085254 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -27,12 +27,35 @@ type icon_placement = [ `leading | `trailing | `top ] type anchor = [ `above | `below | `left | `right ] type anchor_alignment = [ `start | `end_ | `stretch ] +type edge = [ `top | `bottom | `leading | `trailing ] + +(** Two-dimensional alignment for [overlay] content: a corner, an edge + center, or [center]. *) +type alignment = + [ `top_leading | `top | `top_trailing + | `leading | `center | `trailing + | `bottom_leading | `bottom | `bottom_trailing ] + type frame_axes = [ `horizontal | `vertical | `both | `min_horizontal | `min_vertical | `min_both ] 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). *) @@ -54,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. *) @@ -135,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 @@ -331,6 +360,119 @@ val stack : ?background_signal:string Signal.signal -> ?style_class:string -> ?on_appear:(Lui_protocol.event -> unit) -> t list -> t + +(** [edge_inset ~edge \[content; pinned; ...\]] pins every child after the + first to [edge] while [content] fills the view and scrolls beneath + (`.safeAreaInset`). [~visible:false] hides the pinned children in place + (animated) without unmounting them; [~visible_signal] toggles it + reactively. The element's own surface props style the pinned region, so + e.g. [~background:"bar"] gives a material chrome bar. [~gap] sets the + spacing between [content] and the pinned children (default 0). *) +val edge_inset : + ?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) -> + edge:edge -> + ?visible:bool -> + ?visible_signal:bool Signal.signal -> t list -> t + +(** [overlay \[base; overlay_child; ...\]] renders the children after the + first floating over [base] without affecting its layout. Each overlay + child positions itself by its own [alignment] hint (set with {!align}), + falling back to the [overlay]'s [~alignment] (default [`center]). *) +val overlay : + ?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) -> + ?alignment:alignment -> t list -> t + +(** [align a child] sets [child]'s [alignment] hint, honored when [child] + is mounted as an [overlay] child — e.g. + [overlay \[base; align `top_trailing controls\]]. Inert elsewhere. *) +val align : alignment -> t -> t + +(** [view_that_fits \[first; fallback; ...\]] renders the first child that + fits along [~orientation] (default [`horizontal`]; [ViewThatFits]). *) +val view_that_fits : + ?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) -> + ?orientation:orientation -> t list -> t val panel : ?key:string -> ?gap:int -> @@ -540,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 -> @@ -1563,6 +1713,46 @@ val slider : ?disabled:bool -> ?disabled_signal:bool Signal.signal -> ?on_change:(Lui_protocol.event -> unit) -> t list -> t +val number_stepper : + ?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) -> + ?value:float -> + ?value_signal:float Signal.signal -> + ?min:float -> + ?max:float -> + ?step:float -> + ?text:string -> + ?text_signal:string Signal.signal -> + ?label:string -> + ?enabled:bool -> + ?enabled_signal:bool Signal.signal -> + ?on_value_changed:(Lui_protocol.event -> unit) -> t list -> t val progress : ?key:string -> ?gap:int -> @@ -2149,6 +2339,10 @@ val sheet : ?on_appear:(Lui_protocol.event -> unit) -> ?text:string -> ?text_signal:string Signal.signal -> + ?detents:string -> + ?detents_signal:string Signal.signal -> + ?sizing:string -> + ?sizing_signal:string Signal.signal -> ?on_dismiss:(Lui_protocol.event -> unit) -> t list -> t val tooltip : ?key:string -> @@ -2217,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 -> @@ -2378,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 -> @@ -2480,6 +2752,85 @@ val media_surface : ?on_appear:(Lui_protocol.event -> unit) -> ?surface:int -> ?surface_signal:int Signal.signal -> ?label:string -> nothing list -> t +val link : + ?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) -> + ?url:string -> + ?url_signal:string Signal.signal -> + ?text:string -> + ?text_signal:string Signal.signal -> + ?icon:icon -> + ?icon_signal:icon Signal.signal -> + ?icon_placement:icon_placement -> + ?label:string -> + ?disabled:bool -> ?disabled_signal:bool Signal.signal -> t list -> t +val file_image : + ?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) -> + ?path:string -> + ?path_signal:string Signal.signal -> + ?max_pixel_size:int -> + ?label:string -> + ?on_press:(Lui_protocol.event -> unit) -> nothing list -> t +val file_preview : + ?key:string -> + ?path:string -> + ?path_signal:string Signal.signal -> + ?accessibility_identifier:string -> + ?accessibility_identifier_signal:string Signal.signal -> + ?on_dismiss:(Lui_protocol.event -> unit) -> nothing list -> t val stepper : ?key:string -> ?gap:int -> diff --git a/src/lui_json_view.ml b/src/lui_json_view.ml index a0b40d31..0242794b 100644 --- a/src/lui_json_view.ml +++ b/src/lui_json_view.ml @@ -397,6 +397,9 @@ let rec element ~emit (node : value) : E.t = | "slider" -> E.slider ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?value:(v_float props "value") ?label:(v_str props "label") ?disabled:(v_bool props "disabled") ?on_change:(ev "change" emit props) (List.map (element ~emit) child_nodes) + | "number-stepper" -> + E.number_stepper + ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?value:(v_float props "value") ?min:(v_float props "min") ?max:(v_float props "max") ?step:(v_float props "step") ?text:(v_str props "text") ?label:(v_str props "label") ?enabled:(v_bool props "enabled") ?on_value_changed:(ev "change" emit props) (List.map (element ~emit) child_nodes) | "progress" -> E.progress ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?value:(v_float props "value") [] @@ -447,7 +450,7 @@ let rec element ~emit (node : value) : E.t = ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?description:(v_str props "description") ?on_dismiss:(ev "dismiss" emit props) (List.map (element ~emit) child_nodes) | "sheet" -> E.sheet - ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?on_dismiss:(ev "dismiss" emit props) (List.map (element ~emit) child_nodes) + ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?detents:(v_str props "detents") ?sizing:(v_str props "sizing") ?on_dismiss:(ev "dismiss" emit props) (List.map (element ~emit) child_nodes) | "tooltip" -> E.tooltip ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?anchor:(anchor_opt (v_str props "anchor")) ?anchor_alignment:(anchor_align_opt (v_str props "anchor-alignment")) ?anchor_offset:(v_float props "anchor-offset") ?tooltip_delay:(v_int props "tooltip-delay") [] diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index ebdfeea7..5726ee46 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -9,6 +9,9 @@ type node_kind = | Column | Grid | Stack + | EdgeInset + | Overlay + | ViewThatFits | Panel | Card | Alert @@ -24,6 +27,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -76,6 +80,15 @@ type node_kind = | Toast | Toolbar | StatusBar + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions + | SwipeAction + | FilePicker + | Link + | FileImage + | FilePreview type operating_system = | GenericOS @@ -173,6 +186,30 @@ type property = | 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 module Property_map = Map.Make @@ -199,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 = @@ -239,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 @@ -257,6 +300,7 @@ let context_menu_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -278,6 +322,7 @@ let context_menu_leaf_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -305,7 +350,9 @@ let event_supported kind event = | Text | TableCell | TimelineItem - | BottomTab -> true + | FileImage + | BottomTab + | SwipeAction -> true | _ -> false) | LongPress _ -> (match kind with @@ -340,12 +387,14 @@ let event_supported kind event = | Toggle | Radio | Accordion - | Drawer -> true + | Drawer + | ListItem -> true | _ -> false) | Change _ -> kind = Radio | ValueChanged _ -> (match kind with | Slider + | NumberStepper | Split -> true | _ -> false) | Dismiss _ -> @@ -356,10 +405,14 @@ let event_supported kind event = | Toast | Dialog | Drawer - | Sheet -> true + | Sheet + | FilePreview + | FilePicker -> true | _ -> false) | DoublePress _ -> kind = ListItem | Appear _ -> kind <> Root + | ScrollCompleted _ | VisibleRange _ -> kind = ListContainer + | Picked _ -> kind = FilePicker | ExtensionEvent _ -> false let true_property properties property = @@ -516,6 +569,12 @@ 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" + let main_alignment_supported value = value = "start" || value = "center" || value = "end" || value = "space_between" @@ -526,6 +585,12 @@ let horizontal_container kind = kind = Tabs || kind = ButtonGroup || kind = ToggleGroup || kind = Breadcrumb || kind = Pagination +let alignment_supported value = + value = "top-leading" || value = "top" || value = "top-trailing" + || value = "leading" || value = "center" || value = "trailing" + || value = "bottom-leading" || value = "bottom" + || value = "bottom-trailing" + let can_contain_children kind = if horizontal_container kind || context_menu_leaf_host_kind kind then true else @@ -535,6 +600,9 @@ let can_contain_children kind = | Column | Grid | Stack + | EdgeInset + | Overlay + | ViewThatFits | Panel | Card | Box @@ -564,7 +632,13 @@ let can_contain_children kind = | Alert | Bubble | BottomTabs - | BottomTab -> true + | BottomTab + | Link + | FilePicker + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions -> true | _ -> false let common_property_supported kind property = @@ -593,16 +667,22 @@ let common_property_supported kind property = | ForegroundValue -> (match kind with | Row | Column | Grid | Box | Panel | Card | Stack | Scroll | Avatar + | EdgeInset | Overlay | ViewThatFits | Text | Heading | Paragraph | Label | Button | ToggleButton | TextField | SecureField | Input | SearchField | Textarea | Checkbox | Toggle | Radio - | Slider | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem - | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar -> true + | Slider | NumberStepper | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem + | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar + | Link | FileImage -> true | _ -> false) | WidthValue | HeightValue -> kind <> Tooltip | MinWidth | MaxWidth | MinHeight | MaxHeight -> (not (modal_surface kind)) && kind <> Tooltip | ContainerRelativeFrameValue | ContainerRelativeFrameInset -> kind <> Root && not (modal_surface kind) + (* Position hint honored on [overlay] children and on [overlay] itself; + inert elsewhere. [property_supported] also admits it ahead of the + restrictive arms so aligned children of restrictive kinds validate. *) + | AlignmentValue -> kind <> Root | StyleClass -> kind <> Tooltip | AccessibilityLabel -> kind = Button @@ -619,6 +699,7 @@ let common_property_supported kind property = || kind = RadioGroup || kind = Radio || kind = Slider + || kind = NumberStepper || horizontal_container kind || kind = Avatar || kind = Image @@ -630,6 +711,8 @@ let common_property_supported kind property = || kind = Alert || kind = Bubble || kind = ListItem + || kind = Link + || kind = FileImage || tree_row_kind kind | AccessibilityIdentifier -> true | PlaceholderValue -> @@ -643,8 +726,10 @@ let common_property_supported kind property = | HeadingLevel -> kind = Heading | Checked -> kind = Checkbox || kind = SwitchControl || kind = Toggle || kind = Radio - | ProgressValue -> kind = Progress || kind = Slider || kind = Split - | OrientationValue -> kind = Divider || kind = Tabs || kind = Scroll + | ProgressValue -> + kind = Progress || kind = Slider || kind = NumberStepper || kind = Split + | OrientationValue -> + kind = Divider || kind = Tabs || kind = Scroll || kind = ViewThatFits | PlacementValue -> kind = Toolbar | SizeValue -> kind = Button || kind = ToggleButton || kind = Spinner || kind = Icon @@ -659,7 +744,9 @@ let common_property_supported kind property = || kind = MenuItem || kind = ListItem || kind = BottomTab - | IconPlacementValue -> kind = Button || kind = ToggleButton || kind = ListItem + || kind = Link + | IconPlacementValue -> + kind = Button || kind = ToggleButton || kind = ListItem || kind = Link | Selected -> kind = Button || kind = ToggleButton @@ -692,6 +779,7 @@ let common_property_supported kind property = || kind = ListItem || kind = TableCell || kind = BottomTab + || kind = FileImage || tree_row_kind kind | SubmitEnabled -> kind = Combobox || kind = ListItem | DoublePressEnabled -> kind = ListItem @@ -712,7 +800,17 @@ 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 | ThemeValue | ThemeMode -> can_contain_children kind | TextValue -> (match kind with @@ -743,7 +841,9 @@ let common_property_supported kind property = | TableCell | Alert | Bubble - | StatusBar -> true + | NumberStepper + | StatusBar + | Link -> true | _ -> false) | Enabled -> (match kind with @@ -759,15 +859,21 @@ let common_property_supported kind property = | Toggle | Radio | Slider + | NumberStepper | Select | Combobox | MenuItem | ListItem | Drawer - | BottomTab -> true + | BottomTab + | Link -> true | _ -> false) - | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector -> - false + | PathValue -> kind = FileImage || kind = FilePreview + | UrlValue -> kind = Link + | MaxPixelSize -> kind = FileImage + | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector + | PickerRequest | PickerTypes | PickerMultiple | PickerSource + | PickerCompletion -> false | TitleValue -> kind = BottomTab | Gap -> kind = Row @@ -783,10 +889,16 @@ let common_property_supported kind property = || kind = Panel || kind = Box || kind = Split + || kind = EdgeInset || horizontal_container kind + | Visible -> kind = EdgeInset let property_supported kind property = if property = AccessibilityIdentifier then true + (* Position hint honored on [overlay] children and on [overlay] itself; + admitted before the restrictive arms so e.g. an aligned [menu_trigger] + child still carries it. Inert elsewhere, like [container-relative-frame]. *) + else if property = AlignmentValue then kind <> Root else (* The restrictive arms below mirror schema/components.json kindProperties; test/property_matrix asserts the two stay in sync. *) @@ -811,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 @@ -827,8 +944,22 @@ 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 | _ -> common_property_supported kind property let is_finite value = @@ -917,6 +1048,35 @@ 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 + | Detents, StringValue _ -> true + | Sizing, StringValue value -> sizing_supported value + | PathValue, StringValue _ | UrlValue, StringValue _ -> true + | MaxPixelSize, IntValue value -> value > 0 + | Visible, BoolValue _ -> true + | AlignmentValue, StringValue value -> + alignment_supported value | _ -> false let property_value_supported_for_kind kind property value = @@ -994,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 @@ -1039,8 +1203,14 @@ let node_properties_supported kind properties = || (source_x >= 0.0 && source_y >= 0.0 && source_width > 0.0 && source_height > 0.0)) else true) + && (if kind = FileImage || kind = FilePreview then + string_property_nonempty properties PathValue + else true) + && (if kind = Link then string_property_nonempty properties UrlValue + else true) && (if kind = MediaSurface then Property_map.mem SurfaceIdValue properties else true) + && (if kind = EdgeInset then Property_map.mem EdgeValue properties else true) && (if kind = Stepper then Property_map.mem ActiveIndex properties else true) && (if kind = Step || kind = TimelineItem || kind = BottomTabs then let property = @@ -1059,6 +1229,21 @@ let node_properties_supported kind properties = | Some (FloatValue _) -> true | _ -> false else true) + && (if kind = NumberStepper then + (let text = string_property_or properties TextValue "" in + let label = string_property_or properties AccessibilityLabel "" in + text <> "" || label <> "") + && + (match Property_map.find_opt ProgressValue properties with + | Some (FloatValue value) -> is_finite value + | _ -> false) + && + (* min defaults to 0.0 and max is unbounded; compare endpoints after + those defaults so a lone negative max still fails. *) + let minimum = float_property properties MinValue 0.0 in + let maximum = float_property properties MaxValue Float.max_float in + minimum <= maximum + else true) && (if kind = Tree || kind = Toolbar then string_property_nonempty properties AccessibilityLabel else true) @@ -1077,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) @@ -1089,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 7cb03118..bf8b7e05 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -7,6 +7,9 @@ type node_kind = | Column | Grid | Stack + | EdgeInset + | Overlay + | ViewThatFits | Panel | Card | Alert @@ -22,6 +25,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -74,6 +78,15 @@ type node_kind = | Toast | Toolbar | StatusBar + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions + | SwipeAction + | FilePicker + | Link + | FileImage + | FilePreview type operating_system = | GenericOS @@ -171,6 +184,30 @@ type property = | 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 module Property_map : Map.S with type key = property @@ -194,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 d94e64e2..9a159a32 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -91,6 +91,9 @@ let row context = create context Row let column context = create context Column let grid context = create context Grid let stack context = create context Stack +let edge_inset context = create context EdgeInset +let overlay context = create context Overlay +let view_that_fits context = create context ViewThatFits let panel context = create context Panel let card context = create context Card let alert context = create context Alert @@ -133,15 +136,24 @@ 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 +let link context = create context Link +let file_image context = create context FileImage +let file_preview context = create context FilePreview let stepper context = create context Stepper let step context = create context Step 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 @@ -308,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 72e11fcf..4fecabf5 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -47,6 +47,9 @@ val row : ui_context -> int val column : ui_context -> int val grid : ui_context -> int val stack : ui_context -> int +val edge_inset : ui_context -> int +val overlay : ui_context -> int +val view_that_fits : ui_context -> int val panel : ui_context -> int val card : ui_context -> int val alert : ui_context -> int @@ -89,15 +92,24 @@ 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 +val link : ui_context -> int +val file_image : ui_context -> int +val file_preview : ui_context -> int val stepper : ui_context -> int val step : ui_context -> int 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 @@ -150,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 8185329b..36148614 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -10,6 +10,9 @@ let node_kind_name kind = | Column -> "column" | Grid -> "grid" | Stack -> "stack" + | EdgeInset -> "edge-inset" + | Overlay -> "overlay" + | ViewThatFits -> "view-that-fits" | Panel -> "panel" | Card -> "card" | Alert -> "alert" @@ -25,6 +28,7 @@ let node_kind_name kind = | RadioGroup -> "radio-group" | Radio -> "radio" | Slider -> "slider" + | NumberStepper -> "number-stepper" | TextField -> "text-field" | SecureField -> "secure-field" | Input -> "input" @@ -77,6 +81,15 @@ 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" let standard_node_name name = match name with @@ -85,6 +98,9 @@ let standard_node_name name = | "column" -> true | "grid" -> true | "stack" -> true + | "edge-inset" -> true + | "overlay" -> true + | "view-that-fits" -> true | "panel" -> true | "card" -> true | "alert" -> true @@ -100,6 +116,7 @@ let standard_node_name name = | "radio-group" -> true | "radio" -> true | "slider" -> true + | "number-stepper" -> true | "text-field" -> true | "secure-field" -> true | "input" -> true @@ -152,6 +169,15 @@ 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 | _ -> false let property_name property = @@ -229,6 +255,30 @@ 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" + | Detents -> "detents" + | Sizing -> "sizing" + | PathValue -> "path" + | UrlValue -> "url" + | MaxPixelSize -> "max-pixel-size" + | Visible -> "visible" + | AlignmentValue -> "alignment" let kind_property_matrix kind = match kind with @@ -244,6 +294,11 @@ 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 let kind_extra_properties kind = @@ -251,6 +306,6 @@ let kind_extra_properties kind = | Dialog -> [ DescriptionValue ] | _ -> [] -let all_node_kinds = [ Root; Row; Column; Grid; Stack; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; 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 ] +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 ] +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 1cb50bb2..d34bf66a 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,379 @@ 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 + (can_contain_children Link); + Alcotest.(check bool) "file-image is leaf" false + (can_contain_children FileImage); + Alcotest.(check bool) "file-preview is leaf" false + (can_contain_children FilePreview); + Alcotest.(check bool) "link allows url" true + (property_supported Link UrlValue); + Alcotest.(check bool) "link allows text" true + (property_supported Link TextValue); + Alcotest.(check bool) "link allows icon" true + (property_supported Link InlineIconName); + Alcotest.(check bool) "link drops path" false + (property_supported Link PathValue); + Alcotest.(check bool) "file-image allows path" true + (property_supported FileImage PathValue); + Alcotest.(check bool) "file-image allows max-pixel-size" true + (property_supported FileImage MaxPixelSize); + Alcotest.(check bool) "file-image allows press" true + (property_supported FileImage PressEnabled); + Alcotest.(check bool) "file-image drops url" false + (property_supported FileImage UrlValue); + Alcotest.(check bool) "file-preview allows path" true + (property_supported FilePreview PathValue); + Alcotest.(check bool) "file-preview drops width" false + (property_supported FilePreview WidthValue); + Alcotest.(check bool) "file-preview keeps accessibility-identifier" true + (property_supported FilePreview AccessibilityIdentifier); + Alcotest.(check bool) "max-pixel-size positive" true + (property_value_supported MaxPixelSize (IntValue 1024)); + Alcotest.(check bool) "max-pixel-size rejects zero" false + (property_value_supported MaxPixelSize (IntValue 0)); + Alcotest.(check bool) "path accepts string" true + (property_value_supported PathValue (StringValue "/x.png")); + Alcotest.(check bool) "url accepts string" true + (property_value_supported UrlValue (StringValue "https://e.com")); + Alcotest.(check bool) "file-image press event" true + (event_supported FileImage (Press 0)); + Alcotest.(check bool) "file-preview dismiss event" true + (event_supported FilePreview (Dismiss 0)); + Alcotest.(check bool) "link drops dismiss" false + (event_supported Link (Dismiss 0)); + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "file-image needs path" false + (node_properties_supported FileImage (props [])); + Alcotest.(check bool) "file-image ok" true + (node_properties_supported FileImage + (props [ (PathValue, StringValue "/x.png") ])); + Alcotest.(check bool) "file-preview needs path" false + (node_properties_supported FilePreview (props [])); + Alcotest.(check bool) "link needs url" false + (node_properties_supported Link (props [])); + Alcotest.(check bool) "link ok" true + (node_properties_supported Link + (props [ (UrlValue, StringValue "https://e.com") ])) + +let media_view _context _model _send = + Lui_elements.column + [ + Lui_elements.link ~url:"https://example.com" ~text:"Example" []; + Lui_elements.file_image ~path:"/tmp/pic.png" ~max_pixel_size:512 []; + Lui_elements.file_preview ~path:"/tmp/doc.pdf" []; + ] + +let test_media_file_mount () = + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + media_view + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + let open Lui_protocol in + let creates kind = + List.exists (function CreateNode (_, k) -> k = kind | _ -> false) ops + in + Alcotest.(check bool) "link mounted" true (creates Link); + Alcotest.(check bool) "file-image mounted" true (creates FileImage); + Alcotest.(check bool) "file-preview mounted" true (creates FilePreview); + Alcotest.(check bool) "url prop set" true + (List.exists + (function + | SetProp (_, UrlValue, StringValue "https://example.com") -> true + | _ -> false) + ops); + Alcotest.(check bool) "path prop set" true + (List.exists + (function + | SetProp (_, PathValue, StringValue "/tmp/pic.png") -> true + | _ -> false) + ops); + Alcotest.(check bool) "max-pixel-size prop set" true + (List.exists + (function + | SetProp (_, MaxPixelSize, IntValue 512) -> true + | _ -> false) + ops); + ignore (Lui_app.dispose app) + +let test_edge_overlay_fit_rules () = + let open Lui_protocol in + Alcotest.(check bool) "edge-inset contains children" true + (can_contain_children EdgeInset); + Alcotest.(check bool) "overlay contains children" true + (can_contain_children Overlay); + Alcotest.(check bool) "view-that-fits contains children" true + (can_contain_children ViewThatFits); + Alcotest.(check bool) "overlay accepts a list child" true + (child_kind_supported Overlay ListContainer); + Alcotest.(check bool) "overlay accepts a row child" true + (child_kind_supported Overlay Row); + Alcotest.(check bool) "overlay rejects root child" false + (child_kind_supported Overlay Root); + Alcotest.(check bool) "edge-inset holds edge" true + (property_supported EdgeInset EdgeValue); + Alcotest.(check bool) "edge-inset holds visible" true + (property_supported EdgeInset Visible); + Alcotest.(check bool) "edge-inset holds gap" true + (property_supported EdgeInset Gap); + Alcotest.(check bool) "edge-inset styles background" true + (property_supported EdgeInset BackgroundValue); + Alcotest.(check bool) "row drops edge" false + (property_supported Row EdgeValue); + Alcotest.(check bool) "row drops visible" false + (property_supported Row Visible); + Alcotest.(check bool) "view-that-fits holds orientation" true + (property_supported ViewThatFits OrientationValue); + Alcotest.(check bool) "view-that-fits drops edge" false + (property_supported ViewThatFits EdgeValue); + Alcotest.(check bool) "alignment lands on overlay" true + (property_supported Overlay AlignmentValue); + Alcotest.(check bool) "alignment lands on overlay children" true + (property_supported Text AlignmentValue); + (* Restrictive kinds still carry the hint so e.g. an aligned + menu-trigger overlay child validates. *) + Alcotest.(check bool) "alignment lands on restrictive kinds" true + (property_supported MenuTrigger AlignmentValue); + Alcotest.(check bool) "root drops alignment" false + (property_supported Root AlignmentValue); + Alcotest.(check bool) "edge-inset takes foreground" true + (property_supported EdgeInset ForegroundValue); + Alcotest.(check bool) "overlay takes foreground" true + (property_supported Overlay ForegroundValue); + Alcotest.(check bool) "view-that-fits takes foreground" true + (property_supported ViewThatFits ForegroundValue); + Alcotest.(check bool) "edge accepts top" true + (property_value_supported EdgeValue (StringValue "top")); + Alcotest.(check bool) "edge accepts leading" true + (property_value_supported EdgeValue (StringValue "leading")); + Alcotest.(check bool) "edge rejects side anchors" false + (property_value_supported EdgeValue (StringValue "left")); + Alcotest.(check bool) "visible takes a bool" true + (property_value_supported Visible (BoolValue false)); + Alcotest.(check bool) "alignment accepts corner" true + (property_value_supported AlignmentValue + (StringValue "bottom-trailing")); + Alcotest.(check bool) "alignment rejects anchor vocab" false + (property_value_supported AlignmentValue (StringValue "above")); + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "edge-inset needs edge" false + (node_properties_supported EdgeInset (props [])); + Alcotest.(check bool) "edge-inset with edge ok" true + (node_properties_supported EdgeInset + (props [ (EdgeValue, StringValue "bottom") ])) + let test_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -974,6 +1347,8 @@ let test_property_matrix_sync () = | Some allowed -> let expected = property = Lui_protocol.AccessibilityIdentifier + || (kind <> Lui_protocol.Root + && property = Lui_protocol.AlignmentValue) || List.mem property allowed in Alcotest.(check bool) @@ -992,6 +1367,167 @@ let test_property_matrix_sync () = Lui_wire_schema.all_properties) Lui_wire_schema.all_node_kinds +let test_number_stepper_props () = + let changes = ref [] in + let stepper_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 stepper_node + (Lui_elements.number_stepper ~value:5.0 ~min:0.0 ~max:3660.0 + ~step:1.0 ~text:"Days" + ~on_value_changed:(fun event -> + match event with + | Lui_protocol.ValueChanged (_, value) -> + changes := value :: !changes + | _ -> ()) + []) ]) + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + Alcotest.(check bool) "number-stepper created" true + (List.exists + (function + | Lui_protocol.CreateNode (_, Lui_protocol.NumberStepper) -> true + | _ -> false) + ops); + let emitted property value = + List.exists + (function + | Lui_protocol.SetProp (_, property', value') -> + property' = property && value' = value + | _ -> false) + ops + in + Alcotest.(check bool) "value" true + (emitted Lui_protocol.ProgressValue (Lui_protocol.FloatValue 5.0)); + Alcotest.(check bool) "min" true + (emitted Lui_protocol.MinValue (Lui_protocol.FloatValue 0.0)); + Alcotest.(check bool) "max" true + (emitted Lui_protocol.MaxValue (Lui_protocol.FloatValue 3660.0)); + Alcotest.(check bool) "step" true + (emitted Lui_protocol.StepValue (Lui_protocol.FloatValue 1.0)); + Alcotest.(check bool) "text" true + (emitted Lui_protocol.TextValue (Lui_protocol.StringValue "Days")); + ignore + (Lui_app.dispatch_event app + (Lui_protocol.ValueChanged (!stepper_node, 5.0))); + ignore + (Lui_app.dispatch_event app + (Lui_protocol.ValueChanged (!stepper_node, 6.0))); + flush_app app; + Alcotest.(check (list (float 0.0))) "echo dropped, change delivered" + [ 6.0 ] !changes; + ignore (Lui_app.dispose app) + +let test_number_stepper_schema () = + let open Lui_protocol in + List.iter + (fun (property, kinds) -> + List.iter + (fun kind -> + Alcotest.(check bool) + (Printf.sprintf "%s x %s" + (Lui_wire_schema.property_name property) + (Lui_wire_schema.node_kind_name kind)) + true (property_supported kind property)) + kinds) + [ (MinValue, [ NumberStepper ]); + (MaxValue, [ NumberStepper ]); + (StepValue, [ NumberStepper ]); + (Detents, [ Sheet ]); + (Sizing, [ Sheet ]) ]; + Alcotest.(check bool) "min off slider" false + (property_supported Slider MinValue); + Alcotest.(check bool) "detents off stepper" false + (property_supported NumberStepper Detents); + Alcotest.(check bool) "value on stepper" true + (property_supported NumberStepper ProgressValue); + Alcotest.(check bool) "enabled on stepper" true + (property_supported NumberStepper Enabled); + Alcotest.(check bool) "value-changed on stepper" true + (event_supported NumberStepper (ValueChanged (0, 1.0))); + Alcotest.(check bool) "value-changed off button" false + (event_supported Button (ValueChanged (0, 1.0))); + Alcotest.(check bool) "positive step" true + (property_value_supported StepValue (FloatValue 0.5)); + Alcotest.(check bool) "zero step rejected" false + (property_value_supported StepValue (FloatValue 0.0)); + Alcotest.(check bool) "finite min" true + (property_value_supported MinValue (FloatValue 1.0)); + Alcotest.(check bool) "sizing token" true + (property_value_supported Sizing (StringValue "form")); + Alcotest.(check bool) "unknown sizing rejected" false + (property_value_supported Sizing (StringValue "huge")); + Alcotest.(check bool) "detents is freeform" true + (property_value_supported Detents (StringValue "medium,0.4,large")); + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "stepper node ok" true + (node_properties_supported NumberStepper + (props [ (TextValue, StringValue "Days"); + (ProgressValue, FloatValue 1.0); + (MinValue, FloatValue 0.0); + (MaxValue, FloatValue 10.0) ])); + Alcotest.(check bool) "stepper needs value" false + (node_properties_supported NumberStepper + (props [ (TextValue, StringValue "Days") ])); + Alcotest.(check bool) "stepper needs label or text" false + (node_properties_supported NumberStepper + (props [ (ProgressValue, FloatValue 1.0) ])); + Alcotest.(check bool) "label suffices" true + (node_properties_supported NumberStepper + (props [ (AccessibilityLabel, StringValue "Days"); + (ProgressValue, FloatValue 1.0) ])); + Alcotest.(check bool) "min over max rejected" false + (node_properties_supported NumberStepper + (props [ (TextValue, StringValue "Days"); + (ProgressValue, FloatValue 1.0); + (MinValue, FloatValue 10.0); + (MaxValue, FloatValue 0.0) ])) + +let test_sheet_presentation_props () = + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + (fun _context _model_source _send -> + Lui_elements.column + [ Lui_elements.sheet ~text:"Settings" + ~detents:"medium,large" ~sizing:"form" [] ]) + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + Alcotest.(check bool) "sheet created" true + (List.exists + (function + | Lui_protocol.CreateNode (_, Lui_protocol.Sheet) -> true + | _ -> false) + ops); + Alcotest.(check bool) "detents emitted" true + (List.exists + (function + | Lui_protocol.SetProp (_, Lui_protocol.Detents, + Lui_protocol.StringValue "medium,large") -> + true + | _ -> false) + ops); + Alcotest.(check bool) "sizing emitted" true + (List.exists + (function + | Lui_protocol.SetProp (_, Lui_protocol.Sizing, + Lui_protocol.StringValue "form") -> true + | _ -> false) + ops); + Alcotest.(check bool) "detents only on sheet" true + (Lui_protocol.property_supported Lui_protocol.Sheet Lui_protocol.Detents); + Alcotest.(check bool) "detents off dialog" false + (Lui_protocol.property_supported Lui_protocol.Dialog + Lui_protocol.Detents); + ignore (Lui_app.dispose app) + let test_theme_tokens_json () = Alcotest.(check string) "fixed + adaptive values" {|{"background":{"light":"#fff","dark":"#000"},"primary":"#7c3aed"}|} @@ -1483,6 +2019,13 @@ 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 + test_edge_overlay_fit_rules; Alcotest.test_case "property matrix sync" `Quick test_property_matrix_sync; ] ); @@ -1510,6 +2053,15 @@ let () = Alcotest.test_case "unset default echoes dropped" `Quick test_dispatch_drops_unset_default_echoes; ] ); + ( "number stepper + sheet sizing", + [ + Alcotest.test_case "props + value-changed" `Quick + test_number_stepper_props; + Alcotest.test_case "schema surface" `Quick + test_number_stepper_schema; + Alcotest.test_case "sheet detents + sizing" `Quick + test_sheet_presentation_props; + ] ); ( "theming", [ Alcotest.test_case "theme_tokens_json" `Quick