diff --git a/.github/actions/setup-lui-ocaml/action.yml b/.github/actions/setup-lui-ocaml/action.yml index 7ae721e5..ea2c95da 100644 --- a/.github/actions/setup-lui-ocaml/action.yml +++ b/.github/actions/setup-lui-ocaml/action.yml @@ -102,6 +102,9 @@ runs: _opam C:/.opam key: ${{ steps.environment.outputs.cache-primary-key }} + - name: Export active switch for subprocesses outside the workspace + shell: bash + run: echo "OPAMSWITCH=$(opam switch show)" >> "$GITHUB_ENV" - name: Check active compiler shell: bash run: opam exec -- ocamlc -version diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiBackend.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiBackend.kt index d51d3be2..dcccf3f3 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiBackend.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiBackend.kt @@ -77,8 +77,9 @@ class LuiBackend( /** Resolver hook for `file-image` paths (e.g. remap asset paths). */ var filePathResolver: ((String) -> String)? = null - internal var lastError: String? = null + var lastError: String? = null private set + internal val scrollPositions = mutableMapOf() private fun observed(id: Long): NodeObservation = observedNodes.getOrPut(id) { NodeObservation().also { refresh(it, id) } 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 2a6b3bf5..3a3a8e37 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt @@ -448,6 +448,7 @@ internal object LuiKindRules { kind == LuiNodeKind.menuItem || kind == LuiNodeKind.listItem || kind == LuiNodeKind.tableRow || + kind == LuiNodeKind.virtualList || kind == LuiNodeKind.drawer || isTreeRowKind(kind)) "long-press-enabled" -> 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 8b64c492..205975c6 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -33,6 +35,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -60,8 +63,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.RadioButton import androidx.compose.material3.Slider import androidx.compose.material3.Surface @@ -89,19 +90,26 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** * Renders one node of the retained tree plus its subtree. Ported from the @@ -136,7 +144,15 @@ private fun LuiChromeSurface(backend: LuiBackend, node: LuiNodeLike, id: Long) { var contextMenuAt by remember(id) { mutableStateOf(null) } val contextMenuId = node.children.firstOrNull { backend.node(it)?.kind == LuiNodeKind.contextMenu } - val modifier = Modifier + val hidden = node.propString("display") == "none" || + (node.kind == LuiNodeKind.virtualList && node.prop("selected")?.boolValue == false) + val retainedVisibility = if (hidden) { + Modifier.layout { measurable, constraints -> + val measured = measurable.measure(constraints) + layout(0, 0) { } + } + } else Modifier + val modifier = retainedVisibility .luiChrome(node) .luiGestures(backend, node, id) { offset -> contextMenuAt = offset } @@ -330,12 +346,7 @@ private fun LuiNodeContent( } } - LuiNodeKind.virtualList -> LazyColumn( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(gapOf(node).dp), - ) { - items(children, key = { it }) { childId -> LuiNodeView(backend, childId) } - } + LuiNodeKind.virtualList -> LuiVirtualList(backend, node, id, modifier) LuiNodeKind.grid -> LuiGrid(backend, node, modifier, children) @@ -372,7 +383,8 @@ private fun LuiNodeContent( LuiNodeKind.paragraph, LuiNodeKind.label, -> Text( - node.text(), + if (node.text().isEmpty() && node.propString("style-class").orEmpty().split(' ').contains("navigation-title")) + LocalLuiSectionTitle.current.orEmpty() else node.text(), modifier = modifier, color = luiThemeColor(node.propString("foreground"), foreground = true) ?: Color.Unspecified, @@ -922,10 +934,21 @@ private fun LuiButton( "lg", "icon" -> 24.dp else -> 18.dp } + val defaultContentPadding = androidx.compose.material3.ButtonDefaults.TextButtonContentPadding + val layoutDirection = LocalLayoutDirection.current + val horizontalPadding = node.prop("padding-horizontal")?.intValue ?: node.prop("padding")?.intValue + val verticalPadding = node.prop("padding-vertical")?.intValue ?: node.prop("padding")?.intValue + val contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = horizontalPadding?.dp ?: defaultContentPadding.calculateStartPadding(layoutDirection), + end = horizontalPadding?.dp ?: defaultContentPadding.calculateEndPadding(layoutDirection), + top = verticalPadding?.dp ?: defaultContentPadding.calculateTopPadding(), + bottom = verticalPadding?.dp ?: defaultContentPadding.calculateBottomPadding(), + ) val label: @Composable () -> Unit = { if (children.isNotEmpty()) { Row( + modifier = if (node.propString("text-alignment") != null) Modifier.fillMaxWidth() else Modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -940,6 +963,11 @@ private fun LuiButton( ) icon == null -> Text( node.text(), + modifier = if (node.propString("text-alignment") != null) Modifier.fillMaxWidth() else Modifier, + textAlign = textAlignment(node.propString("text-alignment")), + style = if (node.propString("style-class") != null) { + LuiTypography.textStyle(node.propString("size"), node.propString("style-class")) + } else MaterialTheme.typography.labelLarge.luiInherited(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -1032,8 +1060,9 @@ private fun LuiButton( } else { TextButton( onClick = onClick, - modifier = modifier, + modifier = if (node.propDouble("grow") > 0) modifier.fillMaxWidth() else modifier, enabled = enabled, + contentPadding = contentPadding, // Borderless buttons use the accent in Compose, but the Apple // backend renders them with the dark foreground — match it so // header/toolbar glyphs are not tinted primary. @@ -1084,6 +1113,18 @@ private fun LuiTextControl( val grouped = node.parent?.let { backend.node(it)?.kind } == LuiNodeKind.inputGroup val borderless = (node.propString("style-class") ?: "").split(' ') .contains("composer-input") + val focusRequester = remember(id) { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + val autofocus = node.prop("autofocus")?.boolValue == true + var observedAutofocus by remember(id) { mutableStateOf(null) } + LaunchedEffect(autofocus) { + // The protocol toggles this flag to request focus again on an existing field. + if (enabled && (autofocus || observedAutofocus != null)) { + focusRequester.requestFocus() + keyboard?.show() + } + observedAutofocus = autofocus + } // Local text keeps typing responsive. The model echoes our own edits // back through the `text` prop, possibly one patch per keystroke, so a @@ -1104,8 +1145,9 @@ private fun LuiTextControl( val fieldModifier = modifier.then( if (grouped || borderless) Modifier.fillMaxWidth() else Modifier.width(240.dp), - ) - val fieldTextStyle = MaterialTheme.typography.bodyMedium.copy( + ).focusRequester(focusRequester) + val fieldTextStyle = (if (borderless) MaterialTheme.typography.bodyLarge.copy(fontSize = 17.sp) + else MaterialTheme.typography.bodyMedium).copy( color = luiThemeColor(node.propString("foreground"), foreground = true) ?: MaterialTheme.colorScheme.onSurface, ).luiInherited() @@ -1140,25 +1182,27 @@ private fun LuiTextControl( } if (borderless) { - TextField( + BasicTextField( value = localText, onValueChange = onValueChange, modifier = fieldModifier, enabled = enabled, - placeholder = placeholderContent, singleLine = !multiline, - minLines = if (multiline) 3 else 1, visualTransformation = transformation, - trailingIcon = trailing, textStyle = fieldTextStyle, - colors = TextFieldDefaults.colors( - focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, - disabledContainerColor = androidx.compose.ui.graphics.Color.Transparent, - focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - disabledIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (localText.isEmpty()) { + Text( + node.propString("placeholder") ?: "", + style = fieldTextStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + innerTextField() + } + }, ) } else { OutlinedTextField( @@ -1207,7 +1251,9 @@ internal fun LuiMenuEntry(backend: LuiBackend, childId: Long) { val child = backend.node(childId) ?: return when (child.kind) { LuiNodeKind.divider -> HorizontalDivider() - LuiNodeKind.menuItem -> LuiMenuItem(backend, child, childId, Modifier) + LuiNodeKind.menuItem -> LuiThemeScope(child) { + LuiMenuItem(backend, child, childId, Modifier.luiChrome(child)) + } LuiNodeKind.menuTrigger -> LuiMenuTrigger(backend, child, childId, Modifier) else -> LuiNodeView(backend, childId) } @@ -1221,15 +1267,18 @@ private fun LuiMenuItem(backend: LuiBackend, node: LuiNodeLike, id: Long, modifi val icon = node.propString("icon")?.let { backend.icons.icon(it) } val selected = node.propBool("selected") val destructive = node.propString("variant") == "destructive" + val iconTint = luiThemeColor(node.propString("foreground"), foreground = true) + ?: MaterialTheme.colorScheme.onSurface + val iconSize = if (node.propString("size") == "sm") 16.dp else 22.dp if (submenuId != null) { var submenuOpen by remember(id) { mutableStateOf(false) } Box { DropdownMenuItem( - text = { Text(node.text()) }, + text = { Text(node.text(), style = LuiTypography.textStyle(null, node.propString("style-class"))) }, onClick = { submenuOpen = true }, enabled = enabled, - leadingIcon = icon?.let { { Icon(it, null, Modifier.size(16.dp)) } }, + leadingIcon = icon?.let { { Icon(it, null, Modifier.size(iconSize), tint = iconTint) } }, trailingIcon = { Icon(Icons.Filled.KeyboardArrowRight, null, Modifier.size(16.dp)) }, @@ -1251,13 +1300,14 @@ private fun LuiMenuItem(backend: LuiBackend, node: LuiNodeLike, id: Long, modifi text = { Text( node.text(), + style = LuiTypography.textStyle(null, node.propString("style-class")), color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified, ) }, onClick = { if (enabled) backend.emit(LuiEvent.Press(id)) }, enabled = enabled, modifier = modifier, - leadingIcon = icon?.let { { Icon(it, null, Modifier.size(16.dp)) } }, + leadingIcon = icon?.let { { Icon(it, null, Modifier.size(iconSize), tint = iconTint) } }, trailingIcon = if (selected) { { Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) } } else { @@ -1829,6 +1879,8 @@ private fun LuiDrawer( val presented = node.propBool("selected") val width = node.propInt("width", 320) val enabled = node.isEnabled() + var swiping by remember(id) { mutableStateOf(false) } + val threshold = with(LocalDensity.current) { 48.dp.toPx() } // Android back closes the drawer instead of leaving the app — mirrors // tapping the scrim. androidx.activity.compose.BackHandler( @@ -1836,8 +1888,24 @@ private fun LuiDrawer( ) { backend.emit(LuiEvent.ToggleChanged(id, false)) } - Box(modifier.fillMaxSize()) { - children.firstOrNull()?.let { LuiNodeView(backend, it) } + Box(modifier.fillMaxSize().pointerInput(id, enabled, presented) { + if (enabled && node.propBool("toggle-enabled")) { + var distance = 0f + detectHorizontalDragGestures( + onDragStart = { distance = 0f; swiping = true }, + onDragCancel = { swiping = false }, + onDragEnd = { + if ((!presented && distance > threshold) || (presented && distance < -threshold)) { + backend.emit(LuiEvent.ToggleChanged(id, !presented)) + } + swiping = false + }, + ) { change, amount -> change.consume(); distance += amount } + } + }) { + CompositionLocalProvider(LocalLuiScrollingEnabled provides (!presented && !swiping)) { + children.firstOrNull()?.let { LuiNodeView(backend, it) } + } if (presented) { Box( Modifier.fillMaxSize() diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt index 4b1b2e5c..8b523f44 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt @@ -189,7 +189,9 @@ internal fun Modifier.luiChrome(node: LuiNodeLike): Modifier { ) } if (radius > 0) modifier = modifier.clip(shape) - if (paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0) { + // Ghost buttons own their label padding so the full painted surface stays clickable. + val buttonContentPadding = node.kind == LuiNodeKind.button && node.propString("variant") == "ghost" + if (!buttonContentPadding && (paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0)) { modifier = modifier.absolutePadding( left = paddingLeft.dp, top = paddingTop.dp, right = paddingRight.dp, bottom = paddingBottom.dp, diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiTheme.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiTheme.kt index 456d3a4d..99bd5126 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiTheme.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiTheme.kt @@ -319,7 +319,7 @@ object LuiTypography { 4 -> theme.titleMedium.copy(fontSize = 20.sp) 5 -> theme.bodyLarge.copy(fontSize = 17.sp) else -> theme.titleSmall.copy(fontSize = 15.sp) - }.luiInherited() + }.copy(fontWeight = FontWeight.Bold).luiInherited() } @Composable diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiVirtualList.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiVirtualList.kt new file mode 100644 index 00000000..a83c269b --- /dev/null +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiVirtualList.kt @@ -0,0 +1,136 @@ +package dev.lui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.distinctUntilChanged + +/** Native scroll state survives destinations being removed from composition. */ +internal data class LuiScrollPosition(val index: Int = 0, val offset: Int = 0) +val LocalLuiSectionTitle = compositionLocalOf { null } +val LocalLuiSectionTitleChanged = compositionLocalOf<(String) -> Unit> { {} } +val LocalLuiScrollingEnabled = compositionLocalOf { true } + +internal fun LuiNodeLike.dataAttributes(): Map = + propString("data-attrs").orEmpty().split('\u001e').mapNotNull { entry -> + val split = entry.indexOf('\u001f') + if (split < 0) null else entry.substring(0, split) to entry.substring(split + 1) + }.toMap() + +private data class WindowedChildren( + val before: List, val window: Long?, val after: List, +) + +private fun windowedChildren(backend: LuiBackend, children: List): WindowedChildren { + val before = mutableListOf() + val after = mutableListOf() + var window: Long? = null + fun hasWindow(id: Long): Boolean { + val child = backend.node(id) ?: return false + return child.dataAttributes().containsKey("data-virtual-count") || child.children.any(::hasWindow) + } + fun visit(ids: List) { + ids.forEach { id -> + val child = backend.node(id) + when { + child?.dataAttributes()?.containsKey("data-virtual-count") == true -> window = id + child != null && hasWindow(id) -> visit(child.children) + window == null -> before.add(id) + else -> after.add(id) + } + } + } + visit(children) + return WindowedChildren(before, window, after) +} + +/** One native lazy list owns headers, virtual rows and pagination sentinels. */ +@Composable +internal fun LuiVirtualList(backend: LuiBackend, node: LuiNodeLike, id: Long, modifier: Modifier) { + val identity = node.dataAttributes()["data-scroll-identity"] ?: "node:$id" + val content = windowedChildren(backend, node.children) + val window = content.window?.let { backend.node(it) } + val attributes = window?.dataAttributes().orEmpty() + val count = attributes["data-virtual-count"]?.toIntOrNull() ?: 0 + val start = attributes["data-virtual-start"]?.toIntOrNull() ?: 0 + val extent = attributes["data-item-extent"]?.toIntOrNull() ?: 32 + val rows = window?.children.orEmpty() + val initial = remember(identity) { backend.scrollPositions[identity] ?: LuiScrollPosition() } + val state = remember(identity) { LazyListState(initial.index, initial.offset) } + val heights = remember(identity) { mutableMapOf() } + val density = LocalDensity.current + val titleChanged = LocalLuiSectionTitleChanged.current + val visible = node.prop("selected")?.boolValue != false + val sectionTitles = attributes.entries.mapNotNull { (key, value) -> + key.removePrefix("data-section-title-").toIntOrNull()?.let { it to value } + }.sortedBy { it.first } + val currentContent by rememberUpdatedState(content) + val currentCount by rememberUpdatedState(count) + val currentWindow by rememberUpdatedState(content.window) + val currentTitles by rememberUpdatedState(sectionTitles) + val currentTitleChanged by rememberUpdatedState(titleChanged) + val currentVisible by rememberUpdatedState(visible) + val target = attributes["data-scroll-key"] + val targetIndex = attributes["data-scroll-index"]?.toIntOrNull() + + DisposableEffect(identity) { + onDispose { backend.scrollPositions[identity] = LuiScrollPosition(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) } + } + LaunchedEffect(identity, target, targetIndex) { + if (target != null && targetIndex != null && count > 0) { + state.scrollToItem(content.before.size + targetIndex.coerceIn(0, count - 1)) + } + } + LaunchedEffect(identity, state) { + var previousRange: Pair? = null + snapshotFlow { + val visible = state.layoutInfo.visibleItemsInfo + Triple(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset, visible.lastOrNull()?.index ?: 0) + }.distinctUntilChanged().collect { (firstItem, offset, lastItem) -> + backend.scrollPositions[identity] = LuiScrollPosition(firstItem, offset) + val first = (firstItem - currentContent.before.size).coerceAtLeast(0) + val last = (lastItem - currentContent.before.size).coerceAtMost(currentCount - 1) + val id = currentWindow + val range = first to last + if (id != null && last >= first && range != previousRange) { + previousRange = range + backend.emit(LuiEvent.VisibleRange(id, first.toLong(), last.toLong())) + } + if (currentVisible) currentTitles.lastOrNull { it.first <= first }?.second?.let { currentTitleChanged(it) } + } + } + LaunchedEffect(sectionTitles, visible) { + if (visible) sectionTitles.lastOrNull { it.first <= (state.firstVisibleItemIndex - content.before.size).coerceAtLeast(0) } + ?.second?.let(titleChanged) + } + LazyColumn(modifier = modifier, state = state, userScrollEnabled = visible && LocalLuiScrollingEnabled.current) { + content.before.forEach { id -> item(key = "fixed:$id") { LuiNodeView(backend, id) } } + items(count, key = { "row:$it" }) { index -> + val id = rows.getOrNull(index - start) + if (id != null) { + Box(Modifier.fillMaxWidth().onSizeChanged { heights[index] = it.height }) { + LuiNodeView(backend, id) + } + } else { + val height = heights[index]?.let { with(density) { it.toDp() } } ?: extent.dp + Spacer(Modifier.fillMaxWidth().height(height)) + } + } + content.after.forEach { id -> item(key = "fixed:$id") { LuiNodeView(backend, id) } } + } +} diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 1055e734..3186e186 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -975,7 +975,12 @@ public final class LUIAppleBackend { var tC = tB var tD = tB - if coalescesCommits, CFAbsoluteTimeGetCurrent() < coalesceWindowUntil { + let updatesVirtualWindow = effects.touched.contains { id in + tree.nodes[id]?.properties[.dataAttrs]?.stringValue? + .contains("data-virtual-count\u{1f}") == true + } + if coalescesCommits, !updatesVirtualWindow, + CFAbsoluteTimeGetCurrent() < coalesceWindowUntil { // Inside a burst window: this patch's mutations merge into the // pending commit and land as one view update once the stream // quiets down. iOS's update-coalescing collection view replays @@ -1066,7 +1071,9 @@ public final class LUIAppleBackend { // No dialog/sheet/filePreview is mounted, so the DFS // could only produce empty presentations — clear any // stale state directly instead of walking the tree. - modalPresentation.nestedSheets = [:] + if !modalPresentation.nestedSheets.isEmpty { + modalPresentation.nestedSheets = [:] + } modalPresentation.synchronize(with: nil) filePreviewPresentation.synchronize(with: nil) } @@ -1765,7 +1772,9 @@ public final class LUIAppleBackend { for rootID in rootIDs { visit(rootID, rootID: rootID, dialogAnchorID: nil, parentSheetID: nil) } - modalPresentation.nestedSheets = nestedSheets + if modalPresentation.nestedSheets != nestedSheets { + modalPresentation.nestedSheets = nestedSheets + } modalPresentation.synchronize(with: presentation) filePreviewPresentation.synchronize(with: filePreview) } diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index bb2603f1..7f565f3a 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if os(iOS) +import UIKit +#endif enum LUIDrawerGeometry { static func gestureIsEligible( @@ -105,7 +108,7 @@ enum LUIDrawerInteractionPolicy { } static func mainCornerRadius(progress: CGFloat) -> CGFloat { - 40.0 * progress + 52.0 * progress } } @@ -117,7 +120,6 @@ struct LUIDrawerView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var presented: Bool - @State private var hasLoadedPanel: Bool @State private var dragOffset: CGFloat = 0 @State private var isGestureActive = false @State private var rejectedGesture = false @@ -131,7 +133,6 @@ struct LUIDrawerView: View { self.model = model self.backend = backend _presented = State(initialValue: model.isSelected) - _hasLoadedPanel = State(initialValue: model.isSelected) } /// On regular-width windows (iPad, the iPhone Duo inner display) the panel @@ -156,7 +157,7 @@ struct LUIDrawerView: View { GeometryReader { geometry in let width = min( CGFloat(model.property(.width)?.intValue ?? 360), - geometry.size.width * 0.84 + geometry.size.width * 0.79 ) if pinsSidebar { pinnedBody(width: width, geometry: geometry) @@ -187,7 +188,7 @@ struct LUIDrawerView: View { @ViewBuilder private func pinnedBody(width: CGFloat, geometry: GeometryProxy) -> some View { NavigationSplitView(columnVisibility: splitColumnVisibility) { - if hasLoadedPanel, let panelID = model.children.dropFirst().first { + if let panelID = model.children.dropFirst().first { LUIAnyNodeView(nodeID: panelID, backend: backend) .frame(maxHeight: .infinity, alignment: .leading) .navigationSplitViewColumnWidth( @@ -229,11 +230,6 @@ struct LUIDrawerView: View { ) let progress = width > 0.0 ? visibleWidth / width : 0.0 let isDragging = abs(dragOffset) > 0.0 - let interactionsLocked = LUIDrawerInteractionPolicy.disablesInteraction( - isDragging: isDragging, - isAnimating: isAnimating, - isGestureActive: isGestureActive - ) let contentControlsEnabled = LUIDrawerInteractionPolicy.contentControlsAreEnabled( isDragging: isDragging, @@ -241,9 +237,11 @@ struct LUIDrawerView: View { isGestureActive: isGestureActive ) + // Native scroll locking preserves the descendant SwiftUI environment, + // so drawer transitions do not invalidate a long content tree. ZStack(alignment: .leading) { // Retain the panel after its first reveal so closing preserves its state. - if hasLoadedPanel, let panelID = model.children.dropFirst().first { + if let panelID = model.children.dropFirst().first { LUIAnyNodeView(nodeID: panelID, backend: backend) .frame(width: width) .frame(maxHeight: CGFloat.infinity, alignment: Alignment.leading) @@ -251,7 +249,6 @@ struct LUIDrawerView: View { .opacity(LUIDrawerInteractionPolicy.sidebarOpacity(progress: progress)) .scaleEffect(0.96 + (0.04 * Double(progress))) .offset(x: -20.0 * (1.0 - progress)) - .scrollDisabled(interactionsLocked) .allowsHitTesting(presented && contentControlsEnabled) .accessibilityHidden( LUIDrawerInteractionPolicy.panelIsAccessibilityHidden( @@ -268,7 +265,13 @@ struct LUIDrawerView: View { top: geometry.safeAreaInsets.top )) .modifier(LUIDrawerMainSurfaceModifier()) - .scrollDisabled(interactionsLocked) + // Fade content toward the page background while preserving + // the surface color and the retained content subtree. + .overlay { + (semanticColors["background"] ?? LUIThemeColorResolver.systemBackground) + .opacity(0.5 * Double(progress)) + .allowsHitTesting(false) + } .allowsHitTesting(contentControlsEnabled) .overlay { if presented { @@ -288,6 +291,9 @@ struct LUIDrawerView: View { progress: progress ) )) + // Shadow the combined panel once so the translucent fade + // does not cast another shadow onto the content below it. + .compositingGroup() .shadow( color: Color.black.opacity( LUIDrawerInteractionPolicy.shadowOpacity @@ -310,63 +316,80 @@ struct LUIDrawerView: View { .modifier(LUIDrawerInteractionShieldModifier()) } } + #if os(iOS) + .background(LUIDrawerNativeInteraction( + isEnabled: (presented || model.isEnabled) && !isAnimating, + isPresented: presented, + isLocked: !contentControlsEnabled, + onChanged: { updateDrag(translation: $0) }, + onEnded: { translation, predicted in + finishDrag(translation: translation, predicted: predicted, width: width) + }, + onCancelled: { + animatePresentation(presented) + isGestureActive = false + } + )) + .sensoryFeedback(.impact(weight: .light), trigger: presented) + #else .simultaneousGesture( drawerGesture(width: width), isEnabled: (presented || model.isEnabled) && !isAnimating ) - #if os(iOS) - .sensoryFeedback(.impact(weight: .light), trigger: presented) #endif } - private func drawerGesture(width: CGFloat) -> some Gesture { - let gesture = DragGesture(minimumDistance: 10, coordinateSpace: .global) - return gesture - .onChanged { value in - guard !isAnimating, !rejectedGesture else { return } - // Once a drag commits to scrolling, never turn it into a drawer swipe. - if !isGestureActive, abs(value.translation.height) >= 10, - abs(value.translation.width) <= abs(value.translation.height) * 2 { - rejectedGesture = true - return - } - let activationAllowed = presented || model.isEnabled - let gestureIsEligible = LUIDrawerGeometry.gestureIsEligible( - enabled: activationAllowed, - translationX: Double(value.translation.width), - translationY: Double(value.translation.height) - ) - if isGestureActive || gestureIsEligible { - backend.setDrawerInteractionLocked(true, node: model.id) - var transaction = Transaction() - transaction.animation = nil - withTransaction(transaction) { - if value.translation.width > 0 { hasLoadedPanel = true } - isGestureActive = true - dragOffset = CGFloat(LUIDrawerGeometry.dragOffset( - isPresented: presented, - translation: Double(value.translation.width), - width: Double(width) - )) - } - } else { - dragOffset = 0 - } - } - .onEnded { value in - rejectedGesture = false - guard !isAnimating else { return } - guard isGestureActive else { - dragOffset = 0 - return - } - updatePresentation(LUIDrawerGeometry.targetIsPresented( + private func updateDrag(translation: CGSize) { + guard !isAnimating, !rejectedGesture else { return } + if !isGestureActive, abs(translation.height) >= 10, + abs(translation.width) <= abs(translation.height) * 2 { + rejectedGesture = true + return + } + let gestureIsEligible = LUIDrawerGeometry.gestureIsEligible( + enabled: presented || model.isEnabled, + translationX: Double(translation.width), + translationY: Double(translation.height) + ) + if isGestureActive || gestureIsEligible { + backend.setDrawerInteractionLocked(true, node: model.id) + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + isGestureActive = true + dragOffset = CGFloat(LUIDrawerGeometry.dragOffset( isPresented: presented, - translation: Double(value.translation.width), - predictedTranslation: Double(value.predictedEndTranslation.width), - width: Double(width) + translation: Double(translation.width), + width: 0 )) - isGestureActive = false + } + } else { + dragOffset = 0 + } + } + + private func finishDrag(translation: CGSize, predicted: CGSize, width: CGFloat) { + rejectedGesture = false + guard !isAnimating else { return } + guard isGestureActive else { + dragOffset = 0 + return + } + updatePresentation(LUIDrawerGeometry.targetIsPresented( + isPresented: presented, + translation: Double(translation.width), + predictedTranslation: Double(predicted.width), + width: Double(width) + )) + isGestureActive = false + } + + private func drawerGesture(width: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 10, coordinateSpace: .global) + .onChanged { updateDrag(translation: $0.translation) } + .onEnded { + finishDrag(translation: $0.translation, + predicted: $0.predictedEndTranslation, width: width) } } @@ -382,7 +405,6 @@ struct LUIDrawerView: View { } private func animatePresentation(_ selected: Bool) { - if selected { hasLoadedPanel = true } transitionGeneration += 1 let generation = transitionGeneration isAnimating = true @@ -464,3 +486,151 @@ private struct LUIDrawerMainSurfaceModifier: ViewModifier { content.background(.ultraThinMaterial) } } + +#if os(iOS) +/// Arbitrate drawer pans before descendant scroll views begin, without changing +/// SwiftUI's scroll environment and reevaluating every mounted row. +private struct LUIDrawerNativeInteraction: UIViewRepresentable { + let isEnabled: Bool + let isPresented: Bool + let isLocked: Bool + let onChanged: (CGSize) -> Void + let onEnded: (CGSize, CGSize) -> Void + let onCancelled: () -> Void + + func makeUIView(context: Context) -> DrawerInteractionView { + DrawerInteractionView(configuration: self) + } + + func updateUIView(_ view: DrawerInteractionView, context: Context) { + view.configuration = self + view.updateInteraction() + // Descendant ScrollViews commit their UIKit configuration in this update. + DispatchQueue.main.async { [weak view] in view?.updateInteraction() } + } + + static func dismantleUIView(_ view: DrawerInteractionView, coordinator: ()) { + view.detach() + } + + final class DrawerInteractionView: UIView, UIGestureRecognizerDelegate { + var configuration: LUIDrawerNativeInteraction + private weak var gestureRoot: UIView? + private var registeredScrollViews = NSHashTable.weakObjects() + private struct ScrollState { + let scroll: UIScrollView + let enabled: Bool + let verticalIndicator: Bool + let horizontalIndicator: Bool + let offset: CGPoint + } + private var suspended: [ObjectIdentifier: ScrollState] = [:] + private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(dragged)) + + init(configuration: LUIDrawerNativeInteraction) { + self.configuration = configuration + super.init(frame: .zero) + isUserInteractionEnabled = false + pan.delegate = self + pan.name = "lui.drawer" + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { detach() } else { updateInteraction() } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateInteraction() + } + + func updateInteraction() { + guard window != nil else { return } + if gestureRoot == nil { + var responder: UIResponder? = self + while let current = responder { + if let controller = current as? UIViewController { + gestureRoot = controller.view + controller.view.addGestureRecognizer(pan) + break + } + responder = current.next + } + } + guard let root = gestureRoot else { return } + let dragging = pan.state == .began || pan.state == .changed + let locked = configuration.isLocked || dragging + var pending = [root] + while let view = pending.popLast() { + pending.append(contentsOf: view.subviews) + guard let scroll = view as? UIScrollView else { continue } + if !registeredScrollViews.contains(scroll) { + scroll.panGestureRecognizer.require(toFail: pan) + registeredScrollViews.add(scroll) + } + if locked { + let id = ObjectIdentifier(scroll) + let state = suspended[id] ?? ScrollState(scroll: scroll, + enabled: scroll.isScrollEnabled, + verticalIndicator: scroll.showsVerticalScrollIndicator, + horizontalIndicator: scroll.showsHorizontalScrollIndicator, + offset: scroll.contentOffset) + suspended[id] = state + scroll.setContentOffset(state.offset, animated: false) + scroll.showsVerticalScrollIndicator = false + scroll.showsHorizontalScrollIndicator = false + scroll.isScrollEnabled = false + scroll.contentOffset = state.offset + } + } + if !locked { restoreScrolling() } + } + + private func restoreScrolling() { + for state in suspended.values { + state.scroll.isScrollEnabled = state.enabled + state.scroll.showsVerticalScrollIndicator = state.verticalIndicator + state.scroll.showsHorizontalScrollIndicator = state.horizontalIndicator + state.scroll.contentOffset = state.offset + } + suspended.removeAll() + } + + func detach() { + restoreScrolling() + gestureRoot?.removeGestureRecognizer(pan) + gestureRoot = nil + registeredScrollViews.removeAllObjects() + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard configuration.isEnabled, !configuration.isLocked else { return false } + let velocity = pan.velocity(in: gestureRoot) + guard abs(velocity.x) > abs(velocity.y) * 2 else { return false } + return configuration.isPresented ? velocity.x < 0 : velocity.x > 0 + } + + @objc private func dragged() { + let translation = pan.translation(in: gestureRoot) + let size = CGSize(width: translation.x, height: translation.y) + switch pan.state { + case .began, .changed: + updateInteraction() + configuration.onChanged(size) + case .ended: + let velocity = pan.velocity(in: gestureRoot) + configuration.onEnded(size, CGSize(width: translation.x + velocity.x * 0.2, + height: translation.y + velocity.y * 0.2)) + updateInteraction() + case .cancelled, .failed: + configuration.onCancelled() + updateInteraction() + default: break + } + } + } +} +#endif diff --git a/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift b/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift index f209e1dd..99898901 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift @@ -8,7 +8,7 @@ public struct LUIScrollTitlePreferenceKey: PreferenceKey { } } -struct LUIScrollTitleCoordinateSpace: Hashable { +struct LUIScrollTitleCoordinateSpace: Hashable, Sendable { let nodeID: Int } @@ -41,13 +41,31 @@ final class LUIScrollSectionTitleTracker { private(set) var title: String? // Row visibility must not invalidate LazyVStack while it is calculating layout. // Only the separate title emitter observes the derived title. - @ObservationIgnored private var visibleSections: [Int: String] = [:] + @ObservationIgnored private var visibleSections: [Int: (title: String, startsSection: Bool)] = [:] + @ObservationIgnored private var headingStates: [String: Bool] = [:] - func update(index: Int, title: String?) { - guard visibleSections[index] != title else { return } - visibleSections[index] = title - let next = visibleSections.min(by: { $0.key < $1.key })?.value - if self.title != next { self.title = next } + func updateHeading(title: String, isPastTop: Bool) { + guard headingStates[title] != isPastTop else { return } + headingStates[title] = isPastTop + deriveTitle() + } + + func update(index: Int, title: String?, startsSection: Bool = false) { + if let title { + if let previous = visibleSections[index], previous.title == title, + previous.startsSection == startsSection { return } + visibleSections[index] = (title, startsSection) + } else if visibleSections.removeValue(forKey: index) == nil { return } + deriveTitle() + } + + private func deriveTitle() { + let candidate = visibleSections.min(by: { $0.key < $1.key })?.value + let next = candidate.flatMap { candidate in + let past = !candidate.startsSection || headingStates[candidate.title] == true + return past ? candidate.title : nil + } + if title != next { title = next } } } @@ -60,3 +78,35 @@ struct LUIScrollSectionTitleEmitter: View { value: isActive ? tracker.title : nil) } } + +struct LUIScrollHeadingContext: Sendable { + let coordinateSpace: LUIScrollTitleCoordinateSpace + let tracker: LUIScrollSectionTitleTracker +} + +private struct LUIScrollHeadingContextKey: EnvironmentKey { + static let defaultValue: LUIScrollHeadingContext? = nil +} + +extension EnvironmentValues { + var luiScrollHeadingContext: LUIScrollHeadingContext? { + get { self[LUIScrollHeadingContextKey.self] } + set { self[LUIScrollHeadingContextKey.self] = newValue } + } +} + +struct LUIScrollHeadingModifier: ViewModifier { + let title: String + let enabled: Bool + @Environment(\.luiScrollHeadingContext) private var context + + @ViewBuilder func body(content: Content) -> some View { + if enabled, let context { + content.onGeometryChange(for: Bool.self) { geometry in + geometry.frame(in: .named(context.coordinateSpace)).maxY <= 0 + } action: { past in + context.tracker.updateHeading(title: title, isPastTop: past) + } + } else { content } + } +} diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 29902509..2d97bbab 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -8,12 +8,16 @@ import AppKit import UIKit #endif -struct LUIModalPresentation: Identifiable { +struct LUIModalPresentation: Identifiable, Equatable { let model: LUINodeModel let rootID: Int let anchorID: Int var id: Int { model.id } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.model === rhs.model && lhs.rootID == rhs.rootID && lhs.anchorID == rhs.anchorID + } } @Observable @@ -104,7 +108,7 @@ final class LUIModalPresentationStore { } } -struct LUIFilePreviewPresentation: Identifiable { +struct LUIFilePreviewPresentation: Identifiable, Equatable { let nodeID: Int let rootID: Int let url: URL @@ -124,6 +128,7 @@ final class LUIFilePreviewStore { func synchronize(with item: LUIFilePreviewPresentation?) { if let pendingDismissalID, item?.id == pendingDismissalID { return } pendingDismissalID = nil + guard self.item != item else { return } self.item = item } @@ -575,6 +580,7 @@ struct LUIAnyNodeView: View, Equatable { let nodeID: Int let backend: LUIAppleBackend private let retainedSnapshot: LUIRetainedNodeSnapshot + private static let probesEnabled = ProcessInfo.processInfo.environment["LOGSEQ_PERF"] != nil init(nodeID: Int, backend: LUIAppleBackend) { self.nodeID = nodeID @@ -592,7 +598,7 @@ struct LUIAnyNodeView: View, Equatable { } private func perfProbe() -> Int { - guard ProcessInfo.processInfo.environment["LOGSEQ_PERF"] != nil else { return 0 } + guard Self.probesEnabled else { return 0 } let kind = backend.model(id: nodeID) != nil ? "n" : (backend.extensionModel(id: nodeID) != nil ? "e" : "nil") @@ -654,9 +660,10 @@ private struct LUIFrameReportModifier: ViewModifier { private struct LUIExtensionNodeView: View { let model: LUIExtensionNodeModel let backend: LUIAppleBackend + private static let probesEnabled = ProcessInfo.processInfo.environment["LOGSEQ_PERF"] != nil private func perfProbe() -> Int { - guard ProcessInfo.processInfo.environment["LOGSEQ_PERF"] != nil else { return 0 } + guard Self.probesEnabled else { return 0 } let kids = model.children.prefix(6).map(String.init).joined(separator: ",") FileHandle.standardError.write( "PERF ext id=\(model.id) rev=\(model.revision) kids=[\(kids)] t=\(CFAbsoluteTimeGetCurrent())\n" @@ -806,6 +813,8 @@ private struct LUINodeView: View { ) .luiTypographyMetrics(typography, baseSize: headingSize) .accessibilityAddTraits(.isHeader) + .modifier(LUIScrollHeadingModifier(title: model.text, + enabled: model.derived.styleClassTokens.contains("scroll-section-title"))) ) case .paragraph: return AnyView( @@ -1348,7 +1357,11 @@ private struct LUIContextMenuModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { - if let menuID = model.childData.contextMenuID, + // Buttons consume their context-menu children as a primary native Menu. + // Do not attach a second, long-press menu to the same control. + if model.kind == .button || model.kind == .toggleButton { + content + } else if let menuID = model.childData.contextMenuID, let menu = backend.model(id: menuID) { #if os(macOS) // `.contextMenu` never sees right-clicks that land on Button/Menu @@ -2197,12 +2210,19 @@ private struct LUIOverlayView: View { } else { base = AnyView(EmptyView()) } - return model.children.dropFirst().reduce(base) { content, childID in - AnyView( - content.overlay(alignment: alignment(of: childID)) { + // Keep the base at the same structural position as overlays appear and + // disappear, preserving native controls and their first responder. + return base.overlay { + ZStack { + ForEach(Array(model.children.dropFirst()), id: \.self) { childID in LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: alignment(of: childID) + ) } - ) + } } } } @@ -3393,7 +3413,8 @@ private struct LUIToolbarGroupAnchor: View { // scrolling fused capsule so a pinned trailing sibling stays // reachable when the content overflows the bar. ScrollView(.horizontal, showsIndicators: false) { - capsuleView(childIDs, at: index) + HStack(spacing: 0) { capsuleChildren(childIDs, at: index) } + .modifier(LUIToolbarMenuCapsule(enabled: childIDs.contains(where: containsMenu))) } case let .capsule(childIDs): // Consecutive interactive children fuse into one toolbar item so @@ -6140,7 +6161,7 @@ private struct LUIRowView: View { // Cross-axis stretch makes children accept any offered height, which // also makes the row greedy in a VStack. Keep it at its ideal height // unless the wire explicitly sizes it. - if model.derived.grow > 0 + if cross != "stretch" || model.derived.grow > 0 || model.surfaceHeight != nil || model.surfaceMinHeight != nil { rowContent .modifier(LUIContainerPressModifier(model: model, backend: backend)) @@ -6896,53 +6917,117 @@ private struct LUIListSectionSeparatorModifier: ViewModifier { } } +// Only cheap integer identities are enumerated by SwiftUI. Row descriptors +// (and their child-model lookups) are resolved only for materialized slots. +// The editor keeps one identity when a split moves it to a neighboring row. +struct LUIVirtualRowIndices: RandomAccessCollection { + struct Slot { + let position: Int + let id: Int + } + let count: Int + let editor: (position: Int, source: Int)? + var startIndex: Int { 0 } + var endIndex: Int { count } + subscript(position: Int) -> Slot { + Slot(position: position, id: position == editor?.position ? -(editor!.source + 1) : position) + } +} + private struct LUIVirtualListView: View { let model: LUINodeModel let backend: LUIAppleBackend + @State private var appliedScrollKey: String? @State private var unobscuredHeight: CGFloat? @State private var titleTracker = LUIScrollSectionTitleTracker() + @State private var windowTracker = LUIVirtualWindowTracker() + @State private var firstSectionHeights: [SlotID: CGFloat] = [:] var body: some View { - ScrollView { - LazyVStack( - alignment: .leading, - spacing: CGFloat(model.property(.gap)?.intValue ?? 0) - ) { - ForEach(sectionRows, id: \.id) { row in - let rowView = LUIAnyNodeView(nodeID: row.id, backend: backend) - .equatable() - .frame(maxWidth: .infinity, alignment: .leading) - if tracksSectionTitles { - rowView.modifier(LUIScrollSectionFrameModifier( - index: row.index, title: row.title, - coordinateSpace: titleCoordinateSpace, - onVisibilityChange: { index, title in - titleTracker.update(index: index, title: title) - } - )) - } else { - rowView + 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 } + } + .id(slot.id) } } + .environment(\.luiUnobscuredScrollHeight, unobscuredHeight) + } + .onAppear { revealTarget(using: proxy) } + .onChange(of: scrollRequest) { _, _ in revealTarget(using: proxy) } + .coordinateSpace(name: titleCoordinateSpace) + .environment(\.luiScrollHeadingContext, tracksSectionTitles + ? LUIScrollHeadingContext(coordinateSpace: titleCoordinateSpace, tracker: titleTracker) : nil) + .environment(\.luiInsideScroll, true) + .background { + LUIScrollSectionTitleEmitter(tracker: titleTracker, + isActive: tracksSectionTitles && model.isSelected) + } + .background { + GeometryReader { _ in + // Minimum-height content uses the viewport before keyboard avoidance. + Color.clear + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in + unobscuredHeight = height + } + } + .ignoresSafeArea(.keyboard, edges: .bottom) } - .environment(\.luiUnobscuredScrollHeight, unobscuredHeight) - } - .coordinateSpace(name: titleCoordinateSpace) - .environment(\.luiInsideScroll, true) - .background { - LUIScrollSectionTitleEmitter(tracker: titleTracker, - isActive: tracksSectionTitles && model.isSelected) } - .background { - GeometryReader { _ in - // Minimum-height content uses the viewport before keyboard avoidance. - Color.clear - .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in - unobscuredHeight = height - } + } + + private struct ScrollRequest: Equatable { + let key: String + let position: Int + } + + private var scrollRequest: ScrollRequest? { + for segment in sectionRows.segments { + let sourceID = segment.source + guard let source = backend.model(id: sourceID), + let encoded = source.property(.dataAttrs)?.stringValue else { continue } + var attributes: [String: String] = [:] + for record in encoded.split(separator: "\u{1e}") { + let pair = record.split(separator: "\u{1f}", maxSplits: 1) + if pair.count == 2 { attributes[String(pair[0])] = String(pair[1]) } + } + if let key = attributes["data-scroll-key"], + let index = attributes["data-scroll-index"].flatMap(Int.init), index >= 0 { + let paddingOffset = segment.padding > 0 && index >= (segment.window?.firstSectionCount ?? 0) ? 1 : 0 + return ScrollRequest(key: key, position: segment.base + index + paddingOffset) } - .ignoresSafeArea(.keyboard, edges: .bottom) } + return nil + } + + private func revealTarget(using proxy: ScrollViewProxy) { + guard let request = scrollRequest, appliedScrollKey != request.key else { return } + proxy.scrollTo(request.position, anchor: .top) + appliedScrollKey = request.key } private var tracksSectionTitles: Bool { @@ -6953,21 +7038,233 @@ private struct LUIVirtualListView: View { LUIScrollTitleCoordinateSpace(nodeID: model.id) } - private struct SectionRow { - let id: Int + struct SlotID: Hashable { + let source: Int + let offset: Int + } + + struct SectionRow { + let id: SlotID + let nodeID: Int? + let windowID: Int? + let windowIndex: Int let index: Int let title: String? + var startsSection = false + let estimatedHeight: CGFloat + let tracksSectionHeight: Bool + } + + // Random access keeps metadata lookup lazy too: a 10k-row collection + // must not allocate 10k strings and row descriptors on every viewport update. + struct SectionRows: RandomAccessCollection { + struct Segment { + let source: Int + let base: Int + let count: Int + let window: LUIVirtualWindow? + let children: [Int] + let title: String? + let startsSection: Bool + let padding: CGFloat + let tracksSectionHeight: Bool + let titles: [Int: String] + } + let segments: [Segment] + let endIndex: Int + var startIndex: Int { 0 } + var editor: (position: Int, source: Int)? { + for segment in segments { + if let window = segment.window, let index = window.pinnedIndex { + return (segment.base + index + (segment.padding > 0 && index >= window.firstSectionCount ? 1 : 0), segment.source) + } + } + return nil + } + subscript(index: Int) -> SectionRow { + let segment = segments.first { index >= $0.base && index < $0.base + $0.count }! + var offset = index - segment.base + if let window = segment.window { + if segment.padding > 0, offset == window.firstSectionCount { + return SectionRow(id: SlotID(source: segment.source, offset: -2), + nodeID: nil, windowID: nil, windowIndex: 0, index: index, + title: window.sectionTitle(at: window.firstSectionCount - 1), + estimatedHeight: segment.padding, tracksSectionHeight: false) + } + if segment.padding > 0, offset > window.firstSectionCount { offset -= 1 } + let childOffset = offset - window.start + 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, + title: window.sectionTitle(at: offset) + ?? (segment.children.indices.contains(childOffset) ? segment.titles[segment.children[childOffset]] : nil) + ?? segment.title, + startsSection: window.sectionTitles.contains(where: { $0.index == offset }), + estimatedHeight: window.extent, + tracksSectionHeight: segment.tracksSectionHeight && offset < window.firstSectionCount) + } + return SectionRow(id: SlotID(source: segment.source, offset: -1), + nodeID: segment.source, windowID: nil, windowIndex: 0, + index: index, title: segment.title, startsSection: segment.startsSection, estimatedHeight: 0, tracksSectionHeight: false) + } } - private var sectionRows: [SectionRow] { + private var sectionRows: SectionRows { let titles = model.childData.sectionTitles ?? [:] - return model.visibleChildren.enumerated().map { index, id in - SectionRow(id: id, index: index, title: titles[id]) + var segments: [SectionRows.Segment] = [] + var total = 0 + var inheritedTitle: String? + for id in model.visibleChildren { + let source = backend.model(id: id) + let window = LUIVirtualWindow(source?.property(.dataAttrs)?.stringValue) + var padding: CGFloat = 0 + var tracksHeight = false + if let window, window.firstSectionCount > 0, + window.firstSectionCount <= Int((unobscuredHeight ?? 0) / 16) + 1 { + tracksHeight = true + let measured = (0.. 0 ? 1 : 0) + let startsSection = titles[id] != nil && titles[id] != inheritedTitle + if let title = titles[id] { inheritedTitle = title } + segments.append(SectionRows.Segment(source: id, base: total, count: count, + window: window, children: window == nil ? [] : (source?.visibleChildren ?? []), + title: inheritedTitle, startsSection: startsSection, padding: padding, tracksSectionHeight: tracksHeight, + titles: source?.childData.sectionTitles ?? [:])) + total += count } + return SectionRows(segments: segments, endIndex: total) } } +// 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 extent: CGFloat + let firstSectionCount: Int + let firstSectionInset: CGFloat + let pinnedIndex: Int? + let sectionTitles: [(index: Int, title: String)] + + func sectionTitle(at index: Int) -> String? { + var lower = 0 + var upper = sectionTitles.count + while lower < upper { + let middle = lower + (upper - lower) / 2 + if sectionTitles[middle].index <= index { lower = middle + 1 } + else { upper = middle } + } + return lower > 0 ? sectionTitles[lower - 1].title : nil + } + + init?(_ encoded: String?) { + guard let encoded else { return nil } + var attributes: [String: String] = [:] + for record in encoded.split(separator: "\u{1e}") { + let pair = record.split(separator: "\u{1f}", maxSplits: 1) + if pair.count == 2 { attributes[String(pair[0])] = String(pair[1]) } + } + guard let count = attributes["data-virtual-count"].flatMap(Int.init), count >= 0, + let start = attributes["data-virtual-start"].flatMap(Int.init), start >= 0 else { return nil } + sectionTitles = attributes.compactMap { key, title -> (index: Int, title: String)? in + let prefix = "data-section-title-" + guard key.hasPrefix(prefix), let index = Int(key.dropFirst(prefix.count)), + index >= 0 else { return nil } + return (index: index, title: title) + }.sorted { $0.index < $1.index } + self.count = count + self.start = start + let pinned = attributes["data-pinned-index"].flatMap(Int.init) ?? -1 + pinnedIndex = (0.. Void + @State private var measurement = LUIVirtualRowMeasurement() + + var body: some View { + Group { + if let nodeID = row.nodeID { + LUIAnyNodeView(nodeID: nodeID, backend: backend) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in + if height > 0 { + measurement.height = height + if row.tracksSectionHeight { onMeasure(row.id, height) } + } + } + } else { + Color.clear.frame(height: measurement.height ?? row.estimatedHeight) + .accessibilityHidden(true) + } + } + .onGeometryChange(for: Bool.self) { geometry in + let frame = geometry.frame(in: .named(coordinateSpace)) + return frame.maxY > -16 && frame.minY < viewportHeight + 16 + } action: { visible in + if let windowID = row.windowID { + tracker.update(window: windowID, index: row.windowIndex, + visible: visible, backend: backend) + } + } + .onDisappear { + if let windowID = row.windowID { + tracker.update(window: windowID, index: row.windowIndex, + visible: false, backend: backend) + } + } + } +} + +@MainActor +private final class LUIVirtualWindowTracker { + private var visible: [Int: Set] = [:] + private var emitted: [Int: ClosedRange] = [:] + private var pending: Task? + + func update(window: Int, index: Int, visible isVisible: Bool, backend: LUIAppleBackend) { + if isVisible { visible[window, default: []].insert(index) } + else { visible[window]?.remove(index) } + guard pending == nil else { return } + pending = Task { @MainActor [weak self, weak backend] in + await Task.yield() + guard let self, let backend else { return } + self.pending = nil + for (window, indices) in self.visible { + guard let first = indices.min(), let last = indices.max() else { continue } + let range = first...last + if self.emitted[window] != range { + self.emitted[window] = range + backend.performVisibleRange(node: window, first: first, last: last) + } + } + } + } +} + // Each scroll axis needs its own stack: VStack for vertical, HStack for // horizontal (`~orientation:`horizontal`). // Each scroll axis needs its own stack: VStack for vertical, HStack for @@ -7434,15 +7731,13 @@ enum LUIClipRenderingPolicy { return isSemanticSurface || hasVisibleBackground || cornerRadius > 0 || borderWidth > 0 } - /// Interactive Liquid Glass scales the whole surface on press; a clip - /// applied outside `glassEffect` pins the capsule to its unpressed bounds, - /// so a glass surface clips its content inside the glass instead. The - /// same applies to `glass-container`, whose descendants each carry their - /// own interactive glass. + /// Clip the content before applying its glass background, so the outer + /// clip does not trim the material's outline and shadows. A glass + /// container follows the same order for its descendant glass surfaces. static func clipsInsideGlass(background: String?, inFusedCapsule: Bool) -> Bool { - let isInteractiveGlass = background == "glass-container" || + let isGlassSurface = background == "glass-container" || (background == "glass" && !inFusedCapsule) - guard isInteractiveGlass else { return false } + guard isGlassSurface else { return false } if #available(iOS 26.0, macOS 26.0, *) { return true } return false } @@ -7501,7 +7796,9 @@ private struct LUIBackgroundStyleModifier: ViewModifier { cornerRadius: clipRadius, clipsContent: clipsContent )) - .glassEffect(.regular.interactive(), in: glassShape) + // A background decorates its descendants; it must not own + // a glass gesture over their presented native menus. + .glassEffect(.regular, in: glassShape) } else { content.background(.regularMaterial, in: glassShape) } @@ -7526,6 +7823,42 @@ private struct LUIBackgroundStyleModifier: ViewModifier { } } +enum LUIPlainContainerSurfacePolicy { + private static let decoratedProperties: Set = [ + .background, .foreground, .borderColor, .borderWidth, .cornerRadius, + .opacity, .tooltip, .minWidth, .maxWidth, .minHeight, .maxHeight, + .margin, .marginHorizontal, .marginVertical, .marginTop, .marginRight, + .marginBottom, .marginLeft, .containerRelativeFrame, .containerRelativeFrameInset + ] + + static func applies(kind: LUINodeKind, properties: [LUIProperty: LUIWireValue], styleClass: String?) -> Bool { + guard kind == .row || kind == .column || kind == .box, + !properties.keys.contains(where: { decoratedProperties.contains($0) }) else { return false } + let classes = styleClass?.split(separator: " ") ?? [] + return !classes.contains("body-line") && !classes.contains("composer-surface") + } +} + +// Plain layout containers need padding and sizing, not transparent shapes, +// empty overlays, zero-radius shadows and several unconstrained frame layers. +private struct LUIPlainContainerSurfaceModifier: ViewModifier { + let model: LUINodeModel + + @ViewBuilder func body(content: Content) -> some View { + let padding = model.property(.padding)?.intValue ?? 0 + let horizontal = CGFloat(model.property(.paddingHorizontal)?.intValue ?? padding) + let vertical = CGFloat(model.property(.paddingVertical)?.intValue ?? padding) + let padded = content.padding(EdgeInsets(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal)) + if model.surfaceWidth != nil || model.surfaceHeight != nil { + padded.frame(maxWidth: LUIIntrinsicSurfacePolicy.fillsWidth(kind: model.kind, grow: model.derived.grow) ? .infinity : nil, + alignment: .leading) + .frame(width: model.surfaceWidth.map(CGFloat.init), height: model.surfaceHeight.map(CGFloat.init)) + } else if LUIIntrinsicSurfacePolicy.fillsWidth(kind: model.kind, grow: model.derived.grow) { + padded.frame(maxWidth: .infinity, alignment: .leading) + } else { padded } + } +} + private struct LUISurfaceModifier: ViewModifier { let model: LUINodeModel let backend: LUIAppleBackend @@ -7548,7 +7881,15 @@ private struct LUISurfaceModifier: ViewModifier { #endif } - func body(content: Content) -> some View { + @ViewBuilder func body(content: Content) -> some View { + if LUIPlainContainerSurfacePolicy.applies(kind: model.kind, properties: model.properties, styleClass: model.derived.styleClass) { + content.modifier(LUIPlainContainerSurfaceModifier(model: model)) + } else { + decorated(content: content) + } + } + + private func decorated(content: Content) -> some View { let isSurface = model.kind == .panel || model.kind == .card || model.kind == .resizable || model.kind == .alert || model.kind == .bubble let isTabs = model.kind == .tabs && @@ -7595,7 +7936,7 @@ private struct LUISurfaceModifier: ViewModifier { inFusedCapsule: inFusedCapsule ) - content + return content .padding(insets) .opacity(model.property(.opacity)?.doubleValue ?? 1) .modifier(LUITooltipModifier( diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index a1d588d4..61c53053 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -259,7 +259,7 @@ enum LUIWireValue: Decodable, Equatable { return value == "treeitem" || value == "navigation" || value == "navigation-heading" || (kind == .popover && ["menu", "dialog"].contains(value)) - case .treeLevel, .gridColumnSpan: + case .treeLevel, .gridColumnSpan, .lineClamp: guard let value = intValue else { return false } return value > 0 case .theme: @@ -383,7 +383,7 @@ enum LUIWireValue: Decodable, Equatable { case .popupX, .popupY: guard let value = doubleValue else { return false } return value.isFinite - case .availableHeight: + case .availableHeight, .collisionPadding: guard let value = doubleValue else { return false } return value.isFinite && value >= 0 } @@ -1246,6 +1246,8 @@ struct LUIRetainedTree { kind == .dropdownMenu || kind == .tooltip || kind == .popover case .popupX, .popupY: kind == .popover || kind == .dropdownMenu case .availableHeight: kind == .popover + case .collisionPadding: kind == .popover || kind == .dropdownMenu || kind == .tooltip + case .lineClamp: kind == .text || kind == .heading || kind == .paragraph || kind == .label case .tooltipDelay: kind == .tooltip case .duration: false case .textAlignment: @@ -1562,7 +1564,9 @@ struct LUIRetainedTree { let allowed: Set = [ .text, .enabled, + .selected, .pressEnabled, + .pointerEnabled, .icon, .foreground, .variant, diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index f9ea571c..0bbc310b 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -31,6 +31,54 @@ private let captureAppleEvent: LUIAppleEventCallback = { kind, node, text in @MainActor @Suite("LUI SwiftUI backend", .serialized) struct LUISwiftUIBackendTests { + @Test("scroll titles wait for the heading to pass the viewport top") + func scrollTitleWaitsForHeading() { + let tracker = LUIScrollSectionTitleTracker() + tracker.update(index: 0, title: "Today", startsSection: true) + #expect(tracker.title == nil) + tracker.updateHeading(title: "Today", isPastTop: false) + #expect(tracker.title == nil) + tracker.updateHeading(title: "Today", isPastTop: true) + #expect(tracker.title == "Today") + tracker.update(index: 0, title: nil) + tracker.update(index: 1, title: "Today") + #expect(tracker.title == "Today") + tracker.update(index: 0, title: "Today", startsSection: true) + tracker.updateHeading(title: "Today", isPastTop: false) + #expect(tracker.title == nil) + tracker.updateHeading(title: "Today", isPastTop: true) + tracker.update(index: 0, title: nil) + tracker.update(index: 2, title: "Yesterday", startsSection: true) + tracker.updateHeading(title: "Yesterday", isPastTop: false) + #expect(tracker.title == "Today") + tracker.update(index: 1, title: nil) + #expect(tracker.title == nil) + tracker.updateHeading(title: "Yesterday", isPastTop: true) + #expect(tracker.title == "Yesterday") + } + + @Test("evicting a standalone heading exposes the visible section body") + func evictedStandaloneHeading() { + let tracker = LUIScrollSectionTitleTracker() + tracker.update(index: 0, title: "Project", startsSection: true) + tracker.updateHeading(title: "Project", isPastTop: false) + tracker.update(index: 1, title: "Project") + #expect(tracker.title == nil) + tracker.update(index: 0, title: nil) + #expect(tracker.title == "Project") + tracker.update(index: 0, title: "Project", startsSection: true) + #expect(tracker.title == nil) + } + + @Test("virtual window replacement bypasses the collection burst delay") + func virtualWindowReplacementCommitsImmediately() throws { + let backend = LUIAppleBackend() + try backend.apply(json: #"{"generation":1,"ops":[{"op":"create-node","id":1,"kind":"list"},{"op":"set-prop","id":1,"property":"data-attrs","value":"data-virtual-count\u001f10000\u001edata-virtual-start\u001f0"}]}"#) + backend.coalescesCommits = true + try backend.apply(json: #"{"generation":2,"ops":[{"op":"set-prop","id":1,"property":"data-attrs","value":"data-virtual-count\u001f10000\u001edata-virtual-start\u001f9000"}]}"#) + #expect(backend.model(id: 1)?.property(.dataAttrs)?.stringValue?.contains("9000") == true) + } + @Test("wrapping code text retains its whitespace mode") func wrappingCodeWhitespace() { #expect(LUIWireValue.string("pre-wrap").matches(.whiteSpace, on: .text)) @@ -1936,8 +1984,8 @@ struct LUISwiftUIBackendTests { #expect(LUIDrawerInteractionPolicy.sidebarOpacity(progress: 0.5) == 0.675) #expect(LUIDrawerInteractionPolicy.sidebarOpacity(progress: 1) == 1) #expect(LUIDrawerInteractionPolicy.mainCornerRadius(progress: 0) == 0) - #expect(LUIDrawerInteractionPolicy.mainCornerRadius(progress: 0.5) == 20) - #expect(LUIDrawerInteractionPolicy.mainCornerRadius(progress: 1) == 40) + #expect(LUIDrawerInteractionPolicy.mainCornerRadius(progress: 0.5) == 26) + #expect(LUIDrawerInteractionPolicy.mainCornerRadius(progress: 1) == 52) #expect(LUIDrawerInteractionPolicy.shadowOpacity == 0.18) #expect(LUIDrawerInteractionPolicy.panelIsAccessibilityHidden( isPresented: false, @@ -3672,7 +3720,9 @@ struct LUISwiftUIBackendTests { {"op":"create-node","id":5,"kind":"menu-item"}, {"op":"set-prop","id":1,"property":"text","value":"Document"}, {"op":"set-prop","id":3,"property":"text","value":"Rename"}, + {"op":"set-prop","id":3,"property":"selected","value":true}, {"op":"set-prop","id":3,"property":"press-enabled","value":true}, + {"op":"set-prop","id":3,"property":"pointer-enabled","value":true}, {"op":"set-prop","id":3,"property":"icon","value":"trash"}, {"op":"set-prop","id":3,"property":"foreground","value":"success-foreground"}, {"op":"set-prop","id":3,"property":"accessibility-identifier","value":"menu.rename"}, @@ -3693,9 +3743,13 @@ struct LUISwiftUIBackendTests { #expect(menu.kind == .contextMenu) #expect(host.children == [2]) #expect(menu.children == [3, 4, 5]) + #expect(backend.model(id: 3)?.isSelected == true) _ = LUISwiftUIRoot(backend: backend, rootID: 1) try backend.performPress(node: 3) - #expect(events == [.press(node: 3)]) + #expect(events == [ + .press(node: 3), + .pressDetail(node: 3, x: 0, y: 0, modifiers: 0, button: 0, targetClass: ""), + ]) #expect(throws: LUIBackendError.self) { try backend.performPress(node: 5) } @@ -4145,6 +4199,19 @@ struct LUISwiftUIBackendTests { #expect(backend.generation == 3) } + @Test("ordinary row replacement does not invalidate presentation stores") + func rowReplacementPreservesPresentationObservation() throws { + let backend = LUIAppleBackend() + try backend.apply(json: #"{"generation":1,"ops":[{"op":"create-node","id":1,"kind":"column"}]}"#) + let preview = ObservationFlag() + let sheets = ObservationFlag() + withObservationTracking { _ = backend.filePreviewPresentation.item } onChange: { preview.value = true } + withObservationTracking { _ = backend.modalPresentation.nestedSheets } onChange: { sheets.value = true } + try backend.apply(json: #"{"generation":2,"ops":[{"op":"create-node","id":2,"kind":"text"},{"op":"insert-child","parent":1,"child":2,"index":0}]}"#) + #expect(!preview.value) + #expect(!sheets.value) + } + @Test("file-preview presents through the store and dismiss emits an event") func mapsFilePreview() throws { let backend = LUIAppleBackend() diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index 347f1642..4a6e83de 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift @@ -232,7 +232,7 @@ struct LUIBackendParityTests { #expect(image.width <= 56) } - @Test("an open drawer pushes undimmed main content beside the sidebar") + @Test("an open drawer fades pushed main content and restores its contrast when closed") func openDrawerPushesMainContent() throws { let backend = LUIAppleBackend() try backend.apply(json: """ @@ -283,12 +283,122 @@ struct LUIBackendParityTests { let bitmap = NSBitmapImageRep(cgImage: image) let pushedMainLeadingColor = try #require(bitmap.colorAt(x: 205, y: 100)) - #expect(pushedMainLeadingColor.greenComponent > 0.65) - #expect(pushedMainLeadingColor.redComponent < 0.2) + #expect(pushedMainLeadingColor.greenComponent > pushedMainLeadingColor.redComponent + 0.1) - let undimmedMainColor = try #require(bitmap.colorAt(x: 300, y: 100)) - #expect(undimmedMainColor.redComponent > 0.8) - #expect(undimmedMainColor.greenComponent < 0.3) + let fadedMainColor = try #require(bitmap.colorAt(x: 300, y: 100)) + #expect(fadedMainColor.redComponent > fadedMainColor.greenComponent + 0.2) + #expect(fadedMainColor.greenComponent > 0.35) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":1,"property":"selected","value":false} + ]} + """) + let closedRenderer = ImageRenderer(content: LUIDrawerView( + model: try #require(backend.model(id: 1)), backend: backend + ).environment(\.horizontalSizeClass, .compact).frame(width: 400, height: 200)) + closedRenderer.scale = 1 + let closedBitmap = NSBitmapImageRep(cgImage: try #require(closedRenderer.cgImage)) + let restoredColor = try #require(closedBitmap.colorAt(x: 300, y: 100)) + #expect(restoredColor.redComponent > 0.94) + #expect(restoredColor.greenComponent + 0.1 < fadedMainColor.greenComponent) + } + + @Test("drawer fades foreground while preserving the background in light and dark themes") + func drawerPreservesBackgroundWhileFadingContent() throws { + let themes: [(ColorScheme, Color, Color)] = [ + (.light, Color(red: 0.95, green: 0.96, blue: 0.97), .black), + (.dark, Color(red: 0, green: 0.18, blue: 0.22), .white), + ] + for (scheme, background, foreground) in themes { + let registry = LUIAppleExtensionRegistry() + try registry.register(LUIAppleExtension( + identifier: "drawer-contrast-probe", fingerprint: "drawer-contrast-v1" + ) { _ in + AnyView(background.overlay(alignment: .top) { + foreground.frame(height: 40).padding(.top, 80) + }.frame(width: 400, height: 240)) + }) + let backend = try LUIAppleBackend(extensionRegistry: registry) + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"drawer"}, + {"op":"set-prop","id":1,"property":"width","value":200}, + {"op":"create-extension","id":2,"identifier":"drawer-contrast-probe","fingerprint":"drawer-contrast-v1"}, + {"op":"create-node","id":3,"kind":"box"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":1,"child":3,"index":1} + ]} + """) + func render(selected: Bool, generation: Int) throws -> NSBitmapImageRep { + try backend.apply(json: """ + {"generation":\(generation),"ops":[ + {"op":"set-prop","id":1,"property":"selected","value":\(selected)} + ]} + """) + let renderer = ImageRenderer(content: LUIDrawerView( + model: try #require(backend.model(id: 1)), backend: backend + ).environment(\.horizontalSizeClass, .compact) + .environment(\.colorScheme, scheme) + .luiSemanticColors(["background": background]) + .frame(width: 400, height: 240)) + renderer.scale = 1 + return NSBitmapImageRep(cgImage: try #require(renderer.cgImage)) + } + let closed = try render(selected: false, generation: 2) + let opened = try render(selected: true, generation: 3) + let restored = try render(selected: false, generation: 4) + let closedBackground = try #require(closed.colorAt(x: 300, y: 180)) + let openedBackground = try #require(opened.colorAt(x: 300, y: 180)) + let closedForeground = try #require(closed.colorAt(x: 300, y: 100)) + let openedForeground = try #require(opened.colorAt(x: 300, y: 100)) + let restoredForeground = try #require(restored.colorAt(x: 300, y: 100)) + for component in [\NSColor.redComponent, \NSColor.greenComponent, \NSColor.blueComponent] { + #expect(abs(closedBackground[keyPath: component] - openedBackground[keyPath: component]) < 0.01) + #expect(abs(closedForeground[keyPath: component] - restoredForeground[keyPath: component]) < 0.01) + } + let originalContrast = abs(closedForeground.redComponent - closedBackground.redComponent) + let fadedContrast = abs(openedForeground.redComponent - openedBackground.redComponent) + #expect(fadedContrast > originalContrast * 0.35) + #expect(fadedContrast < originalContrast * 0.65) + } + } + + @Test("compact drawer leaves a wider main preview with a larger curved edge") + func compactDrawerMatchesPhoneProportions() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"drawer"}, + {"op":"set-prop","id":1,"property":"selected","value":true}, + {"op":"set-prop","id":1,"property":"width","value":360}, + {"op":"create-node","id":2,"kind":"panel"}, + {"op":"set-prop","id":2,"property":"width","value":393}, + {"op":"set-prop","id":2,"property":"height","value":300}, + {"op":"set-prop","id":2,"property":"background","value":"green"}, + {"op":"set-prop","id":2,"property":"border-width","value":0}, + {"op":"set-prop","id":2,"property":"corner-radius","value":0}, + {"op":"create-node","id":3,"kind":"panel"}, + {"op":"set-prop","id":3,"property":"width","value":360}, + {"op":"set-prop","id":3,"property":"height","value":300}, + {"op":"set-prop","id":3,"property":"background","value":"blue"}, + {"op":"set-prop","id":3,"property":"border-width","value":0}, + {"op":"set-prop","id":3,"property":"corner-radius","value":0}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":1,"child":3,"index":1} + ]} + """) + let renderer = ImageRenderer(content: LUIDrawerView( + model: try #require(backend.model(id: 1)), backend: backend + ).environment(\.horizontalSizeClass, .compact).frame(width: 393, height: 300)) + renderer.scale = 1 + let bitmap = NSBitmapImageRep(cgImage: try #require(renderer.cgImage)) + let mainEdge = try #require(bitmap.colorAt(x: 312, y: 150)) + #expect(mainEdge.greenComponent > 0.55) + #expect(mainEdge.greenComponent > mainEdge.blueComponent + 0.15) + let roundedEdge = try #require(bitmap.colorAt(x: 321, y: 16)) + #expect(roundedEdge.blueComponent > 0.65) + #expect(roundedEdge.blueComponent > roundedEdge.greenComponent + 0.2) } @Test("an open drawer main surface spans the container safe areas") diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIReviewIdentityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIReviewIdentityTests.swift index 6f1b2634..5484fa97 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIReviewIdentityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIReviewIdentityTests.swift @@ -67,5 +67,52 @@ struct LUIReviewIdentityTests { #expect(!lifetime.appearances.isEmpty) #expect(lifetime.appearances.allSatisfy { $0 == initial }) } + @Test("Adding and removing overlays preserves base-local SwiftUI state") + func overlayUpdatesPreserveBaseState() async throws { + let lifetime = ReviewRowLifetime() + let registry = LUIAppleExtensionRegistry() + try registry.register(LUIAppleExtension( + identifier: "review-stateful-row", fingerprint: "review-v1" + ) { _ in AnyView(ReviewStatefulRow(lifetime: lifetime)) }) + let backend = try LUIAppleBackend(extensionRegistry: registry) + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"overlay"}, + {"op":"create-extension","id":2,"identifier":"review-stateful-row","fingerprint":"review-v1"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"create-node","id":3,"kind":"text"}, + {"op":"set-prop","id":3,"property":"text","value":"Confirmation"}, + {"op":"create-node","id":4,"kind":"text"}, + {"op":"set-prop","id":4,"property":"text","value":"Another overlay"} + ]} + """) + _ = NSApplication.shared + let hosting = NSHostingView(rootView: LUISwiftUIRoot(backend: backend, rootID: 1)) + let window = NSWindow(contentRect: NSRect(x: -10_000, y: 0, width: 400, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = hosting + window.orderFrontRegardless() + defer { window.orderOut(nil); window.contentView = nil } + for _ in 0..<50 where lifetime.appearances.isEmpty { + hosting.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(10)) + } + _ = try #require(lifetime.appearances.first) + let updates = [ + #"{"op":"insert-child","parent":1,"child":3,"index":1}"#, + #"{"op":"insert-child","parent":1,"child":4,"index":2}"#, + #"{"op":"remove-child","parent":1,"child":3}"#, + #"{"op":"remove-child","parent":1,"child":4}"#, + ] + for (index, update) in updates.enumerated() { + try backend.apply(json: "{\"generation\":\(index + 2),\"ops\":[\(update)]}") + for _ in 0..<20 { + hosting.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(10)) + } + #expect(lifetime.appearances.count == 1) + } + } + } #endif