From e6cbe8c12cb817e4fc1f199fceb39c1a85852f7e Mon Sep 17 00:00:00 2001 From: zy C Date: Sun, 27 Sep 2026 23:56:58 -0700 Subject: [PATCH 1/4] 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 4ff32f91d318842cb2f9fb1ef9175ed1accc3121 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:21:00 -0700 Subject: [PATCH 2/4] 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 497a9bbcd229e44e7a1b41dc0709f0a4cfbbd11d Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:46:50 -0700 Subject: [PATCH 3/4] 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 f9d9d510904a5c91f5049f37f7bc6a572b527668 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 10:56:53 -0700 Subject: [PATCH 4/4] apple: bound edge-inset pinned region to ideal size A pinned child that expands to its proposal (e.g. overlay's ZStack) made safeAreaInset consume the whole screen, collapsing the scrolling base content to zero height. Hug the pinned stack along the edge axis. --- platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 8a5fee0c..3ad3a47f 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -1760,12 +1760,16 @@ private struct LUIEdgeInsetView: View { pinnedViews(Array(model.children.dropFirst())) } .frame(maxHeight: .infinity) + // Hug along the edge: an expansive pinned child (e.g. overlay) + // would otherwise eat the entire safe-area inset region. + .fixedSize(horizontal: true, vertical: false) .modifier(LUISurfaceModifier(model: model, backend: backend)) } else { VStack(spacing: 0) { pinnedViews(Array(model.children.dropFirst())) } .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) .modifier(LUISurfaceModifier(model: model, backend: backend)) } }