diff --git a/examples/gallery/view.ml b/examples/gallery/view.ml index 7928038..f1a0313 100644 --- a/examples/gallery/view.ml +++ b/examples/gallery/view.ml @@ -455,6 +455,69 @@ let card_section model_source : t = ] ] +let inset_rows n = + List.init n (fun i -> + card ~padding:12 + [ text ~value:(Printf.sprintf "Inset demo row %d" (i + 1)) [] ]) + +let edge_inset_section model_source send : t = + let checked = model_source >|= Model.checked in + section "Edge Inset" + [ switch_ ~text:"Show pinned bar" ~checked:(reactive checked) + ~on_toggle:(on_toggle send (fun v -> Model.SetChecked v)) [] + ; edge_inset ~edge:`top ~background:"bar" ~max_width:560 ~height:240 + ~visible:(reactive checked) + [ scroll ~height:200 [ list ~gap:8 ~cross:`stretch (inset_rows 12) ] + ; row ~padding:8 ~gap:8 ~cross:`center + [ text ~value:"Pinned top bar" [] + ; spacer [] + ; text ~value:"edge-inset · top" ~foreground:"muted-foreground" [] + ] + ] + ; edge_inset ~edge:`bottom ~background:"bar" ~max_width:560 ~height:180 + [ scroll ~height:150 [ list ~gap:8 ~cross:`stretch (inset_rows 10) ] + ; row ~padding:8 ~gap:8 ~cross:`center + [ text ~value:"Pinned bottom bar" [] + ; spacer [] + ; text ~value:"edge-inset · bottom" ~foreground:"muted-foreground" [] + ] + ] + ] + +let overlay_section : t = + section "Overlay" + [ overlay ~max_width:560 + [ card ~width:560 ~height:160 ~padding:16 + [ column ~gap:8 + [ text ~value:"Base card — this content sizes the overlay" [] + ; paragraph + ~value:"Floating children do not shift this layout." [] + ] + ] + ; align `top_trailing + (card ~padding:8 [ text ~value:"Float · top-trailing" [] ]) + ; align `bottom_leading + (card ~padding:8 [ text ~value:"Float · bottom-leading" [] ]) + ] + ] + +let view_that_fits_section : t = + section "View That Fits" + [ paragraph + ~value:"Widen the window for the expanded layout; shrink it below ~700pt wide for the compact fallback." + [] + ; view_that_fits + [ card ~width:640 ~padding:16 + [ row ~gap:8 ~cross:`center + [ text ~value:"EXPANDED — wide layout" [] + ; spacer [] + ; text ~value:"640pt" ~foreground:"muted-foreground" [] + ] + ] + ; text ~value:"COMPACT — narrow fallback" ~foreground:"accent" [] + ] + ] + let alert_section : t = section "Alert" [ alert ~text:"Sync paused" ~variant:`secondary @@ -1577,6 +1640,9 @@ let view context model_source send : t = ; row_section ; column_section ; grid_section + ; edge_inset_section model_source send + ; overlay_section + ; view_that_fits_section ; text_section ; spacer_section ; toggle_button_section model_source send diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c..3ad3a47 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,160 @@ 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) + // 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)) + } + } + } + + 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 +5901,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 f257137..257599a 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", @@ -680,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. @@ -717,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 || @@ -745,7 +769,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 +792,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 +843,9 @@ 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 + // `.alignment` is admitted ahead of the restrictive matrix above. + case .alignment: kind != .root } } @@ -827,6 +856,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 +871,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 +931,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 8e2e550..c769622 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 1fc6a1f..cd110d6 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 06a72c3..fe2b57d 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1949,6 +1949,79 @@ final class LUIFlutterBackend { ); } + /// Pins the children after the first to `edge` while the first child + /// fills the view beneath them. Pinned children group into a row across + /// side edges and a column under top/bottom; `gap` insets the group + /// from the pinned edge. + Widget edgeInset() { + final visible = state.properties['visible'] as bool? ?? true; + final gap = (state.properties['gap'] as num?)?.toDouble() ?? 0; + final edge = state.properties['edge'] as String? ?? 'top'; + final sideEdge = edge == 'leading' || edge == 'trailing'; + final pinned = children.skip(1).toList(growable: false); + return Stack( + children: [ + if (children.isNotEmpty) children.first, + if (visible && pinned.isNotEmpty) + Positioned.fill( + child: Padding( + padding: switch (edge) { + 'bottom' => EdgeInsetsDirectional.only(bottom: gap), + 'leading' => EdgeInsetsDirectional.only(start: gap), + 'trailing' => EdgeInsetsDirectional.only(end: gap), + _ => EdgeInsetsDirectional.only(top: gap), + }, + child: Align( + alignment: switch (edge) { + 'bottom' => AlignmentDirectional.bottomCenter, + 'leading' => AlignmentDirectional.centerStart, + 'trailing' => AlignmentDirectional.centerEnd, + _ => AlignmentDirectional.topCenter, + }, + child: sideEdge + ? Row(mainAxisSize: MainAxisSize.min, children: pinned) + : Column(mainAxisSize: MainAxisSize.min, children: pinned), + ), + ), + ), + ], + ); + } + + /// Renders the children after the first floating over the first child + /// without affecting its layout; each overlay child positions by its own + /// 'alignment' prop, falling back to the overlay's (default center). + Widget overlay() { + final fallback = + _overlayAlignment(state.properties['alignment'] as String?) ?? + AlignmentDirectional.center; + // `children` skips the context-menu child, so index the visible ids. + final overlayIDs = state.children + .where((child) => child != contextMenuID) + .toList(growable: false); + return Stack( + children: [ + if (children.isNotEmpty) children.first, + for (var index = 1; index < children.length; index += 1) + Positioned.fill( + child: Align( + alignment: + _overlayAlignment( + _states[overlayIDs[index]]?.properties['alignment'] + as String?, + ) ?? + fallback, + child: children[index], + ), + ), + ], + ); + } + + /// No native ViewThatFits equivalent; render the first candidate. + Widget viewThatFits() => + children.isEmpty ? const SizedBox.shrink() : children.first; + Widget select() { final colors = Theme.of(context).colorScheme; final title = accessibilityLabel ?? placeholder ?? ''; @@ -2681,6 +2754,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), @@ -3464,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) { @@ -3473,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, @@ -3674,7 +3771,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 +3863,14 @@ final class LUIFlutterBackend { value is num && value.isFinite && (kind == _NodeKind.dropdownMenu || kind == _NodeKind.tooltip), + 'edge' => + value is String && + const {'top', 'bottom', 'leading', 'trailing'}.contains(value) && + kind == _NodeKind.edgeInset, + 'visible' => value is bool && kind == _NodeKind.edgeInset, + // 'alignment' is admitted ahead of the restrictive kinds above. + 'alignment' => + value is String && _overlayAlignment(value) != null, 'tooltip-delay' => value is int && value >= 0 && @@ -3782,6 +3888,7 @@ final class LUIFlutterBackend { kind == _NodeKind.tableRow || kind == _NodeKind.tree || kind == _NodeKind.split || + kind == _NodeKind.edgeInset || _isHorizontalGroupKind(kind)), 'padding' => value is int && kind != _NodeKind.avatar && kind != _NodeKind.tooltip, @@ -3799,7 +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 || @@ -3925,6 +4035,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 +4455,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 +4466,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 || @@ -4697,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/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cf..c3123a3 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 9ca673f..bff6a20 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 38ee30c..10023ba 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, @@ -413,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, @@ -467,7 +471,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 +590,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; + // `alignment` is admitted ahead of the restrictive arms in + // propertySupported below. + case Property::AlignmentValue: + return kind != NodeKind::Root; } return false; } @@ -595,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: @@ -803,6 +820,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 +861,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 fd11ca9..e25fb8b 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 0000000..dd7fbe0 --- /dev/null +++ b/platform/qt/qml/LuiEdgeInset.qml @@ -0,0 +1,64 @@ +import QtQuick + +// Wire kind: edge-inset — children after the first are pinned to `edge` +// while the first child fills the view beneath them. `visible` hides the +// pinned region in place; `gap` insets it from the pinned edge. +Item { + id: edgeInset + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property string edge: props.edge || "top" + readonly property bool pinnedVisible: props.visible !== false + readonly property bool sideEdge: edge === "leading" || edge === "trailing" + readonly property real gap: props.gap || 0 + readonly property var pinnedChildren: node ? node.children.slice(1) : [] + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + LuiNodeView { + id: content + anchors.fill: parent + node: edgeInset.node && edgeInset.node.children.length > 0 + ? edgeInset.node.children[0] : null + } + + // Pinned children group into one region so they stack across the edge + // instead of overlapping (column under top/bottom, row along the sides, + // matching the Apple backend). + Item { + id: pinnedRegion + visible: edgeInset.pinnedVisible + implicitWidth: pinnedFlow.implicitWidth + implicitHeight: pinnedFlow.implicitHeight + + // Bound (not imperative) anchors so an `edge` change re-anchors; + // `undefined` clears the anchor. + anchors.top: edgeInset.edge === "top" || edgeInset.sideEdge + ? parent.top : undefined + anchors.bottom: edgeInset.edge === "bottom" || edgeInset.sideEdge + ? parent.bottom : undefined + anchors.left: !edgeInset.sideEdge || edgeInset.edge === "leading" + ? parent.left : undefined + anchors.right: !edgeInset.sideEdge || edgeInset.edge === "trailing" + ? parent.right : undefined + anchors.topMargin: edgeInset.edge === "top" ? edgeInset.gap : 0 + anchors.bottomMargin: edgeInset.edge === "bottom" ? edgeInset.gap : 0 + anchors.leftMargin: edgeInset.edge === "leading" ? edgeInset.gap : 0 + anchors.rightMargin: edgeInset.edge === "trailing" ? edgeInset.gap : 0 + + Grid { + id: pinnedFlow + flow: edgeInset.sideEdge ? Grid.LeftToRight : Grid.TopToBottom + columns: edgeInset.sideEdge ? edgeInset.pinnedChildren.length : 1 + Repeater { + model: edgeInset.pinnedChildren + delegate: LuiNodeView { + required property var modelData + node: modelData + } + } + } + } +} diff --git a/platform/qt/qml/LuiOverlay.qml b/platform/qt/qml/LuiOverlay.qml new file mode 100644 index 0000000..64516b5 --- /dev/null +++ b/platform/qt/qml/LuiOverlay.qml @@ -0,0 +1,54 @@ +import QtQuick + +// Wire kind: overlay — children after the first float over the first child +// without affecting its layout. Each overlay child positions itself by its +// own `alignment` property, falling back to the overlay's (default center). +Item { + id: overlay + required property var node + readonly property var props: node ? node.properties : ({}) + + implicitWidth: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 + implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 + + Repeater { + id: rep + model: overlay.node ? overlay.node.children : [] + delegate: LuiNodeView { + required property var modelData + required property int index + node: modelData + + readonly property bool _isBase: index === 0 + // Own `alignment` hint, falling back to the overlay's. + readonly property string _align: { + var p = node ? node.properties : null + return (p && p.alignment) || overlay.props.alignment || + "center" + } + readonly property bool _top: !_isBase && + (_align === "top-leading" || _align === "top" || + _align === "top-trailing") + readonly property bool _bottom: !_isBase && + (_align === "bottom-leading" || _align === "bottom" || + _align === "bottom-trailing") + readonly property bool _leading: !_isBase && + (_align === "top-leading" || _align === "leading" || + _align === "bottom-leading") + readonly property bool _trailing: !_isBase && + (_align === "top-trailing" || _align === "trailing" || + _align === "bottom-trailing") + + // Bound anchors re-evaluate when `alignment` changes; + // `undefined` clears the anchor. The base child fills. + anchors.top: _isBase || _top ? overlay.top : undefined + anchors.bottom: _isBase || _bottom ? overlay.bottom : undefined + anchors.left: _isBase || _leading ? overlay.left : undefined + anchors.right: _isBase || _trailing ? overlay.right : undefined + anchors.horizontalCenter: !_isBase && !_leading && !_trailing + ? overlay.horizontalCenter : undefined + anchors.verticalCenter: !_isBase && !_top && !_bottom + ? overlay.verticalCenter : undefined + } + } +} diff --git a/platform/qt/qml/LuiStyle.js b/platform/qt/qml/LuiStyle.js index 3368580..9fd539f 100644 --- a/platform/qt/qml/LuiStyle.js +++ b/platform/qt/qml/LuiStyle.js @@ -10,6 +10,7 @@ var palette = { "secondary": "#f4f4f5", "secondary-foreground": "#18181b", "glass": "#bfffffff", + "bar": "#d9ffffff", "success": "#dcfce7", "success-foreground": "#166534", "warning": "#fef9c3", diff --git a/platform/qt/qml/LuiViewThatFits.qml b/platform/qt/qml/LuiViewThatFits.qml new file mode 100644 index 0000000..0b503ab --- /dev/null +++ b/platform/qt/qml/LuiViewThatFits.qml @@ -0,0 +1,26 @@ +import QtQuick + +// Wire kind: view-that-fits — renders the first child that fits along +// `orientation` (default horizontal). QML has no intrinsic fit-or-fallback +// container; renders the first candidate. +Item { + id: viewThatFits + required property var node + readonly property var props: node ? node.properties : ({}) + + implicitWidth: rep.itemAt(0) ? rep.itemAt(0).implicitWidth : 0 + implicitHeight: rep.itemAt(0) ? rep.itemAt(0).implicitHeight : 0 + + Repeater { + id: rep + model: { + var children = viewThatFits.node ? viewThatFits.node.children : [] + return children.length > 0 ? [children[0]] : [] + } + delegate: LuiNodeView { + required property var modelData + node: modelData + anchors.fill: viewThatFits + } + } +} diff --git a/platform/web/melange/core/lui_web_util.ml b/platform/web/melange/core/lui_web_util.ml index 4843c91..9409f82 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/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b..a4d09fe 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/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 19c5c81..d34e8e8 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 ecb4bf8..0aa70bc 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -397,6 +397,105 @@ body { @apply relative; } + /* edge-inset: first child is the scrollable content; pinned children + group at the named edge (flex order, no per-child overlap). */ + .lui-edge-inset { + @apply flex flex-col; + } + + .lui-edge-inset > :first-child { + @apply min-h-0 min-w-0 flex-1; + } + + .lui-edge-inset > :not(:first-child) { + order: -1; + } + + .lui-edge-inset[data-edge='bottom'] > :not(:first-child) { + order: 0; + } + + .lui-edge-inset[data-edge='leading'], + .lui-edge-inset[data-edge='trailing'] { + @apply flex-row; + } + + .lui-edge-inset[data-edge='trailing'] > :not(:first-child) { + order: 0; + } + + .lui-edge-inset[data-pinned-hidden] > :not(:first-child) { + @apply hidden; + } + + /* overlay: the first child sizes the view; overlay children are + absolutely positioned so they never contribute intrinsic size. */ + .lui-overlay { + @apply relative inline-grid; + } + + .lui-overlay > :not(:first-child) { + @apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='top-leading'], + .lui-overlay[data-alignment='top-leading'] > :not(:first-child):not([data-alignment]) { + @apply left-0 top-0 translate-x-0 translate-y-0; + } + + .lui-overlay > [data-alignment='top'], + .lui-overlay[data-alignment='top'] > :not(:first-child):not([data-alignment]) { + @apply left-1/2 top-0 -translate-x-1/2 translate-y-0; + } + + .lui-overlay > [data-alignment='top-trailing'], + .lui-overlay[data-alignment='top-trailing'] > :not(:first-child):not([data-alignment]) { + @apply right-0 top-0 translate-x-0 translate-y-0; + } + + .lui-overlay > [data-alignment='leading'], + .lui-overlay[data-alignment='leading'] > :not(:first-child):not([data-alignment]) { + @apply left-0 top-1/2 translate-x-0 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='center'], + .lui-overlay[data-alignment='center'] > :not(:first-child):not([data-alignment]) { + @apply left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='trailing'], + .lui-overlay[data-alignment='trailing'] > :not(:first-child):not([data-alignment]) { + @apply right-0 top-1/2 translate-x-0 -translate-y-1/2; + } + + .lui-overlay > [data-alignment='bottom-leading'], + .lui-overlay[data-alignment='bottom-leading'] > :not(:first-child):not([data-alignment]) { + @apply bottom-0 left-0 translate-x-0 translate-y-0; + } + + .lui-overlay > [data-alignment='bottom'], + .lui-overlay[data-alignment='bottom'] > :not(:first-child):not([data-alignment]) { + @apply bottom-0 left-1/2 -translate-x-1/2 translate-y-0; + } + + .lui-overlay > [data-alignment='bottom-trailing'], + .lui-overlay[data-alignment='bottom-trailing'] > :not(:first-child):not([data-alignment]) { + @apply bottom-0 right-0 translate-x-0 translate-y-0; + } + + .lui-view-that-fits { + @apply grid; + } + + .lui-view-that-fits > * { + grid-area: 1 / 1; + } + + /* No CSS fit probing; keep only the first candidate. */ + .lui-view-that-fits > :not(:first-child) { + @apply hidden; + } + .lui-tooltip { @apply w-fit rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground shadow-md; } diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b..9933b83 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 69449b5..5b3041a 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: @@ -547,7 +550,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 +663,13 @@ public static bool CommonPropertySupported( case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); + case LUIProperty.EdgeValue: + case LUIProperty.Visible: + return kind == LUINodeKind.EdgeInset; + // AlignmentValue is admitted ahead of the restrictive matrix + // in PropertySupported. + case LUIProperty.AlignmentValue: + return kind != LUINodeKind.Root; case LUIProperty.TextValue: switch (kind) { @@ -741,6 +752,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Panel || kind == LUINodeKind.Box || kind == LUINodeKind.Split || + kind == LUINodeKind.EdgeInset || HorizontalContainer(kind); default: return false; @@ -751,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. @@ -1089,6 +1106,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 +1225,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 +1424,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 513775f..a99c386 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/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index 28d01ad..df80812 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/LUIElementFactory.cs b/platform/winui/LUI.WinUI/LUIElementFactory.cs index 5e2e3d5..8c153a6 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/platform/winui/LUI.WinUI/LUIThemeColors.cs b/platform/winui/LUI.WinUI/LUIThemeColors.cs index 331ce47..bf9505a 100644 --- a/platform/winui/LUI.WinUI/LUIThemeColors.cs +++ b/platform/winui/LUI.WinUI/LUIThemeColors.cs @@ -85,6 +85,9 @@ is string token && case "glass": return Resource("AcrylicBackgroundFillColorDefaultBrush", Color.FromArgb(0xCC, 0xF9, 0xF9, 0xF9)); + case "bar": + return Resource("AcrylicBackgroundFillColorDefaultBrush", + Color.FromArgb(0xD9, 0xF9, 0xF9, 0xF9)); case "secondary-foreground": return Resource("TextFillColorSecondaryBrush", Color.FromArgb(0x9E, 0x1B, 0x1B, 0x1B)); diff --git a/schema/components.json b/schema/components.json index 5890108..21c5a03 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 b75ee82..2541cd6 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 7f63da1..72d3767 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 ebdfeea..f0315a9 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 @@ -593,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 @@ -603,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 @@ -644,7 +664,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,10 +804,16 @@ let common_property_supported kind property = || kind = Panel || kind = Box || kind = Split + || kind = EdgeInset || horizontal_container kind + | EdgeValue | Visible -> kind = EdgeInset let property_supported kind property = if property = AccessibilityIdentifier then true + (* Position hint honored on [overlay] children and on [overlay] itself; + admitted before the restrictive arms so e.g. an aligned [menu_trigger] + child still carries it. Inert elsewhere, like [container-relative-frame]. *) + else if property = AlignmentValue then kind <> Root else (* The restrictive arms below mirror schema/components.json kindProperties; test/property_matrix asserts the two stay in sync. *) @@ -917,6 +944,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 +1074,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 7cb0311..38f27f4 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 d94e64e..88cf464 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 72e11fc..6aa1246 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 8185329..0d1272d 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 1cb50bb..137efd6 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,72 @@ 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); + (* Restrictive kinds still carry the hint so e.g. an aligned + menu-trigger overlay child validates. *) + Alcotest.(check bool) "alignment lands on restrictive kinds" true + (property_supported MenuTrigger AlignmentValue); + Alcotest.(check bool) "root drops alignment" false + (property_supported Root AlignmentValue); + Alcotest.(check bool) "edge-inset takes foreground" true + (property_supported EdgeInset ForegroundValue); + Alcotest.(check bool) "overlay takes foreground" true + (property_supported Overlay ForegroundValue); + Alcotest.(check bool) "view-that-fits takes foreground" true + (property_supported ViewThatFits ForegroundValue); + Alcotest.(check bool) "edge accepts top" true + (property_value_supported EdgeValue (StringValue "top")); + Alcotest.(check bool) "edge accepts leading" true + (property_value_supported EdgeValue (StringValue "leading")); + Alcotest.(check bool) "edge rejects side anchors" false + (property_value_supported EdgeValue (StringValue "left")); + Alcotest.(check bool) "visible takes a bool" true + (property_value_supported Visible (BoolValue false)); + Alcotest.(check bool) "alignment accepts corner" true + (property_value_supported AlignmentValue + (StringValue "bottom-trailing")); + Alcotest.(check bool) "alignment rejects anchor vocab" false + (property_value_supported AlignmentValue (StringValue "above")); + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "edge-inset needs edge" false + (node_properties_supported EdgeInset (props [])); + Alcotest.(check bool) "edge-inset with edge ok" true + (node_properties_supported EdgeInset + (props [ (EdgeValue, StringValue "bottom") ])) + let test_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -974,6 +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) @@ -1483,6 +1551,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; ] );