From e6cbe8c12cb817e4fc1f199fceb39c1a85852f7e Mon Sep 17 00:00:00 2001 From: zy C Date: Sun, 27 Sep 2026 23:56:58 -0700 Subject: [PATCH 01/17] schema + apple: edge-inset, overlay, view-that-fits layout primitives Adds three general-purpose container kinds so apps can compose pinned chrome, floating controls, and fit-or-fallback content without host-side hacks: - edge-inset (EdgeInset): pins children after the first to a screen edge via .safeAreaInset(edge:) while the first child scrolls beneath. New 'edge' prop (required; top|bottom|leading|trailing) picks the edge, new 'visible' bool animates the pinned region in/out, 'gap' maps to safeAreaInset spacing. Surface props style the pinned region (the node bypasses the container surface modifier); 'background' gains the 'bar' material name. - overlay (Overlay): children[0] is the base and sizes the view; later children float over it via .overlay(alignment:). New 'alignment' prop (9-point grid: top-leading..bottom-trailing) on each child picks its anchor, falling back to the overlay's own alignment (default center). Distinct from stack's popover anchors (above|below|left|right), which can't express inside-corner placement. - view-that-fits (ViewThatFits): renders ViewThatFits(in:), picking the first child that fits; reuses 'orientation' (horizontal|vertical, default horizontal) as the axis. Validation parity across OCaml/Swift/Dart/C++/C#: edge-inset requires 'edge', vocabularies are closed, 'alignment' is allowed on any non-root kind (position hint on overlay children; inert elsewhere). Flutter/Qt/WinUI approximate (Stack+Positioned, anchors, LUIGrid); web maps to grid-overlay CSS. OCaml constructors: Lui_elements.edge_inset, overlay, view_that_fits, plus align combinator. --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 166 +++++++++++++++++- .../LUIAppleBackend/LUIWireProtocol.swift | 33 +++- .../LUIAppleBackend/LUIWireSchema.swift | 6 + .../LUIBackendParityTests.swift | 97 ++++++++++ platform/flutter/lib/lui_flutter_backend.dart | 101 ++++++++++- platform/flutter/lib/lui_wire_schema.g.dart | 6 + platform/qt/lib/CMakeLists.txt | 3 + platform/qt/lib/lui_schema.cpp | 34 +++- platform/qt/lib/lui_wire_schema.h | 24 +++ platform/qt/qml/LuiEdgeInset.qml | 56 ++++++ platform/qt/qml/LuiOverlay.qml | 75 ++++++++ platform/qt/qml/LuiViewThatFits.qml | 26 +++ platform/web/melange/nodes/lui_web_nodes.ml | 3 + platform/web/src/lui.css | 19 ++ platform/winui/LUI.Core/LUIBackend.cs | 8 + platform/winui/LUI.Core/LUISchema.cs | 40 ++++- platform/winui/LUI.Core/LUIWireSchema.g.cs | 21 +++ platform/winui/LUI.WinUI/LUIElementFactory.cs | 6 + schema/components.json | 48 +++++ src/lui_elements.ml | 60 +++++++ src/lui_elements.mli | 122 +++++++++++++ src/lui_protocol.ml | 30 +++- src/lui_protocol.mli | 6 + src/lui_ui.ml | 3 + src/lui_ui.mli | 3 + src/lui_wire_schema.ml | 13 +- test/test_lui.ml | 58 ++++++ 27 files changed, 1054 insertions(+), 13 deletions(-) create mode 100644 platform/qt/qml/LuiEdgeInset.qml create mode 100644 platform/qt/qml/LuiOverlay.qml create mode 100644 platform/qt/qml/LuiViewThatFits.qml diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c8..8a5fee0c 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -462,7 +462,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 } } @@ -619,6 +621,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: @@ -1687,6 +1695,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 @@ -5739,7 +5897,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.glassEffect(.regular.interactive(), in: glassShape) } else { diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..fbe83338 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -210,6 +210,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 +240,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", @@ -745,7 +763,7 @@ struct LUIRetainedTree { 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: @@ -768,7 +786,9 @@ struct LUIRetainedTree { kind == .checkbox || kind == .switchControl || kind == .toggle || kind == .radio case .progressValue: kind == .progress || kind == .slider || 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 || @@ -817,6 +837,10 @@ struct LUIRetainedTree { case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) case .active, .title, .description, .meta, .indicator, .connector: false + case .edge, .visible: kind == .edgeInset + // Per-child position hint for `overlay` content (and the overlay's + // own default); inert on other kinds, like container-relative-frame. + case .alignment: true } } @@ -827,6 +851,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) @@ -841,6 +866,7 @@ struct LUIRetainedTree { 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 || @@ -900,6 +926,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 ?? "" diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e5505..c7696226 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" @@ -150,6 +153,9 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case edge = "edge" + case visible = "visible" + case alignment = "alignment" } enum LUISchemaMatrix { diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index 1fc6a1f5..cd110d6c 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift @@ -636,4 +636,101 @@ 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) + } } diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3a..c6356862 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1949,6 +1949,70 @@ final class LUIFlutterBackend { ); } + static Alignment? _overlayAlignment(String? name) => switch (name) { + 'top-leading' => Alignment.topLeft, + 'top' => Alignment.topCenter, + 'top-trailing' => Alignment.topRight, + 'leading' => Alignment.centerLeft, + 'trailing' => Alignment.centerRight, + 'bottom-leading' => Alignment.bottomLeft, + 'bottom' => Alignment.bottomCenter, + 'bottom-trailing' => Alignment.bottomRight, + _ => null, + }; + + /// Pins the children after the first to `edge` while the first child + /// fills the view beneath them. + Widget edgeInset() { + final visible = state.properties['visible'] as bool? ?? true; + final alignment = switch (state.properties['edge'] as String? ?? 'top') { + 'bottom' => Alignment.bottomCenter, + 'leading' => Alignment.centerLeft, + 'trailing' => Alignment.centerRight, + _ => Alignment.topCenter, + }; + return Stack( + children: [ + if (children.isNotEmpty) children.first, + if (visible) + for (final pinned in children.skip(1)) + Positioned.fill( + child: Align(alignment: alignment, child: 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?) ?? + Alignment.center; + return Stack( + children: [ + if (children.isNotEmpty) children.first, + for (var index = 1; index < children.length; index += 1) + Positioned.fill( + child: Align( + alignment: + _overlayAlignment( + _states[state.children[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 ?? ''; @@ -2681,6 +2745,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), @@ -3674,7 +3741,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) || @@ -3765,6 +3833,26 @@ 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, + // Per-child position hint for overlay content (and the overlay's own + // default); inert on other kinds, like container-relative-frame. + 'alignment' => + value is String && + const { + 'top-leading', + 'top', + 'top-trailing', + 'leading', + 'center', + 'trailing', + 'bottom-leading', + 'bottom', + 'bottom-trailing', + }.contains(value), 'tooltip-delay' => value is int && value >= 0 && @@ -3782,6 +3870,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, @@ -3925,6 +4014,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? ?? ''; @@ -4341,6 +4434,9 @@ final class LUIFlutterBackend { kind == _NodeKind.bottomTabs || kind == _NodeKind.bottomTab || _isContextMenuLeafHost(kind) || + kind == _NodeKind.edgeInset || + kind == _NodeKind.overlay || + kind == _NodeKind.viewThatFits || kind.isModalSurface; static bool _acceptsExtensionChildren(_NodeKind kind) => @@ -4349,6 +4445,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 || diff --git a/platform/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cff..c3123a32 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, @@ -86,6 +89,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, diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 9ca673f2..bff6a20b 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -58,6 +58,7 @@ qt_add_qml_module(lui_qml ../qml/LuiDivider.qml ../qml/LuiDrawer.qml ../qml/LuiDropdownMenu.qml + ../qml/LuiEdgeInset.qml ../qml/LuiGrid.qml ../qml/LuiHeading.qml ../qml/LuiIcon.qml @@ -71,6 +72,7 @@ qt_add_qml_module(lui_qml ../qml/LuiMediaSurface.qml ../qml/LuiMenuItem.qml ../qml/LuiNodeView.qml + ../qml/LuiOverlay.qml ../qml/LuiPagination.qml ../qml/LuiPanel.qml ../qml/LuiParagraph.qml @@ -115,6 +117,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_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c0..3ed10579 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -179,7 +179,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, @@ -196,7 +197,8 @@ bool canContainChildren(NodeKind kind) { 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, @@ -467,7 +469,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { {NodeKind::Progress, NodeKind::Slider, 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, @@ -585,8 +588,16 @@ bool commonPropertySupported(NodeKind kind, Property property) { 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; + // Per-child position hint for overlay content (and the overlay's own + // default); inert on other kinds, like container-relative-frame. + case Property::AlignmentValue: + return kind != NodeKind::Root; } return false; } @@ -803,6 +814,17 @@ bool propertyValueSupported(Property property, const QVariant &value) { case Property::ThemeMode: return isString(value) && inSet(value.toString(), {"system", "light", "dark"}); + 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 +855,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 = diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fd11ca9b..e25fb8ba 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, @@ -154,6 +157,9 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + EdgeValue, + Visible, + AlignmentValue, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -163,6 +169,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"; @@ -240,6 +249,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; } @@ -322,6 +334,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: @@ -372,6 +387,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"; @@ -518,6 +536,9 @@ 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::EdgeValue: return "edge"; + case Property::Visible: return "visible"; + case Property::AlignmentValue: return "alignment"; } return "unknown"; } @@ -596,6 +617,9 @@ 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, "edge") == 0) { *property = Property::EdgeValue; return true; } + if (std::strcmp(name, "visible") == 0) { *property = Property::Visible; return true; } + if (std::strcmp(name, "alignment") == 0) { *property = Property::AlignmentValue; return true; } return false; } diff --git a/platform/qt/qml/LuiEdgeInset.qml b/platform/qt/qml/LuiEdgeInset.qml new file mode 100644 index 00000000..926aa40d --- /dev/null +++ b/platform/qt/qml/LuiEdgeInset.qml @@ -0,0 +1,56 @@ +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 children in place; `gap` is the spacing between content and the +// pinned children (here: pinned children simply overlay at the 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" + + implicitWidth: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 + implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 + + Repeater { + id: rep + model: edgeInset.node ? edgeInset.node.children : [] + delegate: LuiNodeView { + required property var modelData + required property int index + node: modelData + visible: index === 0 || edgeInset.pinnedVisible + } + onItemAdded: function(index, item) { + if (index === 0) { + item.anchors.fill = edgeInset + return + } + switch (edgeInset.edge) { + case "bottom": + item.anchors.bottom = edgeInset.bottom + item.anchors.left = edgeInset.left + item.anchors.right = edgeInset.right + break + case "leading": + item.anchors.left = edgeInset.left + item.anchors.top = edgeInset.top + item.anchors.bottom = edgeInset.bottom + break + case "trailing": + item.anchors.right = edgeInset.right + item.anchors.top = edgeInset.top + item.anchors.bottom = edgeInset.bottom + break + default: + item.anchors.top = edgeInset.top + item.anchors.left = edgeInset.left + item.anchors.right = edgeInset.right + } + } + } +} diff --git a/platform/qt/qml/LuiOverlay.qml b/platform/qt/qml/LuiOverlay.qml new file mode 100644 index 00000000..66c37346 --- /dev/null +++ b/platform/qt/qml/LuiOverlay.qml @@ -0,0 +1,75 @@ +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 + + function alignmentOf(item) { + var childProps = item && item.node ? item.node.properties : null + return (childProps && childProps.alignment) || props.alignment || + "center" + } + + function anchorOverlay(item) { + switch (alignmentOf(item)) { + case "top-leading": + item.anchors.top = overlay.top + item.anchors.left = overlay.left + break + case "top": + item.anchors.top = overlay.top + item.anchors.horizontalCenter = overlay.horizontalCenter + break + case "top-trailing": + item.anchors.top = overlay.top + item.anchors.right = overlay.right + break + case "leading": + item.anchors.left = overlay.left + item.anchors.verticalCenter = overlay.verticalCenter + break + case "trailing": + item.anchors.right = overlay.right + item.anchors.verticalCenter = overlay.verticalCenter + break + case "bottom-leading": + item.anchors.bottom = overlay.bottom + item.anchors.left = overlay.left + break + case "bottom": + item.anchors.bottom = overlay.bottom + item.anchors.horizontalCenter = overlay.horizontalCenter + break + case "bottom-trailing": + item.anchors.bottom = overlay.bottom + item.anchors.right = overlay.right + break + default: + item.anchors.centerIn = overlay + } + } + + Repeater { + id: rep + model: overlay.node ? overlay.node.children : [] + delegate: LuiNodeView { + required property var modelData + required property int index + node: modelData + } + onItemAdded: function(index, item) { + if (index === 0) { + item.anchors.fill = overlay + } else { + overlay.anchorOverlay(item) + } + } + } +} 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/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..a4d09fef 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" diff --git a/platform/web/src/lui.css b/platform/web/src/lui.css index ecb4bf85..2d888671 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -397,6 +397,25 @@ body { @apply relative; } + /* edge-inset/overlay/view-that-fits: the web backend approximates the + platform semantics — all children share one grid cell (stacked over the + first child); view-that-fits renders only its first child. */ + .lui-edge-inset, + .lui-overlay, + .lui-view-that-fits { + @apply relative grid; + } + + .lui-edge-inset > *, + .lui-overlay > *, + .lui-view-that-fits > * { + grid-area: 1 / 1; + } + + .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; } diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b2..9933b83e 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -682,6 +682,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 +740,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); diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 69449b55..ef0d7c7d 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -547,7 +547,8 @@ public static bool CommonPropertySupported( 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: @@ -659,6 +660,13 @@ public static bool CommonPropertySupported( case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); + case LUIProperty.EdgeValue: + case LUIProperty.Visible: + return kind == LUINodeKind.EdgeInset; + // Per-child position hint for overlay content (and the + // overlay's own default); inert elsewhere. + case LUIProperty.AlignmentValue: + return kind != LUINodeKind.Root; case LUIProperty.TextValue: switch (kind) { @@ -741,6 +749,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Panel || kind == LUINodeKind.Box || kind == LUINodeKind.Split || + kind == LUINodeKind.EdgeInset || HorizontalContainer(kind); default: return false; @@ -1089,6 +1098,27 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + 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 +1217,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) { @@ -1381,6 +1416,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: diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 513775f4..a99c386f 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, @@ -157,6 +160,9 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + EdgeValue, + Visible, + AlignmentValue, } public static class LUIWireSchema @@ -169,6 +175,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 }, @@ -314,6 +323,9 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "edge", LUIProperty.EdgeValue }, + { "visible", LUIProperty.Visible }, + { "alignment", LUIProperty.AlignmentValue }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -323,6 +335,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", @@ -406,6 +421,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 @@ -520,6 +538,9 @@ LUINodeKind.Toast or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.EdgeValue => "edge", + LUIProperty.Visible => "visible", + LUIProperty.AlignmentValue => "alignment", _ => "unknown", }; diff --git a/platform/winui/LUI.WinUI/LUIElementFactory.cs b/platform/winui/LUI.WinUI/LUIElementFactory.cs index 5e2e3d50..8c153a66 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: diff --git a/schema/components.json b/schema/components.json index 5890108b..21c5a03b 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" @@ -330,6 +342,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", @@ -1165,6 +1198,21 @@ "lg": "ThemeMode", "wire": "theme-mode", "swift": "themeMode" + }, + { + "lg": "EdgeValue", + "wire": "edge", + "swift": "edge" + }, + { + "lg": "Visible", + "wire": "visible", + "swift": "visible" + }, + { + "lg": "AlignmentValue", + "wire": "alignment", + "swift": "alignment" } ], "events": [ diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..2541cd62 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 @@ -111,6 +117,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" @@ -376,6 +399,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 diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1c..72d37679 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -27,6 +27,15 @@ 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 ] @@ -331,6 +340,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 -> diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index ebdfeea7..097bc6c5 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 @@ -173,6 +176,9 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | EdgeValue + | Visible + | AlignmentValue module Property_map = Map.Make @@ -526,6 +532,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 +547,9 @@ let can_contain_children kind = | Column | Grid | Stack + | EdgeInset + | Overlay + | ViewThatFits | Panel | Card | Box @@ -644,7 +659,8 @@ let common_property_supported kind property = | Checked -> kind = Checkbox || kind = SwitchControl || kind = Toggle || kind = Radio | ProgressValue -> kind = Progress || kind = Slider || kind = Split - | OrientationValue -> kind = Divider || kind = Tabs || kind = Scroll + | OrientationValue -> + kind = Divider || kind = Tabs || kind = Scroll || kind = ViewThatFits | PlacementValue -> kind = Toolbar | SizeValue -> kind = Button || kind = ToggleButton || kind = Spinner || kind = Icon @@ -783,7 +799,12 @@ let common_property_supported kind property = || kind = Panel || kind = Box || kind = Split + || kind = EdgeInset || horizontal_container kind + | EdgeValue | Visible -> kind = EdgeInset + (* Honored on [overlay] children (position hint) and on [overlay] itself + as the default; inert elsewhere, like [container-relative-frame]. *) + | AlignmentValue -> kind <> Root let property_supported kind property = if property = AccessibilityIdentifier then true @@ -917,6 +938,12 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_supported value + | EdgeValue, StringValue value -> + value = "top" || value = "bottom" || value = "leading" + || value = "trailing" + | Visible, BoolValue _ -> true + | AlignmentValue, StringValue value -> + alignment_supported value | _ -> false let property_value_supported_for_kind kind property value = @@ -1041,6 +1068,7 @@ let node_properties_supported kind properties = 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 = diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 7cb03118..38f27f41 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 @@ -171,6 +174,9 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | EdgeValue + | Visible + | AlignmentValue module Property_map : Map.S with type key = property diff --git a/src/lui_ui.ml b/src/lui_ui.ml index d94e64e2..88cf4641 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 diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 72e11fcf..6aa12467 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 diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329b..0d1272d9 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" @@ -85,6 +88,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 @@ -229,6 +235,9 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | EdgeValue -> "edge" + | Visible -> "visible" + | AlignmentValue -> "alignment" let kind_property_matrix kind = match kind with @@ -251,6 +260,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; 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_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; EdgeValue; Visible; AlignmentValue ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb2..d12cc4e3 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,62 @@ let test_menu_trigger_rules () = (node_properties_supported MenuTrigger (props [ (TextValue, StringValue "More") ])) +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); + Alcotest.(check bool) "root drops alignment" false + (property_supported Root AlignmentValue); + 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 @@ -1483,6 +1539,8 @@ let () = Alcotest.test_case "helpers" `Quick test_protocol_helpers; Alcotest.test_case "menu-trigger rules" `Quick test_menu_trigger_rules; + Alcotest.test_case "edge/overlay/fit rules" `Quick + test_edge_overlay_fit_rules; Alcotest.test_case "property matrix sync" `Quick test_property_matrix_sync; ] ); From bbf1db5220aedebaaf0ec4100e5d4d9ca519d758 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:04:34 -0700 Subject: [PATCH 02/17] media: file-image, file-preview (QuickLook), and link elements Three general-purpose element kinds sufficient to replace journal-media style per-app extensions: - file-image: loads a local path / file:// URL off the main actor via CGImageSource thumbnail decoding (NSCache keyed by path+pixel size, ~32MB cap), with loading spinner, failure icon fallback, and the standard press-enabled/Press path. - file-preview: a non-rendering node that presents QuickLook via .quickLookPreview. Presence in the tree means presented, matching the sheet/dialog 'mount to present' pattern but on its own presentation lane (not isModalSurface, which forces a text title and sheet chrome). Interactive close emits Dismiss and is suppressed from re-asserting until the wire drops the node, same as modal dismissal. - link: a container rendering SwiftUI Link(destination:) from a url prop; children (or text/icon props) form the label, so it composes inside list rows and overlays. New wire properties: path, url, max-pixel-size. file-preview is a restrictive kind (path + accessibility-identifier only). Flutter/Qt/ WinUI/web schema mirrors updated; Qt gains stub QML views; WinUI's restrictive matrix covers file-preview automatically. --- .../LUIAppleBackend/LUIAppleBackend.swift | 20 +- .../LUIAppleBackend/LUIFileImageLoader.swift | 81 +++++++ .../LUIAppleBackend/LUISwiftUIRoot.swift | 201 +++++++++++++++++- .../LUIAppleBackend/LUIWireProtocol.swift | 40 +++- .../LUIAppleBackend/LUIWireSchema.swift | 7 + .../LUIAppleBackendTests.swift | 145 +++++++++++++ platform/flutter/lib/lui_flutter_backend.dart | 42 +++- platform/flutter/lib/lui_wire_schema.g.dart | 6 + platform/qt/lib/CMakeLists.txt | 3 + platform/qt/lib/lui_schema.cpp | 47 +++- platform/qt/lib/lui_wire_schema.h | 22 ++ platform/qt/qml/LuiFileImage.qml | 35 +++ platform/qt/qml/LuiFilePreview.qml | 11 + platform/qt/qml/LuiLink.qml | 50 +++++ platform/web/melange/nodes/lui_web_nodes.ml | 5 + platform/winui/LUI.Core/LUISchema.cs | 43 +++- platform/winui/LUI.Core/LUIWireSchema.g.cs | 22 +- schema/components.json | 51 +++++ src/lui_elements.ml | 50 +++++ src/lui_elements.mli | 79 +++++++ src/lui_protocol.ml | 40 +++- src/lui_protocol.mli | 6 + src/lui_ui.ml | 3 + src/lui_ui.mli | 3 + src/lui_wire_schema.ml | 14 +- test/test_lui.ml | 104 +++++++++ 26 files changed, 1091 insertions(+), 39 deletions(-) create mode 100644 platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift create mode 100644 platform/qt/qml/LuiFileImage.qml create mode 100644 platform/qt/qml/LuiFilePreview.qml create mode 100644 platform/qt/qml/LuiLink.qml diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 22fe3d89..38bbd86f 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -440,6 +440,7 @@ public final class LUIAppleBackend { private let extensionRegistry: LUIAppleExtensionRegistry let tooltipSession = LUITooltipSession() let modalPresentation = LUIModalPresentationStore() + let filePreviewPresentation = LUIFilePreviewStore() public init( appIcons: [String: LUIAppleIconSource] = [:], @@ -644,6 +645,7 @@ public final class LUIAppleBackend { model.kind == .combobox || model.kind == .menuItem || model.kind == .listItem || (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") @@ -761,7 +763,8 @@ public final class LUIAppleBackend { 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 else { throw invalid("node \(node) is not dismissible") } emit(.dismiss(node: node)) @@ -774,7 +777,8 @@ public final class LUIAppleBackend { return } switch model.kind { - case .button, .select, .combobox, .menuItem, .listItem, .timelineItem: + case .button, .select, .combobox, .menuItem, .listItem, .timelineItem, + .fileImage: try performPress(node: node) case .toggleButton: try performToggle(node: node, checked: !model.isSelected) @@ -952,11 +956,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 +1009,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/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/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c8..37fcb19d 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,18 @@ public struct LUISwiftUIRoot: View { } try? backend.performDismiss(node: nodeID) } + + private var filePreviewBinding: Binding { + Binding( + get: { backend.filePreviewPresentation.item?.url }, + set: { url in + guard url == nil, + 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 +185,22 @@ 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: { backend.filePreviewPresentation.item?.url }, + set: { url in + guard url == nil, + let nodeID = backend.filePreviewPresentation + .dismissFromPresentation() else { return } + try? backend.performDismiss(node: nodeID) + } + ) + } + private var sheetBinding: Binding { Binding( get: { @@ -548,7 +606,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 @@ -710,6 +768,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: @@ -2927,6 +2991,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 diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..5602a901 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -186,6 +186,11 @@ 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) @@ -331,7 +336,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 +368,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 @@ -724,7 +731,8 @@ struct LUIRetainedTree { || 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,13 +741,13 @@ struct LUIRetainedTree { kind == .tooltip || kind == .tableCell || kind == .alert || kind == .bubble || kind == .statusBar || - isModalSurface(kind) || kind == .drawer + isModalSurface(kind) || kind == .drawer || kind == .link case .enabled: kind == .button || kind == .toggleButton || isTextEntry(kind) || kind == .checkbox || kind == .switchControl || kind == .toggle || kind == .radio || kind == .slider || 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 || @@ -761,7 +769,7 @@ struct LUIRetainedTree { kind == .mediaSurface || kind == .tree || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || - kind == .listItem || + kind == .listItem || kind == .link || kind == .fileImage || isTreeRow(kind) case .headingLevel: kind == .heading case .checked: @@ -779,12 +787,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 +806,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,6 +825,9 @@ struct LUIRetainedTree { kind == .tableCell || kind == .bubble || kind == .statusBar case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) + case .path: kind == .fileImage || kind == .filePreview + case .url: kind == .link + case .maxPixelSize: kind == .fileImage case .active, .title, .description, .meta, .indicator, .connector: false } } @@ -836,7 +848,7 @@ struct LUIRetainedTree { kind == .stepper || kind == .timeline || kind == .inputGroup || kind == .inputGroupActions || kind == .toast || kind == .toolbar || kind == .bottomTabs || kind == .bottomTab || - kind == .menuTrigger || isContextMenuLeafHost(kind) + kind == .menuTrigger || isContextMenuLeafHost(kind) || kind == .link } private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool { @@ -1116,6 +1128,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") } diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e5505..ab7c639e 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -74,6 +74,9 @@ enum LUINodeKind: String, Decodable, Equatable { case toast = "toast" case toolbar = "toolbar" case statusBar = "status-bar" + case link = "link" + case fileImage = "file-image" + case filePreview = "file-preview" } enum LUIProperty: String, Decodable, Hashable { @@ -150,6 +153,9 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case path = "path" + case url = "url" + case maxPixelSize = "max-pixel-size" } enum LUISchemaMatrix { @@ -166,6 +172,7 @@ 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], + .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..1aa07bb6 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -3792,6 +3792,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/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3a..1512540c 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1197,6 +1197,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( @@ -1478,6 +1480,7 @@ final class LUIFlutterBackend { state.kind != _NodeKind.combobox && state.kind != _NodeKind.dropdownMenu && state.kind != _NodeKind.toast && + state.kind != _NodeKind.filePreview && !state.kind.isModalSurface) { throw LUIBackendException('node $node is not dismissible'); } @@ -2899,6 +2902,8 @@ final class LUIFlutterBackend { color: foreground, ), _NodeKind.statusBar => statusBar(), + _NodeKind.link => column(), + _NodeKind.fileImage || _NodeKind.filePreview => const SizedBox.shrink(), }; if (state.kind == _NodeKind.root || state.kind.isModalSurface) { @@ -3473,6 +3478,9 @@ final class LUIFlutterBackend { _themeModes.contains(value)); } if (kind == _NodeKind.contextMenu) return false; + if (kind == _NodeKind.filePreview) { + return property == 'path' && value is String; + } if (kind == _NodeKind.accordion) { return switch (property) { 'text' => value is String, @@ -3636,6 +3644,7 @@ final class LUIFlutterBackend { kind == _NodeKind.bubble || kind == _NodeKind.statusBar || kind == _NodeKind.drawer || + kind == _NodeKind.link || kind.isModalSurface), 'enabled' => value is bool && @@ -3649,7 +3658,8 @@ final class LUIFlutterBackend { kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.listItem || - kind == _NodeKind.drawer), + kind == _NodeKind.drawer || + kind == _NodeKind.link), 'value' => value is double && value.isFinite && @@ -3703,11 +3713,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 +3749,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 +3765,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) && @@ -3821,7 +3837,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 && @@ -3891,6 +3909,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 && @@ -4194,6 +4214,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,6 +4371,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toolbar || kind == _NodeKind.bottomTabs || kind == _NodeKind.bottomTab || + kind == _NodeKind.link || _isContextMenuLeafHost(kind) || kind.isModalSurface; diff --git a/platform/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cff..29d6549a 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -74,6 +74,9 @@ enum _NodeKind { toast, toolbar, statusBar, + link, + fileImage, + filePreview, } _NodeKind _decodeNodeKind(Object? value) { @@ -153,6 +156,9 @@ _NodeKind _decodeNodeKind(Object? value) { 'toast' => _NodeKind.toast, 'toolbar' => _NodeKind.toolbar, 'status-bar' => _NodeKind.statusBar, + 'link' => _NodeKind.link, + 'file-image' => _NodeKind.fileImage, + 'file-preview' => _NodeKind.filePreview, _ => throw const LUIBackendException('unknown node kind'), }; } diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 9ca673f2..90934179 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -58,6 +58,8 @@ qt_add_qml_module(lui_qml ../qml/LuiDivider.qml ../qml/LuiDrawer.qml ../qml/LuiDropdownMenu.qml + ../qml/LuiFileImage.qml + ../qml/LuiFilePreview.qml ../qml/LuiGrid.qml ../qml/LuiHeading.qml ../qml/LuiIcon.qml @@ -66,6 +68,7 @@ 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 diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c0..cce963c8 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -190,7 +190,7 @@ 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}); } bool acceptsExtensionChildren(NodeKind kind) { @@ -247,7 +247,7 @@ 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}); case Event::LongPress: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -273,7 +273,8 @@ bool eventSupported(NodeKind kind, Event event) { 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}); case Event::DoublePress: return kind == NodeKind::ListItem; case Event::Appear: @@ -423,7 +424,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { 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; @@ -448,7 +450,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { 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; @@ -481,11 +484,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 +514,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 +530,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: @@ -562,7 +571,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::Link}); case Property::Enabled: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -571,7 +581,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio, NodeKind::Slider, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, - NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab}); + NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab, + NodeKind::Link}); case Property::ActiveIndex: case Property::DescriptionValue: case Property::MetaValue: @@ -655,6 +666,8 @@ bool propertySupported(NodeKind kind, Property property) { case NodeKind::Dialog: return property == Property::DescriptionValue || commonPropertySupported(kind, property); + case NodeKind::FilePreview: + return property == Property::PathValue; default: return commonPropertySupported(kind, property); } @@ -750,10 +763,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); @@ -902,6 +921,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; diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fd11ca9b..2b4a20da 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -78,6 +78,9 @@ enum class NodeKind { Toast, Toolbar, StatusBar, + Link, + FileImage, + FilePreview, }; enum class Property { @@ -154,6 +157,9 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + PathValue, + UrlValue, + MaxPixelSize, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -230,6 +236,9 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::Toast: return "toast"; case NodeKind::Toolbar: return "toolbar"; case NodeKind::StatusBar: return "status-bar"; + case NodeKind::Link: return "link"; + case NodeKind::FileImage: return "file-image"; + case NodeKind::FilePreview: return "file-preview"; } return "unknown"; } @@ -307,6 +316,9 @@ 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, "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; } @@ -358,6 +370,7 @@ inline bool containerNodeKind(NodeKind kind) { case NodeKind::Sheet: case NodeKind::Toast: case NodeKind::Toolbar: + case NodeKind::Link: return true; default: return false; @@ -439,6 +452,9 @@ 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::Link: return "LuiLink.qml"; + case NodeKind::FileImage: return "LuiFileImage.qml"; + case NodeKind::FilePreview: return "LuiFilePreview.qml"; } return "LuiBox.qml"; } @@ -518,6 +534,9 @@ 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::PathValue: return "path"; + case Property::UrlValue: return "url"; + case Property::MaxPixelSize: return "max-pixel-size"; } return "unknown"; } @@ -596,6 +615,9 @@ 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, "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; } return false; } 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/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/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..6a2e6e79 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -86,6 +86,9 @@ let base_class_name kind = | Split -> "lui-split" | Drawer -> "lui-drawer" | StatusBar -> "lui-status-bar" + | Link -> "lui-link" + | FileImage -> "lui-file-image" + | FilePreview -> "lui-file-preview" let create_split_node renderer = let document = renderer.web_document in @@ -259,6 +262,7 @@ let simple_node_tag kind = | Slider -> "input" | Divider -> "hr" | Tooltip -> "span" + | Link -> "a" | _ -> "div" let simple_node_attributes kind = @@ -306,6 +310,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 = diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 69449b55..04de5cab 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -155,6 +155,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Text: case LUINodeKind.TableCell: case LUINodeKind.TimelineItem: + case LUINodeKind.FileImage: case LUINodeKind.BottomTab: return true; default: @@ -214,6 +215,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Dialog: case LUINodeKind.Drawer: case LUINodeKind.Sheet: + case LUINodeKind.FilePreview: return true; default: return false; @@ -479,6 +481,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; @@ -522,6 +526,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; @@ -570,11 +576,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 +622,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 +640,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: @@ -690,6 +706,7 @@ public static bool CommonPropertySupported( case LUINodeKind.Alert: case LUINodeKind.Bubble: case LUINodeKind.StatusBar: + case LUINodeKind.Link: return true; default: return false; @@ -715,6 +732,7 @@ public static bool CommonPropertySupported( case LUINodeKind.ListItem: case LUINodeKind.Drawer: case LUINodeKind.BottomTab: + case LUINodeKind.Link: return true; default: return false; @@ -1089,6 +1107,15 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + case LUIProperty.PathValue: + case LUIProperty.UrlValue: + { + return value is LUIWireValue.String; + } + case LUIProperty.MaxPixelSize: + { + return value is LUIWireValue.Int { Value: > 0 }; + } default: { return false; @@ -1292,6 +1319,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)) { @@ -1410,6 +1448,7 @@ public static bool CanContainChildren(LUINodeKind kind) case LUINodeKind.Bubble: case LUINodeKind.BottomTabs: case LUINodeKind.BottomTab: + case LUINodeKind.Link: return true; default: return false; diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 513775f4..cc7c97a4 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -80,6 +80,9 @@ public enum LUINodeKind Toast, Toolbar, StatusBar, + Link, + FileImage, + FilePreview, } public enum LUIProperty @@ -157,6 +160,9 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + PathValue, + UrlValue, + MaxPixelSize, } public static class LUIWireSchema @@ -236,6 +242,9 @@ public static class LUIWireSchema { "toast", LUINodeKind.Toast }, { "toolbar", LUINodeKind.Toolbar }, { "status-bar", LUINodeKind.StatusBar }, + { "link", LUINodeKind.Link }, + { "file-image", LUINodeKind.FileImage }, + { "file-preview", LUINodeKind.FilePreview }, }; private static readonly Dictionary PropertiesByWireName = @@ -314,6 +323,9 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "path", LUIProperty.PathValue }, + { "url", LUIProperty.UrlValue }, + { "max-pixel-size", LUIProperty.MaxPixelSize }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -390,6 +402,9 @@ public static class LUIWireSchema LUINodeKind.Toast => "toast", LUINodeKind.Toolbar => "toolbar", LUINodeKind.StatusBar => "status-bar", + LUINodeKind.Link => "link", + LUINodeKind.FileImage => "file-image", + LUINodeKind.FilePreview => "file-preview", _ => "unknown", }; @@ -441,7 +456,8 @@ LUINodeKind.Dialog or LUINodeKind.Drawer or LUINodeKind.Sheet or LUINodeKind.Toast or - LUINodeKind.Toolbar => true, + LUINodeKind.Toolbar or + LUINodeKind.Link => true, _ => false, }; @@ -520,6 +536,9 @@ LUINodeKind.Toast or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.PathValue => "path", + LUIProperty.UrlValue => "url", + LUIProperty.MaxPixelSize => "max-pixel-size", _ => "unknown", }; @@ -544,6 +563,7 @@ 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.FilePreview, Set(LUIProperty.PathValue) }, }; public static readonly IReadOnlyDictionary> ExtraMatrix = diff --git a/schema/components.json b/schema/components.json index 5890108b..6d833386 100644 --- a/schema/components.json +++ b/schema/components.json @@ -292,6 +292,18 @@ { "name": "input-group-actions", "status": "supported" + }, + { + "name": "link", + "status": "supported" + }, + { + "name": "file-image", + "status": "supported" + }, + { + "name": "file-preview", + "status": "supported" } ], "nodeKinds": [ @@ -798,6 +810,27 @@ "dart": "statusBar", "swift": "statusBar", "container": false + }, + { + "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 +1198,21 @@ "lg": "ThemeMode", "wire": "theme-mode", "swift": "themeMode" + }, + { + "lg": "PathValue", + "wire": "path", + "swift": "path" + }, + { + "lg": "UrlValue", + "wire": "url", + "swift": "url" + }, + { + "lg": "MaxPixelSize", + "wire": "max-pixel-size", + "swift": "maxPixelSize" } ], "events": [ @@ -1309,6 +1357,9 @@ "enabled", "foreground", "style-class" + ], + "file-preview": [ + "path" ] }, "kindExtraProperties": { diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..6c1217fa 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -1452,6 +1452,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..4a034dcf 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -2480,6 +2480,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_protocol.ml b/src/lui_protocol.ml index ebdfeea7..dc4fec05 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -76,6 +76,9 @@ type node_kind = | Toast | Toolbar | StatusBar + | Link + | FileImage + | FilePreview type operating_system = | GenericOS @@ -173,6 +176,9 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | PathValue + | UrlValue + | MaxPixelSize module Property_map = Map.Make @@ -305,6 +311,7 @@ let event_supported kind event = | Text | TableCell | TimelineItem + | FileImage | BottomTab -> true | _ -> false) | LongPress _ -> @@ -356,7 +363,8 @@ let event_supported kind event = | Toast | Dialog | Drawer - | Sheet -> true + | Sheet + | FilePreview -> true | _ -> false) | DoublePress _ -> kind = ListItem | Appear _ -> kind <> Root @@ -564,7 +572,8 @@ let can_contain_children kind = | Alert | Bubble | BottomTabs - | BottomTab -> true + | BottomTab + | Link -> true | _ -> false let common_property_supported kind property = @@ -596,7 +605,8 @@ let common_property_supported kind property = | 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 + | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar + | Link | FileImage -> true | _ -> false) | WidthValue | HeightValue -> kind <> Tooltip | MinWidth | MaxWidth | MinHeight | MaxHeight -> @@ -630,6 +640,8 @@ let common_property_supported kind property = || kind = Alert || kind = Bubble || kind = ListItem + || kind = Link + || kind = FileImage || tree_row_kind kind | AccessibilityIdentifier -> true | PlaceholderValue -> @@ -659,7 +671,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 +706,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 @@ -743,7 +758,8 @@ let common_property_supported kind property = | TableCell | Alert | Bubble - | StatusBar -> true + | StatusBar + | Link -> true | _ -> false) | Enabled -> (match kind with @@ -764,8 +780,12 @@ let common_property_supported kind property = | MenuItem | ListItem | Drawer - | BottomTab -> true + | BottomTab + | Link -> true | _ -> false) + | PathValue -> kind = FileImage || kind = FilePreview + | UrlValue -> kind = Link + | MaxPixelSize -> kind = FileImage | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector -> false | TitleValue -> kind = BottomTab @@ -829,6 +849,7 @@ let property_supported kind property = | InputGroupActions -> property = Gap | Dialog -> property = DescriptionValue || common_property_supported kind property + | FilePreview -> property = PathValue | _ -> common_property_supported kind property let is_finite value = @@ -917,6 +938,8 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_supported value + | PathValue, StringValue _ | UrlValue, StringValue _ -> true + | MaxPixelSize, IntValue value -> value > 0 | _ -> false let property_value_supported_for_kind kind property value = @@ -1039,6 +1062,11 @@ 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 = Stepper then Property_map.mem ActiveIndex properties else true) diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 7cb03118..bcb06470 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -74,6 +74,9 @@ type node_kind = | Toast | Toolbar | StatusBar + | Link + | FileImage + | FilePreview type operating_system = | GenericOS @@ -171,6 +174,9 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | PathValue + | UrlValue + | MaxPixelSize module Property_map : Map.S with type key = property diff --git a/src/lui_ui.ml b/src/lui_ui.ml index d94e64e2..e13c2c97 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -136,6 +136,9 @@ let list_item context = create context ListItem 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 diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 72e11fcf..17182d99 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -92,6 +92,9 @@ val list_item : 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 diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329b..b99a8af1 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -77,6 +77,9 @@ let node_kind_name kind = | Toast -> "toast" | Toolbar -> "toolbar" | StatusBar -> "status-bar" + | Link -> "link" + | FileImage -> "file-image" + | FilePreview -> "file-preview" let standard_node_name name = match name with @@ -152,6 +155,9 @@ let standard_node_name name = | "toast" -> true | "toolbar" -> true | "status-bar" -> true + | "link" -> true + | "file-image" -> true + | "file-preview" -> true | _ -> false let property_name property = @@ -229,6 +235,9 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | PathValue -> "path" + | UrlValue -> "url" + | MaxPixelSize -> "max-pixel-size" let kind_property_matrix kind = match kind with @@ -244,6 +253,7 @@ 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 ] + | FilePreview -> Some [ PathValue ] | _ -> None let kind_extra_properties kind = @@ -251,6 +261,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; 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; 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; PathValue; UrlValue; MaxPixelSize ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb2..7bc1f3f7 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,108 @@ let test_menu_trigger_rules () = (node_properties_supported MenuTrigger (props [ (TextValue, StringValue "More") ])) +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_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -1483,6 +1585,8 @@ let () = Alcotest.test_case "helpers" `Quick test_protocol_helpers; Alcotest.test_case "menu-trigger rules" `Quick test_menu_trigger_rules; + 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 "property matrix sync" `Quick test_property_matrix_sync; ] ); From e0e0e68737315e8e74ffbf39c3d615c831a894f4 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:07:57 -0700 Subject: [PATCH 03/17] elements: number-stepper kind + sheet detents/sizing props Adds a general-purpose numeric stepper element (kind number-stepper, props value/min/max/step/text/enabled, emits ValueChanged) so apps can express bounded numeric input with a step increment, and two presentation props on sheet: detents (comma-separated medium|large|fraction) and sizing (form|fitted|page). Apple backend: LUINumberStepperView renders a SwiftUI Stepper over a clamped range; sheets apply .presentationDetents and .presentationSizing (iOS 18+/macOS 15+ gated) via LUIModalPresentationPolicy on the shared modal surface, so navigation-form sheets compose. Integral wire floats (e.g. value:60.0) decode as Int through Foundation's JSONDecoder, so float-typed props now normalize int -> double like grow/sourceX already did. Web/melange renders an ; qt, flutter, and winui gain schema support and minimal renderers; winui coerces integral JSON numbers for float props the same way. --- .../LUIAppleBackend/LUIAppleBackend.swift | 22 ++- .../LUIAppleBackend/LUISwiftUIRoot.swift | 91 +++++++++- .../LUIAppleBackend/LUIWireProtocol.swift | 48 ++++- .../LUIAppleBackend/LUIWireSchema.swift | 6 + .../LUIAppleBackendTests.swift | 116 +++++++++++- platform/flutter/lib/lui_flutter_backend.dart | 80 ++++++++- platform/flutter/lib/lui_wire_schema.g.dart | 2 + platform/qt/lib/lui_qml_backend.cpp | 16 +- platform/qt/lib/lui_schema.cpp | 55 +++++- platform/qt/lib/lui_wire_schema.h | 19 ++ platform/web/melange/core/lui_web_store.ml | 3 + platform/web/melange/events/lui_web_events.ml | 2 +- platform/web/melange/nodes/lui_web_nodes.ml | 9 + platform/web/melange/render/lui_web_props.ml | 29 ++- platform/winui/LUI.Core/LUIBackend.cs | 34 +++- platform/winui/LUI.Core/LUISchema.cs | 68 ++++++- platform/winui/LUI.Core/LUIWireSchema.g.cs | 18 ++ platform/winui/LUI.WinUI/LUIElement.Sync.cs | 33 ++++ platform/winui/LUI.WinUI/LUIElementFactory.cs | 2 + schema/components.json | 36 ++++ src/lui_elements.ml | 27 ++- src/lui_elements.mli | 44 +++++ src/lui_json_view.ml | 5 +- src/lui_protocol.ml | 44 ++++- src/lui_protocol.mli | 6 + src/lui_wire_schema.ml | 11 +- test/test_lui.ml | 170 ++++++++++++++++++ 27 files changed, 955 insertions(+), 41 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 22fe3d89..340e1531 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -171,6 +171,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 } @@ -749,12 +759,20 @@ 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 { diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c8..b0ff8a7e 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -765,6 +765,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) @@ -2208,22 +2210,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 +2289,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, @@ -2949,6 +3007,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 diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..67c723e2 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -148,6 +148,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" @@ -720,7 +731,8 @@ struct LUIRetainedTree { 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 || @@ -733,11 +745,12 @@ struct LUIRetainedTree { kind == .tooltip || kind == .tableCell || kind == .alert || kind == .bubble || kind == .statusBar || - isModalSurface(kind) || kind == .drawer + isModalSurface(kind) || kind == .drawer || kind == .numberStepper 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 case .gap: @@ -761,12 +774,14 @@ struct LUIRetainedTree { kind == .mediaSurface || kind == .tree || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || - kind == .listItem || + kind == .listItem || kind == .numberStepper || 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 .placement: kind == .toolbar @@ -816,6 +831,8 @@ struct LUIRetainedTree { kind == .tableCell || kind == .bubble || kind == .statusBar case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) + case .minValue, .maxValue, .stepValue: kind == .numberStepper + case .detents, .sizing: kind == .sheet case .active, .title, .description, .meta, .indicator, .connector: false } } @@ -917,6 +934,23 @@ 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") + } + if case let .double(minimum)? = node.properties[.minValue], + case let .double(maximum)? = node.properties[.maxValue] { + 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") @@ -1218,7 +1252,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..973f73cb 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -22,6 +22,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" @@ -150,6 +151,11 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case minValue = "min" + case maxValue = "max" + case stepValue = "step" + case detents = "detents" + case sizing = "sizing" } enum LUISchemaMatrix { diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 6f99514d..5281994c 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() diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3a..0989ab20 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1461,12 +1461,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) { @@ -1580,6 +1590,31 @@ 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; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(stepperState.properties['text'] as String? ?? '')), + IconButton( + icon: const Icon(Icons.remove), + onPressed: stepperEnabled + ? () => performValueChange(id, current - step) + : null, + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: stepperEnabled + ? () => performValueChange(id, current + step) + : null, + ), + ], + ); + } + Widget textControl({required _NodeKind kind}) { final multiline = kind == _NodeKind.textarea; final combobox = kind == _NodeKind.combobox; @@ -2838,6 +2873,7 @@ final class LUIFlutterBackend { ), onChanged: enabled ? (value) => performValueChange(id, value) : null, ), + _NodeKind.numberStepper => numberStepperView(state, enabled, id), _NodeKind.textField || _NodeKind.secureField || _NodeKind.input || @@ -3625,6 +3661,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 || @@ -3646,6 +3683,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toggle || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.listItem || @@ -3655,7 +3693,20 @@ final class LUIFlutterBackend { 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' => @@ -3809,6 +3860,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 || @@ -3880,6 +3932,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 || @@ -3954,6 +4007,27 @@ 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'); + } + final minimum = state.properties['min']; + final maximum = state.properties['max']; + if (minimum is num && maximum is num && 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( @@ -4396,6 +4470,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, @@ -4419,6 +4494,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, diff --git a/platform/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cff..e16c5c7d 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -22,6 +22,7 @@ enum _NodeKind { radioGroup, radio, slider, + numberStepper, textField, secureField, input, @@ -101,6 +102,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, diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8e92d688..6835f9f8 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 { @@ -1298,14 +1299,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..a0b1bca7 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -116,7 +116,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 +128,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, @@ -269,7 +271,8 @@ bool eventSupported(NodeKind kind, Event event) { 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, @@ -418,7 +421,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { 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, @@ -444,7 +448,8 @@ 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, @@ -463,8 +468,8 @@ 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}); @@ -562,7 +567,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}); case Property::Enabled: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -570,7 +576,8 @@ 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::NumberStepper, NodeKind::Select, + NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab}); case Property::ActiveIndex: case Property::DescriptionValue: @@ -580,6 +587,13 @@ 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, @@ -803,6 +817,16 @@ bool propertyValueSupported(Property property, const QVariant &value) { case Property::ThemeMode: return isString(value) && inSet(value.toString(), {"system", "light", "dark"}); + 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"}); } return false; } @@ -922,6 +946,19 @@ 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; + } + const QVariant minimum = properties.value(QStringLiteral("min")); + const QVariant maximum = properties.value(QStringLiteral("max")); + if (isNumeric(minimum) && isNumeric(maximum) && + minimum.toDouble() > maximum.toDouble()) { + return false; + } + } if (kind == NodeKind::Tree || kind == NodeKind::Toolbar) { if (!stringPropertyNonempty(properties, "accessibility-label")) { return false; diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fd11ca9b..45ce22f0 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -26,6 +26,7 @@ enum class NodeKind { RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -154,6 +155,11 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + MinValue, + MaxValue, + StepValue, + Detents, + Sizing, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -178,6 +184,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"; @@ -255,6 +262,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; } @@ -387,6 +395,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"; @@ -518,6 +527,11 @@ 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::MinValue: return "min"; + case Property::MaxValue: return "max"; + case Property::StepValue: return "step"; + case Property::Detents: return "detents"; + case Property::Sizing: return "sizing"; } return "unknown"; } @@ -596,6 +610,11 @@ 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, "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; } return false; } diff --git a/platform/web/melange/core/lui_web_store.ml b/platform/web/melange/core/lui_web_store.ml index ebaeb70c..2b89e4fe 100644 --- a/platform/web/melange/core/lui_web_store.ml +++ b/platform/web/melange/core/lui_web_store.ml @@ -450,6 +450,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/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index eb1b2bac..491bd782 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -458,7 +458,7 @@ let attach_events renderer node kind dom_node = | Checkbox | SwitchControl -> attach_toggle_event renderer node kind dom_node | Radio -> attach_radio_event renderer node dom_node - | Slider -> attach_slider_event renderer node dom_node + | Slider | NumberStepper -> attach_slider_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) diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..8abad288 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -31,6 +31,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" @@ -147,6 +148,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" @@ -368,6 +376,7 @@ 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 diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 19c5c818..115b5b16 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 @@ -239,7 +241,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 +301,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 = @@ -539,6 +543,19 @@ and apply_secondary_property renderer node kind dom_node property value = | 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 + | Sizing, StringValue value -> + W.Element.setAttribute "data-sizing" value dom_node | ResizeDuration, IntValue _duration -> Lui_web_split.update_split renderer node | ResizeEasing, StringValue _easing -> @@ -690,6 +707,14 @@ let remove_property renderer node kind dom_node property = W.Element.removeAttribute "aria-label" (if Store.direct_toggle kind 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 + | Sizing -> W.Element.removeAttribute "data-sizing" dom_node | AccessibilityIdentifier -> W.Element.removeAttribute "id" dom_node | OrientationValue -> if kind = Tabs then begin diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b2..3097f83a 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -353,6 +353,20 @@ void ApplyState( } LUIWireValue value = WireValue( operation.GetProperty("value"), "value"); + if (value is LUIWireValue.Int intValue && + (property == LUIProperty.Grow || + 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)) @@ -1462,16 +1476,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) diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 69449b55..f3a067ed 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -45,6 +45,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 +75,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: @@ -203,7 +205,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) { @@ -466,6 +470,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: @@ -511,6 +516,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 || @@ -543,6 +549,7 @@ 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 || @@ -656,6 +663,13 @@ public static bool CommonPropertySupported( case LUIProperty.ResizeEasing: case LUIProperty.ResizeOrigin: return kind == LUINodeKind.Split; + 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); @@ -686,6 +700,7 @@ 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: @@ -709,6 +724,7 @@ 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: @@ -1089,6 +1105,27 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + 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"); + } default: { return false; @@ -1327,6 +1364,35 @@ 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; + } + if (properties.TryGetValue( + LUIProperty.MinValue, + out LUIWireValue? minimum) && + properties.TryGetValue( + LUIProperty.MaxValue, + out LUIWireValue? maximum) && + minimum is LUIWireValue.Float minFloat && + maximum is LUIWireValue.Float maxFloat && + minFloat.Value > maxFloat.Value) + { + return false; + } + } if (kind == LUINodeKind.Tree || kind == LUINodeKind.Toolbar) { if (!StringPropertyNonempty( diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 513775f4..22c9902e 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -28,6 +28,7 @@ public enum LUINodeKind RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -157,6 +158,11 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + MinValue, + MaxValue, + StepValue, + Detents, + Sizing, } public static class LUIWireSchema @@ -184,6 +190,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 }, @@ -314,6 +321,11 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "min", LUIProperty.MinValue }, + { "max", LUIProperty.MaxValue }, + { "step", LUIProperty.StepValue }, + { "detents", LUIProperty.Detents }, + { "sizing", LUIProperty.Sizing }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -338,6 +350,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", @@ -520,6 +533,11 @@ LUINodeKind.Toast or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.MinValue => "min", + LUIProperty.MaxValue => "max", + LUIProperty.StepValue => "step", + LUIProperty.Detents => "detents", + LUIProperty.Sizing => "sizing", _ => "unknown", }; diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index 28d01ada..a71568cb 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -86,6 +86,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: @@ -382,6 +385,36 @@ void OnSliderChanged( } } + void SyncNumberStepper(LUINodeState state, LUISyncContext context) + { + if (Control is not NumberBox box) return; + 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..b6e79426 100644 --- a/platform/winui/LUI.WinUI/LUIElementFactory.cs +++ b/platform/winui/LUI.WinUI/LUIElementFactory.cs @@ -65,6 +65,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/schema/components.json b/schema/components.json index 5890108b..77f5025a 100644 --- a/schema/components.json +++ b/schema/components.json @@ -225,6 +225,10 @@ "name": "slider", "status": "supported" }, + { + "name": "number-stepper", + "status": "supported" + }, { "name": "progress", "status": "supported" @@ -435,6 +439,13 @@ "swift": "slider", "container": false }, + { + "lg": "NumberStepper", + "wire": "number-stepper", + "dart": "numberStepper", + "swift": "numberStepper", + "container": false + }, { "lg": "TextField", "wire": "text-field", @@ -1165,6 +1176,31 @@ "lg": "ThemeMode", "wire": "theme-mode", "swift": "themeMode" + }, + { + "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" } ], "events": [ diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..ef74c3a8 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -913,6 +913,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 +1227,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 -> ()); diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1c..a5cb890d 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -1563,6 +1563,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 +2189,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 -> 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..1323bcbf 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -24,6 +24,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -173,6 +174,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | MinValue + | MaxValue + | StepValue + | Detents + | Sizing module Property_map = Map.Make @@ -257,6 +263,7 @@ let context_menu_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -278,6 +285,7 @@ let context_menu_leaf_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -346,6 +354,7 @@ let event_supported kind event = | ValueChanged _ -> (match kind with | Slider + | NumberStepper | Split -> true | _ -> false) | Dismiss _ -> @@ -516,6 +525,9 @@ let icon_name_supported value = let theme_mode_supported value = value = "system" || value = "light" || value = "dark" +let sizing_supported value = + value = "form" || value = "fitted" || value = "page" + let main_alignment_supported value = value = "start" || value = "center" || value = "end" || value = "space_between" @@ -595,7 +607,7 @@ let common_property_supported kind property = | Row | Column | Grid | Box | Panel | Card | Stack | Scroll | Avatar | Text | Heading | Paragraph | Label | Button | ToggleButton | TextField | SecureField | Input | SearchField | Textarea | Checkbox | Toggle | Radio - | Slider | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem + | Slider | NumberStepper | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar -> true | _ -> false) | WidthValue | HeightValue -> kind <> Tooltip @@ -619,6 +631,7 @@ let common_property_supported kind property = || kind = RadioGroup || kind = Radio || kind = Slider + || kind = NumberStepper || horizontal_container kind || kind = Avatar || kind = Image @@ -643,7 +656,8 @@ 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 + | ProgressValue -> + kind = Progress || kind = Slider || kind = NumberStepper || kind = Split | OrientationValue -> kind = Divider || kind = Tabs || kind = Scroll | PlacementValue -> kind = Toolbar | SizeValue -> @@ -713,6 +727,8 @@ let common_property_supported kind property = | RoleValue -> tree_row_kind kind || kind = ListItem | TreeLevel | Expanded -> tree_row_kind kind | 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,6 +759,7 @@ let common_property_supported kind property = | TableCell | Alert | Bubble + | NumberStepper | StatusBar -> true | _ -> false) | Enabled -> @@ -759,6 +776,7 @@ let common_property_supported kind property = | Toggle | Radio | Slider + | NumberStepper | Select | Combobox | MenuItem @@ -917,6 +935,11 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_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 | _ -> false let property_value_supported_for_kind kind property value = @@ -1059,6 +1082,23 @@ 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) + && + match + ( Property_map.find_opt MinValue properties, + Property_map.find_opt MaxValue properties ) + with + | Some (FloatValue minimum), Some (FloatValue maximum) -> + minimum <= maximum + | _ -> true + else true) && (if kind = Tree || kind = Toolbar then string_property_nonempty properties AccessibilityLabel else true) diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 7cb03118..f642e19c 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -22,6 +22,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -171,6 +172,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | MinValue + | MaxValue + | StepValue + | Detents + | Sizing module Property_map : Map.S with type key = property diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329b..ff6d7d68 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -25,6 +25,7 @@ let node_kind_name kind = | RadioGroup -> "radio-group" | Radio -> "radio" | Slider -> "slider" + | NumberStepper -> "number-stepper" | TextField -> "text-field" | SecureField -> "secure-field" | Input -> "input" @@ -100,6 +101,7 @@ let standard_node_name name = | "radio-group" -> true | "radio" -> true | "slider" -> true + | "number-stepper" -> true | "text-field" -> true | "secure-field" -> true | "input" -> true @@ -229,6 +231,11 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | MinValue -> "min" + | MaxValue -> "max" + | StepValue -> "step" + | Detents -> "detents" + | Sizing -> "sizing" let kind_property_matrix kind = match kind with @@ -251,6 +258,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; 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 ] -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; MinValue; MaxValue; StepValue; Detents; Sizing ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb2..aa589dc5 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -992,6 +992,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"}|} @@ -1510,6 +1671,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 From ef52d087707b6d02cfea6e01f89c29ffefdec439 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:10:31 -0700 Subject: [PATCH 04/17] Add file-picker element with Apple file/photo/camera backends Introduces a generic non-visual 'file-picker' node kind driven by the schema: a 'request' token presents a picker for 'source' (files/photos/camera), 'picked' emits a JSON payload echoing the token plus per-file {path, name, content-type}, 'completion' releases retained security-scoped resources, and a cancelled presentation reports through 'dismiss'. 'types' takes a comma-separated UTI list and 'multiple' enables multi-select. Apple backend mounts fileImporter, SwiftUI PhotosPicker, or an iOS-only UIImagePickerController camera wrapper; macOS camera requests answer with dismiss. Picked URLs keep their security scope (or temp copies for photo/camera results) until completion echoes the token or the node is dropped, and the iOS fileImporter never-calls-on-completion-on-cancel quirk is covered by presentation-state cancel detection. Flutter, Qt, and WinUI get schema parity and stub rendering only. --- .../LUIComponentsApp/LUIComponentsApp.swift | 4 + .../components/native/components_bridge.ml | 3 + .../Sources/LUIMarkdown/main.swift | 12 + examples/markdown/native/markdown_bridge.ml | 3 + .../Sources/LUISplitDemo/main.swift | 12 + examples/split/native/split_bridge.ml | 3 + .../macos-appkit/Sources/LUITodos/main.swift | 12 + examples/todos/native/todos_bridge.ml | 3 + .../LUIAppleBackend/LUIAppleBackend.swift | 10 +- .../LUIAppleBackend/LUIAppleBridge.swift | 2 + .../LUIAppleBackend/LUIFilePickerView.swift | 453 ++++++++++++++++++ .../LUIAppleBackend/LUISwiftUIRoot.swift | 4 +- .../LUIAppleBackend/LUIWireProtocol.swift | 13 +- .../LUIAppleBackend/LUIWireSchema.swift | 7 + .../LUIFilePickerTests.swift | 112 +++++ platform/flutter/lib/lui_flutter_backend.dart | 34 ++ platform/flutter/lib/lui_ocaml_bridge.dart | 17 + platform/flutter/lib/lui_wire_schema.g.dart | 2 + platform/flutter/native/lui_ocaml_bridge.c | 13 + platform/native/lui_ocaml_bridge.c | 13 + platform/qt/lib/CMakeLists.txt | 1 + platform/qt/lib/lui_schema.cpp | 26 +- platform/qt/lib/lui_schema.h | 1 + platform/qt/lib/lui_wire_schema.h | 20 + platform/qt/qml/LuiFilePicker.qml | 25 + platform/web/melange/nodes/lui_web_nodes.ml | 1 + platform/winui/LUI.Core/LUIBackend.cs | 12 + platform/winui/LUI.Core/LUINodeState.cs | 1 + platform/winui/LUI.Core/LUIOcamlBridge.cs | 7 + platform/winui/LUI.Core/LUISchema.cs | 25 + platform/winui/LUI.Core/LUIWireSchema.g.cs | 22 +- platform/winui/LUI.WinUI/LUIElement.Sync.cs | 5 + schema/components.json | 52 ++ src/lui_elements.ml | 35 ++ src/lui_elements.mli | 57 +++ src/lui_protocol.ml | 33 +- src/lui_protocol.mli | 7 + src/lui_ui.ml | 1 + src/lui_ui.mli | 1 + src/lui_wire_schema.ml | 12 +- test/test_lui.ml | 94 ++++ 41 files changed, 1158 insertions(+), 12 deletions(-) create mode 100644 platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift create mode 100644 platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift create mode 100644 platform/qt/qml/LuiFilePicker.qml diff --git a/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift b/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift index 3e5c4d67..5570126c 100644 --- a/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift +++ b/examples/components/ios-swiftui/Sources/LUIComponentsApp/LUIComponentsApp.swift @@ -28,6 +28,8 @@ private func luiOCamlTextChanged(_ node: Int64, _ text: UnsafePointer?) - private func luiOCamlSubmit(_ node: Int64) -> Int32 @_silgen_name("lui_ocaml_dismiss") private func luiOCamlDismiss(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_picked") +private func luiOCamlPicked(_ node: Int64, _ payload: UnsafePointer) -> Int32 @_silgen_name("lui_ocaml_double_press") private func luiOCamlDoublePress(_ node: Int64) -> Int32 @_silgen_name("lui_ocaml_toggle_changed") @@ -73,6 +75,8 @@ private final class GalleryHost { text.withCString { _ = luiOCamlTextChanged(Int64(node), $0) } case let .submit(node): _ = luiOCamlSubmit(Int64(node)) case let .dismiss(node): _ = luiOCamlDismiss(Int64(node)) + case let .picked(node, payload): + payload.withCString { _ = luiOCamlPicked(Int64(node), $0) } case let .doublePress(node): _ = luiOCamlDoublePress(Int64(node)) case let .toggleChanged(node, checked): _ = luiOCamlToggleChanged(Int64(node), checked ? 1 : 0) diff --git a/examples/components/native/components_bridge.ml b/examples/components/native/components_bridge.ml index 046fc129..a9807c09 100644 --- a/examples/components/native/components_bridge.ml +++ b/examples/components/native/components_bridge.ml @@ -80,6 +80,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -104,6 +106,7 @@ let register prefix = register (prefix ^ "_text_changed") text_changed; register (prefix ^ "_submit") submit; register (prefix ^ "_dismiss") dismiss; + register (prefix ^ "_picked") picked; register (prefix ^ "_double_press") double_press; register (prefix ^ "_toggle_changed") toggle_changed; register (prefix ^ "_radio_changed") radio_changed; diff --git a/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift b/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift index 54e92d7f..a4b8305c 100644 --- a/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift +++ b/examples/markdown/macos-appkit/Sources/LUIMarkdown/main.swift @@ -11,6 +11,8 @@ private typealias StopFunction = @convention(c) () -> Int32 private typealias NodeFunction = @convention(c) (Int64) -> Int32 private typealias TextChangedFunction = @convention(c) (Int64, UnsafePointer?) -> Int32 +private typealias PickedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 private typealias SliderChangedFunction = @convention(c) (Int64, Double) -> Int32 private typealias ExtensionEventFunction = @@ -38,6 +40,7 @@ private final class NativeMarkdownRuntime { private let textChangedFunction: TextChangedFunction private let submitFunction: NodeFunction private let dismissFunction: NodeFunction + private let pickedFunction: PickedFunction private let doublePressFunction: NodeFunction private let toggleChangedFunction: ToggleChangedFunction private let radioChangedFunction: NodeFunction @@ -57,6 +60,7 @@ private final class NativeMarkdownRuntime { textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) submitFunction = try Self.load("lui_ocaml_submit", from: handle) dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + pickedFunction = try Self.load("lui_ocaml_picked", from: handle) doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) @@ -86,6 +90,12 @@ private final class NativeMarkdownRuntime { func submit(node: Int) { _ = submitFunction(Int64(node)) } func dismiss(node: Int) { _ = dismissFunction(Int64(node)) } + + func picked(node: Int, payload: String) { + payload.withCString { source in + _ = pickedFunction(Int64(node), source) + } + } func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } func toggleChanged(node: Int, checked: Bool) { @@ -166,6 +176,8 @@ private final class MarkdownHost: NSObject, NSApplicationDelegate, NSWindowDeleg self?.runtime?.sliderChanged(node: node, value: value) case let .dismiss(node): self?.runtime?.dismiss(node: node) + case let .picked(node, payload): + self?.runtime?.picked(node: node, payload: payload) case let .doublePress(node): self?.runtime?.doublePress(node: node) case let .appear(node): diff --git a/examples/markdown/native/markdown_bridge.ml b/examples/markdown/native/markdown_bridge.ml index 98b8b370..69dfc011 100644 --- a/examples/markdown/native/markdown_bridge.ml +++ b/examples/markdown/native/markdown_bridge.ml @@ -72,6 +72,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -106,6 +108,7 @@ let () = Callback.register "lui_ocaml_text_changed" text_changed; Callback.register "lui_ocaml_submit" submit; Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_double_press" double_press; Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; diff --git a/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift b/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift index bab9ee5d..0fc65660 100644 --- a/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift +++ b/examples/split/macos-appkit/Sources/LUISplitDemo/main.swift @@ -15,6 +15,8 @@ private typealias TextChangedFunction = @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias SubmitFunction = @convention(c) (Int64) -> Int32 private typealias DismissFunction = @convention(c) (Int64) -> Int32 +private typealias PickedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias DoublePressFunction = @convention(c) (Int64) -> Int32 private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 private typealias RadioChangedFunction = @convention(c) (Int64) -> Int32 @@ -83,6 +85,7 @@ private final class NativeSplitRuntime { private let textChangedFunction: TextChangedFunction private let submitFunction: SubmitFunction private let dismissFunction: DismissFunction + private let pickedFunction: PickedFunction private let doublePressFunction: DoublePressFunction private let toggleChangedFunction: ToggleChangedFunction private let radioChangedFunction: RadioChangedFunction @@ -102,6 +105,7 @@ private final class NativeSplitRuntime { textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) submitFunction = try Self.load("lui_ocaml_submit", from: handle) dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + pickedFunction = try Self.load("lui_ocaml_picked", from: handle) doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) @@ -135,6 +139,12 @@ private final class NativeSplitRuntime { func dismiss(node: Int) { _ = dismissFunction(Int64(node)) } + func picked(node: Int, payload: String) { + payload.withCString { source in + _ = pickedFunction(Int64(node), source) + } + } + func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } func toggleChanged(node: Int, checked: Bool) { @@ -237,6 +247,8 @@ private final class SplitDemoHost: NSObject, NSApplicationDelegate, NSWindowDele self?.runtime?.sliderChanged(node: node, value: value) case let .dismiss(node): self?.runtime?.dismiss(node: node) + case let .picked(node, payload): + self?.runtime?.picked(node: node, payload: payload) case let .doublePress(node): self?.runtime?.doublePress(node: node) case let .`extension`(node, identifier, name, values): diff --git a/examples/split/native/split_bridge.ml b/examples/split/native/split_bridge.ml index d18a3c4d..07705c42 100644 --- a/examples/split/native/split_bridge.ml +++ b/examples/split/native/split_bridge.ml @@ -72,6 +72,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -106,6 +108,7 @@ let () = Callback.register "lui_ocaml_text_changed" text_changed; Callback.register "lui_ocaml_submit" submit; Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_double_press" double_press; Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; diff --git a/examples/todos/macos-appkit/Sources/LUITodos/main.swift b/examples/todos/macos-appkit/Sources/LUITodos/main.swift index 1f0ab0c1..287ac712 100644 --- a/examples/todos/macos-appkit/Sources/LUITodos/main.swift +++ b/examples/todos/macos-appkit/Sources/LUITodos/main.swift @@ -14,6 +14,8 @@ private typealias TextChangedFunction = @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias SubmitFunction = @convention(c) (Int64) -> Int32 private typealias DismissFunction = @convention(c) (Int64) -> Int32 +private typealias PickedFunction = + @convention(c) (Int64, UnsafePointer?) -> Int32 private typealias DoublePressFunction = @convention(c) (Int64) -> Int32 private typealias ToggleChangedFunction = @convention(c) (Int64, Int32) -> Int32 private typealias RadioChangedFunction = @convention(c) (Int64) -> Int32 @@ -39,6 +41,7 @@ private final class NativeTodosRuntime { private let textChangedFunction: TextChangedFunction private let submitFunction: SubmitFunction private let dismissFunction: DismissFunction + private let pickedFunction: PickedFunction private let doublePressFunction: DoublePressFunction private let toggleChangedFunction: ToggleChangedFunction private let radioChangedFunction: RadioChangedFunction @@ -56,6 +59,7 @@ private final class NativeTodosRuntime { textChangedFunction = try Self.load("lui_ocaml_text_changed", from: handle) submitFunction = try Self.load("lui_ocaml_submit", from: handle) dismissFunction = try Self.load("lui_ocaml_dismiss", from: handle) + pickedFunction = try Self.load("lui_ocaml_picked", from: handle) doublePressFunction = try Self.load("lui_ocaml_double_press", from: handle) toggleChangedFunction = try Self.load("lui_ocaml_toggle_changed", from: handle) radioChangedFunction = try Self.load("lui_ocaml_radio_changed", from: handle) @@ -94,6 +98,12 @@ private final class NativeTodosRuntime { _ = dismissFunction(Int64(node)) } + func picked(node: Int, payload: String) { + payload.withCString { source in + _ = pickedFunction(Int64(node), source) + } + } + func doublePress(node: Int) { _ = doublePressFunction(Int64(node)) } @@ -161,6 +171,8 @@ private final class TodosHost: NSObject, NSApplicationDelegate, NSWindowDelegate self?.runtime?.sliderChanged(node: node, value: value) case let .dismiss(node): self?.runtime?.dismiss(node: node) + case let .picked(node, payload): + self?.runtime?.picked(node: node, payload: payload) case let .doublePress(node): self?.runtime?.doublePress(node: node) } diff --git a/examples/todos/native/todos_bridge.ml b/examples/todos/native/todos_bridge.ml index 7e7eeb50..7639bba5 100644 --- a/examples/todos/native/todos_bridge.ml +++ b/examples/todos/native/todos_bridge.ml @@ -71,6 +71,8 @@ let submit node = dispatch (Submit node) let dismiss node = dispatch (Dismiss node) +let picked node payload = dispatch (Picked (node, payload)) + let double_press node = dispatch (DoublePress node) let toggle_changed node checked = dispatch (ToggleChanged (node, checked)) @@ -94,6 +96,7 @@ let () = Callback.register "lui_ocaml_text_changed" text_changed; Callback.register "lui_ocaml_submit" submit; Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_double_press" double_press; Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 22fe3d89..23b98899 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -761,12 +761,20 @@ public final class LUIAppleBackend { 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 == .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 { diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift index bbcb8b91..1ad3dd7f 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift @@ -81,6 +81,8 @@ private final class LUIAppleBridge { "".withCString { callback(8, Int32(node), $0) } case let .appear(node): "".withCString { callback(10, Int32(node), $0) } + case let .picked(node, payload): + payload.withCString { callback(11, Int32(node), $0) } case let .extension(node, identifier, name, values): let payload: [String: Any] = [ "identifier": identifier, diff --git a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift new file mode 100644 index 00000000..26e36c29 --- /dev/null +++ b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift @@ -0,0 +1,453 @@ +import SwiftUI +import UniformTypeIdentifiers +#if canImport(PhotosUI) +import PhotosUI +#endif + +/// 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 +/// the request is answered with `dismiss` without presenting anything. +struct LUIFilePickerView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + /// Picked files held until `completion` acknowledges the request token. + private struct RetainedFile { + 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 + } + + /// The request token currently being served: presenting, or picked and + /// awaiting its `completion`. Cleared on cancel or completion match. + @State private var operation: LUIWireValue? + /// 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 + @State private var retained: [RetainedFile] = [] + #if canImport(PhotosUI) + @State private var photoSelection: [PhotosPickerItem] = [] + #endif + + var body: some View { + 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 { + evaluate() + evaluateCompletion() + } + .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`). Both state + // writes of a pick land in one transaction, so a non-empty + // photoSelection means the selection handler will claim it. + guard !isPresented, operation != nil, !handled else { return } + #if canImport(PhotosUI) + if source == "photos", !photoSelection.isEmpty { return } + #endif + finishCancelled() + } + .onDisappear { releaseRetained() } + } + + // 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() { + guard let request, operation == nil, enabled else { return } + operation = request + handled = false + if source == "camera" { + #if os(iOS) + presented = true + #else + // No camera capture on macOS: answer the request with a cancel. + finishCancelled() + #endif + } else { + presented = true + } + } + + private func evaluateCompletion() { + guard let completion, let operation, completion == operation else { return } + releaseRetained() + self.operation = nil + } + + private func finishPicked(files: [RetainedFile]) { + guard let operation else { return } + handled = true + retained = files + emitPicked(token: operation, files: files) + presented = false + } + + private func finishCancelled() { + handled = true + releaseRetained() + operation = nil + try? backend.performDismiss(node: model.id) + } + + private func releaseRetained() { + for file in retained { + if file.securityScoped { + file.url.stopAccessingSecurityScopedResource() + } + if file.temporary { + try? FileManager.default.removeItem(at: file.url) + } + } + retained = [] + } + + // MARK: - results + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls) where !urls.isEmpty: + let files = urls.map { url in + RetainedFile( + 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 } + photoSelection = [] + Task { + var files: [RetainedFile] = [] + for item in items { + guard let file = await Self.importPhotoItem(item) else { continue } + files.append(file) + } + if files.isEmpty { + finishCancelled() + } else { + finishPicked(files: files) + } + } + } + + private static func importPhotoItem(_ item: PhotosPickerItem) async -> RetainedFile? { + guard let data = try? await item.loadTransferable(type: Data.self) else { + return nil + } + let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "bin" + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("lui-picked-\(UUID().uuidString)") + .appendingPathExtension(ext) + guard (try? data.write(to: url)) != nil else { return nil } + return RetainedFile(url: url, securityScoped: false, temporary: true) + } + #endif + + #if os(iOS) + private func handleCamera(_ outcome: LUICameraPicker.Outcome) { + switch outcome { + case let .captured(url): + finishPicked(files: [ + RetainedFile(url: url, securityScoped: false, temporary: true), + ]) + case .cancelled: + finishCancelled() + } + } + #endif + + // MARK: - payload + + private func emitPicked(token: LUIWireValue, files: [RetainedFile]) { + 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 abe710c8..ee68798f 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -462,7 +462,7 @@ enum LUIDirectRevisionObservationPolicy { enum LUIUnmodifiedNodePolicy { static func bypassesSurface(kind: LUINodeKind) -> Bool { - kind == .spacer + kind == .spacer || kind == .filePicker } } @@ -679,6 +679,8 @@ 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: diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..cfe35978 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -15,6 +15,7 @@ public enum LUIEvent: Equatable, Sendable { case dismiss(node: Int) case doublePress(node: Int) case appear(node: Int) + case picked(node: Int, payload: String) case `extension`( node: Int, identifier: String, @@ -201,6 +202,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) @@ -817,6 +827,7 @@ struct LUIRetainedTree { case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) case .active, .title, .description, .meta, .indicator, .connector: false + case .request, .types, .multiple, .source, .completion: false } } @@ -836,7 +847,7 @@ 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) } private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool { diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e5505..93e48f76 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -74,6 +74,7 @@ enum LUINodeKind: String, Decodable, Equatable { case toast = "toast" case toolbar = "toolbar" case statusBar = "status-bar" + case filePicker = "file-picker" } enum LUIProperty: String, Decodable, Hashable { @@ -150,6 +151,11 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case request = "request" + case types = "types" + case multiple = "multiple" + case source = "source" + case completion = "completion" } enum LUISchemaMatrix { @@ -166,6 +172,7 @@ 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], + .filePicker: [.request, .types, .multiple, .source, .completion, .enabled, .appearEnabled], ] static let extra: [LUINodeKind: Set] = [ diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift new file mode 100644 index 00000000..7e6e4d12 --- /dev/null +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift @@ -0,0 +1,112 @@ +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: "{}") + } + } +} diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3a..95012f78 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -34,6 +34,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, @@ -94,6 +98,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; @@ -1478,6 +1495,7 @@ final class LUIFlutterBackend { state.kind != _NodeKind.combobox && state.kind != _NodeKind.dropdownMenu && state.kind != _NodeKind.toast && + state.kind != _NodeKind.filePicker && !state.kind.isModalSurface) { throw LUIBackendException('node $node is not dismissible'); } @@ -2899,6 +2917,10 @@ final class LUIFlutterBackend { color: foreground, ), _NodeKind.statusBar => statusBar(), + // Non-visual node: presentation is driven by its properties on + // platforms that implement the file-picker backend; children render + // inline. + _NodeKind.filePicker => Stack(children: children), }; if (state.kind == _NodeKind.root || state.kind.isModalSurface) { @@ -3578,6 +3600,17 @@ final class LUIFlutterBackend { _ => 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') { @@ -4340,6 +4373,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toolbar || kind == _NodeKind.bottomTabs || kind == _NodeKind.bottomTab || + kind == _NodeKind.filePicker || _isContextMenuLeafHost(kind) || kind.isModalSurface; 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..4b1d2872 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -74,6 +74,7 @@ enum _NodeKind { toast, toolbar, statusBar, + filePicker, } _NodeKind _decodeNodeKind(Object? value) { @@ -153,6 +154,7 @@ _NodeKind _decodeNodeKind(Object? value) { 'toast' => _NodeKind.toast, 'toolbar' => _NodeKind.toolbar, 'status-bar' => _NodeKind.statusBar, + 'file-picker' => _NodeKind.filePicker, _ => 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..6cb3cb8a 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -58,6 +58,7 @@ qt_add_qml_module(lui_qml ../qml/LuiDivider.qml ../qml/LuiDrawer.qml ../qml/LuiDropdownMenu.qml + ../qml/LuiFilePicker.qml ../qml/LuiGrid.qml ../qml/LuiHeading.qml ../qml/LuiIcon.qml diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c0..0e4e3f8a 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -190,7 +190,8 @@ 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::FilePicker}); } bool acceptsExtensionChildren(NodeKind kind) { @@ -273,11 +274,14 @@ bool eventSupported(NodeKind kind, Event event) { 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::FilePicker}); case Event::DoublePress: return kind == NodeKind::ListItem; case Event::Appear: return kind != NodeKind::Root; + case Event::Picked: + return kind == NodeKind::FilePicker; } return false; } @@ -652,6 +656,14 @@ bool propertySupported(NodeKind kind, Property property) { property == Property::GrowValue; case NodeKind::InputGroupActions: return property == Property::Gap; + 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); @@ -803,6 +815,16 @@ bool propertyValueSupported(Property property, const QVariant &value) { case Property::ThemeMode: return isString(value) && inSet(value.toString(), {"system", "light", "dark"}); + 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"}); } return false; } diff --git a/platform/qt/lib/lui_schema.h b/platform/qt/lib/lui_schema.h index f9e8589c..be1b34cc 100644 --- a/platform/qt/lib/lui_schema.h +++ b/platform/qt/lib/lui_schema.h @@ -21,6 +21,7 @@ enum class Event { Dismiss, DoublePress, Appear, + 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..dc3b8f18 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -78,6 +78,7 @@ enum class NodeKind { Toast, Toolbar, StatusBar, + FilePicker, }; enum class Property { @@ -154,6 +155,11 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + PickerRequest, + PickerTypes, + PickerMultiple, + PickerSource, + PickerCompletion, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -230,6 +236,7 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::Toast: return "toast"; case NodeKind::Toolbar: return "toolbar"; case NodeKind::StatusBar: return "status-bar"; + case NodeKind::FilePicker: return "file-picker"; } return "unknown"; } @@ -307,6 +314,7 @@ 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, "file-picker") == 0) { *kind = NodeKind::FilePicker; return true; } return false; } @@ -358,6 +366,7 @@ inline bool containerNodeKind(NodeKind kind) { case NodeKind::Sheet: case NodeKind::Toast: case NodeKind::Toolbar: + case NodeKind::FilePicker: return true; default: return false; @@ -439,6 +448,7 @@ 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::FilePicker: return "LuiFilePicker.qml"; } return "LuiBox.qml"; } @@ -518,6 +528,11 @@ 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::PickerRequest: return "request"; + case Property::PickerTypes: return "types"; + case Property::PickerMultiple: return "multiple"; + case Property::PickerSource: return "source"; + case Property::PickerCompletion: return "completion"; } return "unknown"; } @@ -596,6 +611,11 @@ 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, "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; } return false; } diff --git a/platform/qt/qml/LuiFilePicker.qml b/platform/qt/qml/LuiFilePicker.qml new file mode 100644 index 00000000..68e01e81 --- /dev/null +++ b/platform/qt/qml/LuiFilePicker.qml @@ -0,0 +1,25 @@ +import QtQuick +import QtQuick.Layouts + +// Wire kind: file-picker — non-visual element; the Qt backend does not +// present a native file dialog. Children (if any) render inline. +Item { + id: picker + required property var node + readonly property var props: node ? node.properties : ({}) + + implicitWidth: col.implicitWidth + implicitHeight: col.implicitHeight + + ColumnLayout { + id: col + spacing: 0 + Repeater { + model: picker.node ? picker.node.children : [] + delegate: LuiNodeView { + required property var modelData + node: modelData + } + } + } +} diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..d6f44dc0 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -86,6 +86,7 @@ let base_class_name kind = | Split -> "lui-split" | Drawer -> "lui-drawer" | StatusBar -> "lui-status-bar" + | FilePicker -> "lui-file-picker" let create_split_node renderer = let document = renderer.web_document in diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b2..e9bc47d0 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -1481,6 +1481,7 @@ public void PerformDismiss(long node) state.Kind != LUINodeKind.Combobox && state.Kind != LUINodeKind.DropdownMenu && state.Kind != LUINodeKind.Toast && + state.Kind != LUINodeKind.FilePicker && !LUISchema.ModalSurface(state.Kind)) { throw new LUIBackendException($"node {node} is not dismissible"); @@ -1488,6 +1489,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..db249a66 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -18,6 +18,7 @@ public enum LUIEventKind Dismiss, DoublePress, Appear, + Picked, Extension, } @@ -214,12 +215,15 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Dialog: case LUINodeKind.Drawer: case LUINodeKind.Sheet: + case LUINodeKind.FilePicker: 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.Extension: @@ -1089,6 +1093,26 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + 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"); + } default: { return false; @@ -1410,6 +1434,7 @@ public static bool CanContainChildren(LUINodeKind kind) case LUINodeKind.Bubble: case LUINodeKind.BottomTabs: case LUINodeKind.BottomTab: + case LUINodeKind.FilePicker: return true; default: return false; diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 513775f4..3c661b04 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -80,6 +80,7 @@ public enum LUINodeKind Toast, Toolbar, StatusBar, + FilePicker, } public enum LUIProperty @@ -157,6 +158,11 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + PickerRequest, + PickerTypes, + PickerMultiple, + PickerSource, + PickerCompletion, } public static class LUIWireSchema @@ -236,6 +242,7 @@ public static class LUIWireSchema { "toast", LUINodeKind.Toast }, { "toolbar", LUINodeKind.Toolbar }, { "status-bar", LUINodeKind.StatusBar }, + { "file-picker", LUINodeKind.FilePicker }, }; private static readonly Dictionary PropertiesByWireName = @@ -314,6 +321,11 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "request", LUIProperty.PickerRequest }, + { "types", LUIProperty.PickerTypes }, + { "multiple", LUIProperty.PickerMultiple }, + { "source", LUIProperty.PickerSource }, + { "completion", LUIProperty.PickerCompletion }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -390,6 +402,7 @@ public static class LUIWireSchema LUINodeKind.Toast => "toast", LUINodeKind.Toolbar => "toolbar", LUINodeKind.StatusBar => "status-bar", + LUINodeKind.FilePicker => "file-picker", _ => "unknown", }; @@ -441,7 +454,8 @@ LUINodeKind.Dialog or LUINodeKind.Drawer or LUINodeKind.Sheet or LUINodeKind.Toast or - LUINodeKind.Toolbar => true, + LUINodeKind.Toolbar or + LUINodeKind.FilePicker => true, _ => false, }; @@ -520,6 +534,11 @@ LUINodeKind.Toast or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.PickerRequest => "request", + LUIProperty.PickerTypes => "types", + LUIProperty.PickerMultiple => "multiple", + LUIProperty.PickerSource => "source", + LUIProperty.PickerCompletion => "completion", _ => "unknown", }; @@ -544,6 +563,7 @@ 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.FilePicker, Set(LUIProperty.PickerRequest, LUIProperty.PickerTypes, LUIProperty.PickerMultiple, LUIProperty.PickerSource, LUIProperty.PickerCompletion, LUIProperty.Enabled, LUIProperty.AppearEnabled) }, }; 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..0ec24e6c 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -168,6 +168,11 @@ void SyncKind(LUINodeState state, LUISyncContext context) break; case LUINodeKind.MenuItem: break; + case LUINodeKind.FilePicker: + // Non-visual node: the WinUI backend does not present a + // picker; children sync inline. + SyncStackLike(state, context); + break; case LUINodeKind.BottomTabs: SyncBottomTabs(state, context); break; diff --git a/schema/components.json b/schema/components.json index 5890108b..d9111975 100644 --- a/schema/components.json +++ b/schema/components.json @@ -292,6 +292,10 @@ { "name": "input-group-actions", "status": "supported" + }, + { + "name": "file-picker", + "status": "supported" } ], "nodeKinds": [ @@ -798,6 +802,13 @@ "dart": "statusBar", "swift": "statusBar", "container": false + }, + { + "lg": "FilePicker", + "wire": "file-picker", + "dart": "filePicker", + "swift": "filePicker", + "container": true } ], "properties": [ @@ -1165,6 +1176,31 @@ "lg": "ThemeMode", "wire": "theme-mode", "swift": "themeMode" + }, + { + "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" } ], "events": [ @@ -1230,6 +1266,13 @@ "fields": [ ":int" ] + }, + { + "lg": "Picked", + "fields": [ + ":int", + ":string" + ] } ], "kindProperties": { @@ -1309,6 +1352,15 @@ "enabled", "foreground", "style-class" + ], + "file-picker": [ + "request", + "types", + "multiple", + "source", + "completion", + "enabled", + "appear-enabled" ] }, "kindExtraProperties": { diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..edeec22f 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -31,6 +31,11 @@ type frame_axes = type resize_easing = [ `linear | `standard | `emphasized | `spring ] type role = [ `treeitem | `navigation | `navigation_heading ] +(** [~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 ] + 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 | `app of string ] @@ -130,6 +135,11 @@ let role_value : role -> string = function | `navigation -> "navigation" | `navigation_heading -> "navigation-heading" +let file_picker_source_value : file_picker_source -> string = function + | `files -> "files" + | `photos -> "photos" + | `camera -> "camera" + let icon_value : icon -> string = function | `app name -> "app:" ^ name | `alert -> "alert" @@ -278,6 +288,7 @@ 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 @@ -305,6 +316,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) @@ -1247,6 +1261,27 @@ 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 (Lui_ui.string_property context node PickerRequest) request; + Option.iter (Lui_ui.string_property_signal context node PickerRequest) 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 (Lui_ui.string_property context node PickerCompletion) completion; + Option.iter (Lui_ui.string_property_signal context node PickerCompletion) 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 diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1c..975c8b48 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -34,6 +34,11 @@ type frame_axes = type resize_easing = [ `linear | `standard | `emphasized | `spring ] type role = [ `treeitem | `navigation | `navigation_heading ] +(** [~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 ] + (** [~icon]/[~name] values: the schema icon names, or [`app "name"] for an application-registered icon ([app:name] on the wire). *) type icon = @@ -2217,6 +2222,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:string -> + ?request_signal:string Signal.signal -> + ?types:string -> + ?types_signal:string Signal.signal -> + ?multiple:bool -> + ?multiple_signal:bool Signal.signal -> + ?disabled:bool -> + ?disabled_signal:bool Signal.signal -> + ?completion:string -> + ?completion_signal:string Signal.signal -> + ?on_picked:(Lui_protocol.event -> unit) -> + ?on_dismiss:(Lui_protocol.event -> unit) -> t list -> t val toolbar : ?key:string -> ?gap:int -> diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index ebdfeea7..85dea10e 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -76,6 +76,7 @@ type node_kind = | Toast | Toolbar | StatusBar + | FilePicker type operating_system = | GenericOS @@ -173,6 +174,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | PickerRequest + | PickerTypes + | PickerMultiple + | PickerSource + | PickerCompletion module Property_map = Map.Make @@ -199,6 +205,7 @@ type event = | Dismiss of int | DoublePress of int | Appear of int + | Picked of int * string | ExtensionEvent of int * string * string * wire_value String_map.t type patch_op = @@ -239,6 +246,7 @@ let event_node event = | Dismiss node | DoublePress node | Appear node + | Picked (node, _) | ExtensionEvent (node, _, _, _) -> node let modal_surface kind = kind = Dialog || kind = Drawer || kind = Sheet @@ -356,10 +364,12 @@ let event_supported kind event = | Toast | Dialog | Drawer - | Sheet -> true + | Sheet + | FilePicker -> true | _ -> false) | DoublePress _ -> kind = ListItem | Appear _ -> kind <> Root + | Picked _ -> kind = FilePicker | ExtensionEvent _ -> false let true_property properties property = @@ -516,6 +526,9 @@ let icon_name_supported value = let theme_mode_supported value = value = "system" || value = "light" || value = "dark" +let picker_source_supported value = + value = "files" || value = "photos" || value = "camera" + let main_alignment_supported value = value = "start" || value = "center" || value = "end" || value = "space_between" @@ -564,7 +577,8 @@ let can_contain_children kind = | Alert | Bubble | BottomTabs - | BottomTab -> true + | BottomTab + | FilePicker -> true | _ -> false let common_property_supported kind property = @@ -766,8 +780,9 @@ let common_property_supported kind property = | Drawer | BottomTab -> true | _ -> false) - | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector -> - false + | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector + | PickerRequest | PickerTypes | PickerMultiple | PickerSource + | PickerCompletion -> false | TitleValue -> kind = BottomTab | Gap -> kind = Row @@ -811,6 +826,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 @@ -917,6 +937,11 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_supported value + | PickerRequest, (StringValue _ | IntValue _) + | PickerCompletion, (StringValue _ | IntValue _) -> true + | PickerTypes, StringValue _ -> true + | PickerMultiple, BoolValue _ -> true + | PickerSource, StringValue value -> picker_source_supported value | _ -> false let property_value_supported_for_kind kind property value = diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 7cb03118..0a96e527 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -74,6 +74,7 @@ type node_kind = | Toast | Toolbar | StatusBar + | FilePicker type operating_system = | GenericOS @@ -171,6 +172,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | PickerRequest + | PickerTypes + | PickerMultiple + | PickerSource + | PickerCompletion module Property_map : Map.S with type key = property @@ -194,6 +200,7 @@ type event = | Dismiss of int | DoublePress of int | Appear of 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..2e4eabd2 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -142,6 +142,7 @@ let timeline context = create context Timeline let timeline_item context = create context TimelineItem let input_group context = create context InputGroup let input_group_actions context = create context InputGroupActions +let file_picker context = create context FilePicker let button context = create context Button let toggle_button context = create context ToggleButton let toggle context = create context Toggle diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 72e11fcf..b53ca989 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -98,6 +98,7 @@ val timeline : ui_context -> int val timeline_item : ui_context -> int val input_group : ui_context -> int val input_group_actions : ui_context -> int +val file_picker : ui_context -> int val button : ui_context -> int val toggle_button : ui_context -> int val toggle : ui_context -> int diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329b..1f9d8177 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -77,6 +77,7 @@ let node_kind_name kind = | Toast -> "toast" | Toolbar -> "toolbar" | StatusBar -> "status-bar" + | FilePicker -> "file-picker" let standard_node_name name = match name with @@ -152,6 +153,7 @@ let standard_node_name name = | "toast" -> true | "toolbar" -> true | "status-bar" -> true + | "file-picker" -> true | _ -> false let property_name property = @@ -229,6 +231,11 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | PickerRequest -> "request" + | PickerTypes -> "types" + | PickerMultiple -> "multiple" + | PickerSource -> "source" + | PickerCompletion -> "completion" let kind_property_matrix kind = match kind with @@ -244,6 +251,7 @@ 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 ] + | FilePicker -> Some [ PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; Enabled; AppearEnabled ] | _ -> None let kind_extra_properties kind = @@ -251,6 +259,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; 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; FilePicker ] -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; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb2..a646a213 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,99 @@ let test_menu_trigger_rules () = (node_properties_supported MenuTrigger (props [ (TextValue, StringValue "More") ])) +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 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:"op-1" ~types:"public.image" ~multiple:true + ~completion:"" + ~on_picked:(fun event -> + match event with + | Picked (_, payload) -> + picked_payloads := payload :: !picked_payloads + | _ -> ()) + ~on_dismiss:(fun _ -> incr dismissed) + []) ]) + 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) "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_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -1483,6 +1576,7 @@ let () = Alcotest.test_case "helpers" `Quick test_protocol_helpers; Alcotest.test_case "menu-trigger rules" `Quick test_menu_trigger_rules; + Alcotest.test_case "file-picker rules" `Quick test_file_picker; Alcotest.test_case "property matrix sync" `Quick test_property_matrix_sync; ] ); From 2034b75fcee9f597b58a04e30be66bb73d9db4a1 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:19:55 -0700 Subject: [PATCH 05/17] Add list suite: sections, disclosure rows, swipe actions, scroll and visible-range tracking Schema gains five node kinds (list-section, list-section-header, list-section-footer, swipe-actions, swipe-action), nine properties (key, separator, style, scroll-target, scroll-anchor, scroll-token, scroll-animated, track-visible-range, edge), and two list events (scroll-completed, visible-range). OCaml: list_item gains ?expanded/?on_toggle disclosure support plus ?separator/?swipe_actions; list gains ?style, scroll request props and ?on_scroll_completed/?on_visible_range; new list_section, swipe_actions and swipe_action constructors. Apple backend: list-section children render as grouped sections with arbitrary content headers/footers (existing heading/footnote inference still applies when no explicit sections are present); a list-item with children renders a DisclosureGroup; explicit swipe-actions render on both edges while the context-menu trailing-swipe derivation is preserved; scroll-target/scroll-token drive ScrollViewReader requests reporting scroll-completed outcomes; track-visible-range emits debounced flat-position visible-range events. Flutter/Qt/WinUI gain schema-parity validation; rendering stays a container fallback. --- .../LUIAppleBackend/LUIAppleBackend.swift | 41 +- .../LUIAppleBackend/LUIAppleBridge.swift | 16 + .../LUIAppleBackend/LUISwiftUIRoot.swift | 621 ++++++++++++++++-- .../LUIAppleBackend/LUIWireProtocol.swift | 92 ++- .../LUIAppleBackend/LUIWireSchema.swift | 17 + .../LUIBackendParityTests.swift | 163 +++++ platform/flutter/lib/lui_flutter_backend.dart | 232 ++++++- platform/flutter/lib/lui_wire_schema.g.dart | 10 + platform/qt/lib/lui_schema.cpp | 85 ++- platform/qt/lib/lui_schema.h | 2 + platform/qt/lib/lui_wire_schema.h | 51 ++ platform/web/melange/nodes/lui_web_nodes.ml | 5 + platform/winui/LUI.Core/LUISchema.cs | 99 ++- platform/winui/LUI.Core/LUIWireSchema.g.cs | 51 +- schema/components.json | 122 ++++ src/lui_elements.ml | 129 +++- src/lui_elements.mli | 48 +- src/lui_protocol.ml | 88 ++- src/lui_protocol.mli | 16 + src/lui_ui.ml | 5 + src/lui_ui.mli | 5 + src/lui_wire_schema.ml | 26 +- test/test_lui.ml | 109 +++ 23 files changed, 1908 insertions(+), 125 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 22fe3d89..1262942b 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 } @@ -642,7 +652,7 @@ public final class LUIAppleBackend { (model.kind == .tableCell && model.supportsPress) || model.kind == .select || model.kind == .combobox || model.kind == .menuItem || - model.kind == .listItem || + model.kind == .listItem || model.kind == .swipeAction || (model.kind == .timelineItem && model.supportsPress) || (model.isTreeItem && model.supportsPress), model.isEnabled else { @@ -695,6 +705,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 +742,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") } @@ -774,7 +794,8 @@ public final class LUIAppleBackend { return } switch model.kind { - case .button, .select, .combobox, .menuItem, .listItem, .timelineItem: + case .button, .select, .combobox, .menuItem, .listItem, .timelineItem, + .swipeAction: try performPress(node: node) case .toggleButton: try performToggle(node: node, checked: !model.isSelected) @@ -901,10 +922,16 @@ public final class LUIAppleBackend { } for id in touched where !dropped.contains(id) { if let state = tree.nodes[id] { - // Kinds are immutable, so context-menu membership in the + // Kinds are immutable, so auxiliary-slot membership in the // child list is fixed until the list itself changes. - let visibleChildren = state.children.filter { - tree.nodes[$0]?.kind != .contextMenu + let visibleChildren = state.children.filter { childID in + switch tree.nodes[childID]?.kind { + case .contextMenu, .swipeActions, .listSectionHeader, + .listSectionFooter: + false + default: + true + } } if let model = models[id] { model.apply(state: state, visibleChildren: visibleChildren) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift index bbcb8b91..f7a78120 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBridge.swift @@ -81,6 +81,22 @@ 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 .extension(node, identifier, name, values): let payload: [String: Any] = [ "identifier": identifier, diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c8..ddafe4fe 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -685,6 +685,21 @@ private struct LUINodeView: View { 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: @@ -1207,6 +1222,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 +1258,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 { @@ -2475,6 +2505,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 @@ -3633,7 +3664,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 +3700,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 +3728,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 +3767,7 @@ private struct LUIListItemView: View { : Color.secondary ) } - if visibleChildren.isEmpty { + if contentChildIDs.isEmpty { Text(verbatim: model.text) .font(isNavigationHeading ? .title3 : .body) .fontWeight( @@ -3724,7 +3777,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 +3881,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) } @@ -3947,12 +4020,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 +4052,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 +5165,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 +5199,51 @@ 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? + ) -> [LUIListSection] { + var result: [LUIListSection] = [] + var pending: [Int] = [] + + func flush() { + guard !pending.isEmpty else { return } + result.append(LUIListSection( + nodeID: nil, + headerID: nil, + childIDs: pending, + footerID: nil + )) + 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 +5257,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 +5291,130 @@ 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) } - 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. + if let token = model.scrollToken { + 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 { 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 +5426,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,15 +5495,29 @@ 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 - }, + 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) } + ) + } + return LUIListSectionPolicy.sections( + childIDs: childIDs, isHeading: { childID in backend.model(id: childID)?.kind == .heading }, @@ -5190,6 +5531,164 @@ private struct LUIListView: View { } ) } + + /// 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? { + flatRowOrder.first { backend.model(id: $0)?.rowKey == key } + } + + 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 { diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..93b278de 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -15,6 +15,8 @@ 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 `extension`( node: Int, identifier: String, @@ -131,14 +133,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: @@ -169,7 +172,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 } @@ -189,6 +192,18 @@ enum LUIWireValue: Decodable, Equatable { case .textAlignment: guard let value = stringValue else { return false } return Self.textAlignments.contains(value) + case .separator: + guard let value = stringValue else { return false } + return value == "visible" || value == "hidden" + case .style: + guard let value = stringValue else { return false } + return ["plain", "inset", "inset-grouped"].contains(value) + case .scrollAnchor: + guard let value = stringValue else { return false } + return ["top", "center", "bottom"].contains(value) + case .edge: + guard let value = stringValue else { return false } + return value == "leading" || value == "trailing" case .role: guard let value = stringValue else { return false } return value == "treeitem" || value == "navigation" || @@ -632,6 +647,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 { @@ -816,6 +854,11 @@ 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 case .active, .title, .description, .meta, .indicator, .connector: false } } @@ -836,7 +879,9 @@ struct LUIRetainedTree { kind == .stepper || kind == .timeline || kind == .inputGroup || kind == .inputGroupActions || kind == .toast || kind == .toolbar || kind == .bottomTabs || kind == .bottomTab || - kind == .menuTrigger || isContextMenuLeafHost(kind) + kind == .menuTrigger || isContextMenuLeafHost(kind) || + kind == .listSection || kind == .listSectionHeader || + kind == .listSectionFooter || kind == .swipeActions } private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool { @@ -846,7 +891,8 @@ struct LUIRetainedTree { kind == .sheet || kind == .accordion || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || kind == .toast || kind == .toolbar || - kind == .bottomTab + kind == .bottomTab || kind == .listSection || + kind == .listSectionHeader || kind == .listSectionFooter } private static func isModalSurface(_ kind: LUINodeKind) -> Bool { @@ -1053,9 +1099,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 +1126,33 @@ struct LUIRetainedTree { } } if node.kind == .listItem { + let disclosure = node.properties[.expanded] != nil let hasText = !(node.properties[.text]?.stringValue ?? "").isEmpty - let hasChildren = node.children.contains { nodes[$0]?.kind != .contextMenu } + let hasChildren = node.children.contains { childID in + guard let child = nodes[childID] else { return false } + if child.kind == .contextMenu || child.kind == .swipeActions { + return false + } + // Nested list items on a disclosure row are rows, not content. + return !(disclosure && child.kind == .listItem) + } guard hasText || hasChildren else { throw invalid("list-item requires text or children") } guard !(hasText && hasChildren) else { throw invalid("list-item accepts text or children, not both") } + if node.children.contains(where: { nodes[$0]?.kind == .listItem }), + node.properties[.expanded] == nil { + throw invalid("nested list-item children require expanded") + } + } + if node.kind == .swipeAction { + let text = node.properties[.text]?.stringValue ?? "" + let icon = node.properties[.icon]?.stringValue ?? "" + guard !text.isEmpty || !icon.isEmpty else { + throw invalid("swipe-action requires text or icon") + } } if node.kind == .avatar || node.kind == .image { if node.kind == .avatar, diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e5505..8d9cb5eb 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -74,6 +74,11 @@ 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" } enum LUIProperty: String, Decodable, Hashable { @@ -150,6 +155,15 @@ 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" } enum LUISchemaMatrix { @@ -166,6 +180,9 @@ 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], ] static let extra: [LUINodeKind: Set] = [ diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index 1fc6a1f5..6e66bea6 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift @@ -637,3 +637,166 @@ struct LUIBackendParityTests { _ = LUISwiftUIRoot(backend: backend, rootID: 1) } } + +@MainActor +@Suite("LUI list suite", .serialized) +struct LUIListSuiteTests { + @Test("grouped list with sections, disclosure rows, and swipe actions builds") + func groupedListBuilds() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"set-prop","id":1,"property":"style","value":"inset-grouped"}, + {"op":"set-prop","id":1,"property":"scroll-target","value":"row-2"}, + {"op":"set-prop","id":1,"property":"scroll-anchor","value":"center"}, + {"op":"set-prop","id":1,"property":"scroll-token","value":1}, + {"op":"set-prop","id":1,"property":"scroll-animated","value":false}, + {"op":"set-prop","id":1,"property":"track-visible-range","value":true}, + {"op":"create-node","id":2,"kind":"list-section"}, + {"op":"set-prop","id":2,"property":"key","value":"s-1"}, + {"op":"set-prop","id":2,"property":"separator","value":"hidden"}, + {"op":"create-node","id":3,"kind":"list-section-header"}, + {"op":"create-node","id":4,"kind":"text"}, + {"op":"set-prop","id":4,"property":"text","value":"Section one"}, + {"op":"create-node","id":5,"kind":"list-item"}, + {"op":"set-prop","id":5,"property":"key","value":"row-1"}, + {"op":"set-prop","id":5,"property":"expanded","value":true}, + {"op":"set-prop","id":5,"property":"toggle-enabled","value":true}, + {"op":"create-node","id":6,"kind":"text"}, + {"op":"set-prop","id":6,"property":"text","value":"Parent row"}, + {"op":"create-node","id":7,"kind":"list-item"}, + {"op":"set-prop","id":7,"property":"key","value":"row-2"}, + {"op":"create-node","id":8,"kind":"text"}, + {"op":"set-prop","id":8,"property":"text","value":"Nested row"}, + {"op":"create-node","id":9,"kind":"swipe-actions"}, + {"op":"create-node","id":10,"kind":"swipe-action"}, + {"op":"set-prop","id":10,"property":"text","value":"Delete"}, + {"op":"set-prop","id":10,"property":"icon","value":"trash"}, + {"op":"set-prop","id":10,"property":"variant","value":"destructive"}, + {"op":"set-prop","id":10,"property":"edge","value":"trailing"}, + {"op":"create-node","id":11,"kind":"swipe-action"}, + {"op":"set-prop","id":11,"property":"text","value":"Pin"}, + {"op":"set-prop","id":11,"property":"edge","value":"leading"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":2,"child":5,"index":1}, + {"op":"insert-child","parent":3,"child":4,"index":0}, + {"op":"insert-child","parent":5,"child":6,"index":0}, + {"op":"insert-child","parent":5,"child":7,"index":1}, + {"op":"insert-child","parent":5,"child":9,"index":2}, + {"op":"insert-child","parent":7,"child":8,"index":0}, + {"op":"insert-child","parent":9,"child":10,"index":0}, + {"op":"insert-child","parent":9,"child":11,"index":1} + ]} + """) + + let list = try #require(backend.model(id: 1)) + #expect(list.listStyle == "inset-grouped") + #expect(list.scrollTarget == "row-2") + #expect(list.scrollAnchor == "center") + #expect(list.scrollToken == 1) + #expect(list.scrollAnimated == false) + #expect(list.tracksVisibleRange) + #expect(backend.model(id: 2)?.rowKey == "s-1") + #expect(backend.model(id: 2)?.separatorVisibility == "hidden") + #expect(backend.model(id: 5)?.isExpanded == true) + #expect(backend.model(id: 5)?.supportsToggle == true) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + } + + @Test("list suite validation rejects malformed structures") + func listSuiteValidation() throws { + let backend = LUIAppleBackend() + // A swipe action needs text or an icon. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"list-item"}, + {"op":"set-prop","id":2,"property":"text","value":"Row"}, + {"op":"create-node","id":3,"kind":"swipe-actions"}, + {"op":"create-node","id":4,"kind":"swipe-action"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":3,"child":4,"index":0} + ]} + """) + } + // A list-section must sit directly under a list. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"row"}, + {"op":"create-node","id":2,"kind":"list-section"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + } + // swipe-actions belong to a list-item. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"swipe-actions"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + } + // Disclosure rows still need toggle-enabled with expanded. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"list-item"}, + {"op":"set-prop","id":2,"property":"text","value":"Row"}, + {"op":"set-prop","id":2,"property":"expanded","value":true}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + } + // Unknown style vocab is rejected. + #expect(throws: (any Error).self) { + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"set-prop","id":1,"property":"style","value":"cards"} + ]} + """) + } + } + + @Test("list suite events emit on the wire") + func listSuiteEvents() throws { + let backend = LUIAppleBackend() + var events: [LUIEvent] = [] + backend.onEvent = { events.append($0) } + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list"}, + {"op":"create-node","id":2,"kind":"list-item"}, + {"op":"set-prop","id":2,"property":"text","value":"Row"}, + {"op":"set-prop","id":2,"property":"expanded","value":false}, + {"op":"set-prop","id":2,"property":"toggle-enabled","value":true}, + {"op":"create-node","id":3,"kind":"swipe-actions"}, + {"op":"create-node","id":4,"kind":"swipe-action"}, + {"op":"set-prop","id":4,"property":"text","value":"Delete"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":3,"child":4,"index":0} + ]} + """) + + try backend.performPress(node: 4) + try backend.performToggle(node: 2, checked: true) + backend.performScrollCompleted(node: 1, token: 7, outcome: "succeeded") + backend.performVisibleRange(node: 1, first: 0, last: 5) + #expect(events == [ + .press(node: 4), + .toggleChanged(node: 2, checked: true), + .scrollCompleted(node: 1, token: 7, outcome: "succeeded"), + .visibleRange(node: 1, first: 0, last: 5), + ]) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + } +} diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3a..653061c3 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -41,6 +41,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 @@ -181,6 +191,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 +411,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) => @@ -877,7 +928,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 +952,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 +999,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 +1039,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; @@ -1808,7 +1863,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 +1895,7 @@ final class LUIFlutterBackend { ], ); } + Widget horizontalGroupFlex() => LUIFlex( direction: Axis.horizontal, expandsForAlignment: state.properties.containsKey('main'), @@ -1874,13 +1931,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) => @@ -2491,10 +2546,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( @@ -2781,6 +2834,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(), @@ -3462,6 +3520,44 @@ final class LUIFlutterBackend { 'toolbar accepts only interactive controls and dividers', ); } + if (parent.kind == _NodeKind.listSection && + child.kind != _NodeKind.listItem && + child.kind != _NodeKind.listSectionHeader && + child.kind != _NodeKind.listSectionFooter) { + throw const LUIBackendException( + 'list-section accepts only list rows and section header/footer', + ); + } + if ((child.kind == _NodeKind.listSectionHeader || + child.kind == _NodeKind.listSectionFooter) && + parent.kind != _NodeKind.listSection) { + throw const LUIBackendException( + 'list-section header/footer requires a list-section parent', + ); + } + if (child.kind == _NodeKind.listSection && parent.kind != _NodeKind.list) { + throw const LUIBackendException( + 'list-section requires a direct list parent', + ); + } + if (child.kind == _NodeKind.swipeActions && + parent.kind != _NodeKind.listItem) { + throw const LUIBackendException( + 'swipe-actions requires a list-item parent', + ); + } + if (parent.kind == _NodeKind.swipeActions && + child.kind != _NodeKind.swipeAction) { + throw const LUIBackendException( + 'swipe-actions accepts only swipe-action children', + ); + } + if (child.kind == _NodeKind.swipeAction && + parent.kind != _NodeKind.swipeActions) { + throw const LUIBackendException( + 'swipe-action requires a swipe-actions parent', + ); + } } static bool _supports(_NodeKind kind, String property, Object? value) { @@ -3578,6 +3674,27 @@ 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, + }; + } // Mirrors OCaml common_property_supported: theme props are admitted on // every non-restrictive container kind. if (property == 'theme') { @@ -3903,6 +4020,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, }; } @@ -4104,8 +4240,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 +4274,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 +4296,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? ?? ''; @@ -4341,6 +4509,10 @@ final class LUIFlutterBackend { kind == _NodeKind.bottomTabs || kind == _NodeKind.bottomTab || _isContextMenuLeafHost(kind) || + kind == _NodeKind.listSection || + kind == _NodeKind.listSectionHeader || + kind == _NodeKind.listSectionFooter || + kind == _NodeKind.swipeActions || kind.isModalSurface; static bool _acceptsExtensionChildren(_NodeKind kind) => @@ -4366,7 +4538,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 || @@ -6091,12 +6266,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_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cff..06ecea74 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -74,6 +74,11 @@ enum _NodeKind { toast, toolbar, statusBar, + listSection, + listSectionHeader, + listSectionFooter, + swipeActions, + swipeAction, } _NodeKind _decodeNodeKind(Object? value) { @@ -153,6 +158,11 @@ _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, _ => throw const LUIBackendException('unknown node kind'), }; } diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c0..2c6730d4 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -190,7 +190,9 @@ 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::ListSection, NodeKind::ListSectionHeader, + NodeKind::ListSectionFooter, NodeKind::SwipeActions}); } bool acceptsExtensionChildren(NodeKind kind) { @@ -202,7 +204,9 @@ bool acceptsExtensionChildren(NodeKind kind) { NodeKind::ListItem, NodeKind::Dialog, NodeKind::Sheet, NodeKind::Accordion, NodeKind::Resizable, NodeKind::Split, NodeKind::Drawer, NodeKind::Alert, NodeKind::Bubble, - NodeKind::Toast, NodeKind::Toolbar, NodeKind::BottomTab}); + NodeKind::Toast, NodeKind::Toolbar, NodeKind::BottomTab, + NodeKind::ListSection, NodeKind::ListSectionHeader, + NodeKind::ListSectionFooter}); } bool childKindSupported(NodeKind parent, NodeKind child) { @@ -235,9 +239,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 +265,7 @@ bool eventSupported(NodeKind kind, Event event) { NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Text, NodeKind::TableCell, NodeKind::TimelineItem, - NodeKind::BottomTab}); + NodeKind::BottomTab, NodeKind::SwipeAction}); case Event::LongPress: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -265,7 +283,7 @@ bool eventSupported(NodeKind kind, Event event) { return oneOf(kind, {NodeKind::ToggleButton, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio, NodeKind::Accordion, - NodeKind::Drawer}); + NodeKind::Drawer, NodeKind::ListItem}); case Event::Change: return kind == NodeKind::Radio; case Event::ValueChanged: @@ -278,6 +296,9 @@ bool eventSupported(NodeKind kind, Event event) { return kind == NodeKind::ListItem; case Event::Appear: return kind != NodeKind::Root; + case Event::ScrollCompleted: + case Event::VisibleRange: + return kind == NodeKind::ListContainer; } return false; } @@ -548,6 +569,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); @@ -652,6 +685,16 @@ 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::Dialog: return property == Property::DescriptionValue || commonPropertySupported(kind, property); @@ -803,6 +846,28 @@ 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"}); } return false; } @@ -943,7 +1008,15 @@ 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; diff --git a/platform/qt/lib/lui_schema.h b/platform/qt/lib/lui_schema.h index f9e8589c..b5b5825f 100644 --- a/platform/qt/lib/lui_schema.h +++ b/platform/qt/lib/lui_schema.h @@ -21,6 +21,8 @@ enum class Event { Dismiss, DoublePress, Appear, + ScrollCompleted, + VisibleRange, }; bool modalSurface(NodeKind kind); diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fd11ca9b..10c8e8d0 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -78,6 +78,11 @@ enum class NodeKind { Toast, Toolbar, StatusBar, + ListSection, + ListSectionHeader, + ListSectionFooter, + SwipeActions, + SwipeAction, }; enum class Property { @@ -154,6 +159,15 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + KeyValue, + SeparatorValue, + StyleValue, + ScrollTarget, + ScrollAnchor, + ScrollToken, + ScrollAnimated, + TrackVisibleRange, + EdgeValue, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -230,6 +244,11 @@ 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"; } return "unknown"; } @@ -307,6 +326,11 @@ 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; } return false; } @@ -358,6 +382,10 @@ 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: return true; default: return false; @@ -439,6 +467,11 @@ 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"; } return "LuiBox.qml"; } @@ -518,6 +551,15 @@ 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"; } return "unknown"; } @@ -596,6 +638,15 @@ 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; } return false; } diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..c1a96c35 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -86,6 +86,11 @@ 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" let create_split_node renderer = let document = renderer.web_document in diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 69449b55..a1d458e5 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -19,6 +19,8 @@ public enum LUIEventKind DoublePress, Appear, Extension, + ScrollCompleted, + VisibleRange, } public static class LUISchema @@ -156,6 +158,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.TableCell: case LUINodeKind.TimelineItem: case LUINodeKind.BottomTab: + case LUINodeKind.SwipeAction: return true; default: return false; @@ -196,6 +199,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; @@ -222,6 +226,9 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) return kind == LUINodeKind.ListItem; case LUIEventKind.Appear: return kind != LUINodeKind.Root; + case LUIEventKind.ScrollCompleted: + case LUIEventKind.VisibleRange: + return kind == LUINodeKind.ListContainer; case LUIEventKind.Extension: return false; default: @@ -656,6 +663,19 @@ 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; case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); @@ -1089,6 +1109,42 @@ 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.EdgeValue: + { + return value is LUIWireValue.String text && + (text.Value == "leading" || text.Value == "trailing"); + } default: { return false; @@ -1358,7 +1414,18 @@ 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)) { @@ -1410,6 +1477,10 @@ 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: return true; default: return false; @@ -1456,9 +1527,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..879f517a 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -80,6 +80,11 @@ public enum LUINodeKind Toast, Toolbar, StatusBar, + ListSection, + ListSectionHeader, + ListSectionFooter, + SwipeActions, + SwipeAction, } public enum LUIProperty @@ -157,6 +162,15 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + KeyValue, + SeparatorValue, + StyleValue, + ScrollTarget, + ScrollAnchor, + ScrollToken, + ScrollAnimated, + TrackVisibleRange, + EdgeValue, } public static class LUIWireSchema @@ -236,6 +250,11 @@ 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 }, }; private static readonly Dictionary PropertiesByWireName = @@ -314,6 +333,15 @@ 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 }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -390,6 +418,11 @@ 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", _ => "unknown", }; @@ -441,7 +474,11 @@ 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 => true, _ => false, }; @@ -520,6 +557,15 @@ 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", _ => "unknown", }; @@ -544,6 +590,9 @@ 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) }, }; public static readonly IReadOnlyDictionary> ExtraMatrix = diff --git a/schema/components.json b/schema/components.json index 5890108b..38c28346 100644 --- a/schema/components.json +++ b/schema/components.json @@ -125,6 +125,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" @@ -798,6 +810,41 @@ "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 } ], "properties": [ @@ -1165,6 +1212,51 @@ "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" } ], "events": [ @@ -1230,6 +1322,22 @@ "fields": [ ":int" ] + }, + { + "lg": "ScrollCompleted", + "fields": [ + ":int", + ":int", + ":string" + ] + }, + { + "lg": "VisibleRange", + "fields": [ + ":int", + ":int", + ":int" + ] } ], "kindProperties": { @@ -1309,6 +1417,20 @@ "enabled", "foreground", "style-class" + ], + "list-section": [ + "key", + "separator" + ], + "swipe-actions": [], + "swipe-action": [ + "text", + "icon", + "variant", + "edge", + "enabled", + "background", + "press-enabled" ] }, "kindExtraProperties": { diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..394bdbe6 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -30,6 +30,10 @@ 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 ] 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 +51,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. *) @@ -130,6 +135,24 @@ 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 icon_value : icon -> string = function | `app name -> "app:" ^ name | `alert -> "alert" @@ -258,6 +281,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) @@ -280,6 +304,8 @@ let is_toggle = function ToggleChanged _ -> true | _ -> false let is_dismiss = function Dismiss _ -> 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) @@ -312,6 +338,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; @@ -434,10 +466,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 @@ -1358,10 +1402,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 +1420,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 +1454,84 @@ let list_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal | None -> ()); attach context parent node; mount_children context node children; + Option.iter + (fun actions -> + let container = Lui_ui.swipe_actions context in + attach context (Some node) container; + mount_children context container actions) + swipe_actions; + node + +let list_section ?key ?accessibility_identifier ?separator ?header ?footer + (children : t list) : t = + fun context parent -> + let node = Lui_ui.list_section context in + Option.iter (Lui_ui.key context node) key; + Option.iter (Lui_ui.string_property context node KeyValue) key; + Option.iter + (Lui_ui.string_property context node AccessibilityIdentifier) + accessibility_identifier; + Option.iter (Lui_ui.string_property context node SeparatorValue) + (Option.map separator_value separator); + attach context parent node; + Option.iter + (fun content -> + let slot = Lui_ui.list_section_header context in + attach context (Some node) slot; + ignore (content context (Some slot))) + header; + mount_children context node children; + Option.iter + (fun content -> + let slot = Lui_ui.list_section_footer context in + attach context (Some node) slot; + ignore (content context (Some slot))) + footer; + node + +let swipe_actions ?key ?accessibility_identifier + (children : swipe_action_el list) : t = + fun context parent -> + let node = Lui_ui.swipe_actions context in + Option.iter (Lui_ui.key context node) key; + Option.iter + (Lui_ui.string_property context node AccessibilityIdentifier) + accessibility_identifier; + attach context parent node; + mount_children context node children; + node + +let swipe_action ?key ?accessibility_identifier ?text ?text_signal ?icon + ?icon_signal ?variant ?edge ?background ?disabled ?on_press + (_children : nothing list) : swipe_action_el = + fun context parent -> + let node = Lui_ui.swipe_action context in + Option.iter (Lui_ui.key context node) key; + Option.iter + (Lui_ui.string_property context node AccessibilityIdentifier) + accessibility_identifier; + Option.iter (Lui_ui.string_property context node TextValue) text; + Option.iter (Lui_ui.string_property_signal context node TextValue) + text_signal; + Option.iter (Lui_ui.string_property context node InlineIconName) + (Option.map icon_value icon); + Option.iter + (fun signal -> + Lui_ui.string_property_signal context node InlineIconName + (Signal.map icon_value signal)) + icon_signal; + Option.iter (Lui_ui.string_property context node VariantValue) + (Option.map variant_value variant); + Option.iter (Lui_ui.string_property context node EdgeValue) + (Option.map swipe_edge_value edge); + Option.iter (Lui_ui.string_property context node BackgroundValue) background; + Option.iter (Lui_ui.disabled context node) disabled; + (match on_press with + | Some handler -> + enable context node PressEnabled; + register_press context node handler + | None -> ()); + attach context parent node; node let avatar ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?text ?text_signal ?image ?image_signal ?source_x ?source_y ?source_width ?source_height ?label (_children : nothing list) : t = diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1c..e027de10 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -33,6 +33,10 @@ 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 ] (** [~icon]/[~name] values: the schema icon names, or [`app "name"] for an application-registered icon ([app:name] on the wire). *) @@ -54,6 +58,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 +140,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 @@ -540,7 +550,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 -> @@ -2378,7 +2396,33 @@ val list_item : ?on_double_press:(Lui_protocol.event -> unit) -> ?on_submit:(Lui_protocol.event -> unit) -> ?on_input:(Lui_protocol.event -> unit) -> - ?on_toggle:(Lui_protocol.event -> unit) -> t list -> t + ?on_toggle:(Lui_protocol.event -> unit) -> + ?separator:separator_visibility -> + ?swipe_actions:swipe_action_el list -> t list -> t + +(** A named group of rows inside a {!list}: [~header]/[~footer] take any + element and mount it in a dedicated slot node, [~key] sets both the + reload key and the wire [key] identity used by scroll targeting. *) +val list_section : + ?key:string -> + ?accessibility_identifier:string -> + ?separator:separator_visibility -> + ?header:t -> ?footer:t -> t list -> t +val swipe_actions : + ?key:string -> + ?accessibility_identifier:string -> swipe_action_el list -> t +val swipe_action : + ?key:string -> + ?accessibility_identifier:string -> + ?text:string -> + ?text_signal:string Signal.signal -> + ?icon:icon -> + ?icon_signal:icon Signal.signal -> + ?variant:variant -> + ?edge:swipe_edge -> + ?background:string -> + ?disabled:bool -> + ?on_press:(Lui_protocol.event -> unit) -> nothing list -> swipe_action_el val avatar : ?key:string -> ?gap:int -> diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index ebdfeea7..c16f8e68 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -76,6 +76,11 @@ type node_kind = | Toast | Toolbar | StatusBar + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions + | SwipeAction type operating_system = | GenericOS @@ -173,6 +178,15 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | KeyValue + | SeparatorValue + | StyleValue + | ScrollTarget + | ScrollAnchor + | ScrollToken + | ScrollAnimated + | TrackVisibleRange + | EdgeValue module Property_map = Map.Make @@ -199,6 +213,8 @@ type event = | Dismiss of int | DoublePress of int | Appear of int + | ScrollCompleted of int * int * string + | VisibleRange of int * int * int | ExtensionEvent of int * string * string * wire_value String_map.t type patch_op = @@ -239,6 +255,8 @@ let event_node event = | Dismiss node | DoublePress node | Appear node + | ScrollCompleted (node, _, _) + | VisibleRange (node, _, _) | ExtensionEvent (node, _, _, _) -> node let modal_surface kind = kind = Dialog || kind = Drawer || kind = Sheet @@ -305,7 +323,8 @@ let event_supported kind event = | Text | TableCell | TimelineItem - | BottomTab -> true + | BottomTab + | SwipeAction -> true | _ -> false) | LongPress _ -> (match kind with @@ -340,7 +359,8 @@ let event_supported kind event = | Toggle | Radio | Accordion - | Drawer -> true + | Drawer + | ListItem -> true | _ -> false) | Change _ -> kind = Radio | ValueChanged _ -> @@ -360,6 +380,7 @@ let event_supported kind event = | _ -> false) | DoublePress _ -> kind = ListItem | Appear _ -> kind <> Root + | ScrollCompleted _ | VisibleRange _ -> kind = ListContainer | ExtensionEvent _ -> false let true_property properties property = @@ -564,7 +585,11 @@ let can_contain_children kind = | Alert | Bubble | BottomTabs - | BottomTab -> true + | BottomTab + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions -> true | _ -> false let common_property_supported kind property = @@ -712,6 +737,14 @@ let common_property_supported kind property = || kind = StatusBar | RoleValue -> tree_row_kind kind || kind = ListItem | TreeLevel | Expanded -> tree_row_kind kind + | KeyValue | SeparatorValue -> kind = ListItem || kind = ListSection + | StyleValue + | ScrollTarget + | ScrollAnchor + | ScrollToken + | ScrollAnimated + | TrackVisibleRange -> kind = ListContainer + | EdgeValue -> kind = SwipeAction | ResizeDuration | ResizeEasing | ResizeOrigin -> kind = Split | ThemeValue | ThemeMode -> can_contain_children kind | TextValue -> @@ -827,6 +860,19 @@ let property_supported kind property = property = AccessibilityLabel || property = WidthValue || property = HeightValue || property = MinWidth || property = GrowValue | InputGroupActions -> property = Gap + | ListSection -> property = KeyValue || property = SeparatorValue + | SwipeActions -> false + | SwipeAction -> + List.mem + property + [ TextValue + ; InlineIconName + ; VariantValue + ; EdgeValue + ; Enabled + ; BackgroundValue + ; PressEnabled + ] | Dialog -> property = DescriptionValue || common_property_supported kind property | _ -> common_property_supported kind property @@ -917,6 +963,19 @@ 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 = "leading" || value = "trailing" | _ -> false let property_value_supported_for_kind kind property value = @@ -994,6 +1053,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 @@ -1077,7 +1140,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 +1162,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..256ec72b 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -74,6 +74,11 @@ type node_kind = | Toast | Toolbar | StatusBar + | ListSection + | ListSectionHeader + | ListSectionFooter + | SwipeActions + | SwipeAction type operating_system = | GenericOS @@ -171,6 +176,15 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | KeyValue + | SeparatorValue + | StyleValue + | ScrollTarget + | ScrollAnchor + | ScrollToken + | ScrollAnimated + | TrackVisibleRange + | EdgeValue module Property_map : Map.S with type key = property @@ -194,6 +208,8 @@ type event = | Dismiss of int | DoublePress of int | Appear of int + | ScrollCompleted of int * int * string + | VisibleRange of int * int * int | 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..39ecb5de 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -133,6 +133,11 @@ let accordion context = create context Accordion let menu_item context = create context MenuItem let menu_trigger context = create context MenuTrigger let list_item context = create context ListItem +let list_section context = create context ListSection +let list_section_header context = create context ListSectionHeader +let list_section_footer context = create context ListSectionFooter +let swipe_actions context = create context SwipeActions +let swipe_action context = create context SwipeAction let avatar context = create context Avatar let image context = create context Image let media_surface context = create context MediaSurface diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 72e11fcf..e0c2229e 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -89,6 +89,11 @@ val accordion : ui_context -> int val menu_item : ui_context -> int val menu_trigger : ui_context -> int val list_item : ui_context -> int +val list_section : ui_context -> int +val list_section_header : ui_context -> int +val list_section_footer : ui_context -> int +val swipe_actions : ui_context -> int +val swipe_action : ui_context -> int val avatar : ui_context -> int val image : ui_context -> int val media_surface : ui_context -> int diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329b..174b4fbf 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -77,6 +77,11 @@ 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" let standard_node_name name = match name with @@ -152,6 +157,11 @@ 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 | _ -> false let property_name property = @@ -229,6 +239,15 @@ 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" let kind_property_matrix kind = match kind with @@ -244,6 +263,9 @@ 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 ] | _ -> None let kind_extra_properties kind = @@ -251,6 +273,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; 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; ListSection; ListSectionHeader; ListSectionFooter; SwipeActions; SwipeAction ] -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 ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb2..de2ab8ae 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,113 @@ 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_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -1483,6 +1590,8 @@ 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 "property matrix sync" `Quick test_property_matrix_sync; ] ); From 4ff32f91d318842cb2f9fb1ef9175ed1accc3121 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:21:00 -0700 Subject: [PATCH 06/17] gallery: demo pages for edge-inset, overlay, view-that-fits Edge Inset page toggles the pinned region via 'visible' and shows top and bottom bars over scrolling lists; Overlay floats aligned children over a base card; View That Fits swaps expanded/compact variants on window resize. --- examples/gallery/view.ml | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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 From 72dada92ed03ad432692799558df87a098116924 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:32:44 -0700 Subject: [PATCH 07/17] review fixes: web link/file props, rootID-scoped previews, qt/winui event gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: link renders as a structured (icon + content spans) so inline icon, text, and children compose; url maps to href (+target=_blank, rel=noopener), path/max-pixel-size become data-* attributes with matching remove handlers; file-image clicks emit Press when press-enabled. - apple: both filePreviewBindings filter on the presentation's rootID and only dismiss previews owned by that root — multiple LUISwiftUIRoots no longer share/dismiss one global preview. - qt: performPress admits FileImage with the same press-enabled guard; performDismiss admits FilePreview (Dismiss is advertised for it). - winui: PerformAction admits FileImage gated on PressEnabled; PerformDismiss admits FilePreview. --- .agents/skills/testing-lui-demos/SKILL.md | 15 ++++++++++++++ .../LUIAppleBackend/LUISwiftUIRoot.swift | 14 +++++++++++-- platform/qt/lib/lui_qml_backend.cpp | 5 ++++- platform/web/melange/core/lui_web_util.ml | 1 + platform/web/melange/events/lui_web_events.ml | 2 +- platform/web/melange/nodes/lui_web_nodes.ml | 10 +++++++++- platform/web/melange/render/lui_web_props.ml | 16 +++++++++++++++ platform/web/src/lui.css | 20 +++++++++++++++++++ platform/winui/LUI.Core/LUIBackend.cs | 4 ++++ 9 files changed, 82 insertions(+), 5 deletions(-) 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/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 37fcb19d..e9b6a157 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -151,9 +151,14 @@ public struct LUISwiftUIRoot: View { private var filePreviewBinding: Binding { Binding( - get: { backend.filePreviewPresentation.item?.url }, + 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) @@ -191,9 +196,14 @@ public struct LUIModalHostModifier: ViewModifier { private var filePreviewBinding: Binding { Binding( - get: { backend.filePreviewPresentation.item?.url }, + 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) diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8e92d688..45fab37a 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -1179,6 +1179,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 +1242,8 @@ 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 && !modalSurface(state->kind)) { return fail(QStringLiteral("node %1 is not dismissible").arg(node)); } emitEvent(node, QStringLiteral("dismiss")); diff --git a/platform/web/melange/core/lui_web_util.ml b/platform/web/melange/core/lui_web_util.ml index 4843c911..e20a8175 100644 --- a/platform/web/melange/core/lui_web_util.ml +++ b/platform/web/melange/core/lui_web_util.ml @@ -149,6 +149,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..648be8eb 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -422,7 +422,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 diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index 6a2e6e79..e89cba2f 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -159,6 +159,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 @@ -262,7 +270,6 @@ let simple_node_tag kind = | Slider -> "input" | Divider -> "hr" | Tooltip -> "span" - | Link -> "a" | _ -> "div" let simple_node_attributes kind = @@ -379,6 +386,7 @@ let platform_node renderer kind = | 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..1c1aaec6 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -211,6 +211,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 @@ -621,6 +623,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 @@ -690,6 +700,12 @@ let remove_property renderer node kind dom_node property = W.Element.removeAttribute "aria-label" (if Store.direct_toggle kind then Util.child_element dom_node 0 else dom_node) + | 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/src/lui.css b/platform/web/src/lui.css index ecb4bf85..4e46abcd 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -1161,6 +1161,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..5621c03a 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -1351,6 +1351,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)) @@ -1481,6 +1484,7 @@ public void PerformDismiss(long node) state.Kind != LUINodeKind.Combobox && state.Kind != LUINodeKind.DropdownMenu && state.Kind != LUINodeKind.Toast && + state.Kind != LUINodeKind.FilePreview && !LUISchema.ModalSurface(state.Kind)) { throw new LUIBackendException($"node {node} is not dismissible"); From 497a9bbcd229e44e7a1b41dc0709f0a4cfbbd11d Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:46:50 -0700 Subject: [PATCH 08/17] fix: non-Apple backend bugs in edge-inset/overlay/view-that-fits - WinUI: route the new kinds through SyncStackLike so children render - Web: handle edge/visible/alignment in apply_secondary_property and remove_property; overlay children go position:absolute so they no longer contribute intrinsic size to the grid - Protocol validators (OCaml/Swift/Qt/WinUI/Flutter): admit 'alignment' ahead of the restrictive kindProperties matrices, and whitelist the new kinds for 'foreground' - Flutter: group pinned edge-inset children in an edge-appropriate Row/Column, honor 'gap', and use AlignmentDirectional so leading/trailing follow RTL - background:'bar' gets a translucent surface fallback on Flutter, Qt, WinUI, and web - Qt: bind anchors declaratively so retained delegates re-anchor when alignment/edge changes --- .../LUIAppleBackend/LUIWireProtocol.swift | 11 +- platform/flutter/lib/lui_flutter_backend.dart | 103 +++++++++++------- platform/qt/lib/lui_schema.cpp | 10 +- platform/qt/qml/LuiEdgeInset.qml | 84 +++++++------- platform/qt/qml/LuiOverlay.qml | 81 +++++--------- platform/qt/qml/LuiStyle.js | 1 + platform/web/melange/core/lui_web_util.ml | 4 + platform/web/melange/render/lui_web_props.ml | 10 ++ platform/web/src/lui.css | 96 ++++++++++++++-- platform/winui/LUI.Core/LUISchema.cs | 12 +- platform/winui/LUI.WinUI/LUIElement.Sync.cs | 5 + platform/winui/LUI.WinUI/LUIThemeColors.cs | 3 + src/lui_protocol.ml | 12 +- test/test_lui.ml | 12 ++ 14 files changed, 297 insertions(+), 147 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index fbe83338..257599a0 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -698,6 +698,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. @@ -735,6 +739,8 @@ 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 || @@ -838,9 +844,8 @@ struct LUIRetainedTree { case .treeLevel, .expanded: isTreeRow(kind) case .active, .title, .description, .meta, .indicator, .connector: false case .edge, .visible: kind == .edgeInset - // Per-child position hint for `overlay` content (and the overlay's - // own default); inert on other kinds, like container-relative-frame. - case .alignment: true + // `.alignment` is admitted ahead of the restrictive matrix above. + case .alignment: kind != .root } } diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index c6356862..fe2b57d3 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1949,36 +1949,41 @@ final class LUIFlutterBackend { ); } - static Alignment? _overlayAlignment(String? name) => switch (name) { - 'top-leading' => Alignment.topLeft, - 'top' => Alignment.topCenter, - 'top-trailing' => Alignment.topRight, - 'leading' => Alignment.centerLeft, - 'trailing' => Alignment.centerRight, - 'bottom-leading' => Alignment.bottomLeft, - 'bottom' => Alignment.bottomCenter, - 'bottom-trailing' => Alignment.bottomRight, - _ => null, - }; - /// Pins the children after the first to `edge` while the first child - /// fills the view beneath them. + /// 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 alignment = switch (state.properties['edge'] as String? ?? 'top') { - 'bottom' => Alignment.bottomCenter, - 'leading' => Alignment.centerLeft, - 'trailing' => Alignment.centerRight, - _ => Alignment.topCenter, - }; + 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) - for (final pinned in children.skip(1)) - Positioned.fill( - child: Align(alignment: alignment, child: pinned), + 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), + ), ), + ), ], ); } @@ -1989,7 +1994,11 @@ final class LUIFlutterBackend { Widget overlay() { final fallback = _overlayAlignment(state.properties['alignment'] as String?) ?? - Alignment.center; + 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, @@ -1998,7 +2007,7 @@ final class LUIFlutterBackend { child: Align( alignment: _overlayAlignment( - _states[state.children[index]]?.properties['alignment'] + _states[overlayIDs[index]]?.properties['alignment'] as String?, ) ?? fallback, @@ -3531,6 +3540,20 @@ final class LUIFlutterBackend { } } + /// 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) { @@ -3540,6 +3563,13 @@ final class LUIFlutterBackend { _themeModes.contains(value)); } if (kind == _NodeKind.contextMenu) return false; + // 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, @@ -3838,21 +3868,9 @@ final class LUIFlutterBackend { const {'top', 'bottom', 'leading', 'trailing'}.contains(value) && kind == _NodeKind.edgeInset, 'visible' => value is bool && kind == _NodeKind.edgeInset, - // Per-child position hint for overlay content (and the overlay's own - // default); inert on other kinds, like container-relative-frame. + // 'alignment' is admitted ahead of the restrictive kinds above. 'alignment' => - value is String && - const { - 'top-leading', - 'top', - 'top-trailing', - 'leading', - 'center', - 'trailing', - 'bottom-leading', - 'bottom', - 'bottom-trailing', - }.contains(value), + value is String && _overlayAlignment(value) != null, 'tooltip-delay' => value is int && value >= 0 && @@ -3888,7 +3906,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 || @@ -4796,6 +4817,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, diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 3ed10579..10023ba3 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -415,6 +415,8 @@ 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, @@ -594,8 +596,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { case Property::EdgeValue: case Property::Visible: return kind == NodeKind::EdgeInset; - // Per-child position hint for overlay content (and the overlay's own - // default); inert on other kinds, like container-relative-frame. + // `alignment` is admitted ahead of the restrictive arms in + // propertySupported below. case Property::AlignmentValue: return kind != NodeKind::Root; } @@ -606,6 +608,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: diff --git a/platform/qt/qml/LuiEdgeInset.qml b/platform/qt/qml/LuiEdgeInset.qml index 926aa40d..dd7fbe0f 100644 --- a/platform/qt/qml/LuiEdgeInset.qml +++ b/platform/qt/qml/LuiEdgeInset.qml @@ -2,8 +2,7 @@ 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 children in place; `gap` is the spacing between content and the -// pinned children (here: pinned children simply overlay at the edge). +// pinned region in place; `gap` insets it from the pinned edge. Item { id: edgeInset required property var node @@ -12,44 +11,53 @@ Item { 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: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 - implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight - Repeater { - id: rep - model: edgeInset.node ? edgeInset.node.children : [] - delegate: LuiNodeView { - required property var modelData - required property int index - node: modelData - visible: index === 0 || edgeInset.pinnedVisible - } - onItemAdded: function(index, item) { - if (index === 0) { - item.anchors.fill = edgeInset - return - } - switch (edgeInset.edge) { - case "bottom": - item.anchors.bottom = edgeInset.bottom - item.anchors.left = edgeInset.left - item.anchors.right = edgeInset.right - break - case "leading": - item.anchors.left = edgeInset.left - item.anchors.top = edgeInset.top - item.anchors.bottom = edgeInset.bottom - break - case "trailing": - item.anchors.right = edgeInset.right - item.anchors.top = edgeInset.top - item.anchors.bottom = edgeInset.bottom - break - default: - item.anchors.top = edgeInset.top - item.anchors.left = edgeInset.left - item.anchors.right = edgeInset.right + 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/LuiOverlay.qml b/platform/qt/qml/LuiOverlay.qml index 66c37346..64516b56 100644 --- a/platform/qt/qml/LuiOverlay.qml +++ b/platform/qt/qml/LuiOverlay.qml @@ -11,51 +11,6 @@ Item { implicitWidth: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 - function alignmentOf(item) { - var childProps = item && item.node ? item.node.properties : null - return (childProps && childProps.alignment) || props.alignment || - "center" - } - - function anchorOverlay(item) { - switch (alignmentOf(item)) { - case "top-leading": - item.anchors.top = overlay.top - item.anchors.left = overlay.left - break - case "top": - item.anchors.top = overlay.top - item.anchors.horizontalCenter = overlay.horizontalCenter - break - case "top-trailing": - item.anchors.top = overlay.top - item.anchors.right = overlay.right - break - case "leading": - item.anchors.left = overlay.left - item.anchors.verticalCenter = overlay.verticalCenter - break - case "trailing": - item.anchors.right = overlay.right - item.anchors.verticalCenter = overlay.verticalCenter - break - case "bottom-leading": - item.anchors.bottom = overlay.bottom - item.anchors.left = overlay.left - break - case "bottom": - item.anchors.bottom = overlay.bottom - item.anchors.horizontalCenter = overlay.horizontalCenter - break - case "bottom-trailing": - item.anchors.bottom = overlay.bottom - item.anchors.right = overlay.right - break - default: - item.anchors.centerIn = overlay - } - } - Repeater { id: rep model: overlay.node ? overlay.node.children : [] @@ -63,13 +18,37 @@ Item { required property var modelData required property int index node: modelData - } - onItemAdded: function(index, item) { - if (index === 0) { - item.anchors.fill = overlay - } else { - overlay.anchorOverlay(item) + + 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/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/web/melange/core/lui_web_util.ml b/platform/web/melange/core/lui_web_util.ml index 4843c911..9409f826 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 diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 19c5c818..d34e8e81 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -635,6 +635,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,6 +689,10 @@ 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) "" diff --git a/platform/web/src/lui.css b/platform/web/src/lui.css index 2d888671..0aa70bc0 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -397,21 +397,101 @@ body { @apply relative; } - /* edge-inset/overlay/view-that-fits: the web backend approximates the - platform semantics — all children share one grid cell (stacked over the - first child); view-that-fits renders only its first child. */ - .lui-edge-inset, - .lui-overlay, + /* 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 relative grid; + @apply grid; } - .lui-edge-inset > *, - .lui-overlay > *, .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; } diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index ef0d7c7d..5b3041a7 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -451,6 +451,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: @@ -663,8 +666,8 @@ public static bool CommonPropertySupported( case LUIProperty.EdgeValue: case LUIProperty.Visible: return kind == LUINodeKind.EdgeInset; - // Per-child position hint for overlay content (and the - // overlay's own default); inert elsewhere. + // AlignmentValue is admitted ahead of the restrictive matrix + // in PropertySupported. case LUIProperty.AlignmentValue: return kind != LUINodeKind.Root; case LUIProperty.TextValue: @@ -760,6 +763,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. diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index 28d01ada..df808124 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: 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/src/lui_protocol.ml b/src/lui_protocol.ml index 097bc6c5..f0315a9a 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -608,6 +608,7 @@ 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 @@ -618,6 +619,10 @@ let common_property_supported kind property = (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 @@ -802,12 +807,13 @@ let common_property_supported kind property = || kind = EdgeInset || horizontal_container kind | EdgeValue | Visible -> kind = EdgeInset - (* Honored on [overlay] children (position hint) and on [overlay] itself - as the default; inert elsewhere, like [container-relative-frame]. *) - | AlignmentValue -> kind <> Root 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. *) diff --git a/test/test_lui.ml b/test/test_lui.ml index d12cc4e3..137efd65 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -996,8 +996,18 @@ let test_edge_overlay_fit_rules () = (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 @@ -1030,6 +1040,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) From 328cb012b5b722cf44b98f083e13756038fe2626 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:56:53 -0700 Subject: [PATCH 09/17] winui: fix LUIProperty.Grow -> GrowValue enum name --- platform/winui/LUI.Core/LUIBackend.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index 3097f83a..48235184 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -354,7 +354,7 @@ void ApplyState( LUIWireValue value = WireValue( operation.GetProperty("value"), "value"); if (value is LUIWireValue.Int intValue && - (property == LUIProperty.Grow || + (property == LUIProperty.GrowValue || property == LUIProperty.AnchorOffset || property == LUIProperty.SourceX || property == LUIProperty.SourceY || From e40bf9885db1e69cb5b480eeb1ac556d2cd99f77 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:58:17 -0700 Subject: [PATCH 10/17] Address review: lifecycle-safe picker operations, streamed photo import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hold file-picker operations on the backend keyed by node id so view teardown can't release retained files; drop-node releases them. - Stream PhotosPicker items through FileRepresentation instead of loading whole assets into memory as Data. - Guard UIImagePickerController.isSourceTypeAvailable(.camera) — a camera-less device now answers dismiss instead of throwing. - Mark photo selections handled before clearing so async imports are not mistaken for cancels; finishCancelled also tears down the camera sheet; requests equal to completion no longer re-present. - Flutter/WinUI backends answer unservable file-picker requests with dismiss after the batch commits; the Qt stub does the same in QML. - Lui_elements.file_picker request/completion take a file_picker_token ([`String | `Int]) matching the wire's string|int property values. --- .../LUIAppleBackend/LUIAppleBackend.swift | 35 ++++ .../LUIAppleBackend/LUIFilePickerView.swift | 181 ++++++++++++------ .../LUIFilePickerTests.swift | 18 ++ platform/flutter/lib/lui_flutter_backend.dart | 22 ++- platform/qt/lib/lui_qml_backend.cpp | 3 +- platform/qt/qml/LuiFilePicker.qml | 21 +- platform/winui/LUI.Core/LUIBackend.cs | 37 +++- src/lui_elements.ml | 39 +++- src/lui_elements.mli | 13 +- src/lui_ui.ml | 8 + src/lui_ui.mli | 7 + test/test_lui.ml | 11 +- 12 files changed, 317 insertions(+), 78 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 23b98899..f38e1ba9 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -397,6 +397,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 @@ -906,6 +940,7 @@ 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] { diff --git a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift index 26e36c29..31aed1d2 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift @@ -4,6 +4,32 @@ import UniformTypeIdentifiers 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. @@ -21,34 +47,25 @@ import PhotosUI /// `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 -/// the request is answered with `dismiss` without presenting anything. +/// (or a camera-less device) the request is answered with `dismiss` +/// without presenting anything. struct LUIFilePickerView: View { let model: LUINodeModel let backend: LUIAppleBackend - /// Picked files held until `completion` acknowledges the request token. - private struct RetainedFile { - 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 - } - - /// The request token currently being served: presenting, or picked and - /// awaiting its `completion`. Cleared on cancel or completion match. - @State private var operation: LUIWireValue? /// 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 - @State private var retained: [RetainedFile] = [] #if canImport(PhotosUI) @State private var photoSelection: [PhotosPickerItem] = [] #endif + private var operation: LUIFilePickerOperation? { + backend.filePickerOperation(node: model.id) + } + var body: some View { ForEach(model.visibleChildren, id: \.self) { childID in LUIAnyNodeView(nodeID: childID, backend: backend) @@ -82,23 +99,21 @@ struct LUIFilePickerView: View { } #endif .onAppear { - evaluate() 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`). Both state - // writes of a pick land in one transaction, so a non-empty - // photoSelection means the selection handler will claim it. - guard !isPresented, operation != nil, !handled else { return } - #if canImport(PhotosUI) - if source == "photos", !photoSelection.isEmpty { return } - #endif + // 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() } - .onDisappear { releaseRetained() } } // MARK: - properties @@ -192,14 +207,38 @@ struct LUIFilePickerView: View { // MARK: - state machine private func evaluate() { - guard let request, operation == nil, enabled else { return } - operation = request + // 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) - presented = true + // 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 - // No camera capture on macOS: answer the request with a cancel. finishCancelled() #endif } else { @@ -208,45 +247,42 @@ struct LUIFilePickerView: View { } private func evaluateCompletion() { - guard let completion, let operation, completion == operation else { return } - releaseRetained() - self.operation = nil + guard let completion, let operation, completion == operation.token + else { return } + backend.clearFilePickerOperation(node: model.id) + presented = false } - private func finishPicked(files: [RetainedFile]) { - guard let operation else { return } + 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 - retained = files - emitPicked(token: operation, files: files) + 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 - releaseRetained() - operation = nil + presented = false + backend.clearFilePickerOperation(node: model.id) try? backend.performDismiss(node: model.id) } - private func releaseRetained() { - for file in retained { - if file.securityScoped { - file.url.stopAccessingSecurityScopedResource() - } - if file.temporary { - try? FileManager.default.removeItem(at: file.url) - } - } - retained = [] - } - // MARK: - results private func handleFileImport(_ result: Result<[URL], Error>) { switch result { case let .success(urls) where !urls.isEmpty: let files = urls.map { url in - RetainedFile( + LUIRetainedFile( url: url, securityScoped: url.startAccessingSecurityScopedResource(), temporary: false @@ -263,9 +299,13 @@ struct LUIFilePickerView: View { #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: [RetainedFile] = [] + var files: [LUIRetainedFile] = [] for item in items { guard let file = await Self.importPhotoItem(item) else { continue } files.append(file) @@ -278,16 +318,35 @@ struct LUIFilePickerView: View { } } - private static func importPhotoItem(_ item: PhotosPickerItem) async -> RetainedFile? { - guard let data = try? await item.loadTransferable(type: Data.self) else { + /// 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 } - let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "bin" - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("lui-picked-\(UUID().uuidString)") - .appendingPathExtension(ext) - guard (try? data.write(to: url)) != nil else { return nil } - return RetainedFile(url: url, securityScoped: false, temporary: true) + 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 @@ -296,7 +355,7 @@ struct LUIFilePickerView: View { switch outcome { case let .captured(url): finishPicked(files: [ - RetainedFile(url: url, securityScoped: false, temporary: true), + LUIRetainedFile(url: url, securityScoped: false, temporary: true), ]) case .cancelled: finishCancelled() @@ -306,7 +365,7 @@ struct LUIFilePickerView: View { // MARK: - payload - private func emitPicked(token: LUIWireValue, files: [RetainedFile]) { + private func emitPicked(token: LUIWireValue, files: [LUIRetainedFile]) { let request: Any = switch token { case let .string(value): value case let .int(value): NSNumber(value: value) diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift index 7e6e4d12..6661a3c1 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIFilePickerTests.swift @@ -109,4 +109,22 @@ struct LUIFilePickerTests { 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 95012f78..1e88ee6c 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -688,8 +688,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); @@ -778,6 +779,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 @@ -3176,6 +3189,7 @@ final class LUIFlutterBackend { Map states, Map extensions, Map operation, + Set filePickerRequests, ) { switch (_string(operation['op'], 'op')) { case 'create-node': @@ -3218,7 +3232,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)) { @@ -3227,6 +3242,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'); diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8e92d688..e17bddb4 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -1240,7 +1240,8 @@ 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::FilePicker && + !modalSurface(state->kind)) { return fail(QStringLiteral("node %1 is not dismissible").arg(node)); } emitEvent(node, QStringLiteral("dismiss")); diff --git a/platform/qt/qml/LuiFilePicker.qml b/platform/qt/qml/LuiFilePicker.qml index 68e01e81..acbe94ad 100644 --- a/platform/qt/qml/LuiFilePicker.qml +++ b/platform/qt/qml/LuiFilePicker.qml @@ -2,12 +2,31 @@ import QtQuick import QtQuick.Layouts // Wire kind: file-picker — non-visual element; the Qt backend does not -// present a native file dialog. Children (if any) render inline. +// 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 diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index e9bc47d0..9c52d71c 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")) { @@ -363,6 +391,11 @@ void ApplyState( $"{propertyName}"); } node.Properties[property] = value; + if (node.Kind == LUINodeKind.FilePicker && + property == LUIProperty.PickerRequest) + { + filePickerRequests.Add(id); + } break; } case "remove-prop": diff --git a/src/lui_elements.ml b/src/lui_elements.ml index edeec22f..b262d77b 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -36,6 +36,11 @@ type role = [ `treeitem | `navigation | `navigation_heading ] 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 | `app of string ] @@ -140,6 +145,12 @@ let file_picker_source_value : file_picker_source -> string = function | `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" @@ -1266,16 +1277,36 @@ let file_picker ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizont 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 (Lui_ui.string_property context node PickerRequest) request; - Option.iter (Lui_ui.string_property_signal context node PickerRequest) request_signal; + 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 (Lui_ui.string_property context node PickerCompletion) completion; - Option.iter (Lui_ui.string_property_signal context node PickerCompletion) completion_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; diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 975c8b48..1f0deb40 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -39,6 +39,11 @@ type role = [ `treeitem | `navigation | `navigation_heading ] 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). *) type icon = @@ -2262,16 +2267,16 @@ val file_picker : ?style_class:string -> ?on_appear:(Lui_protocol.event -> unit) -> ?source:file_picker_source -> - ?request:string -> - ?request_signal:string Signal.signal -> + ?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:string -> - ?completion_signal:string 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 : diff --git a/src/lui_ui.ml b/src/lui_ui.ml index 2e4eabd2..ee97c0f8 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -309,6 +309,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 b53ca989..cfa2490b 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -151,6 +151,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/test/test_lui.ml b/test/test_lui.ml index a646a213..8ae12d61 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -1000,6 +1000,7 @@ let test_file_picker () = 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) @@ -1007,15 +1008,17 @@ let test_file_picker () = Lui_elements.column [ capture_node picker_node (Lui_elements.file_picker ~source:`photos - ~request:"op-1" ~types:"public.image" ~multiple:true - ~completion:"" + ~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; @@ -1039,6 +1042,8 @@ let test_file_picker () = (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 From cb2e08aeb04f276333454785be21679e3c97f98e Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 01:11:05 -0700 Subject: [PATCH 11/17] Fix review findings: visible-range on reorder, cross-platform swipe-action handling - Apple: emit visible-range on flatRowOrder changes (insert/remove/reorder with same visible ids); wrap headerless explicit sections in Section{}; apply heading/footnote inference to pending non-section runs; resolve scroll targets by section key; guard fallback cancelled emission with handledScrollToken; keep context-menu ellipsis when explicit swipe-actions exist. - Flutter: exclude swipe-actions children from list-item row content. - Web: treat swipe-actions as hidden metadata in list-item content validation and DOM placement (never mounted). - Qt/WinUI: require text or icon on swipe-action for parity. --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 80 ++++++++++++++----- platform/flutter/lib/lui_flutter_backend.dart | 4 +- platform/qt/lib/lui_schema.cpp | 5 ++ platform/web/melange/core/lui_web_store.ml | 34 ++++++-- platform/web/melange/shell/lui_web_apply.ml | 11 ++- platform/winui/LUI.Core/LUISchema.cs | 6 ++ 6 files changed, 109 insertions(+), 31 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index ddafe4fe..f6e49150 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -3949,6 +3949,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 && @@ -5205,18 +5210,20 @@ enum LUIListSectionPolicy { @MainActor static func explicitSections( childIDs: [Int], - model: (Int) -> LUINodeModel? + model: (Int) -> LUINodeModel?, + isHeading: (Int) -> Bool, + isFooter: (Int) -> Bool ) -> [LUIListSection] { var result: [LUIListSection] = [] var pending: [Int] = [] func flush() { guard !pending.isEmpty else { return } - result.append(LUIListSection( - nodeID: nil, - headerID: nil, + // Runs of plain children still get heading/footnote inference. + result.append(contentsOf: sections( childIDs: pending, - footerID: nil + isHeading: isHeading, + isFooter: isFooter )) pending = [] } @@ -5316,6 +5323,11 @@ private struct LUIListView: View { } } .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 listRowTracker: LUIListRowTracker? { @@ -5344,8 +5356,10 @@ private struct LUIListView: View { } } .onChange(of: model.scrollToken, initial: true) { _, _ in - // No scroll surface of its own to scroll on. - if let token = model.scrollToken { + // 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, @@ -5406,6 +5420,15 @@ private struct LUIListView: View { .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) .modifier(LUIListSectionSeparatorModifier( @@ -5513,25 +5536,31 @@ private struct LUIListView: View { }) { return LUIListSectionPolicy.explicitSections( childIDs: childIDs, - model: { backend.model(id: $0) } + model: { backend.model(id: $0) }, + isHeading: isHeading, + isFooter: isFooter ) } return LUIListSectionPolicy.sections( childIDs: childIDs, - 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 - } + 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] { @@ -5567,7 +5596,18 @@ private struct LUIListView: View { } private func rowID(forKey key: String) -> Int? { - flatRowOrder.first { backend.model(id: $0)?.rowKey == key } + 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) { diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 653061c3..f0205ff1 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1547,7 +1547,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]; diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 2c6730d4..33163d3f 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -1022,6 +1022,11 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { return false; } } + if (kind == NodeKind::SwipeAction && + !stringPropertyNonempty(properties, "text") && + !stringPropertyNonempty(properties, "icon")) { + return false; + } return true; } diff --git a/platform/web/melange/core/lui_web_store.ml b/platform/web/melange/core/lui_web_store.ml index ebaeb70c..d7f153b4 100644 --- a/platform/web/melange/core/lui_web_store.ml +++ b/platform/web/melange/core/lui_web_store.ml @@ -319,22 +319,35 @@ let node_properties_error current = else if kind = Icon then "icon requires a valid name" else "node properties conflict" -let context_menu_child nodes child_id = +let metadata_child nodes child_id = match Hashtbl.find_opt nodes child_id with - | Some current -> standard_kind_is current ContextMenu + | Some current -> + standard_kind_is current ContextMenu + || standard_kind_is current SwipeActions + | None -> false + +let list_item_row_child nodes child_id = + match Hashtbl.find_opt nodes child_id with + | Some current -> standard_kind_is current ListItem | None -> false let validate_list_item_content nodes current = if standard_kind_is current ListItem then begin - let text = string_property_of current.retained_properties TextValue in - let visible_children = + let properties = current.retained_properties in + let text = string_property_of properties TextValue in + (* swipe-actions/context-menu children are metadata, not content; a + disclosure row's nested list-items don't count as content either. *) + let disclosure = Property_map.mem Expanded properties in + let content_children = List.filter - (fun child -> not (context_menu_child nodes child)) + (fun child -> + not (metadata_child nodes child) + && not (disclosure && list_item_row_child nodes child)) current.retained_children in - if text <> "" && visible_children <> [] then + if text <> "" && content_children <> [] then invalid_arg "list-item accepts text or children, not both"; - if text = "" && visible_children = [] then + if text = "" && content_children = [] then invalid_arg "list-item requires text or children" end @@ -382,7 +395,12 @@ let validate_context_menu_child nodes child = let validate_context_menu nodes current = let context_children = - List.filter (context_menu_child nodes) current.retained_children + List.filter + (fun child_id -> + match Hashtbl.find_opt nodes child_id with + | Some current -> standard_kind_is current ContextMenu + | None -> false) + current.retained_children in if List.length context_children > 1 then invalid_arg "host accepts at most one context-menu"; diff --git a/platform/web/melange/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/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index a1d458e5..42894cab 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -1432,6 +1432,12 @@ public static bool NodePropertiesSupported( return false; } } + if (kind == LUINodeKind.SwipeAction && + !StringPropertyNonempty(properties, LUIProperty.TextValue) && + !StringPropertyNonempty(properties, LUIProperty.InlineIconName)) + { + return false; + } return true; } From 99550485689be9dc11bce4293e5a87f053f817c9 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 01:26:37 -0700 Subject: [PATCH 12/17] address review: ship Qt number-stepper, sanitize web value events, wire detents/sizing on all backends - add platform/qt/qml/LuiNumberStepper.qml and register it in QML_FILES - web: clamp number-stepper value to retained min/max and drop NaN before dispatching ValueChanged; route aria-label removal to the child input - apply sheet detents (height fraction) and sizing (fitted width) on the web, Qt, Flutter, and WinUI surfaces instead of accepting them as no-ops - winui: copy text to NumberBox.Header; flutter: wrap the stepper in a Semantics label and give the step buttons tooltips - compare stepper min/max after applying defaults so a lone negative max is rejected (ocaml + all backend mirrors) --- .../LUIAppleBackend/LUIWireProtocol.swift | 12 +- platform/flutter/lib/lui_flutter_backend.dart | 117 ++++++++++++++---- platform/qt/lib/CMakeLists.txt | 1 + platform/qt/lib/lui_schema.cpp | 12 +- platform/qt/qml/LuiNumberStepper.qml | 51 ++++++++ platform/qt/qml/LuiSheet.qml | 37 +++++- platform/web/melange/events/lui_web_events.ml | 24 +++- platform/web/melange/render/lui_web_props.ml | 54 +++++++- platform/winui/LUI.Core/LUISchema.cs | 20 +-- platform/winui/LUI.WinUI/LUIElement.Sync.cs | 1 + platform/winui/LUI.WinUI/LUIModalPresenter.cs | 64 +++++++++- src/lui_protocol.ml | 12 +- 12 files changed, 340 insertions(+), 65 deletions(-) create mode 100644 platform/qt/qml/LuiNumberStepper.qml diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 67c723e2..05ce3498 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -944,11 +944,13 @@ struct LUIRetainedTree { value.isFinite else { throw invalid("number-stepper requires a finite value") } - if case let .double(minimum)? = node.properties[.minValue], - case let .double(maximum)? = node.properties[.maxValue] { - guard minimum <= maximum else { - throw invalid("number-stepper min must not exceed max") - } + // 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 { diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 0989ab20..0983d0eb 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'; @@ -1595,23 +1596,31 @@ final class LUIFlutterBackend { (stepperState.properties['step'] as num?)?.toDouble() ?? 1.0; final current = (stepperState.properties['value'] as num?)?.toDouble() ?? 0.0; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(stepperState.properties['text'] as String? ?? '')), - IconButton( - icon: const Icon(Icons.remove), - onPressed: stepperEnabled - ? () => performValueChange(id, current - step) - : null, - ), - IconButton( - icon: const Icon(Icons.add), - onPressed: stepperEnabled - ? () => performValueChange(id, current + step) - : null, - ), - ], + 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, + ), + ], + ), ); } @@ -4020,9 +4029,12 @@ final class LUIFlutterBackend { if (text.isEmpty && label.isEmpty) { throw const LUIBackendException('number-stepper requires a label'); } - final minimum = state.properties['min']; - final maximum = state.properties['max']; - if (minimum is num && maximum is num && minimum > maximum) { + // 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', ); @@ -4824,6 +4836,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(); @@ -4837,12 +4870,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'), }; } diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 9ca673f2..04db2787 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -71,6 +71,7 @@ qt_add_qml_module(lui_qml ../qml/LuiMediaSurface.qml ../qml/LuiMenuItem.qml ../qml/LuiNodeView.qml + ../qml/LuiNumberStepper.qml ../qml/LuiPagination.qml ../qml/LuiPanel.qml ../qml/LuiParagraph.qml diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index a0b1bca7..9bd7b821 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 { @@ -952,12 +953,15 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { !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")); - if (isNumeric(minimum) && isNumeric(maximum) && - minimum.toDouble() > maximum.toDouble()) { - return false; - } + 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")) { 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/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/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index 491bd782..a56144aa 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 -> @@ -458,7 +478,8 @@ let attach_events renderer node kind dom_node = | Checkbox | SwitchControl -> attach_toggle_event renderer node kind dom_node | Radio -> attach_radio_event renderer node dom_node - | Slider | NumberStepper -> attach_slider_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/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 115b5b16..3591a895 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -538,6 +538,40 @@ 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 @@ -553,9 +587,11 @@ and apply_secondary_property renderer node kind dom_node property 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 + 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 + 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 -> @@ -705,7 +741,8 @@ let remove_property renderer node kind dom_node property = "" | 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) @@ -713,8 +750,15 @@ let remove_property renderer node kind dom_node property = 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 - | Sizing -> W.Element.removeAttribute "data-sizing" dom_node + | 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 | AccessibilityIdentifier -> W.Element.removeAttribute "id" dom_node | OrientationValue -> if kind = Tabs then begin diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index f3a067ed..f2e9f881 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -1380,15 +1380,17 @@ public static bool NodePropertiesSupported( { return false; } - if (properties.TryGetValue( - LUIProperty.MinValue, - out LUIWireValue? minimum) && - properties.TryGetValue( - LUIProperty.MaxValue, - out LUIWireValue? maximum) && - minimum is LUIWireValue.Float minFloat && - maximum is LUIWireValue.Float maxFloat && - minFloat.Value > maxFloat.Value) + // 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; } diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index a71568cb..8b832936 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -388,6 +388,7 @@ 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( 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/src/lui_protocol.ml b/src/lui_protocol.ml index 1323bcbf..27ca70ff 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -1091,13 +1091,11 @@ let node_properties_supported kind properties = | Some (FloatValue value) -> is_finite value | _ -> false) && - match - ( Property_map.find_opt MinValue properties, - Property_map.find_opt MaxValue properties ) - with - | Some (FloatValue minimum), Some (FloatValue maximum) -> - minimum <= maximum - | _ -> true + (* 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 From bbf04a712109938c3dfaeab08a5d0409e3ed9cb7 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 01:43:16 -0700 Subject: [PATCH 13/17] regenerate schema after merge --- .../LUIAppleBackend/LUIWireSchema.swift | 6 +++++ platform/qt/lib/lui_wire_schema.h | 24 +++++++++++++++++++ platform/winui/LUI.Core/LUIWireSchema.g.cs | 21 ++++++++++++++++ src/lui_protocol.mli | 6 +++++ src/lui_wire_schema.ml | 13 ++++++++-- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index ab7c639e..123ab4d5 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" @@ -156,6 +159,9 @@ enum LUIProperty: String, Decodable, Hashable { case path = "path" case url = "url" case maxPixelSize = "max-pixel-size" + case edge = "edge" + case visible = "visible" + case alignment = "alignment" } enum LUISchemaMatrix { diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index 2b4a20da..f192ad30 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, @@ -160,6 +163,9 @@ enum class Property { PathValue, UrlValue, MaxPixelSize, + EdgeValue, + Visible, + AlignmentValue, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -169,6 +175,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"; @@ -249,6 +258,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; } @@ -334,6 +346,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: @@ -385,6 +400,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"; @@ -537,6 +555,9 @@ inline const char *propertyWireName(Property property) { case Property::PathValue: return "path"; case Property::UrlValue: return "url"; case Property::MaxPixelSize: return "max-pixel-size"; + case Property::EdgeValue: return "edge"; + case Property::Visible: return "visible"; + case Property::AlignmentValue: return "alignment"; } return "unknown"; } @@ -618,6 +639,9 @@ inline bool decodePropertyWireName(const char *name, Property *property) { if (std::strcmp(name, "path") == 0) { *property = Property::PathValue; return true; } if (std::strcmp(name, "url") == 0) { *property = Property::UrlValue; return true; } if (std::strcmp(name, "max-pixel-size") == 0) { *property = Property::MaxPixelSize; return true; } + if (std::strcmp(name, "edge") == 0) { *property = Property::EdgeValue; return true; } + if (std::strcmp(name, "visible") == 0) { *property = Property::Visible; return true; } + if (std::strcmp(name, "alignment") == 0) { *property = Property::AlignmentValue; return true; } return false; } diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index cc7c97a4..dabafb51 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, @@ -163,6 +166,9 @@ public enum LUIProperty PathValue, UrlValue, MaxPixelSize, + EdgeValue, + Visible, + AlignmentValue, } public static class LUIWireSchema @@ -175,6 +181,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 }, @@ -326,6 +335,9 @@ public static class LUIWireSchema { "path", LUIProperty.PathValue }, { "url", LUIProperty.UrlValue }, { "max-pixel-size", LUIProperty.MaxPixelSize }, + { "edge", LUIProperty.EdgeValue }, + { "visible", LUIProperty.Visible }, + { "alignment", LUIProperty.AlignmentValue }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -335,6 +347,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", @@ -421,6 +436,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 @@ -539,6 +557,9 @@ LUINodeKind.Toolbar or LUIProperty.PathValue => "path", LUIProperty.UrlValue => "url", LUIProperty.MaxPixelSize => "max-pixel-size", + LUIProperty.EdgeValue => "edge", + LUIProperty.Visible => "visible", + LUIProperty.AlignmentValue => "alignment", _ => "unknown", }; diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index bcb06470..c4c88236 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 @@ -177,6 +180,9 @@ type property = | PathValue | UrlValue | MaxPixelSize + | EdgeValue + | Visible + | AlignmentValue module Property_map : Map.S with type key = property diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index b99a8af1..1a7e5f43 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" @@ -88,6 +91,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 @@ -238,6 +244,9 @@ let property_name property = | PathValue -> "path" | UrlValue -> "url" | MaxPixelSize -> "max-pixel-size" + | EdgeValue -> "edge" + | Visible -> "visible" + | AlignmentValue -> "alignment" let kind_property_matrix kind = match kind with @@ -261,6 +270,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; Link; FileImage; FilePreview ] +let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; Link; FileImage; FilePreview ] -let all_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; PathValue; UrlValue; MaxPixelSize ] +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; PathValue; UrlValue; MaxPixelSize; EdgeValue; Visible; AlignmentValue ] From a5f4a5eccb0c21b4d3ee03ba6785259dcd8f6bb5 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 02:05:11 -0700 Subject: [PATCH 14/17] fix merged .edge prop support on apple backend --- .../apple/Sources/LUIAppleBackend/LUIWireProtocol.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 9261ed80..78c8f561 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -218,9 +218,6 @@ enum LUIWireValue: Decodable, Equatable { case .scrollAnchor: guard let value = stringValue else { return false } return ["top", "center", "bottom"].contains(value) - case .edge: - guard let value = stringValue else { return false } - return value == "leading" || value == "trailing" case .role: guard let value = stringValue else { return false } return value == "treeitem" || value == "navigation" || @@ -920,7 +917,7 @@ struct LUIRetainedTree { case .style, .scrollTarget, .scrollAnchor, .scrollToken, .scrollAnimated, .trackVisibleRange: kind == .list - case .edge: kind == .swipeAction + case .edge: kind == .swipeAction || kind == .edgeInset case .minValue, .maxValue, .stepValue: kind == .numberStepper case .detents, .sizing: kind == .sheet case .path: kind == .fileImage || kind == .filePreview @@ -928,7 +925,7 @@ struct LUIRetainedTree { case .maxPixelSize: kind == .fileImage case .active, .title, .description, .meta, .indicator, .connector: false case .request, .types, .multiple, .source, .completion: false - case .edge, .visible: kind == .edgeInset + case .visible: kind == .edgeInset // `.alignment` is admitted ahead of the restrictive matrix above. case .alignment: kind != .root } From 69b3ffd5730e7fd0f6a402ea80e521017c5c0a2d Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 02:07:26 -0700 Subject: [PATCH 15/17] fix merged .edge prop support on winui backend --- platform/winui/LUI.Core/LUISchema.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 16697183..33c203a7 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -706,7 +706,8 @@ public static bool CommonPropertySupported( case LUIProperty.TrackVisibleRange: return kind == LUINodeKind.ListContainer; case LUIProperty.EdgeValue: - return kind == LUINodeKind.SwipeAction; + return kind == LUINodeKind.SwipeAction || + kind == LUINodeKind.EdgeInset; case LUIProperty.MinValue: case LUIProperty.MaxValue: case LUIProperty.StepValue: @@ -717,7 +718,6 @@ public static bool CommonPropertySupported( case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); - case LUIProperty.EdgeValue: case LUIProperty.Visible: return kind == LUINodeKind.EdgeInset; // AlignmentValue is admitted ahead of the restrictive matrix @@ -1195,11 +1195,6 @@ public static bool PropertyValueSupported( { return value is LUIWireValue.Bool; } - case LUIProperty.EdgeValue: - { - return value is LUIWireValue.String text && - (text.Value == "leading" || text.Value == "trailing"); - } case LUIProperty.PickerRequest: case LUIProperty.PickerCompletion: { From 9da8526f26d86add325ea1d7a213f796a0fba62a Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 05:50:22 -0700 Subject: [PATCH 16/17] apple: anchor file-picker presentation modifiers on a Group An empty ForEach mounts no view, so .fileImporter/.photosPicker/.sheet on LUIFilePickerView never presented when the node had no children. --- .../Sources/LUIAppleBackend/LUIFilePickerView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift index 31aed1d2..1fd942e6 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift @@ -67,8 +67,13 @@ struct LUIFilePickerView: View { } var body: some View { - ForEach(model.visibleChildren, id: \.self) { childID in - LUIAnyNodeView(nodeID: childID, backend: backend) + // Group keeps a real view in the hierarchy when the node has no + // children — an empty ForEach anchors no modifiers, so presentations + // would never fire. + Group { + ForEach(model.visibleChildren, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend) + } } .fileImporter( isPresented: filesPresented, From 6b0286a03620d5124caaa7baa2bcd2131addc653 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 06:52:06 -0700 Subject: [PATCH 17/17] apple: render 1pt placeholder so file-picker modifiers install with no children --- .../LUIAppleBackend/LUIFilePickerView.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift index 1fd942e6..db2ad3c4 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIFilePickerView.swift @@ -67,12 +67,16 @@ struct LUIFilePickerView: View { } var body: some View { - // Group keeps a real view in the hierarchy when the node has no - // children — an empty ForEach anchors no modifiers, so presentations - // would never fire. + // 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 { - ForEach(model.visibleChildren, id: \.self) { childID in - LUIAnyNodeView(nodeID: childID, backend: backend) + 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(