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 79e43436..08caa238 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt @@ -74,6 +74,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ModalBottomSheet @@ -590,8 +591,14 @@ private fun LuiNodeContent( if (children.isNotEmpty()) { val selectedIndex = children.indexOfFirst { backend.node(it)?.propBool("selected") == true } .coerceAtLeast(0) - PrimaryTabRow(selectedTabIndex = selectedIndex, modifier = modifier) { - children.forEachNode { LuiNodeView(backend, it) } + if (node.propString("style-class").orEmpty().split(' ').contains("scrollable")) { + PrimaryScrollableTabRow(selectedTabIndex = selectedIndex, modifier = modifier, edgePadding = 0.dp) { + children.forEachNode { LuiNodeView(backend, it) } + } + } else { + PrimaryTabRow(selectedTabIndex = selectedIndex, modifier = modifier) { + children.forEachNode { LuiNodeView(backend, it) } + } } } } @@ -1217,7 +1224,7 @@ private fun LuiTextControl( } val fieldModifier = modifier.then( - if (grouped || borderless || formField) Modifier.fillMaxWidth() else Modifier.width(240.dp), + if (grouped || borderless || formField || search) Modifier.fillMaxWidth() else Modifier.width(240.dp), ).focusRequester(focusRequester) val fieldTextStyle = (if (borderless) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium).copy( @@ -2188,7 +2195,11 @@ private fun LuiModalSurface(backend: LuiBackend, node: LuiNodeLike, id: Long) { ) LuiNodeKind.sheet -> ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = node.prop("detents")?.stringValue + ?.split(",")?.map { it.trim() }?.contains("medium") != true, + ), + containerColor = LocalLuiSemanticColors.current["background"] ?: MaterialTheme.colorScheme.surface, ) { Column(Modifier.fillMaxWidth().heightIn(max = node.prop("height")?.intValue?.dp ?: Dp.Infinity)) { if (node.text().isNotEmpty()) { diff --git a/platform/android/lui/src/test/kotlin/dev/lui/LuiScreenshotTest.kt b/platform/android/lui/src/test/kotlin/dev/lui/LuiScreenshotTest.kt index 453881ff..b5f4c6c2 100644 --- a/platform/android/lui/src/test/kotlin/dev/lui/LuiScreenshotTest.kt +++ b/platform/android/lui/src/test/kotlin/dev/lui/LuiScreenshotTest.kt @@ -237,4 +237,53 @@ class LuiScreenshotTest { @Test fun controlsDark() = controlsScreen(dark = true) + + private fun scrollableTabs(dark: Boolean) { + val backend = backendFor("""{"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"root"}, + {"op":"create-node","id":2,"kind":"column"}, + {"op":"set-prop","id":2,"property":"width","value":220}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"create-node","id":3,"kind":"tabs"}, + {"op":"set-prop","id":3,"property":"style-class","value":"scrollable"}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"create-node","id":4,"kind":"button"}, + {"op":"set-prop","id":4,"property":"text","value":"List"}, + {"op":"set-prop","id":4,"property":"press-enabled","value":true}, + {"op":"insert-child","parent":3,"child":4,"index":0}, + {"op":"create-node","id":5,"kind":"button"}, + {"op":"set-prop","id":5,"property":"text","value":"Table"}, + {"op":"set-prop","id":5,"property":"selected","value":true}, + {"op":"set-prop","id":5,"property":"press-enabled","value":true}, + {"op":"insert-child","parent":3,"child":5,"index":1}, + {"op":"create-node","id":6,"kind":"button"}, + {"op":"set-prop","id":6,"property":"text","value":"Gallery"}, + {"op":"set-prop","id":6,"property":"press-enabled","value":true}, + {"op":"insert-child","parent":3,"child":6,"index":2}, + {"op":"create-node","id":7,"kind":"button"}, + {"op":"set-prop","id":7,"property":"text","value":"Projects"}, + {"op":"set-prop","id":7,"property":"press-enabled","value":true}, + {"op":"insert-child","parent":3,"child":7,"index":3} + ]}""") + snapshot(if (dark) "scrollable-tabs-dark" else "scrollable-tabs-light", backend, dark) + } + + @Test fun scrollableTabsLight() = scrollableTabs(dark = false) + @Test fun scrollableTabsDark() = scrollableTabs(dark = true) + + private fun searchFieldWidth(dark: Boolean) { + val backend = backendFor("""{"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"root"}, + {"op":"create-node","id":2,"kind":"column"}, + {"op":"set-prop","id":2,"property":"width","value":320}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"create-node","id":3,"kind":"search-field"}, + {"op":"set-prop","id":3,"property":"placeholder","value":"Search values"}, + {"op":"insert-child","parent":2,"child":3,"index":0} + ]}""") + snapshot(if (dark) "search-width-dark" else "search-width-light", backend, dark) + } + + @Test fun searchFieldWidthLight() = searchFieldWidth(dark = false) + @Test fun searchFieldWidthDark() = searchFieldWidth(dark = true) } diff --git a/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_scrollableTabsDark_scrollable-tabs-dark.png b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_scrollableTabsDark_scrollable-tabs-dark.png new file mode 100644 index 00000000..5873ebc8 Binary files /dev/null and b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_scrollableTabsDark_scrollable-tabs-dark.png differ diff --git a/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_scrollableTabsLight_scrollable-tabs-light.png b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_scrollableTabsLight_scrollable-tabs-light.png new file mode 100644 index 00000000..61dad84f Binary files /dev/null and b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_scrollableTabsLight_scrollable-tabs-light.png differ diff --git a/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_searchFieldWidthDark_search-width-dark.png b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_searchFieldWidthDark_search-width-dark.png new file mode 100644 index 00000000..a493bcaa Binary files /dev/null and b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_searchFieldWidthDark_search-width-dark.png differ diff --git a/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_searchFieldWidthLight_search-width-light.png b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_searchFieldWidthLight_search-width-light.png new file mode 100644 index 00000000..9af23d40 Binary files /dev/null and b/platform/android/lui/src/test/snapshots/images/dev.lui_LuiScreenshotTest_searchFieldWidthLight_search-width-light.png differ diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 7b9974a8..f1c964be 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -2849,7 +2849,10 @@ private struct LUIModalSurfaceContent: View { var body: some View { surfaceContent + .environment(\.luiSemanticColors, effectiveSemanticColors) + .preferredColorScheme(effectiveColorScheme) .modifier(LUIModalPresentationStyle(model: model)) + .presentationBackground(modalBackground) .sheet(item: Binding( get: { backend.modalPresentation.nestedSheets[model.id] }, set: { value in @@ -2862,7 +2865,7 @@ private struct LUIModalSurfaceContent: View { LUIModalSurfaceContent(model: presentation.model, backend: backend) .luiSheetScope( semanticColors: effectiveSemanticColors, - colorScheme: colorScheme + colorScheme: effectiveColorScheme ) } } @@ -2871,18 +2874,31 @@ private struct LUIModalSurfaceContent: View { /// ancestors never reach it through the environment — walk the chain and /// apply each ancestor's commit-decoded tokens the same way /// `LUIThemeScopeModifier` does (outermost-first, inner wins). - private var effectiveSemanticColors: [String: Color] { + private var themeAncestors: [LUINodeModel] { var ancestors: [LUINodeModel] = [] var cursor = model.parent - while let parentID = cursor, - let parent = backend.model(id: parentID) { - ancestors.append(parent) - cursor = parent.parent + while let parentID = cursor { + if let parent = backend.model(id: parentID) { + ancestors.append(parent) + cursor = parent.parent + } else { + cursor = backend.extensionModel(id: parentID)?.parent + } } + return ancestors.reversed() + } + + private var effectiveColorScheme: ColorScheme { + themeAncestors.reduce(colorScheme) { $1.derived.themeScope.scheme ?? $0 } + } + + private var effectiveSemanticColors: [String: Color] { var colors = semanticColors - for ancestor in ancestors.reversed() { + var scheme = colorScheme + for ancestor in themeAncestors { + scheme = ancestor.derived.themeScope.scheme ?? scheme colors.merge( - ancestor.derived.themeScope.colors(dark: colorScheme == .dark) + ancestor.derived.themeScope.colors(dark: scheme == .dark) ) { _, new in new } } return colors @@ -2936,7 +2952,7 @@ private struct LUIModalSurfaceContent: View { if LUIModalBackgroundPolicy.usesGroupedSystemBackground( styleClass: model.derived.styleClass ) { - return modalGroupedSystemBackground + return effectiveSemanticColors["background"] ?? modalGroupedSystemBackground } return LUIModalBackgroundPolicy.color( semanticColors: effectiveSemanticColors, @@ -6711,6 +6727,7 @@ private struct LUIListView: View { .modifier(LUIListRowSeparatorModifier( visibility: backend.model(id: childID)?.separatorVisibility )) + .environment(\.luiIsNativeFormRow, true) .listRowBackground(semanticColors["surface"]) } } @@ -7447,7 +7464,11 @@ private struct LUITextControlView: View { .submitLabel(model.property(.submitOnEnter)?.boolValue == true ? .send : .return) } else if model.kind == .searchField { #if os(iOS) - iosSearchFieldRow.textFieldStyle(.roundedBorder) + iosSearchFieldRow + .textFieldStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 10)) #else HStack(spacing: 6) { Image(systemName: "magnifyingglass") diff --git a/src/lui_elements.ml b/src/lui_elements.ml index e099ed7a..83c36877 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -438,8 +438,8 @@ let dynamic mount : t = node | None -> (* A dynamic rendered as the branch of another dynamic has no anchor of - its own: mount under a transparent stack so it still has a home. *) - let anchor = Lui_ui.stack context in + its own: mount under a transparent box supported by every backend. *) + let anchor = Lui_ui.box context in Lui_ui.string_property context anchor DisplayValue "contents"; ignore (mount context anchor); anchor diff --git a/test/test_lui.ml b/test/test_lui.ml index cf6592eb..00ee4670 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -423,6 +423,14 @@ let test_dyn_nested_bare () = flush_app app; Alcotest.(check bool) "nested dyn mounts text" true (creates_text (all_ops ())); + let kinds = Hashtbl.create 8 in + List.iter (function + | Lui_protocol.CreateNode (id, kind) -> Hashtbl.replace kinds id kind + | Lui_protocol.SetProp (id, Lui_protocol.DisplayValue, _) -> + Alcotest.(check bool) "transparent anchors support native display properties" true + (List.mem (Hashtbl.find kinds id) + [Lui_protocol.Box; Lui_protocol.Column; Lui_protocol.Row]) + | _ -> ()) (all_ops ()); batches := []; ignore (Lui_app.send app (`Inner "updated")); flush_app app;