From d027f92d31931364f5ab070595cfa9ed666167ce Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 11:54:48 +0800 Subject: [PATCH 01/17] fix(apple): accept pointer metadata on context menu actions --- .../apple/Sources/LUIAppleBackend/LUIWireProtocol.swift | 1 + .../Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 8a3c07d4..55f1c21f 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -1526,6 +1526,7 @@ struct LUIRetainedTree { .text, .enabled, .pressEnabled, + .pointerEnabled, .icon, .foreground, .variant, diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 3a174058..6f3e7668 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -3602,6 +3602,7 @@ struct LUISwiftUIBackendTests { {"op":"set-prop","id":1,"property":"text","value":"Document"}, {"op":"set-prop","id":3,"property":"text","value":"Rename"}, {"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"}, @@ -3624,7 +3625,10 @@ struct LUISwiftUIBackendTests { #expect(menu.children == [3, 4, 5]) _ = 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) } From 2e15c0de5a9d914a3a1566beddea13141630d676 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 13:34:06 +0800 Subject: [PATCH 02/17] Keep drawer scroll environments stable during transitions --- .../apple/Sources/LUIAppleBackend/LUIDrawerView.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index bb2603f1..d86b276e 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -229,11 +229,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,6 +236,9 @@ struct LUIDrawerView: View { isGestureActive: isGestureActive ) + // Keep the descendant scroll environment stable during transitions. + // Hit testing, the shield, and the backend event lock block interaction + // without invalidating every scroll view in 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 { @@ -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,6 @@ struct LUIDrawerView: View { top: geometry.safeAreaInsets.top )) .modifier(LUIDrawerMainSurfaceModifier()) - .scrollDisabled(interactionsLocked) .allowsHitTesting(contentControlsEnabled) .overlay { if presented { From 3ccbbcfa97161dba207f4065ace8f72b068c00f5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 19:36:37 +0800 Subject: [PATCH 03/17] Keep mobile outliner windows bounded and reduce layout invalidations --- .../LUIAppleBackend/LUIAppleBackend.swift | 15 +- .../LUIAppleBackend/LUIDrawerView.swift | 8 +- .../LUIAppleBackend/LUISwiftUIRoot.swift | 314 ++++++++++++++++-- .../LUIAppleBackendTests.swift | 22 ++ 4 files changed, 324 insertions(+), 35 deletions(-) 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 d86b276e..4c670830 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -117,7 +117,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 +130,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 @@ -187,7 +185,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( @@ -241,7 +239,7 @@ struct LUIDrawerView: View { // without invalidating every scroll view in 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) @@ -337,7 +335,6 @@ struct LUIDrawerView: View { var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { - if value.translation.width > 0 { hasLoadedPanel = true } isGestureActive = true dragOffset = CGFloat(LUIDrawerGeometry.dragOffset( isPresented: presented, @@ -378,7 +375,6 @@ struct LUIDrawerView: View { } private func animatePresentation(_ selected: Bool) { - if selected { hasLoadedPanel = true } transitionGeneration += 1 let generation = transitionGeneration isAnimating = true diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index caab7262..90175692 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" @@ -6132,7 +6139,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)) @@ -6888,11 +6895,30 @@ 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 unobscuredHeight: CGFloat? @State private var titleTracker = LUIScrollSectionTitleTracker() + @State private var windowTracker = LUIVirtualWindowTracker() + @State private var firstSectionHeights: [SlotID: CGFloat] = [:] var body: some View { ScrollView { @@ -6900,20 +6926,27 @@ private struct LUIVirtualListView: View { 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 + 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) + } + )) + } else { rowView } } } } @@ -6945,21 +6978,206 @@ 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? + 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 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: nil, 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: segment.children.indices.contains(childOffset) ? segment.titles[segment.children[childOffset]] : nil, + 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, 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 + 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) + segments.append(SectionRows.Segment(source: id, base: total, count: count, + window: window, children: window == nil ? [] : (source?.visibleChildren ?? []), + title: titles[id], 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? + + 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 } + 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 @@ -7515,6 +7733,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 @@ -7537,7 +7791,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 && @@ -7578,7 +7840,7 @@ private struct LUISurfaceModifier: ViewModifier { inFusedCapsule: inFusedCapsule ) - content + return content .padding(.horizontal, CGFloat(horizontal)) .padding(.vertical, CGFloat(vertical)) .opacity(model.property(.opacity)?.doubleValue ?? 1) diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 6f3e7668..bf78054d 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -31,6 +31,15 @@ private let captureAppleEvent: LUIAppleEventCallback = { kind, node, text in @MainActor @Suite("LUI SwiftUI backend", .serialized) struct LUISwiftUIBackendTests { + @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("events raised during a retained batch wait for the commit boundary") func retainedBatchDefersEventsUntilCommitCompletes() throws { let backend = LUIAppleBackend() @@ -4078,6 +4087,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() From b79a6fc3de0c601e52ad53855820ddfdd4b060d0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 21:37:22 +0800 Subject: [PATCH 04/17] Support revealing distant rows in virtual lists without resetting scroll state --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 118 ++++++++++++------ 1 file changed, 77 insertions(+), 41 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 90175692..b83ff119 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -6915,59 +6915,95 @@ struct LUIVirtualRowIndices: RandomAccessCollection { 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) - ) { - 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) - } - )) - } 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) + } + )) + } else { rowView } + } + .id(slot.id) } } + .environment(\.luiUnobscuredScrollHeight, unobscuredHeight) + } + .onAppear { revealTarget(using: proxy) } + .onChange(of: scrollRequest) { _, _ in revealTarget(using: proxy) } + .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 + } + } + .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 { From 8314844329a0e38ef7dfa39ac6e3a10ab4d6c340 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 22:03:11 +0800 Subject: [PATCH 05/17] Avoid nesting glass capsules in scrolling toolbars --- platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index b83ff119..818a09e2 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -3392,7 +3392,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 From 1275bdbdc97870a8829bb099fadfac27c273044c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 23:16:25 +0800 Subject: [PATCH 06/17] Arbitrate drawer swipes with native scroll gestures --- .../LUIAppleBackend/LUIDrawerView.swift | 268 ++++++++++++++---- 1 file changed, 218 insertions(+), 50 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index 4c670830..290cad01 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( @@ -234,9 +237,8 @@ struct LUIDrawerView: View { isGestureActive: isGestureActive ) - // Keep the descendant scroll environment stable during transitions. - // Hit testing, the shield, and the backend event lock block interaction - // without invalidating every scroll view in a long content tree. + // 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 let panelID = model.children.dropFirst().first { @@ -304,62 +306,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) { - 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) } } @@ -456,3 +476,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 From f6702b79c02f873251ae04f1a69822a47598a153 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 20 Oct 2026 00:32:26 +0800 Subject: [PATCH 07/17] Preserve section titles across virtual row windows --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 818a09e2..fc127609 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -7063,14 +7063,16 @@ private struct LUIVirtualListView: View { 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: nil, estimatedHeight: segment.padding, tracksSectionHeight: false) + 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: segment.children.indices.contains(childOffset) ? segment.titles[segment.children[childOffset]] : nil, + title: window.sectionTitle(at: offset) + ?? (segment.children.indices.contains(childOffset) ? segment.titles[segment.children[childOffset]] : nil), estimatedHeight: window.extent, tracksSectionHeight: segment.tracksSectionHeight && offset < window.firstSectionCount) } @@ -7119,6 +7121,18 @@ fileprivate struct LUIVirtualWindow { 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 } @@ -7129,6 +7143,12 @@ fileprivate struct LUIVirtualWindow { } 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 From 6e124bc3071fb403f79df64a0851f693fe027f43 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 20 Oct 2026 08:00:24 +0800 Subject: [PATCH 08/17] Support windowed Android lists and retained navigation state --- .../lui/src/main/kotlin/dev/lui/LuiBackend.kt | 3 +- .../src/main/kotlin/dev/lui/LuiKindRules.kt | 1 + .../src/main/kotlin/dev/lui/LuiNodeView.kt | 130 +++++++++++++---- .../lui/src/main/kotlin/dev/lui/LuiRender.kt | 5 +- .../lui/src/main/kotlin/dev/lui/LuiTheme.kt | 2 +- .../src/main/kotlin/dev/lui/LuiVirtualList.kt | 136 ++++++++++++++++++ 6 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 platform/android/lui/src/main/kotlin/dev/lui/LuiVirtualList.kt 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 3c79b8ff..3c703822 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 a6f3ba57..8752182d 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 @@ -88,19 +89,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 @@ -135,7 +143,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 } @@ -329,12 +345,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) @@ -371,7 +382,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, @@ -921,10 +933,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, ) { @@ -939,6 +962,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, ) @@ -1031,8 +1059,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. @@ -1083,6 +1112,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 @@ -1103,8 +1144,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() @@ -1139,25 +1181,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( @@ -1206,7 +1250,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) } @@ -1220,15 +1266,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)) }, @@ -1250,13 +1299,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 { @@ -1828,6 +1878,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( @@ -1835,8 +1887,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 f7f27289..b13604a9 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt @@ -184,7 +184,10 @@ internal fun Modifier.luiChrome(node: LuiNodeLike): Modifier { ) } if (radius > 0) modifier = modifier.clip(shape) - if (paddingH > 0 || paddingV > 0) { + // A ghost button owns its label padding so the entire painted capsule + // remains clickable and exposes one full-size accessibility target. + val buttonContentPadding = node.kind == LuiNodeKind.button && node.propString("variant") == "ghost" + if (!buttonContentPadding && (paddingH > 0 || paddingV > 0)) { modifier = modifier.padding(horizontal = paddingH.dp, vertical = paddingV.dp) } node.prop("opacity")?.numberValue?.let { opacity -> 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) } } + } +} From 708f85c9193ad59dee221eee06e081c6edb5f960 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 07:52:27 +0800 Subject: [PATCH 09/17] Accept selected state on native context menu actions --- platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift | 1 + .../apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift | 2 ++ 2 files changed, 3 insertions(+) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 55f1c21f..4c333ec3 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -1525,6 +1525,7 @@ struct LUIRetainedTree { let allowed: Set = [ .text, .enabled, + .selected, .pressEnabled, .pointerEnabled, .icon, diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index bf78054d..1c0c99bf 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -3610,6 +3610,7 @@ 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"}, @@ -3632,6 +3633,7 @@ 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 == [ From a3f18f6354eea8a83a91b8aabfd25c1ab4ba7ae1 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 10:33:28 +0800 Subject: [PATCH 10/17] Keep decorative glass from intercepting native menus --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index fc127609..fa0e8264 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -1347,7 +1347,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 @@ -7698,15 +7702,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 } @@ -7765,7 +7767,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) } From 2fd6c670cf8178b916f282730986ab8c57c10031 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 12:25:11 +0800 Subject: [PATCH 11/17] Reveal scroll titles only after their content headings pass the top --- .../LUIScrollSectionTitle.swift | 64 +++++++++++++++++-- .../LUIAppleBackend/LUISwiftUIRoot.swift | 19 ++++-- .../LUIAppleBackendTests.swift | 24 +++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift b/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift index f209e1dd..56a9f3dd 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 = headingStates[candidate.title] ?? !candidate.startsSection + 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 fa0e8264..2f340eaf 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -812,6 +812,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( @@ -6950,7 +6952,7 @@ private struct LUIVirtualListView: View { index: row.index, title: row.title, coordinateSpace: titleCoordinateSpace, onVisibilityChange: { index, title in - titleTracker.update(index: index, title: title) + titleTracker.update(index: index, title: title, startsSection: row.startsSection) } )) } else { rowView } @@ -6963,6 +6965,8 @@ private struct LUIVirtualListView: View { .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, @@ -7031,6 +7035,7 @@ private struct LUIVirtualListView: View { let windowIndex: Int let index: Int let title: String? + var startsSection = false let estimatedHeight: CGFloat let tracksSectionHeight: Bool } @@ -7045,6 +7050,7 @@ private struct LUIVirtualListView: View { let window: LUIVirtualWindow? let children: [Int] let title: String? + let startsSection: Bool let padding: CGFloat let tracksSectionHeight: Bool let titles: [Int: String] @@ -7076,13 +7082,15 @@ private struct LUIVirtualListView: View { 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.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, estimatedHeight: 0, tracksSectionHeight: false) + index: index, title: segment.title, startsSection: segment.startsSection, estimatedHeight: 0, tracksSectionHeight: false) } } @@ -7090,6 +7098,7 @@ private struct LUIVirtualListView: View { let titles = model.childData.sectionTitles ?? [:] 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) @@ -7104,9 +7113,11 @@ private struct LUIVirtualListView: View { padding = max(0, (unobscuredHeight ?? 0) - window.firstSectionInset - measured) } let count = (window?.count ?? 1) + (padding > 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: titles[id], padding: padding, tracksSectionHeight: tracksHeight, + title: inheritedTitle, startsSection: startsSection, padding: padding, tracksSectionHeight: tracksHeight, titles: source?.childData.sectionTitles ?? [:])) total += count } diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 1c0c99bf..76767dd2 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -31,6 +31,30 @@ 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.updateHeading(title: "Today", isPastTop: false) + #expect(tracker.title == nil) + tracker.updateHeading(title: "Today", isPastTop: true) + tracker.update(index: 2, title: "Yesterday") + 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("virtual window replacement bypasses the collection burst delay") func virtualWindowReplacementCommitsImmediately() throws { let backend = LUIAppleBackend() From a87baaf1be4cda49227eb62f59f3e318267b1381 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 12:45:14 +0800 Subject: [PATCH 12/17] Keep scroll titles after standalone headings are evicted --- .../LUIAppleBackend/LUIScrollSectionTitle.swift | 2 +- .../LUIAppleBackendTests.swift | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift b/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift index 56a9f3dd..99898901 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIScrollSectionTitle.swift @@ -62,7 +62,7 @@ final class LUIScrollSectionTitleTracker { private func deriveTitle() { let candidate = visibleSections.min(by: { $0.key < $1.key })?.value let next = candidate.flatMap { candidate in - let past = headingStates[candidate.title] ?? !candidate.startsSection + let past = !candidate.startsSection || headingStates[candidate.title] == true return past ? candidate.title : nil } if title != next { title = next } diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 1c484188..dec5621b 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -43,10 +43,12 @@ struct LUISwiftUIBackendTests { 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: 2, title: "Yesterday") + 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) @@ -55,6 +57,19 @@ struct LUISwiftUIBackendTests { #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() From b30c75d8641911217a2596bc3bf707172f52d9c5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 13:05:42 +0800 Subject: [PATCH 13/17] Export the cached OCaml switch for tests in temporary directories --- .github/actions/setup-lui-ocaml/action.yml | 3 +++ 1 file changed, 3 insertions(+) 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 From 4fae8c3cb93761149c41a24c3d1875ae74af3072 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 13:18:59 +0800 Subject: [PATCH 14/17] Preserve native input identity when overlays change --- .../LUIAppleBackend/LUISwiftUIRoot.swift | 15 ++++-- .../LUIReviewIdentityTests.swift | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abb70333..2d97bbab 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -2210,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) + ) } - ) + } } } } 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 From 811b2b7f9a6250b7e47bed1d5574f48e5724f1d1 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 13:28:35 +0800 Subject: [PATCH 15/17] Refine compact sidebar width and main panel curvature --- .../LUIAppleBackend/LUIDrawerView.swift | 4 +- .../LUIAppleBackendTests.swift | 4 +- .../LUIBackendParityTests.swift | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index 290cad01..c191a462 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -108,7 +108,7 @@ enum LUIDrawerInteractionPolicy { } static func mainCornerRadius(progress: CGFloat) -> CGFloat { - 40.0 * progress + 52.0 * progress } } @@ -157,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) diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index dec5621b..0bbc310b 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -1984,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, diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index 347f1642..fad76c7c 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift @@ -291,6 +291,43 @@ struct LUIBackendParityTests { #expect(undimmedMainColor.greenComponent < 0.3) } + @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.65) + #expect(mainEdge.greenComponent > mainEdge.blueComponent + 0.3) + 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") func openDrawerMainSurfaceSpansContainerSafeAreas() throws { let backend = LUIAppleBackend() From 7a26edcd36e8379918925b8395c0875891c29c7b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 14:19:48 +0800 Subject: [PATCH 16/17] Fade main content with compact drawer transitions --- .../LUIAppleBackend/LUIDrawerView.swift | 6 +++++ .../LUIBackendParityTests.swift | 27 ++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index c191a462..3482e7e6 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -265,6 +265,12 @@ struct LUIDrawerView: View { top: geometry.safeAreaInsets.top )) .modifier(LUIDrawerMainSurfaceModifier()) + // Retain the scrim so opening and closing both interpolate + // with the drawer without replacing the content subtree. + .overlay { + Color.black.opacity(0.12 * Double(progress)) + .allowsHitTesting(false) + } .allowsHitTesting(contentControlsEnabled) .overlay { if presented { diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index fad76c7c..22d8d32e 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 dims pushed main content and restores brightness when closed") func openDrawerPushesMainContent() throws { let backend = LUIAppleBackend() try backend.apply(json: """ @@ -283,12 +283,27 @@ struct LUIBackendParityTests { let bitmap = NSBitmapImageRep(cgImage: image) let pushedMainLeadingColor = try #require(bitmap.colorAt(x: 205, y: 100)) - #expect(pushedMainLeadingColor.greenComponent > 0.65) + #expect(pushedMainLeadingColor.greenComponent > 0.55) #expect(pushedMainLeadingColor.redComponent < 0.2) - let undimmedMainColor = try #require(bitmap.colorAt(x: 300, y: 100)) - #expect(undimmedMainColor.redComponent > 0.8) - #expect(undimmedMainColor.greenComponent < 0.3) + let dimmedMainColor = try #require(bitmap.colorAt(x: 300, y: 100)) + #expect(dimmedMainColor.redComponent > 0.8) + #expect(dimmedMainColor.greenComponent < 0.3) + #expect(dimmedMainColor.redComponent < 0.94) + + 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.redComponent > dimmedMainColor.redComponent + 0.02) } @Test("compact drawer leaves a wider main preview with a larger curved edge") @@ -321,7 +336,7 @@ struct LUIBackendParityTests { 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.65) + #expect(mainEdge.greenComponent > 0.55) #expect(mainEdge.greenComponent > mainEdge.blueComponent + 0.3) let roundedEdge = try #require(bitmap.colorAt(x: 321, y: 16)) #expect(roundedEdge.blueComponent > 0.65) From b077fb049e7f184d9bf7e7be63421411a67c0340 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 21 Oct 2026 14:53:06 +0800 Subject: [PATCH 17/17] Fade drawer content toward its background without dimming the surface --- .../LUIAppleBackend/LUIDrawerView.swift | 10 ++- .../LUIBackendParityTests.swift | 76 ++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift index 3482e7e6..7f565f3a 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIDrawerView.swift @@ -265,10 +265,11 @@ struct LUIDrawerView: View { top: geometry.safeAreaInsets.top )) .modifier(LUIDrawerMainSurfaceModifier()) - // Retain the scrim so opening and closing both interpolate - // with the drawer without replacing the content subtree. + // Fade content toward the page background while preserving + // the surface color and the retained content subtree. .overlay { - Color.black.opacity(0.12 * Double(progress)) + (semanticColors["background"] ?? LUIThemeColorResolver.systemBackground) + .opacity(0.5 * Double(progress)) .allowsHitTesting(false) } .allowsHitTesting(contentControlsEnabled) @@ -290,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 diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIBackendParityTests.swift index 22d8d32e..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 dims pushed main content and restores brightness when closed") + @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,13 +283,11 @@ struct LUIBackendParityTests { let bitmap = NSBitmapImageRep(cgImage: image) let pushedMainLeadingColor = try #require(bitmap.colorAt(x: 205, y: 100)) - #expect(pushedMainLeadingColor.greenComponent > 0.55) - #expect(pushedMainLeadingColor.redComponent < 0.2) + #expect(pushedMainLeadingColor.greenComponent > pushedMainLeadingColor.redComponent + 0.1) - let dimmedMainColor = try #require(bitmap.colorAt(x: 300, y: 100)) - #expect(dimmedMainColor.redComponent > 0.8) - #expect(dimmedMainColor.greenComponent < 0.3) - #expect(dimmedMainColor.redComponent < 0.94) + 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":[ @@ -303,7 +301,67 @@ struct LUIBackendParityTests { 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.redComponent > dimmedMainColor.redComponent + 0.02) + #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") @@ -337,7 +395,7 @@ struct LUIBackendParityTests { 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.3) + #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)