From e8139f2fd2bdd096799df7387308c4fa8400472d Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 01:02:56 +0800 Subject: [PATCH 1/7] fix(apple): retain drawer swipe when horizontal content fits --- platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift | 2 +- .../LUIAppleBackendTests/LUIHorizontalScrollDrawerTests.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index 49c83eac..212c57dd 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -9,7 +9,7 @@ enum LUIDrawerScrollInteractionPolicy { var candidate = view while let current = candidate { if let scroll = current as? UIScrollView, scroll.isScrollEnabled, - scroll.alwaysBounceHorizontal || scroll.contentSize.width + scroll.adjustedContentInset.left + scroll.contentSize.width + scroll.adjustedContentInset.left + scroll.adjustedContentInset.right > scroll.bounds.width + 1 { return true } diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIHorizontalScrollDrawerTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIHorizontalScrollDrawerTests.swift index d8afd276..fd23ae77 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIHorizontalScrollDrawerTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIHorizontalScrollDrawerTests.swift @@ -18,7 +18,10 @@ import XCTest XCTAssertFalse(LUIDrawerScrollInteractionPolicy.defersToHorizontalScroll(from: cell)) table.isScrollEnabled = true table.contentSize.width = 320 + table.alwaysBounceHorizontal = true XCTAssertFalse(LUIDrawerScrollInteractionPolicy.defersToHorizontalScroll(from: cell)) + table.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + XCTAssertTrue(LUIDrawerScrollInteractionPolicy.defersToHorizontalScroll(from: cell)) } } #endif From fba3caa44916e085bae7c858c4f7b755d1b787f1 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 01:54:56 +0800 Subject: [PATCH 2/7] Keep focused virtual rows attached outside lazy slots --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 98 +++++++++++++++---- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 5be63be2..f2fa76d8 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -6976,39 +6976,70 @@ private struct LUIVirtualListView: View { @State private var titleTracker = LUIScrollSectionTitleTracker() @State private var windowTracker = LUIVirtualWindowTracker() @State private var firstSectionHeights: [SlotID: CGFloat] = [:] + @State private var rowFrames = LUIVirtualRowFrames() + @State private var pinnedFrame: PinnedFrame? + @State private var pinnedSize: PinnedSize? + + private struct ContentSpace: Hashable { let nodeID: Int } + private struct PinnedFrame: Equatable { let id: SlotID; let frame: CGRect } + private struct PinnedSize: Equatable { let id: SlotID; let height: CGFloat } var body: some View { + let rows = sectionRows + let editor = rows.editor + let contentSpace = ContentSpace(nodeID: model.id) ScrollViewReader { proxy in ScrollView { LazyVStack( alignment: .leading, spacing: CGFloat(model.property(.gap)?.intValue ?? 0) ) { - let rows = sectionRows ForEach(LUIVirtualRowIndices(count: rows.endIndex, editor: rows.editor), id: \.id) { slot in // A concrete, single-view root keeps SwiftUI from asking // every virtual slot for its conditional view-list count. VStack(alignment: .leading, spacing: 0) { let row = rows[slot.position] - let rowView = LUIVirtualListRow(row: row, backend: backend, - tracker: windowTracker, coordinateSpace: titleCoordinateSpace, - viewportHeight: unobscuredHeight ?? 600, - onMeasure: { id, height in - if firstSectionHeights[id] != height { firstSectionHeights[id] = height } - }) - if tracksSectionTitles { - rowView.modifier(LUIScrollSectionFrameModifier( - index: row.index, title: row.title, - coordinateSpace: titleCoordinateSpace, - onVisibilityChange: { index, title in - titleTracker.update(index: index, title: title, startsSection: row.startsSection) - } - )) - } else { rowView } + if slot.position == editor?.position { + Color.clear + .frame(height: pinnedSize?.id == row.id ? pinnedSize!.height + : rowFrames.frames[row.id]?.height ?? row.estimatedHeight) + } else { virtualRow(row) } + } + .onGeometryChange(for: CGRect.self) { geometry in + geometry.frame(in: .named(contentSpace)) + } action: { frame in + let id = rows[slot.position].id + rowFrames.frames[id] = frame + if slot.position == editor?.position { + let next = PinnedFrame(id: id, frame: frame) + if pinnedFrame != next { pinnedFrame = next } + } } .id(slot.id) } } + .coordinateSpace(name: contentSpace) + .overlay(alignment: .topLeading) { + if let editor { + let row = rows[editor.position] + let frame = pinnedFrame?.id == row.id ? pinnedFrame?.frame : rowFrames.frames[row.id] + if row.nodeID != nil, let frame { + // A focused native control cannot live inside a lazy + // slot: offscreen eviction removes it from the window + // and resigns first responder. Keep one real row in + // this persistent overlay, reserving its slot above. + virtualRow(row) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: PinnedSize.self) { + PinnedSize(id: row.id, height: $0.size.height) + } action: { size in + if pinnedSize != size { pinnedSize = size } + } + .offset(y: frame.minY) + .id(editor.source) + } + } + } .environment(\.luiUnobscuredScrollHeight, unobscuredHeight) } .onAppear { revealTarget(using: proxy) } @@ -7034,6 +7065,24 @@ private struct LUIVirtualListView: View { } } + @ViewBuilder private func virtualRow(_ row: SectionRow) -> some View { + let content = LUIVirtualListRow(row: row, backend: backend, + tracker: windowTracker, coordinateSpace: titleCoordinateSpace, + viewportHeight: unobscuredHeight ?? 600, + onMeasure: { id, height in + if firstSectionHeights[id] != height { firstSectionHeights[id] = height } + }) + if tracksSectionTitles { + content.modifier(LUIScrollSectionFrameModifier( + index: row.index, title: row.title, + coordinateSpace: titleCoordinateSpace, + onVisibilityChange: { index, title in + titleTracker.update(index: index, title: title, startsSection: row.startsSection) + } + )) + } else { content } + } + private struct ScrollRequest: Equatable { let key: String let position: Int @@ -7126,7 +7175,15 @@ private struct LUIVirtualListView: View { estimatedHeight: segment.padding, tracksSectionHeight: false) } if segment.padding > 0, offset > window.firstSectionCount { offset -= 1 } - let childOffset = offset - window.start + let childOffset: Int + if offset >= window.start, offset < window.start + window.length { + childOffset = offset - window.start + } else if offset == window.pinnedIndex { + // The focused row stays mounted after the bounded window. + childOffset = window.length + } else { + childOffset = -1 + } return SectionRow(id: SlotID(source: segment.source, offset: offset), nodeID: segment.children.indices.contains(childOffset) ? segment.children[childOffset] : nil, windowID: segment.source, windowIndex: offset, index: index, @@ -7175,12 +7232,17 @@ private struct LUIVirtualListView: View { } +private final class LUIVirtualRowFrames { + var frames: [LUIVirtualListView.SlotID: CGRect] = [:] +} + // Window sources expose their total size independently of their mounted // children. Stable slot identities let LazyVStack retain measured heights // while distant OCaml scopes and native node models are evicted. fileprivate struct LUIVirtualWindow { let count: Int let start: Int + let length: Int let extent: CGFloat let firstSectionCount: Int let firstSectionInset: CGFloat @@ -7215,6 +7277,8 @@ fileprivate struct LUIVirtualWindow { }.sorted { $0.index < $1.index } self.count = count self.start = start + length = min(max(0, count - start), max(0, + attributes["data-virtual-length"].flatMap(Int.init) ?? (count - start))) let pinned = attributes["data-pinned-index"].flatMap(Int.init) ?? -1 pinnedIndex = (0.. Date: Fri, 23 Oct 2026 02:37:48 +0800 Subject: [PATCH 3/7] Stretch Android row children and table dividers to content height --- .../src/main/kotlin/dev/lui/LuiNodeView.kt | 13 ++++--- .../test/kotlin/dev/lui/LuiRowStretchTest.kt | 36 ++++++++++++++++++ ...ellsMatchRowHeight_stretched-table-row.png | Bin 0 -> 6934 bytes 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 platform/android/lui/src/test/kotlin/dev/lui/LuiRowStretchTest.kt create mode 100644 platform/android/lui/src/test/snapshots/images/dev.lui_LuiRowStretchTest_tableDividersAndGrowingCellsMatchRowHeight_stretched-table-row.png diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt index 7b36e950..2abb6b51 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt @@ -264,10 +264,11 @@ private fun emitValueChange(backend: LuiBackend, node: LuiNodeLike, id: Long, va /** Applies `grow` to a child inside a Row. */ @Composable -private fun RowScope.GrowChildRow(backend: LuiBackend, childId: Long) { +private fun RowScope.GrowChildRow(backend: LuiBackend, childId: Long, stretch: Boolean = false) { val grow = backend.node(childId)?.prop("grow")?.numberValue ?: 0.0 - if (grow > 0) { - Box(Modifier.weight(grow.toFloat().coerceAtLeast(0.001f)), propagateMinConstraints = true) { + if (grow > 0 || stretch) { + val sizing = if (grow > 0) Modifier.weight(grow.toFloat().coerceAtLeast(0.001f)) else Modifier + Box(if (stretch) sizing.fillMaxHeight() else sizing, propagateMinConstraints = true) { LuiNodeView(backend, childId) } } else { @@ -309,7 +310,9 @@ private fun LuiNodeContent( LuiNodeKind.root -> children.firstOrNull()?.let { LuiNodeView(backend, it) } LuiNodeKind.row -> Row( - modifier = if (node.propString("main") == "space_between") modifier.fillMaxWidth() else modifier, + modifier = modifier + .then(if (node.propString("main") == "space_between") Modifier.fillMaxWidth() else Modifier) + .then(if (node.propString("cross") == "stretch") Modifier.height(IntrinsicSize.Min) else Modifier), horizontalArrangement = if (node.propString("main") == "space_between") Arrangement.SpaceBetween else Arrangement.spacedBy( gapOf(node).dp, alignment = mainHorizontalAlignment(node.propString("main")), @@ -320,7 +323,7 @@ private fun LuiNodeContent( else -> Alignment.CenterVertically }, ) { - children.forEachNode { childId -> GrowChildRow(backend, childId) } + children.forEachNode { childId -> GrowChildRow(backend, childId, node.propString("cross") == "stretch") } } LuiNodeKind.column, diff --git a/platform/android/lui/src/test/kotlin/dev/lui/LuiRowStretchTest.kt b/platform/android/lui/src/test/kotlin/dev/lui/LuiRowStretchTest.kt new file mode 100644 index 00000000..ebf6e5b1 --- /dev/null +++ b/platform/android/lui/src/test/kotlin/dev/lui/LuiRowStretchTest.kt @@ -0,0 +1,36 @@ +package dev.lui + +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import org.junit.Rule +import org.junit.Test + +class LuiRowStretchTest { + @get:Rule val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5.copy(softButtons = false)) + + @Test fun tableDividersAndGrowingCellsMatchRowHeight() { + val backend = LuiBackend() + check(backend.applyBatch("""{"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"root"}, + {"op":"create-node","id":2,"kind":"row"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"set-prop","id":2,"property":"cross","value":"stretch"}, + {"op":"create-node","id":3,"kind":"text"}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"set-prop","id":3,"property":"grow","value":1}, + {"op":"set-prop","id":3,"property":"min-height","value":48}, + {"op":"set-prop","id":3,"property":"text","value":"Name"}, + {"op":"set-prop","id":3,"property":"background","value":"secondary"}, + {"op":"create-node","id":4,"kind":"divider"}, + {"op":"insert-child","parent":2,"child":4,"index":1}, + {"op":"set-prop","id":4,"property":"orientation","value":"vertical"}, + {"op":"create-node","id":5,"kind":"text"}, + {"op":"insert-child","parent":2,"child":5,"index":2}, + {"op":"set-prop","id":5,"property":"width","value":170}, + {"op":"set-prop","id":5,"property":"height","value":96}, + {"op":"set-prop","id":5,"property":"text","value":"A taller value cell"}, + {"op":"set-prop","id":5,"property":"background","value":"secondary"} + ]}""")) { backend.lastError.orEmpty() } + paparazzi.snapshot(name = "stretched-table-row") { LuiTheme { backend.Content() } } + } +} diff --git a/platform/android/lui/src/test/snapshots/images/dev.lui_LuiRowStretchTest_tableDividersAndGrowingCellsMatchRowHeight_stretched-table-row.png b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiRowStretchTest_tableDividersAndGrowingCellsMatchRowHeight_stretched-table-row.png new file mode 100644 index 0000000000000000000000000000000000000000..2c6c378333a061dc65821662a1b42f9780ffc0f4 GIT binary patch literal 6934 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 bWHa!U>xlo;t&7tCyJT!nI9gX9zi{mzPU%?8 literal 0 HcmV?d00001 From df9b11ce1f7316afa86ac60e61dbb6985786a1b7 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 05:17:42 +0800 Subject: [PATCH 4/7] Use semantic theme surfaces for inline search fields --- platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index f2fa76d8..7bf4b4e5 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -7486,6 +7486,7 @@ private struct LUITextControlView: View { let backend: LUIAppleBackend let grouped: Bool @Environment(\.luiIsNativeFormRow) private var isNativeFormRow + @Environment(\.luiSemanticColors) private var semanticColors @State private var draftState: LUITextDraftState @FocusState private var focused: Bool @@ -7549,7 +7550,8 @@ private struct LUITextControlView: View { .textFieldStyle(.plain) .padding(.horizontal, 10) .padding(.vertical, 8) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 10)) + .background(semanticColors["surface"] ?? Color(uiColor: .tertiarySystemFill), + in: RoundedRectangle(cornerRadius: 10)) #else HStack(spacing: 6) { Image(systemName: "magnifyingglass") From 0f997d71827760257f6ef1f1271b094e61125905 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 05:50:06 +0800 Subject: [PATCH 5/7] Honor explicit menu trigger text alignment on native hosts --- .../src/main/kotlin/dev/lui/LuiNodeView.kt | 32 +++++++++++++------ .../src/main/kotlin/dev/lui/LuiWireSchema.kt | 2 +- .../LUIAppleBackend/LUISwiftUIRoot.swift | 15 +++++++-- .../LUIAppleBackend/LUIWireSchema.swift | 2 +- schema/components.json | 1 + src/lui_protocol.ml | 1 + src/lui_wire_schema.ml | 2 +- test/test_lui.ml | 2 ++ 8 files changed, 41 insertions(+), 16 deletions(-) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt index 2abb6b51..251fd6b9 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt @@ -1490,6 +1490,7 @@ private fun LuiMenuTrigger(backend: LuiBackend, node: LuiNodeLike, id: Long, mod val iconSize = if (node.text().isEmpty()) 24.dp else 16.dp var open by remember(id) { mutableStateOf(false) } val triggerForeground = luiThemeColor(node.propString("foreground"), foreground = true) + val labelAlignment = node.propString("text-alignment") Box { if (icon != null && node.text().isEmpty()) { @@ -1512,20 +1513,31 @@ private fun LuiMenuTrigger(backend: LuiBackend, node: LuiNodeLike, id: Long, mod onClick = { open = !open }, shapes = ButtonDefaults.shapes(), enabled = enabled, - modifier = modifier, + modifier = if (labelAlignment != null) modifier.width(IntrinsicSize.Max) else modifier, + contentPadding = if (labelAlignment != null) androidx.compose.foundation.layout.PaddingValues(0.dp) + else ButtonDefaults.TextButtonContentPadding, colors = if (triggerForeground == null) androidx.compose.material3.ButtonDefaults.textButtonColors() else androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = triggerForeground), ) { - if (icon != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(icon, null, Modifier.size(iconSize)) - if (node.text().isNotEmpty()) Text(node.text()) + Box( + modifier = if (labelAlignment != null) Modifier.fillMaxWidth() else Modifier, + contentAlignment = when (labelAlignment) { + "start" -> Alignment.CenterStart + "end" -> Alignment.CenterEnd + else -> Alignment.Center + }, + ) { + if (icon != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, Modifier.size(iconSize)) + if (node.text().isNotEmpty()) Text(node.text()) + } + } else { + Text(node.text()) } - } else { - Text(node.text()) } } } diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt index 55bbefb5..c58cf6e0 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt @@ -314,7 +314,7 @@ object LuiSchemaMatrix { LuiNodeKind.toolbar to setOf(LuiProperty.height, LuiProperty.padding, LuiProperty.background, LuiProperty.borderWidth, LuiProperty.cornerRadius, LuiProperty.orientation, LuiProperty.accessibilityLabel, LuiProperty.gap, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.placement, LuiProperty.pointerEnabled), LuiNodeKind.bottomTabs to setOf(LuiProperty.accessibilityLabel, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.grow, LuiProperty.width, LuiProperty.height, LuiProperty.minWidth, LuiProperty.maxWidth, LuiProperty.minHeight, LuiProperty.maxHeight, LuiProperty.pointerEnabled), LuiNodeKind.bottomTab to setOf(LuiProperty.title, LuiProperty.icon, LuiProperty.selected, LuiProperty.enabled, LuiProperty.pressEnabled, LuiProperty.pointerEnabled), - LuiNodeKind.menuTrigger to setOf(LuiProperty.text, LuiProperty.icon, LuiProperty.accessibilityLabel, LuiProperty.enabled, LuiProperty.foreground, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.pointerEnabled), + LuiNodeKind.menuTrigger to setOf(LuiProperty.text, LuiProperty.textAlignment, LuiProperty.icon, LuiProperty.accessibilityLabel, LuiProperty.enabled, LuiProperty.foreground, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.pointerEnabled), LuiNodeKind.listSection to setOf(LuiProperty.key, LuiProperty.separator, LuiProperty.pointerEnabled), LuiNodeKind.swipeActions to setOf(), LuiNodeKind.swipeAction to setOf(LuiProperty.text, LuiProperty.icon, LuiProperty.variant, LuiProperty.edge, LuiProperty.enabled, LuiProperty.background, LuiProperty.pressEnabled, LuiProperty.pointerEnabled), diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 7bf4b4e5..28ce39c2 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -4572,6 +4572,12 @@ private struct LUIMenuTriggerView: View { return fixed == nil && maximum == nil ? 44 : nil } + private var labelAlignment: Alignment { + LUIButtonVisualPolicy.labelAlignment( + textAlignment: model.property(.textAlignment)?.stringValue ?? "center" + ) + } + var body: some View { #if os(iOS) if inHoistedToolbar { @@ -4616,12 +4622,14 @@ private struct LUIMenuTriggerView: View { .padding(.horizontal, capsuleCell && !iconOnly ? 12 : 0) .frame( maxWidth: capsuleCell && iconOnly ? .infinity : nil, - maxHeight: capsuleCell ? .infinity : nil + maxHeight: capsuleCell ? .infinity : nil, + alignment: labelAlignment ) #if os(iOS) .frame( width: sizingCell?.surfaceWidth.map(CGFloat.init), - height: sizingCell?.surfaceHeight.map(CGFloat.init) + height: sizingCell?.surfaceHeight.map(CGFloat.init), + alignment: labelAlignment ) .frame( minWidth: minimumExtent( @@ -4635,7 +4643,8 @@ private struct LUIMenuTriggerView: View { minimum: sizingCell?.surfaceMinHeight, maximum: sizingCell?.surfaceMaxHeight ), - maxHeight: sizingCell?.surfaceMaxHeight.map(CGFloat.init) + maxHeight: sizingCell?.surfaceMaxHeight.map(CGFloat.init), + alignment: labelAlignment ) #endif .contentShape(Rectangle()) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 0a77f61c..f42dc8a5 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -294,7 +294,7 @@ enum LUISchemaMatrix { .toolbar: [.height, .padding, .background, .borderWidth, .cornerRadius, .orientation, .accessibilityLabel, .gap, .styleClass, .dataAttrs, .placement, .pointerEnabled], .bottomTabs: [.accessibilityLabel, .styleClass, .dataAttrs, .grow, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight, .pointerEnabled], .bottomTab: [.title, .icon, .selected, .enabled, .pressEnabled, .pointerEnabled], - .menuTrigger: [.text, .icon, .accessibilityLabel, .enabled, .foreground, .styleClass, .dataAttrs, .pointerEnabled], + .menuTrigger: [.text, .textAlignment, .icon, .accessibilityLabel, .enabled, .foreground, .styleClass, .dataAttrs, .pointerEnabled], .listSection: [.key, .separator, .pointerEnabled], .swipeActions: [], .swipeAction: [.text, .icon, .variant, .edge, .enabled, .background, .pressEnabled, .pointerEnabled], diff --git a/schema/components.json b/schema/components.json index 47f8e0ae..813d9fc5 100644 --- a/schema/components.json +++ b/schema/components.json @@ -2091,6 +2091,7 @@ "menu-trigger": [ "text", + "text-alignment", "icon", "accessibility-label", "enabled", diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index eb66d3d5..6038f849 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -1321,6 +1321,7 @@ let property_supported kind property = || property = PointerEnabled | MenuTrigger -> property = TextValue || property = InlineIconName + || property = TextAlignment || property = AccessibilityLabel || property = Enabled || property = ForegroundValue || property = StyleClass || property = PointerEnabled || property = DataAttrs diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 06f9c963..b5fe7402 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -388,7 +388,7 @@ let kind_property_matrix kind = | Toolbar -> Some [ HeightValue; PaddingValue; BackgroundValue; BorderWidth; CornerRadius; OrientationValue; AccessibilityLabel; Gap; StyleClass; DataAttrs; PlacementValue; PointerEnabled ] | BottomTabs -> Some [ AccessibilityLabel; StyleClass; DataAttrs; GrowValue; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; PointerEnabled ] | BottomTab -> Some [ TitleValue; InlineIconName; Selected; Enabled; PressEnabled; PointerEnabled ] - | MenuTrigger -> Some [ TextValue; InlineIconName; AccessibilityLabel; Enabled; ForegroundValue; StyleClass; DataAttrs; PointerEnabled ] + | MenuTrigger -> Some [ TextValue; TextAlignment; InlineIconName; AccessibilityLabel; Enabled; ForegroundValue; StyleClass; DataAttrs; PointerEnabled ] | ListSection -> Some [ KeyValue; SeparatorValue; PointerEnabled ] | SwipeActions -> Some [ ] | SwipeAction -> Some [ TextValue; InlineIconName; VariantValue; EdgeValue; Enabled; BackgroundValue; PressEnabled; PointerEnabled ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 00ee4670..31531614 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -1875,6 +1875,8 @@ let test_menu_trigger_rules () = (property_supported MenuTrigger TextValue); Alcotest.(check bool) "trigger allows icon" true (property_supported MenuTrigger InlineIconName); + Alcotest.(check bool) "trigger allows explicit text alignment" true + (property_supported MenuTrigger TextAlignment); Alcotest.(check bool) "trigger allows label" true (property_supported MenuTrigger AccessibilityLabel); Alcotest.(check bool) "trigger drops width" false From ebfc32735f3647a825e9daa558dab28b70ae2503 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 05:57:21 +0800 Subject: [PATCH 6/7] Validate menu text alignment in Android schema-backed controls --- .../lui/src/main/kotlin/dev/lui/LuiKindRules.kt | 1 + .../lui/src/test/kotlin/dev/lui/LuiStoreTest.kt | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt index 3a3a8e37..9990dc42 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt @@ -944,6 +944,7 @@ internal object LuiKindRules { "text-decoration" -> textDecorationSupported(value.stringValue) "white-space" -> value.stringValue in setOf("normal", "nowrap", "pre-wrap") "text-overflow" -> value.stringValue in setOf("clip", "ellipsis") + "text-alignment" -> value.stringValue in textAlignments "user-select" -> value.stringValue in setOf("none", "text") "cursor" -> value.stringValue in setOf("default", "pointer", "text") "data-attrs" -> value.stringValue?.let { dataAttrsValueOk(it) } == true diff --git a/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt b/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt index 25110237..60e23ee4 100644 --- a/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt +++ b/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt @@ -14,6 +14,22 @@ class LuiStoreTest { private fun batch(generation: Int, vararg ops: LuiPatchOp) = LuiPatchBatch(generation, ops.toList()) + @Test + fun `native menu accepts semantic label alignments and rejects invalid values`() { + fun apply(value: LuiWireValue) { + tree().apply(batch(1, + LuiPatchOp.CreateNode(1L, LuiNodeKind.root), + LuiPatchOp.CreateNode(2L, LuiNodeKind.menuTrigger), + LuiPatchOp.InsertChild(1L, 2L, 0), + LuiPatchOp.SetProp(2L, "text", LuiWireValue.Str("All")), + LuiPatchOp.SetProp(2L, "text-alignment", value), + )) + } + for (alignment in listOf("start", "center", "end")) apply(LuiWireValue.Str(alignment)) + assertThrows(LuiBackendException::class.java) { apply(LuiWireValue.Str("left")) } + assertThrows(LuiBackendException::class.java) { apply(LuiWireValue.IntValue(1)) } + } + @Test fun `applies create insert set-prop and drop in order`() { val tree = tree() From 4281a5160ca9833d3b71901071dcb40228dcc36d Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 06:01:27 +0800 Subject: [PATCH 7/7] Allow custom button labels alongside context menu metadata --- .../lui/src/main/kotlin/dev/lui/LuiStore.kt | 1 + .../src/test/kotlin/dev/lui/LuiStoreTest.kt | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiStore.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiStore.kt index 395cee84..9927fc73 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiStore.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiStore.kt @@ -409,6 +409,7 @@ class LuiRetainedTree(private val registry: LuiExtensionRegistry) { throw LuiBackendException("menu-item accepts only nested menu metadata") } if (parent.kind != LuiNodeKind.menuItem && + !LuiKindRules.isButtonKind(parent.kind) && LuiKindRules.isContextMenuLeafHost(parent.kind) && child.kind != LuiNodeKind.contextMenu ) { diff --git a/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt b/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt index 60e23ee4..d1795b33 100644 --- a/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt +++ b/platform/android/lui/src/test/kotlin/dev/lui/LuiStoreTest.kt @@ -30,6 +30,42 @@ class LuiStoreTest { assertThrows(LuiBackendException::class.java) { apply(LuiWireValue.IntValue(1)) } } + @Test + fun `buttons retain custom labels alongside context menu metadata`() { + for (kind in listOf(LuiNodeKind.button, LuiNodeKind.toggleButton)) { + val tree = tree() + tree.apply(batch(1, + LuiPatchOp.CreateNode(1L, LuiNodeKind.root), + LuiPatchOp.CreateNode(2L, kind), + LuiPatchOp.CreateNode(3L, LuiNodeKind.row), + LuiPatchOp.CreateNode(4L, LuiNodeKind.icon), + LuiPatchOp.CreateNode(5L, LuiNodeKind.text), + LuiPatchOp.CreateNode(6L, LuiNodeKind.contextMenu), + LuiPatchOp.InsertChild(1L, 2L, 0), + LuiPatchOp.InsertChild(2L, 3L, 0), + LuiPatchOp.InsertChild(3L, 4L, 0), + LuiPatchOp.InsertChild(3L, 5L, 1), + LuiPatchOp.InsertChild(2L, 6L, 1), + LuiPatchOp.SetProp(4L, "name", LuiWireValue.Str("app:disclosure-right")), + LuiPatchOp.SetProp(5L, "text", LuiWireValue.Str("Results")), + )) + assertEquals(listOf(3L, 6L), tree.nodes[2L]!!.children) + } + } + + @Test + fun `text fields still reject custom container children`() { + assertThrows(LuiBackendException::class.java) { + tree().apply(batch(1, + LuiPatchOp.CreateNode(1L, LuiNodeKind.root), + LuiPatchOp.CreateNode(2L, LuiNodeKind.textField), + LuiPatchOp.CreateNode(3L, LuiNodeKind.row), + LuiPatchOp.InsertChild(1L, 2L, 0), + LuiPatchOp.InsertChild(2L, 3L, 0), + )) + } + } + @Test fun `applies create insert set-prop and drop in order`() { val tree = tree()