From 9565a689f3e46fd122e8c1dcc7b2e23c5c98ffc1 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Thu, 29 Oct 2026 18:27:18 +0800 Subject: [PATCH 1/4] fix: keep resident graph handoff stable through native layout --- .../JournalListSnapshotTests.swift | 64 ++++++++ .../JournalResidentGraphAcceptance.swift | 22 ++- swift/JournalChrome.swift | 155 +++++++++++++++++- swift/JournalList.swift | 83 +++++++++- tool/check_graph_switch_motion.py | 82 +++++++++ 5 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 tool/check_graph_switch_motion.py diff --git a/apple-tests/native-list/JournalListSnapshotTests.swift b/apple-tests/native-list/JournalListSnapshotTests.swift index 56551e8..0191ba5 100644 --- a/apple-tests/native-list/JournalListSnapshotTests.swift +++ b/apple-tests/native-list/JournalListSnapshotTests.swift @@ -1,6 +1,9 @@ import Foundation import SwiftUI import Combine +#if os(iOS) +import UIKit +#endif @testable import LUIAppleBackend // Native payload parsing/lifecycle ownership is not reachable through an OCaml @@ -35,6 +38,67 @@ import Combine } static func settle() async { for _ in 0..<10 { await Task.yield() } } static func main() async throws { + #if os(iOS) + let handoff = JournalGraphHandoff() + let cancelledGesture = handoff.startGesture() + handoff.cancel() + check(!handoff.acceptsGesture(cancelledGesture), "scene cancellation rejects an animation completion") + let laterGesture = handoff.startGesture() + check(!handoff.acceptsGesture(cancelledGesture) && handoff.acceptsGesture(laterGesture), "new gesture fences old completion") + handoff.cancel() + let changedFocusGesture = handoff.startGesture() + handoff.focusChanged("B") + check(!handoff.acceptsGesture(changedFocusGesture), "external focus change before cover rejects old animation completion") + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + let firstHandoff = handoff.begin(source: "A", target: "B", image: image)! + check(!handoff.finish(firstHandoff, page: "A", current: "B"), "source list cannot uncover target") + check(!handoff.finish(firstHandoff, page: "B", current: "A"), "target ready cannot uncover wrong focused page") + check(handoff.finish(firstHandoff, page: "B", current: "B"), "matching native layout uncovers target") + let second = handoff.begin(source: "B", target: "A", image: image)! + _ = handoff.begin(source: "A", target: "B", image: image)! + check(!handoff.finish(firstHandoff, page: "B", current: "B"), "repeated page ID rejects old ready nonce") + handoff.expire(second) + check(handoff.request != nil, "old timeout cannot cancel a later handoff") + let waiting = handoff.request!.id + handoff.expire(waiting) + check(handoff.request?.id == waiting, "a layout timeout must not expose source while focus is still queued") + handoff.focusChanged("B") + handoff.expire(waiting) + check(handoff.request == nil, "missing target layout fails open after focus and clears input lock") + check(handoff.begin(source: "A", target: "B", image: nil) == nil, "failed snapshot cannot enter waiting phase") + _ = handoff.begin(source: "A", target: "B", image: image) + handoff.focusChanged("C") + check(handoff.request == nil, "unexpected focus fences the old cover") + _ = handoff.begin(source: "A", target: "B", image: image) + handoff.focusChanged("B") + handoff.focusChanged("A") + check(handoff.request == nil, "leaving an observed target cancels its cover even on return to source") + _ = handoff.begin(source: "A", target: "B", image: image) + handoff.retain(["A", "B", "C"]) + check(handoff.request != nil, "unchanged resident set retains pending focus") + handoff.retain(["A", "C"]) + check(handoff.request == nil, "closing pending target cancels its cover") + _ = handoff.begin(source: "A", target: "B", image: image) + handoff.cancel() + check(handoff.request == nil, "window or scene cancellation clears handoff") + _ = handoff.begin(source: "A", target: "B", image: image) + try await Task.sleep(for: .milliseconds(2100)) + check(handoff.request != nil, "queued focus is not a missing native layout") + handoff.focusChanged("B") + for _ in 0..<80 { + if handoff.request == nil { break } + try await Task.sleep(for: .milliseconds(50)) + } + check(handoff.request == nil, "missing target List layout actually expires after focus") + let anchor = JournalGraphHandoff.Anchor() + anchor.owner = handoff + handoff.anchor = anchor + anchor.bounds = CGRect(x: 0, y: 0, width: 2, height: 2) + _ = handoff.begin(source: "A", target: "B", image: image) + anchor.layoutSubviews() + for _ in 0..<10 { await Task.yield() } + check(handoff.request == nil, "size invalidation cancels a cover even before focus commits") + #endif let offsets = JournalGraphScrollOffsets() offsets.retain(["account1:A:g1", "account1:B:g2"]) offsets.write(page: "account1:A:g1", list: "timeline", y: 515) diff --git a/apple-tests/native-list/JournalResidentGraphAcceptance.swift b/apple-tests/native-list/JournalResidentGraphAcceptance.swift index 3a4bf82..70fea3f 100644 --- a/apple-tests/native-list/JournalResidentGraphAcceptance.swift +++ b/apple-tests/native-list/JournalResidentGraphAcceptance.swift @@ -2,9 +2,10 @@ import XCTest /// Run only against the isolated four-catalog / three-resident synthetic host. /// Native pixel offset is owned by List; the public reducer cannot reproduce it. final class JournalResidentGraphAcceptance: XCTestCase { - @MainActor private func host() -> XCUIApplication { + @MainActor private func host(delay: Bool = false) -> XCUIApplication { let app=XCUIApplication(bundleIdentifier:"org.logseq.journal.resident-sync-probe") app.launchEnvironment=["LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE":"memory","LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE":"memory","JOURNAL_SYNTHETIC_TLS_SHA256":ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_TLS_SHA256"] ?? ""] + if delay { app.launchEnvironment["JOURNAL_SYNTHETIC_CAPTURE_DELAY_SECONDS"] = "8" } app.launchArguments=["--fixture",ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_FIXTURE"] ?? "authoritative-v2/fixture.json","--support-root",ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_SUPPORT_ROOT"] ?? "authoritative-v2/resident-sync-support","--application-only"] app.launch(); _ = app.collectionViews.firstMatch.waitForExistence(timeout:25) Thread.sleep(forTimeInterval:5) @@ -34,6 +35,25 @@ final class JournalResidentGraphAcceptance: XCTestCase { right(app);XCTAssertTrue(title(graph,app).waitForExistence(timeout:10));XCTAssertTrue(title(graph,app).isHittable);record("Native right swipe graph\(graph)",app) } } + @MainActor func test21SavingFocusWaitPreservesLandedTarget() { + continueAfterFailure = false + let app = host(delay: true) + tapID("journal-capture-open", app) + let editor = app.textFields["field.composer"] + XCTAssertTrue(editor.waitForExistence(timeout: 10)) + editor.tap(); editor.typeText("Handoff queued focus " + UUID().uuidString) + tapID("button.send", app) + XCTAssertTrue(app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", "Saving")).firstMatch.exists) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() + drag(app, 0.15, 0.85) + Thread.sleep(forTimeInterval: 2.2) + XCTAssertFalse(title(1, app).isHittable, + "Queued focus must keep the landed target rather than uncovering the source after 2s") + record("Landed target while Save delays focus", app) + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND enabled == true"), object: title(2, app)) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 12), .completed) + record("Queued focus commits to the restored target", app) + } @MainActor private func drag(_ app:XCUIApplication,_ from:CGFloat,_ to:CGFloat) { app.coordinate(withNormalizedOffset:CGVector(dx:from,dy:0.53)).press(forDuration:0.05,thenDragTo:app.coordinate(withNormalizedOffset:CGVector(dx:to,dy:0.53)),withVelocity:.slow,thenHoldForDuration:0.2) } diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 8be4210..d2782fa 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -4,6 +4,105 @@ import SwiftUI import UIKit #endif +#if os(iOS) +/// Bridges one completed swipe until the replacement List has restored layout. +/// The cover contains only this window's pixels and never leaves memory. +@MainActor final class JournalGraphHandoff: ObservableObject { + struct Request { let id: UUID; let source: String; let target: String } + @Published private(set) var request: Request? + weak var anchor: Anchor? + private var deadline: Task? + private var gestureID: UUID? + private var reachedTarget = false + + func startGesture() -> UUID { cancel(); let id = UUID(); gestureID = id; return id } + func acceptsGesture(_ id: UUID) -> Bool { gestureID == id } + var currentGesture: UUID? { gestureID } + + final class Anchor: UIImageView { + weak var owner: JournalGraphHandoff? + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { owner?.cancel() } + } + override func layoutSubviews() { + super.layoutSubviews() + if let image, abs(image.size.width - bounds.width) > 0.5 || abs(image.size.height - bounds.height) > 0.5, + let request = owner?.request { + Task { @MainActor [weak owner] in owner?.invalidate(request.id) } + } + } + } + + func capture() -> UIImage? { + guard let anchor, let window = anchor.window, anchor.bounds.width > 0, + anchor.bounds.height > 0 else { return nil } + let frame = anchor.convert(anchor.bounds, to: window) + var drawn = false + let image = UIGraphicsImageRenderer(size: anchor.bounds.size).image { _ in + drawn = window.drawHierarchy(in: CGRect(origin: CGPoint(x: -frame.minX, y: -frame.minY), + size: window.bounds.size), afterScreenUpdates: false) + } + return drawn ? image : nil + } + + @discardableResult func begin(source: String, target: String, image: UIImage?) -> UUID? { + cancel() + guard let image else { return nil } + let id = UUID() + anchor?.image = image + reachedTarget = false + request = Request(id: id, source: source, target: target) + return id + } + func finish(_ id: UUID, page: String, current: String?) -> Bool { + guard let request, request.id == id, request.target == page, current == page else { return false } + cancel() + return true + } + func focusChanged(_ page: String?) { + guard let request else { cancel(); return } + if page == request.target { + guard !reachedTarget else { return } + reachedTarget = true + let id = request.id + // Focus can be queued behind a Save. Until it commits, removing the + // landed target would reveal the source again. Only target layout has + // a deadline; its loading/error UI is safe to reveal after focus. + deadline = Task { @MainActor [weak self] in + do { try await Task.sleep(for: .seconds(2)) } catch { return } + self?.expire(id) + } + } else if reachedTarget || page != request.source { cancel() } + } + func expire(_ id: UUID) { if request?.id == id && reachedTarget { cancel() } } + func invalidate(_ id: UUID) { if request?.id == id { cancel() } } + func retain(_ pages: [String]) { + if let request, !pages.contains(request.source) || !pages.contains(request.target) { cancel() } + } + func cancel() { + deadline?.cancel(); deadline = nil + gestureID = nil; reachedTarget = false + anchor?.image = nil + request = nil + } +} + +private struct JournalGraphHandoffAccess: UIViewRepresentable { + let owner: JournalGraphHandoff + func makeUIView(context: Context) -> JournalGraphHandoff.Anchor { + let view = JournalGraphHandoff.Anchor() + view.isUserInteractionEnabled = false + view.accessibilityElementsHidden = true + view.contentMode = .scaleToFill + view.owner = owner + owner.anchor = view + return view + } + func updateUIView(_ view: JournalGraphHandoff.Anchor, context: Context) {} +} +#endif + /// The form sheet's native card transform still leaves at least a 44pt target. struct JournalSheetDismissButton: SwiftUI.View { var label: LocalizedStringKey = "Close" @@ -103,6 +202,8 @@ struct JournalSheetDismissButton: SwiftUI.View { @State private var translation: CGFloat = 0 @State private var dragging = false @State private var settling = false + @StateObject private var handoff = JournalGraphHandoff() + @Environment(\.scenePhase) private var scenePhase @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -113,31 +214,44 @@ struct JournalSheetDismissButton: SwiftUI.View { switch pan.state { case .began: guard properties.enabled == true, !settling else { return } + handoff.startGesture() dragging = true emit("prepare") translation = min(width, max(0, pan.translation(in: pan.view).x)) case .changed where dragging: translation = min(width, max(0, pan.translation(in: pan.view).x)) case .ended where dragging: + guard let gesture = handoff.currentGesture else { return } dragging = false let distance = pan.translation(in: pan.view).x let velocity = pan.velocity(in: pan.view).x let commit = distance > width * 0.3 || (distance > 30 && velocity > 650) settling = true - withAnimation(reduceMotion ? .linear(duration: 0.01) : .smooth(duration: 0.24), completionCriteria: .logicallyComplete) { + // Focus dispatch synchronously rebuilds the native page. Wait until the + // spring is removed so that work cannot interrupt its visible tail. + withAnimation(reduceMotion ? .linear(duration: 0.01) : .smooth(duration: 0.24), completionCriteria: .removed) { translation = commit ? width : 0 } completion: { + guard handoff.acceptsGesture(gesture) else { return } + if commit, let source = properties.pageID, let target = properties.targetPageID { + handoff.begin(source: source, target: target, image: handoff.capture()) + } emit(commit ? "commit" : "cancel") if !commit { settling = false } } case .cancelled, .failed: guard dragging else { return } + handoff.cancel() dragging = false withAnimation(.smooth(duration: 0.2)) { translation = 0 } emit("cancel") default: break } } + private var presentationNonce: UUID? { + guard let request = handoff.request, request.target == properties.pageID else { return nil } + return request.id + } var body: some SwiftUI.View { GeometryReader { bounds in ZStack(alignment: .topLeading) { @@ -149,24 +263,55 @@ struct JournalSheetDismissButton: SwiftUI.View { .accessibilityHidden(true) current .environment(\.journalGraphScrollScope, properties.pageID.map { JournalGraphScrollScope(pageID: $0, records: true) }) + .environment(\.journalGraphPresentationNonce, presentationNonce) + .environment(\.journalGraphPresentationReady, { [id = presentationNonce] page in + if let id { _ = handoff.finish(id, page: page, current: properties.pageID) } + }) .frame(width: bounds.size.width, height: bounds.size.height) .offset(x: translation) + .allowsHitTesting(handoff.request == nil) + .accessibilityHidden(handoff.request != nil) } + .overlay(JournalGraphHandoffAccess(owner: handoff) + .frame(width: bounds.size.width, height: bounds.size.height) + .allowsHitTesting(false).accessibilityHidden(true)) .clipped() .gesture(RightGraphPan(enabled: properties.enabled == true && !settling) { handle($0, width: bounds.size.width) }) } .onAppear { graphScrollOffsets?.retain(properties.residentPageIDs ?? []) } - .onChange(of: properties.residentPageIDs) { _, ids in graphScrollOffsets?.retain(ids ?? []) } - .onChange(of: properties.pageID) { _, _ in - translation = 0; dragging = false; settling = false + .onChange(of: properties.residentPageIDs) { _, ids in + graphScrollOffsets?.retain(ids ?? []) + handoff.retain(ids ?? []) + } + .onChange(of: properties.pageID) { _, page in + handoff.focusChanged(page) + withTransaction(Transaction(animation: nil)) { + translation = 0; dragging = false + if handoff.request == nil { settling = false } + } } .onChange(of: properties.targetID) { _, target in - if target == nil && !dragging { translation = 0; settling = false } + if target == nil && !dragging && handoff.request == nil { translation = 0; settling = false } + } + .onChange(of: handoff.request?.id) { _, id in + if id == nil { + withTransaction(Transaction(animation: nil)) { translation = 0; settling = false } + } + } + .onChange(of: scenePhase) { _, phase in + if phase != .active { + let wasSwitching = dragging || settling + handoff.cancel() + translation = 0; dragging = false; settling = false + if wasSwitching { emit("cancel") } + } } + .onDisappear { handoff.cancel() } .onChange(of: properties.enabled) { _, enabled in if enabled != true && dragging { + handoff.cancel() dragging = false withAnimation(.smooth(duration: 0.2)) { translation = 0 } emit("cancel") diff --git a/swift/JournalList.swift b/swift/JournalList.swift index cb9f318..d61d5f7 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -12,7 +12,21 @@ struct JournalGraphScrollScope { private struct JournalGraphScrollScopeKey: EnvironmentKey { static let defaultValue: JournalGraphScrollScope? = nil } +private struct JournalGraphPresentationReadyKey: EnvironmentKey { + static let defaultValue: (@MainActor @Sendable (String) -> Void)? = nil +} +private struct JournalGraphPresentationNonceKey: EnvironmentKey { + static let defaultValue: UUID? = nil +} extension EnvironmentValues { + var journalGraphPresentationNonce: UUID? { + get { self[JournalGraphPresentationNonceKey.self] } + set { self[JournalGraphPresentationNonceKey.self] = newValue } + } + var journalGraphPresentationReady: (@MainActor @Sendable (String) -> Void)? { + get { self[JournalGraphPresentationReadyKey.self] } + set { self[JournalGraphPresentationReadyKey.self] = newValue } + } var journalGraphScrollScope: JournalGraphScrollScope? { get { self[JournalGraphScrollScopeKey.self] } set { self[JournalGraphScrollScopeKey.self] = newValue } @@ -386,6 +400,8 @@ extension EnvironmentValues { var active = false var handledToken: Int64 = 0 private var userInteracting = false + var presentationLayout: (() -> Void)? + private var reportingLayout = false private(set) var atTop = false #if os(iOS) weak var scrollView: UIScrollView? @@ -411,9 +427,21 @@ extension EnvironmentValues { UIView.performWithoutAnimation { scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: y - scrollView.adjustedContentInset.top), animated: false) + scrollView.layoutIfNeeded() } request.complete(y) } + func reportPresentationLayout() { + guard active, presentationLayout != nil, restoredOffset == nil, !reportingLayout, + let scrollView, scrollView.window != nil, scrollView.bounds.height > 0, + scrollView.contentSize.height > 0 else { return } + reportingLayout = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reportingLayout = false + if self.active, self.restoredOffset == nil { self.presentationLayout?() } + } + } private var reduced = false private var deadline: Task? #endif @@ -563,6 +591,7 @@ extension EnvironmentValues { func resolveIfNeeded() { owner?.applyRestoredOffset() + owner?.reportPresentationLayout() guard window != nil, owner?.scrollView?.window == nil, !resolving else { return } resolving = true DispatchQueue.main.async { [weak self] in @@ -575,6 +604,7 @@ extension EnvironmentValues { if let scrollView = self.findScrollView(candidate) { owner.scrollView = scrollView owner.applyRestoredOffset() + owner.reportPresentationLayout() return } ancestor = candidate.superview @@ -619,8 +649,11 @@ extension EnvironmentValues { @State private var restorationY: CGFloat? @State private var scrollMounted = false @State private var boundScrollIdentity: String? + @State private var presentedScrollIdentity: String? @Environment(\.journalGraphScrollScope) private var graphScrollScope @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets + @Environment(\.journalGraphPresentationReady) private var presentationReady + @Environment(\.journalGraphPresentationNonce) private var presentationNonce @Environment(\.accessibilityReduceMotion) private var reduceMotion init(context: LUIAppleExtensionViewContext) { @@ -638,6 +671,7 @@ extension EnvironmentValues { } private func restoreScrollPosition() { + presentedScrollIdentity = nil restoringPosition = true restorationY = nil boundScrollIdentity = scrollIdentity @@ -664,6 +698,33 @@ extension EnvironmentValues { } } + private func reportPresentationLayout() { + guard scrollMounted, presentationNonce != nil, + let identity = boundScrollIdentity, identity == scrollIdentity, + presentedScrollIdentity != identity, let scope = graphScrollScope, scope.records, + properties?.retention_key != nil, let presentationReady else { return } + #if os(iOS) + guard let scroll = topScroll.scrollView else { return } + let offset = scroll.contentOffset.y + scroll.adjustedContentInset.top + if let expected = restorationY { + guard abs(offset - expected) <= 1 else { return } + restorationY = nil + restoringPosition = false + } + #endif + guard !restoringPosition else { return } + presentedScrollIdentity = identity + presentationReady(scope.pageID) + } + + private func bindPresentationLayout() { + if presentationNonce != nil { + topScroll.presentationLayout = { reportPresentationLayout() } + } else { + topScroll.presentationLayout = nil + } + } + private func emit(_ event: Event) { guard let data = try? JSONEncoder().encode(event) else { return } JournalExtensions.emit(context: context, payload: data) @@ -965,6 +1026,7 @@ extension EnvironmentValues { } .onAppear { topScroll.active = true + bindPresentationLayout() prepared.bind(context) scrollMounted = true restoreScrollPosition() @@ -974,6 +1036,8 @@ extension EnvironmentValues { } } .onDisappear { + topScroll.presentationLayout = nil + presentedScrollIdentity = nil scrollMounted = false boundScrollIdentity = nil restoringPosition = true @@ -991,6 +1055,7 @@ extension EnvironmentValues { topScroll.geometryChanged(false) } .onChange(of: context.nodeID) { _, _ in + bindPresentationLayout() restoringPosition = true boundScrollIdentity = nil cancelPull() @@ -1002,12 +1067,28 @@ extension EnvironmentValues { topScroll.geometryChanged(false) prepared.bind(context) restoreScrollPosition() + #if os(iOS) + topScroll.reportPresentationLayout() + #endif observeScrollRequests() } .onChange(of: scrollIdentity) { _, _ in - if scrollMounted && boundScrollIdentity != scrollIdentity { restoreScrollPosition() } + if scrollMounted && boundScrollIdentity != scrollIdentity { + bindPresentationLayout() + restoreScrollPosition() + #if os(iOS) + topScroll.reportPresentationLayout() + #endif + } } .onChange(of: reduceMotion) { _, reduced in topScroll.reduceMotionChanged(reduced) } + .onChange(of: presentationNonce) { _, _ in + presentedScrollIdentity = nil + bindPresentationLayout() + #if os(iOS) + if scrollMounted { topScroll.reportPresentationLayout() } + #endif + } .onChange(of: pendingScroll?.request.token) { _, _ in performPendingScroll() } } } diff --git a/tool/check_graph_switch_motion.py b/tool/check_graph_switch_motion.py new file mode 100644 index 0000000..f993b6c --- /dev/null +++ b/tool/check_graph_switch_motion.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Check a real Simulator switch's final motion against its settled pixels. + +Supply an interval containing the final 80px of motion and a settled frame. +Uses original presentation timestamps, without resampling or speeding up video. +This measures recorded visual holds and jumps, not device display FPS. +""" +import argparse +import json +import subprocess +import sys + + +def check(video, start, end): + metadata = json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", "-fflags", "+igndts", "-select_streams", "v:0", "-show_entries", + "stream=width,height:frame=pts_time", "-of", "json", video])) + width = metadata["streams"][0]["width"] + height = metadata["streams"][0]["height"] + times = [float(frame["pts_time"]) for frame in metadata["frames"] + if start <= float(frame["pts_time"]) <= end] + # Average a broad unchanged body band into a horizontal text signature. + # Excludes navigation/status controls and doesn't depend on OCR accuracy. + band_y, band_h = int(height * .316), int(height * .474) + raw = subprocess.check_output([ + "ffmpeg", "-v", "fatal", "-fflags", "+igndts", "-i", video, "-vf", + f"select=between(t\\,{start}\\,{end}),crop={width}:{band_h}:0:{band_y}," + f"scale={width}:1,format=gray", "-fps_mode", "passthrough", + "-f", "rawvideo", "-"]) + assert len(raw) == len(times) * width, "Frame/timestamp count differs" + assert len(times) >= 3, "Interval needs motion and settled frames" + frames = [raw[i:i + width] for i in range(0, len(raw), width)] + target = frames[-1] + vertical = subprocess.check_output([ + "ffmpeg", "-v", "fatal", "-fflags", "+igndts", "-i", video, "-vf", + f"select=between(t\\,{start}\\,{end}),crop={width}:{band_h}:0:{band_y}," + f"scale=1:{band_h},format=gray", "-fps_mode", "passthrough", + "-f", "rawvideo", "-"]) + assert len(vertical) == len(times) * band_h, "Vertical frame count differs" + vertical_target = vertical[-band_h:] + margin = max(110, int(width * .094)) + samples = range(margin, width - margin) + trace = [] + failures = [] + for index, (time, pixels) in enumerate(zip(times, frames)): + error, x = min((sum(abs(pixels[i + shift] - target[i]) for i in samples), shift) + for shift in range(-80, 11)) + if error > len(samples) * 3: + failures.append({"kind": "body_content_or_layout_mismatch", "time": time, + "match_error": error}) + vertical_pixels = vertical[index * band_h:(index + 1) * band_h] + vertical_error, y = min((sum(abs(vertical_pixels[i + shift] - vertical_target[i]) + for i in range(60, band_h - 60)), shift) + for shift in range(-50, 51)) + trace.append({"time": time, "x_from_settled_px": x, "y_from_settled_px": y, + "match_error": error, "vertical_match_error": vertical_error}) + for frame in trace: + if -40 <= frame["x_from_settled_px"] <= 2 and abs(frame["y_from_settled_px"]) > 3: + failures.append({"kind": "vertical_tail_displacement", "frame": frame}) + for previous, current in zip(trace, trace[1:]): + residual = previous["x_from_settled_px"] + if -40 <= residual <= -3 and current["time"] - previous["time"] > .09: + failures.append({"kind": "unfinished_tail_hold", "from": previous, + "to": current, "seconds": current["time"] - previous["time"]}) + if -40 <= residual <= 0 and current["x_from_settled_px"] < residual - 3: + failures.append({"kind": "tail_moves_backwards", "from": previous, "to": current}) + motion_observed = any(-40 <= frame["x_from_settled_px"] <= -3 for frame in trace) + assert motion_observed or failures, "No measurable motion or handoff failure in interval" + assert all(abs(frame["x_from_settled_px"]) <= 2 for frame in trace[-3:]), "End is not settled" + return {"video": video, "start": start, "end": end, "trace": trace, + "motion_observed": motion_observed, "failures": failures, "passed": not failures} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("video") + parser.add_argument("start", type=float) + parser.add_argument("end", type=float) + args = parser.parse_args() + result = check(args.video, args.start, args.end) + print(json.dumps(result, indent=2)) + sys.exit(0 if result["passed"] else 1) From e37ba13f58a4f06471632b93c00137c7a3d798e2 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Thu, 29 Oct 2026 21:48:18 +0800 Subject: [PATCH 2/4] Fix graph preview image colors and idle readiness updates --- app/application.ml | 4 +- .../JournalResidentGraphAcceptance.swift | 72 +++++++++++++++++++ swift/JournalChrome.swift | 8 ++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/app/application.ml b/app/application.ml index a179c2e..6e10a0f 100644 --- a/app/application.ml +++ b/app/application.ml @@ -6863,7 +6863,9 @@ let start let target=graph_model state owner in ignore(Journal_timeline.Store.synchronize (!timeline_store_cell) target.timeline : int); let inert={send=(fun _->());native=Ui.Event.Handler.create(fun _->())} in - let content=render_graph_timeline ~interactive:false ~header_signal:None + (* Native GraphPages blocks hit testing and accessibility for this pane; + inert dispatch prevents actions. Keep permission-derived row visuals. *) + let content=render_graph_timeline ~interactive:true ~header_signal:None ~store:(!timeline_store_cell) ~media_store:(!media_store_cell) ~dispatch:inert target |> V.Body.Private.to_widget in save_runtime ();List.iter (fun restore->restore ()) restore;active_runtime:=previous;content diff --git a/apple-tests/native-list/JournalResidentGraphAcceptance.swift b/apple-tests/native-list/JournalResidentGraphAcceptance.swift index 70fea3f..44ad66c 100644 --- a/apple-tests/native-list/JournalResidentGraphAcceptance.swift +++ b/apple-tests/native-list/JournalResidentGraphAcceptance.swift @@ -1,4 +1,5 @@ import XCTest +import CoreGraphics /// Run only against the isolated four-catalog / three-resident synthetic host. /// Native pixel offset is owned by List; the public reducer cannot reproduce it. final class JournalResidentGraphAcceptance: XCTestCase { @@ -54,6 +55,77 @@ final class JournalResidentGraphAcceptance: XCTestCase { XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 12), .completed) record("Queued focus commits to the restored target", app) } + /// Requires the synthetic image-blocks fixture and its blue graph-B PNGs. + @MainActor func test22PreviewImageColorAndHitBarrier() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["JOURNAL_SYNTHETIC_FIXTURE"]?.contains("image-blocks") == true, + "Select the isolated image-blocks fixture for this acceptance") + continueAfterFailure = true + let app = host(delay: true) + picker(app); tapID("graph-picker:" + gid(2), app) + XCTAssertTrue(title(2, app).waitForExistence(timeout: 20)) + let slot = app.otherElements["journal-image-slot:92000002-0000-4000-8000-000000000001"].firstMatch + let image = slot.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "journal-media:")).firstMatch + XCTAssertTrue(image.waitForExistence(timeout: 15)) + tapID("journal-capture-open", app) + let field = app.textFields["field.composer"] + XCTAssertTrue(field.waitForExistence(timeout: 10)) + let marker = "B preview draft " + UUID().uuidString + field.tap(); field.typeText(marker) + let draft = field.value as? String + XCTAssertTrue(draft?.contains(marker) == true) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() + picker(app); tapID("graph-picker:" + gid(1), app) + XCTAssertTrue(title(1, app).waitForExistence(timeout: 20)) + tapID("journal-capture-open", app) + XCTAssertTrue(field.waitForExistence(timeout: 10)) + field.tap(); field.typeText("Image preview barrier " + UUID().uuidString) + tapID("button.send", app) + XCTAssertTrue(app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", "Saving")).firstMatch.exists) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() + app.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.75)).press(forDuration: 0.05, + thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.75)), + withVelocity: .slow, thenHoldForDuration: 0.2) + record("Image preview landed while focus is queued", app) + let preview = imagePixel(XCUIScreen.main.screenshot().image.cgImage!) + // Same already-loaded PNG must keep its normal color while input is fenced. + XCTAssertEqual(Double(preview[0]), 50, accuracy: 18) + XCTAssertEqual(Double(preview[1]), 102, accuracy: 18) + XCTAssertEqual(Double(preview[2]), 199, accuracy: 18) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.735, dy: 0.257)).tap() + XCTAssertFalse(app.otherElements["journal-image-gallery"].exists, + "A visible preview must not open its image gallery") + XCTAssertFalse(app.staticTexts["detail-block:80000000-0000-4000-a000-000000000001"].exists) + record("Preview image tap is rejected", app) + // Probe a real action: native AX may report preview text as hittable. + // Only the focused graph can actually open its composer with B's draft. + for _ in 0..<14 { + if field.exists { break } + let open = app.descendants(matching: .any).matching(identifier: "journal-capture-open").firstMatch + if open.exists && open.isEnabled && open.isHittable { open.tap() } + if field.waitForExistence(timeout: 1) { break } + } + XCTAssertTrue(field.exists); XCTAssertEqual(field.value as? String, draft) + record("Focused B draft matches its inert preview", app) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() + XCTAssertTrue(field.waitForNonExistence(timeout: 3)) + let focused = imagePixel(XCUIScreen.main.screenshot().image.cgImage!) + for channel in 0..<3 { XCTAssertEqual(Double(preview[channel]), Double(focused[channel]), accuracy: 16) } + record("Focused B color matches its inert preview", app) + } + + @MainActor private func imagePixel(_ image: CGImage) -> [UInt8] { + let point = CGRect(x: Double(image.width) * 0.735, y: Double(image.height) * 0.257, width: 6, height: 6) + guard let crop = image.cropping(to: point) else { XCTFail("Missing image sample"); return [0, 0, 0] } + var rgba = [UInt8](repeating: 0, count: 4) + let drawn = rgba.withUnsafeMutableBytes { bytes -> Bool in + guard let context = CGContext(data: bytes.baseAddress, width: 1, height: 1, + bitsPerComponent: 8, bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) + else { return false } + context.draw(crop, in: CGRect(x: 0, y: 0, width: 1, height: 1)); return true + } + XCTAssertTrue(drawn); return Array(rgba.prefix(3)) + } @MainActor private func drag(_ app:XCUIApplication,_ from:CGFloat,_ to:CGFloat) { app.coordinate(withNormalizedOffset:CGVector(dx:from,dy:0.53)).press(forDuration:0.05,thenDragTo:app.coordinate(withNormalizedOffset:CGVector(dx:to,dy:0.53)),withVelocity:.slow,thenHoldForDuration:0.2) } diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index d2782fa..e3b1cf5 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -252,6 +252,10 @@ struct JournalSheetDismissButton: SwiftUI.View { guard let request = handoff.request, request.target == properties.pageID else { return nil } return request.id } + private var presentationReady: (@MainActor @Sendable (String) -> Void)? { + guard let id = presentationNonce else { return nil } + return { page in _ = handoff.finish(id, page: page, current: properties.pageID) } + } var body: some SwiftUI.View { GeometryReader { bounds in ZStack(alignment: .topLeading) { @@ -264,9 +268,7 @@ struct JournalSheetDismissButton: SwiftUI.View { current .environment(\.journalGraphScrollScope, properties.pageID.map { JournalGraphScrollScope(pageID: $0, records: true) }) .environment(\.journalGraphPresentationNonce, presentationNonce) - .environment(\.journalGraphPresentationReady, { [id = presentationNonce] page in - if let id { _ = handoff.finish(id, page: page, current: properties.pageID) } - }) + .environment(\.journalGraphPresentationReady, presentationReady) .frame(width: bounds.size.width, height: bounds.size.height) .offset(x: translation) .allowsHitTesting(handoff.request == nil) From 9f447693aae0b8a835823eb29bf14fb619bee325 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 30 Oct 2026 00:30:52 +0800 Subject: [PATCH 3/4] Keep resident graph hosts mounted and finish retired resource cleanup --- app/application.ml | 302 +++++++----- app/journal_header.ml | 21 +- app/journal_header.mli | 9 +- app/journal_lui_native.ml | 6 +- app/journal_lui_native.mli | 1 + app/journal_media_view.ml | 51 ++- app/journal_media_view.mli | 4 + app/journal_timeline.ml | 30 +- app/journal_timeline.mli | 1 + .../JournalListSnapshotTests.swift | 69 +-- docs/agent-guide/001-fixed-timeline-hosts.md | 81 ++++ logseq_sync/lib/pure_reducer/core.ml | 51 ++- swift/JournalChrome.swift | 430 ++++++++++-------- swift/JournalList.swift | 2 +- test/application_view_test.ml | 15 +- 15 files changed, 694 insertions(+), 379 deletions(-) create mode 100644 docs/agent-guide/001-fixed-timeline-hosts.md diff --git a/app/application.ml b/app/application.ml index 6e10a0f..a165849 100644 --- a/app/application.ml +++ b/app/application.ml @@ -3158,6 +3158,8 @@ let start let processing_owner : Graph_service.timeline_scope option ref = ref None in let visible_owner : Graph_service.timeline_scope option ref = ref None in let preview_owner = ref None in + let landed_owner = ref None in + let presentation_enabled = ref true in let pending_graph_pushes = Hashtbl.create 3 in let graph_cells : ((unit -> unit -> unit) * (unit -> unit)) list ref = ref [] in let graph_ref make = @@ -3424,7 +3426,12 @@ let start let timeline_store_cell = graph_ref (fun () -> Journal_timeline.Store.create ~observe:on_view_region ()) in let timeline_store_generation = graph_ref (fun () -> initial_state.graph_state.generation) in + let presentation_owner () = match !landed_owner with + | Some key->Some key | None->Option.map owner_key !visible_owner in let prepare_presentation state = + let owner=model_key state in + let active=(owner=presentation_owner () && !presentation_enabled) || (owner<>None && owner= !preview_owner) in + Journal_timeline.Store.set_active (!timeline_store_cell) active; if !timeline_store_generation <> state.graph_state.generation then ( timeline_store_generation := state.graph_state.generation; @@ -3474,6 +3481,10 @@ let start if media_key state <> context then state else ( + let owner=Option.map owner_key (current_owner ()) in + let active = (owner=presentation_owner () && !presentation_enabled) + || (owner<>None && owner= !preview_owner) in + Journal_media_view.Store.set_active (!media_store_cell) active; List.iter (fun (root, (view : Journal_media_runtime.view)) -> let empty = view.items = [] && view.error = None && not view.more in @@ -5457,17 +5468,19 @@ let start (match next_resident_owner snapshot with | None->Effect.ignore | Some owner -> - preview_owner:=Some(owner_key owner); + landed_owner:=None;preview_owner:=Some(owner_key owner); Effect.bind (update(fun state->{state with graph_preview=Some(Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id)})) ~f:(fun ()->Effect.of_thunk(fun ()->run_owned (Some owner) (fun ()->Effect.of_thunk(fun ()-> ignore(dispatch_worker Graph_service.Get_graph_state : Worker.send_result)))))) | Graph_swipe_commit -> (match next_resident_owner snapshot with | Some owner when snapshot.graph_preview=Some(Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id) -> - send_manager(Graph_service.Focus_resident_graph owner.graph_id) + landed_owner:=Some(owner_key owner); + Effect.bind (update(fun state->{state with graph_preview_revision=state.graph_preview_revision+1})) + ~f:(fun ()->send_manager(Graph_service.Focus_resident_graph owner.graph_id)) | _->update(fun state->{state with graph_preview=None})) | Graph_swipe_cancel -> - preview_owner:=None;update(fun state->{state with graph_preview=None}) + landed_owner:=None;preview_owner:=None;update(fun state->{state with graph_preview=None}) | Cancel_resident_replacement -> update (fun state->{state with resident_replacement=None}) | Remove_resident value -> let ids=Option.fold ~none:[] ~some:(fun (m:Graph_service.snapshot)-> @@ -5986,25 +5999,6 @@ let start send (Native_payload payload)) } in - let timeline_scroll_completed = - Ui.Event.Handler.create ~name:"timeline-scroll-completed" (fun payload -> - let generation = !state_ref.graph_state.generation in - Effect.run - (match V.Native_list.completion_of_payload payload with - | None -> Effect.ignore - | Some completion -> - set_state (fun state -> - if state.graph_state.generation <> generation - then state - else ( - let timeline = - Journal_timeline_state.complete_scroll - state.timeline - ~token:completion.token - ~outcome:completion.outcome - in - if timeline == state.timeline then state else { state with timeline })))) - in (* Every [Edge.on_change] of the bonsai version becomes a post-update key comparison: the key is recomputed from the new model and the callback fires when it differs from the previous post-update value. *) @@ -6106,7 +6100,7 @@ let start | Some saved -> saved | None -> step_root initial_state (Graph_focused {generation=owner.graph_generation;graph_id=owner.graph_id}) in let manager=match saved.manager,root.manager with - | Some saved,Some shared -> Some {saved with catalog=shared.catalog;resident_graphs=shared.resident_graphs; + | Some saved,Some shared -> Some {saved with catalog=shared.catalog;resident_graphs=shared.resident_graphs;resident_scopes=shared.resident_scopes; startup={saved.startup with authenticated=shared.startup.authenticated;account_generation=shared.startup.account_generation}} | None,Some shared -> Some {shared with selected_graph=Some owner.graph_id; startup={shared.startup with graph_generation=owner.graph_generation;presentation_generation=owner.startup.presentation_generation}} @@ -6121,7 +6115,9 @@ let start Option.iter activate_runtime (timeline_scope model); let previous= !processing_owner in processing_owner:=timeline_scope model; - if visible then visible_owner:=timeline_scope model; + if visible then ( + visible_owner:=timeline_scope model; + if model.graph_preview=None then preview_owner:=None); let model=track_capture_session model in state_ref:=model; Option.iter (fun calendar->Journal_graph_runtime.set_calendar (!graph_runtime_cell) calendar) model.calendar; @@ -6287,17 +6283,14 @@ let start && left.capture_error_owner = right.capture_error_owner | _ -> left == right in - let equal_shell_presentation left right = - left.graph_preview=right.graph_preview && left.graph_preview_revision=right.graph_preview_revision - && graph_switch_enabled left=graph_switch_enabled right - && next_resident_owner left=next_resident_owner right - && Option.map (fun (m:Graph_service.snapshot)->m.resident_scopes) left.manager - = Option.map (fun (m:Graph_service.snapshot)->m.resident_scopes) right.manager - && left.graph_state.generation = right.graph_state.generation - && left.graph_state.graph_id = right.graph_state.graph_id - && left.environment.platform = right.environment.platform + let equal_host_shell left right = + left.graph_state.generation=right.graph_state.generation + && left.graph_state.graph_id=right.graph_state.graph_id + && Option.map (fun (manager:Graph_service.snapshot)->manager.startup.account_generation) left.manager + = Option.map (fun (manager:Graph_service.snapshot)->manager.startup.account_generation) right.manager + && left.environment.platform=right.environment.platform in - let body_view model_signal header_signal state dispatch timeline_scroll_completed = + let body_view ~graph_host ~timeline_store ~media_store ~set_state model_signal header_signal state dispatch timeline_scroll_completed = let region name ~equal build = V.of_lui (Lui_elements.stack @@ -6542,16 +6535,14 @@ let start in let root = region "root" ~equal:equal_root_presentation (fun state -> - match Journal_startup.Presentation.screen state.startup_presentation with - | _ when state.graph_picker_open -> manager_page ~picker_signal:model_signal state dispatch - | Waiting | Interaction _ | Account_failure -> manager_page ~picker_signal:model_signal state dispatch - | Timeline -> - render_graph_timeline ~interactive:true ~header_signal:(Some header_signal) - ~store:(!timeline_store_cell) ~media_store:(!media_store_cell) ~dispatch state - |> fun content -> V.of_lui (Lui_elements.stack ~grow:1. - ~on_appear:(fun _ -> Option.iter (fun scope -> + if not graph_host then manager_page ~picker_signal:model_signal state dispatch else + render_graph_timeline ~interactive:true ~header_signal:(Some header_signal) + ~store:timeline_store ~media_store ~dispatch state + |> fun content -> V.of_lui (Lui_elements.stack ~grow:1. + ~on_appear:(fun _ -> Option.iter (fun scope -> + if model_key !state_ref=Some(owner_key scope) then Effect.run (send_manager (Graph_service.Acknowledge_timeline_presented scope))) (timeline_scope state)) - [Ui.mount content])) + [Ui.mount content])) in let capture_adapter = region "capture-import" ~equal:equal_capture_presentation (fun current -> @@ -6679,7 +6670,7 @@ let start in Journal_view.mount (detail_page - ~media_store:(!media_store_cell) + ~media_store ~on_region:on_view_region ~state:focused ~on_scroll_completed:completion @@ -6856,73 +6847,170 @@ let start ; settings ] in - let preview=match state.graph_preview,next_resident_owner state with - | Some id,Some owner when id=Logseq_db_types.Graph_types.Uuid.to_string owner.graph_id -> - let restore=snapshot_cells () and previous= !active_runtime in - activate_runtime owner; - let target=graph_model state owner in - ignore(Journal_timeline.Store.synchronize (!timeline_store_cell) target.timeline : int); - let inert={send=(fun _->());native=Ui.Event.Handler.create(fun _->())} in - (* Native GraphPages blocks hit testing and accessibility for this pane; - inert dispatch prevents actions. Keep permission-derived row visuals. *) - let content=render_graph_timeline ~interactive:true ~header_signal:None - ~store:(!timeline_store_cell) ~media_store:(!media_store_cell) ~dispatch:inert target - |> V.Body.Private.to_widget in - save_runtime ();List.iter (fun restore->restore ()) restore;active_runtime:=previous;content - | _->V.empty () in - let can_switch=graph_switch_enabled state in - let resident_page_ids = Option.fold ~none:[] ~some:(fun (manager:Graph_service.snapshot)-> - List.map (fun (scope:Graph_service.asset_scope)->owner_key - {startup={account_generation=scope.account.account_generation;presentation_generation=scope.account.presentation_generation}; - graph_id=scope.graph_id;graph_generation=scope.graph_generation}) manager.resident_scopes) state.manager in - let body=Journal_header.graph_pages ~enabled:can_switch - ~resident_page_ids ~target_page_id:(Option.map owner_key (next_resident_owner state)) - ~page_id:(Option.value (model_key state) ~default:"startup") - ~target_id:state.graph_preview ~on_prepare:(fun ()->dispatch.send Graph_swipe_prepare) - ~on_commit:(fun ()->dispatch.send Graph_swipe_commit) ~on_cancel:(fun ()->dispatch.send Graph_swipe_cancel) - ~current:body ~preview in V.Body.theme ~data:(application_theme ()) (V.Body.static body) in + let show_graphs state = + not state.graph_picker_open && + match Journal_startup.Presentation.screen state.startup_presentation with + | Timeline -> true + | Waiting | Interaction _ | Account_failure -> false + in + let live_owners state = match state.manager with + | None -> [] + | Some manager when manager.resident_graphs=[] -> Option.to_list (timeline_scope state) + | Some manager -> List.filter_map (fun id-> + List.find_opt (fun (scope:Graph_service.asset_scope)->scope.graph_id=id + && scope.account.account_generation=manager.startup.account_generation) manager.resident_scopes + |> Option.map timeline_owner_of_scope) manager.resident_graphs + in + let header state = + { Journal_header.sync_phase=Option.map (fun (m:Graph_service.snapshot)->m.sync_phase) state.manager + ; sync_error=Option.map (fun notice->sync_failure_message notice.failure) state.sync_error + ; error_available=error_info_available state + ; local_deletion_available=local_deletion_available state + ; capture_enabled=state.write_enabled && state.pending_delete=None && state.pending_status=None && not(capture_saving state) } + in let view context model_signal _send = - let header_signal = - Signal.map - (fun state -> - { Journal_header.sync_phase = - Option.map - (fun (manager : Graph_service.snapshot) -> manager.sync_phase) - state.manager - ; sync_error = - Option.map - (fun notice -> sync_failure_message notice.failure) - state.sync_error - ; error_available = - error_info_available state - ; local_deletion_available = local_deletion_available state - ; capture_enabled = - state.write_enabled - && Option.is_none state.pending_delete - && Option.is_none state.pending_status - && not (capture_saving state) - }) - model_signal + let materialized=ref [] in + let owners=Signal.state context.Lui_ui.ui_scheduler [] in + let payload=Signal.state context.ui_scheduler "{}" in + let refreshers=Hashtbl.create 3 in + let root ()=Signal.sample model_signal in + let host_model state owner = + let model=graph_model state owner in + {model with graph_picker_open=false;resident_replacement=None;graph_preview=None;graph_preview_revision=0} in + let eligible state owner = + show_graphs state && (presentation_owner ()=Some(owner_key owner) + || (!preview_owner=Some(owner_key owner) && state.graph_preview<>None)) in + let with_runtime owner build = + let restore=snapshot_cells () and previous= !active_runtime in + activate_runtime owner; + Fun.protect ~finally:(fun ()->save_runtime ();List.iter (fun restore->restore ()) restore;active_runtime:=previous) build in + let refresh state = + if state.graph_preview=None then (preview_owner:=None;landed_owner:=None); + presentation_enabled:=show_graphs state; + let live=live_owners state in + let selected=model_key state in + let wanted owner = List.mem (owner_key owner) !materialized + || (selected=Some(owner_key owner) && show_graphs state) + || (!preview_owner=Some(owner_key owner) && state.graph_preview<>None) in + let next=List.filter wanted live in + materialized:=List.map owner_key next; + Signal.set owners next; + List.iter (fun owner -> + match Hashtbl.find_opt refreshers (owner_key owner) with + | None->() + | Some refresh->refresh state) next; + let json=Yojson.Safe.to_string (`Assoc + ["mode",`String "graph-pages";"visible",`Bool(show_graphs state) + ;"enabled",`Bool(graph_switch_enabled state) + ;"pageID",(match selected with Some id->`String id | None->`Null) + ;"targetID",(match state.graph_preview with Some id->`String id | None->`Null) + ;"targetPageID",(match next_resident_owner state with Some owner->`String(owner_key owner) | None->`Null) + ;"residentPageIDs",`List(List.map(fun owner->`String(owner_key owner))live) + ;"slotOwners",`List(List.map(fun owner->`String(owner_key owner))next)]) in + if Signal.sample (Signal.value payload)<>json then Signal.set payload json in - Signal.on_dispose context.Lui_ui.ui_scope (fun () -> - Signal.dispose_signal header_signal); - (* Dynamic elements mount under a parent, so the root must be a static - container. *) - Lui_elements.column - [ Lui_elements.dyn - ~equal:equal_shell_presentation - (fun model -> - Journal_view.mount - (body_view - model_signal - header_signal - model - dispatch - timeline_scroll_completed)) - model_signal - ] + let host owner_source host_context parent = + let owner=Signal.sample owner_source in + let key=owner_key owner in + let initial=host_model (root ()) owner in + let timeline_store,media_store=with_runtime owner (fun ()-> !timeline_store_cell,!media_store_cell) in + let presentation_state=Signal.state host_context.Lui_ui.ui_scheduler initial in + let source=Signal.value presentation_state in + let presentation_header=Signal.map header source in + let hide_presenters state = + {state with modal=No_modal;asset_settings_open=false;capture_picker_armed=false} in + let update state = + let active=eligible state owner in + let focused=model_key state=Some key && presentation_owner ()=Some key && show_graphs state in + Journal_media_view.Store.set_interactive media_store focused; + Journal_media_view.Store.set_active media_store active; + Journal_timeline.Store.set_active timeline_store active; + if active then ( + let next=host_model state owner in + let next=if focused then next else hide_presenters next in + let timeline_structure_revision=Journal_timeline.Store.synchronize timeline_store next.timeline in + Signal.set presentation_state {next with timeline_structure_revision}) + else ( + let frozen=Signal.sample source in + if frozen.modal<>No_modal || frozen.asset_settings_open || frozen.capture_picker_armed then + Signal.set presentation_state (hide_presenters frozen)) in + (* A host owns every callback even while another graph is focused. *) + let latest_owner () = + let state=root () in + if model_key state=Some key then timeline_scope state + else List.find_opt (fun candidate->owner_key candidate=key) (live_owners state) in + let owned_update transition ()=Option.iter (fun owner->run_owned (Some owner) + (fun ()->set_state transition)) (latest_owner ()) in + let send action = + let current=root () in + let focused=model_key current=Some key && presentation_owner ()=Some key && show_graphs current in + let permitted = focused || match action with + | Capture_asset _ -> true + | Media (_,event) -> (not event.Journal_media_view.visible) + || (event.action<>Journal_media_view.Preview && eligible current owner) + | Native_payload (Ui.Event.Payload.Int64_pair _ | Visible_range _) -> eligible current owner + | Native_payload (Ui.Event.Payload.Bool false) -> true + | _->false in + if permitted then Option.iter (fun scope-> + if runtime_owner_is_live current scope then run_owned (Some scope) (fun ()->handle_dispatch action)) (latest_owner ()) in + let owned_dispatch={send;native=Ui.Event.Handler.create(fun value->send(Native_payload value))} in + let scroll=Ui.Event.Handler.create(fun value-> + match V.Native_list.completion_of_payload value with + | None->() | Some completion -> owned_update (fun state-> + let timeline=Journal_timeline_state.complete_scroll state.timeline ~token:completion.token ~outcome:completion.outcome in + if timeline==state.timeline then state else {state with timeline}) ()) in + Hashtbl.replace refreshers key update; + update (root ()); + Signal.on_dispose host_context.ui_scope (fun ()-> + Hashtbl.remove refreshers key; + Journal_media_view.Store.set_interactive media_store false; + Journal_media_view.Store.set_active media_store false; + Journal_timeline.Store.set_active timeline_store false; + Signal.dispose_signal presentation_header; + Signal.dispose_signal source); + let body=Lui_elements.dyn ~equal:equal_host_shell + (fun current->Journal_view.mount(body_view ~graph_host:true ~timeline_store ~media_store ~set_state:owned_update + source presentation_header current owned_dispatch scroll)) source in + Journal_view.mount (Journal_header.timeline_host ~owner:key (V.of_lui body)) host_context parent + in + refresh (root ()); + let unsubscribe=Signal.subscribe model_signal refresh in + Signal.on_dispose context.ui_scope (fun ()-> + Signal.dispose_subscription unsubscribe;Hashtbl.clear refreshers; + Signal.dispose_signal (Signal.value owners);Signal.dispose_signal (Signal.value payload)); + let graph_event raw = + try + let open Yojson.Safe.Util in + let event=Yojson.Safe.from_string raw and state=root () in + let action=member "action" event |> to_string + and source=member "source" event |> to_string in + if model_key state=Some source then ( + if action="presented" then Option.iter (fun scope-> + Effect.run(send_manager(Graph_service.Acknowledge_timeline_presented scope))) (timeline_scope state) + else let target=member "target" event |> to_string in + match action with + | "cancel" when !preview_owner=Some target -> dispatch.send Graph_swipe_cancel + | "prepare" when graph_switch_enabled state + && Option.map owner_key (next_resident_owner state)=Some target -> dispatch.send Graph_swipe_prepare + | "commit" when !preview_owner=Some target + && Option.map owner_key (next_resident_owner state)=Some target -> + dispatch.send (if graph_switch_enabled state then Graph_swipe_commit else Graph_swipe_cancel) + | _->()) + with Yojson.Json_error _ | Yojson.Safe.Util.Type_error _ -> () in + let pages=Journal_header.graph_pages ~payload_signal:(Signal.value payload) ~on_event:graph_event + ~children:[Lui_elements.keyed ~source:(Signal.value owners) ~key:owner_key ~cmp:String.compare ~mount:host] in + let global_header=Signal.map header model_signal in + Signal.on_dispose context.ui_scope (fun ()->Signal.dispose_signal global_header); + let overlay=Lui_elements.dyn ~equal:(fun left right-> + show_graphs left=show_graphs right && (show_graphs left || equal_host_shell left right)) + (fun current -> if show_graphs current then Lui_elements.stack [] + else Journal_view.mount(body_view ~graph_host:false ~timeline_store:(!timeline_store_cell) + ~media_store:(!media_store_cell) ~set_state model_signal global_header current dispatch + (Ui.Event.Handler.create(fun _->())))) model_signal in + Lui_elements.stack ~key:"journal-persistent-pages" + [Journal_view.mount pages;overlay] in let backend = { Lui_protocol.backend_profile = Lui_native_bridge.profile platform_code host_code diff --git a/app/journal_header.ml b/app/journal_header.ml index a7341e2..6e28fed 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -541,15 +541,16 @@ let reactive_view ~body ;; -let graph_pages ~enabled ~page_id ~resident_page_ids ~target_page_id ~target_id ~on_prepare ~on_commit ~on_cancel ~current ~preview = +let graph_pages ~payload_signal ~on_event ~children = + V.of_lui (Journal_lui_native.mount ~key:"journal-graph-pages" + ~payload:(Signal.sample payload_signal) ~payload_signal ~children + ~on_event:(fun event->on_event event.Journal_lui_native.payload) + Journal_lui_native.chrome_identifier) +;; + +let timeline_host ~owner content = let payload=Yojson.Safe.to_string (`Assoc - ["mode",`String "graph-pages";"enabled",`Bool enabled;"pageID",`String page_id; - "targetID",(match target_id with None->`Null | Some id->`String id); - "residentPageIDs",`List(List.map(fun id->`String id) resident_page_ids); - "targetPageID",(match target_page_id with None->`Null | Some id->`String id)]) in - V.of_lui (Journal_lui_native.mount ~key:"journal-graph-pages" ~payload - ~on_event:(fun event->match event.Journal_lui_native.payload with - | "prepare"->on_prepare () | "commit"->on_commit () | "cancel"->on_cancel () | _->()) - ~children:[Lui_elements.stack ~grow:1. [Ui.mount current]; - Lui_elements.stack ~grow:1. [Ui.mount preview]] Journal_lui_native.chrome_identifier) + ["mode",`String "timeline-host";"ownerID",`String owner]) in + V.of_lui (Journal_lui_native.mount ~key:("timeline-host:" ^ owner) + ~payload ~children:[Ui.mount content] Journal_lui_native.chrome_identifier) ;; diff --git a/app/journal_header.mli b/app/journal_header.mli index 488d35b..83bade1 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -91,7 +91,8 @@ val graph_picker -> Journal_view.View.Body.t -> Journal_view.View.Body.t -val graph_pages : enabled:bool -> page_id:string -> resident_page_ids:string list - -> target_page_id:string option -> target_id:string option - -> on_prepare:(unit -> unit) -> on_commit:(unit -> unit) -> on_cancel:(unit -> unit) - -> current:Journal_view.View.t -> preview:Journal_view.View.t -> Journal_view.View.t +(** A bounded keyed collection of persistent graph presentation hosts. *) +val graph_pages : payload_signal:string Signal.signal -> on_event:(string -> unit) + -> children:Lui_elements.t list -> Journal_view.View.t + +val timeline_host : owner:string -> Journal_view.View.t -> Journal_view.View.t diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index 22ea913..e2e9d7c 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -103,10 +103,14 @@ let decode_event = function | _ -> None ;; -let mount ?key ~payload ~children ?on_event identifier context parent = +let mount ?key ~payload ?payload_signal ~children ?on_event identifier context parent = let node = Lui_ui.extension context identifier in Option.iter (Lui_ui.key context node) key; Lui_ui.extension_property context node "payload" (StringValue payload); + Option.iter (fun source -> + let values=Signal.map (fun value->StringValue value) source in + Signal.on_dispose context.Lui_ui.ui_scope (fun ()->Signal.dispose_signal values); + Lui_ui.extension_property_signal context node "payload" values) payload_signal; Option.iter (fun handler -> Lui_ui.on_event context node (fun raw -> diff --git a/app/journal_lui_native.mli b/app/journal_lui_native.mli index 7f8f795..b63eb08 100644 --- a/app/journal_lui_native.mli +++ b/app/journal_lui_native.mli @@ -35,6 +35,7 @@ type event = val mount : ?key:string -> payload:string + -> ?payload_signal:string Signal.signal -> children:Lui_elements.t list -> ?on_event:(event -> unit) -> string diff --git a/app/journal_media_view.ml b/app/journal_media_view.ml index ce0c39f..bc9bd80 100644 --- a/app/journal_media_view.ml +++ b/app/journal_media_view.ml @@ -19,6 +19,7 @@ module Store = struct { epoch : int ; mutable active : bool ; callback : unit -> unit + ; mutable pending : string option } type t = @@ -27,6 +28,9 @@ module Store = struct ; items : (string * string, (int, listener) Hashtbl.t) Hashtbl.t ; observe : string -> unit ; mutable epoch : int + ; mutable active : bool + ; mutable interactive : bool + ; interactions : (int, unit -> unit) Hashtbl.t ; mutable serial : int } @@ -36,6 +40,9 @@ module Store = struct ; items = Hashtbl.create 64 ; observe ; epoch = 0 + ; active = true + ; interactive = true + ; interactions = Hashtbl.create 8 ; serial = 0 } ;; @@ -58,7 +65,7 @@ module Store = struct in t.serial <- t.serial + 1; let id = t.serial in - let listener = { epoch = t.epoch; active = true; callback } in + let listener = { epoch = t.epoch; active = true; callback; pending=None } in Hashtbl.add bucket id listener; fun () -> if listener.active @@ -77,14 +84,43 @@ module Store = struct | Some bucket -> let listeners = Hashtbl.fold (fun _ listener acc -> listener :: acc) bucket [] in List.iter - (fun listener -> + (fun (listener:listener) -> if listener.active && listener.epoch = t.epoch then ( - t.observe name; - listener.callback ())) + if t.active then (t.observe name;listener.callback ()) + else listener.pending <- Some name)) listeners ;; + let set_active t active = + if t.active <> active then ( + t.active <- active; + if active then ( + let collect table = Hashtbl.fold (fun _ bucket rest-> + Hashtbl.fold (fun _ listener rest->listener::rest) bucket rest) table [] in + let pending=collect t.structure @ collect t.items in + List.iter (fun listener->match listener.pending with + | Some name when listener.active && listener.epoch=t.epoch && t.active -> + listener.pending<-None;t.observe name;listener.callback () + | _ when not listener.active || listener.epoch<>t.epoch ->listener.pending<-None + | _->()) pending)) + ;; + + let interactive t = t.interactive + + let subscribe_interaction t callback = + t.serial<-t.serial+1;let id=t.serial in + Hashtbl.add t.interactions id callback; + fun ()->Hashtbl.remove t.interactions id + ;; + + let set_interactive t interactive = + if t.interactive<>interactive then ( + t.interactive<-interactive; + let listeners=Hashtbl.to_seq t.interactions |> List.of_seq in + List.iter(fun(id,callback)->if Hashtbl.mem t.interactions id then callback ()) listeners) + ;; + let shape = function | None -> [], false, None | Some (view : Runtime.view) -> @@ -249,7 +285,7 @@ let view_content in let selected_slot = root ^ ":" ^ item.token in let selected = owner, item.token, path, selected_slot in - if unchanged selected + if Store.interactive store && unchanged selected then ( let group = if is_image_type item.file_type @@ -301,7 +337,10 @@ let view_content unsubscribe_preview := fun () -> List.iter (fun unsubscribe -> unsubscribe ()) subscriptions) in - Signal.on_dispose context.ui_scope close_preview; + let stop_interaction=Store.subscribe_interaction store (fun ()-> + if not(Store.interactive store) && (!selected_preview<>[] || Signal.sample(Signal.value preview)<>None) + then close_preview ()) in + Signal.on_dispose context.ui_scope (fun ()->stop_interaction ();close_preview ()); let reactive_item (item : Journal_media_runtime.item) render = fun context parent -> let owner = asset_root item.token in diff --git a/app/journal_media_view.mli b/app/journal_media_view.mli index 9d280c4..aa80342 100644 --- a/app/journal_media_view.mli +++ b/app/journal_media_view.mli @@ -10,6 +10,10 @@ module Store : sig val find : t -> string -> Journal_media_runtime.view option val update : t -> root:string -> Journal_media_runtime.view option -> unit + (** Data keeps advancing while hidden; each dirty subscription catches up once. *) + val set_active : t -> bool -> unit + val set_interactive : t -> bool -> unit + (* Reset presentation and fence subscribers from the previous graph/session. *) val reset : t -> unit end diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml index 2ebc885..c7a4d66 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -33,6 +33,7 @@ module Store = struct { epoch : int ; mutable active : bool ; callback : unit -> unit + ; mutable pending : bool } type shape = @@ -49,6 +50,7 @@ module Store = struct ; mutable shape : (int * shape list) option ; mutable revision : int ; mutable epoch : int + ; mutable active : bool ; mutable serial : int } @@ -62,6 +64,7 @@ module Store = struct ; shape = None ; revision = 0 ; epoch = 0 + ; active = true ; serial = 0 } ;; @@ -73,7 +76,7 @@ module Store = struct let subscribe_scroll t callback = t.serial <- t.serial + 1; let id = t.serial in - let listener = { epoch = t.epoch; active = true; callback } in + let listener = { epoch = t.epoch; active = true; callback; pending=false } in Hashtbl.add t.scroll_listeners id listener; fun () -> listener.active <- false; @@ -84,8 +87,9 @@ module Store = struct if t.scroll_target <> target then ( t.scroll_target <- target; let listeners = Hashtbl.fold (fun _ value rest -> value :: rest) t.scroll_listeners [] in - List.iter (fun listener -> - if listener.active && listener.epoch = t.epoch then listener.callback ()) listeners) + List.iter (fun (listener:listener) -> + if listener.active && listener.epoch = t.epoch then + if t.active then listener.callback () else listener.pending<-true) listeners) ;; let subscribe t block_id callback = @@ -99,7 +103,7 @@ module Store = struct in t.serial <- t.serial + 1; let id = t.serial in - let listener = { epoch = t.epoch; active = true; callback } in + let listener = { epoch = t.epoch; active = true; callback; pending=false } in Hashtbl.add bucket id listener; fun () -> if listener.active @@ -114,15 +118,27 @@ module Store = struct (fun bucket -> let listeners = Hashtbl.fold (fun _ value rest -> value :: rest) bucket [] in List.iter - (fun listener -> + (fun (listener:listener) -> if listener.active && listener.epoch = t.epoch then ( - t.observe "timeline-item-notify"; - listener.callback ())) + if t.active then (t.observe "timeline-item-notify";listener.callback ()) + else listener.pending<-true)) listeners) (Hashtbl.find_opt t.listeners block_id) ;; + let set_active t active = + if t.active<>active then ( + t.active<-active; + if active then ( + let rows=Hashtbl.fold(fun _ bucket rest-> + Hashtbl.fold(fun _ listener rest->listener::rest) bucket rest) t.listeners [] in + let pending=Hashtbl.fold(fun _ listener rest->listener::rest) t.scroll_listeners rows in + List.iter(fun (listener:listener)-> + if listener.pending && listener.active && listener.epoch=t.epoch && t.active then ( + listener.pending<-false;listener.callback ())) pending)) + ;; + (* Only a changed timeline presentation reaches this traversal. Media, navigation, draft and visible-demand writes do not scan retained rows. *) let synchronize t state = diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli index 8b828de..b7cdabc 100644 --- a/app/journal_timeline.mli +++ b/app/journal_timeline.mli @@ -5,6 +5,7 @@ module Store : sig val create : ?observe:(string -> unit) -> unit -> t val synchronize : t -> Journal_timeline_state.t -> int + val set_active : t -> bool -> unit (* Return the native collection's structural presentation revision. Only actual timeline changes reconcile retained slots; item publications go diff --git a/apple-tests/native-list/JournalListSnapshotTests.swift b/apple-tests/native-list/JournalListSnapshotTests.swift index 0191ba5..4968851 100644 --- a/apple-tests/native-list/JournalListSnapshotTests.swift +++ b/apple-tests/native-list/JournalListSnapshotTests.swift @@ -49,55 +49,36 @@ import UIKit let changedFocusGesture = handoff.startGesture() handoff.focusChanged("B") check(!handoff.acceptsGesture(changedFocusGesture), "external focus change before cover rejects old animation completion") - let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } - let firstHandoff = handoff.begin(source: "A", target: "B", image: image)! - check(!handoff.finish(firstHandoff, page: "A", current: "B"), "source list cannot uncover target") - check(!handoff.finish(firstHandoff, page: "B", current: "A"), "target ready cannot uncover wrong focused page") - check(handoff.finish(firstHandoff, page: "B", current: "B"), "matching native layout uncovers target") - let second = handoff.begin(source: "B", target: "A", image: image)! - _ = handoff.begin(source: "A", target: "B", image: image)! - check(!handoff.finish(firstHandoff, page: "B", current: "B"), "repeated page ID rejects old ready nonce") - handoff.expire(second) - check(handoff.request != nil, "old timeout cannot cancel a later handoff") - let waiting = handoff.request!.id - handoff.expire(waiting) - check(handoff.request?.id == waiting, "a layout timeout must not expose source while focus is still queued") - handoff.focusChanged("B") - handoff.expire(waiting) - check(handoff.request == nil, "missing target layout fails open after focus and clears input lock") - check(handoff.begin(source: "A", target: "B", image: nil) == nil, "failed snapshot cannot enter waiting phase") - _ = handoff.begin(source: "A", target: "B", image: image) - handoff.focusChanged("C") - check(handoff.request == nil, "unexpected focus fences the old cover") - _ = handoff.begin(source: "A", target: "B", image: image) - handoff.focusChanged("B") + // Retained pages no longer have snapshot/layout deadlines. Preserve the + // existing owner/nonce/cancel checks against the new focus-only handoff. + let firstHandoff = handoff.begin(source: "A", target: "B") + check(!handoff.finish(firstHandoff, page: "A", current: "B"), "source cannot finish target focus") + check(!handoff.finish(firstHandoff, page: "B", current: "A"), "target cannot finish while Save still queues focus") + check(handoff.finish(firstHandoff, page: "B", current: "B"), "matching focus completes the retained page transition") + let second = handoff.begin(source: "B", target: "A") + let currentHandoff = handoff.begin(source: "A", target: "B") + check(!handoff.finish(firstHandoff, page: "B", current: "B"), "repeated page ID rejects old completion nonce") + check(!handoff.finish(second, page: "A", current: "A"), "old focus cannot complete a later transition") + check(handoff.request?.id == currentHandoff, "stale callbacks leave current target intact") handoff.focusChanged("A") - check(handoff.request == nil, "leaving an observed target cancels its cover even on return to source") - _ = handoff.begin(source: "A", target: "B", image: image) + check(handoff.request?.id == currentHandoff, "source remains selected while Save queues focus") + try await Task.sleep(for: .milliseconds(2100)) + check(handoff.request?.id == currentHandoff, "queued focus is not expired by a presentation timeout") + check(handoff.finish(currentHandoff, page: "B", current: "B"), "queued focus completes after Save publication") + _ = handoff.begin(source: "A", target: "B") + handoff.focusChanged("C") + check(handoff.request == nil, "external selection fences the pending transition") + _ = handoff.begin(source: "A", target: "B") handoff.retain(["A", "B", "C"]) check(handoff.request != nil, "unchanged resident set retains pending focus") handoff.retain(["A", "C"]) - check(handoff.request == nil, "closing pending target cancels its cover") - _ = handoff.begin(source: "A", target: "B", image: image) + check(handoff.request == nil, "closing pending target cancels its transition") + _ = handoff.begin(source: "A", target: "B") handoff.cancel() - check(handoff.request == nil, "window or scene cancellation clears handoff") - _ = handoff.begin(source: "A", target: "B", image: image) - try await Task.sleep(for: .milliseconds(2100)) - check(handoff.request != nil, "queued focus is not a missing native layout") - handoff.focusChanged("B") - for _ in 0..<80 { - if handoff.request == nil { break } - try await Task.sleep(for: .milliseconds(50)) - } - check(handoff.request == nil, "missing target List layout actually expires after focus") - let anchor = JournalGraphHandoff.Anchor() - anchor.owner = handoff - handoff.anchor = anchor - anchor.bounds = CGRect(x: 0, y: 0, width: 2, height: 2) - _ = handoff.begin(source: "A", target: "B", image: image) - anchor.layoutSubviews() - for _ in 0..<10 { await Task.yield() } - check(handoff.request == nil, "size invalidation cancels a cover even before focus commits") + check(handoff.request == nil, "scene or size cancellation clears pending transition") + _ = handoff.begin(source: "A", target: "B") + handoff.retain([]) + check(handoff.request == nil, "account exit clears all pending page owners") #endif let offsets = JournalGraphScrollOffsets() offsets.retain(["account1:A:g1", "account1:B:g2"]) diff --git a/docs/agent-guide/001-fixed-timeline-hosts.md b/docs/agent-guide/001-fixed-timeline-hosts.md new file mode 100644 index 0000000..ac16245 --- /dev/null +++ b/docs/agent-guide/001-fixed-timeline-hosts.md @@ -0,0 +1,81 @@ +# Fixed Resident Timeline Hosts Implementation Plan + +Goal: Keep each materialized graph's Timeline and native navigation instance at one stable owner-keyed location throughout a right-swipe transition. + +Architecture: A bounded keyed collection retains up to three homogeneous graph page hosts. +Each host owns its presentation signal, row/media stores and native navigation state; account, authentication, worker runtimes and platform services remain shared. +Hidden hosts freeze presentation notifications while graph synchronization continues. + +Tech Stack: OCaml, Signal/LUI, SwiftUI native List and navigation, UIKit pan recognition, XCTest, Time Profiler. + +Related: Builds on proposed/architecture/2026-10-28-resident-graphs.md and the external handoff-performance-boundary.md evidence. + +## Problem statement + +A loaded target List is currently adopted from a bare preview into a different navigation subtree at focus. +Its virtual node ID survives, but its SwiftUI ancestor identity changes and triggers repeated mount/restore and a measured240–273ms display callback gap. +The existing image-color fix and idle-ready callback fix are preserved in e37ba13f58a4f06471632b93c00137c7a3d798e2. + +## Testing Plan + +The application mounting boundary owns the retained tree, graph dispatch and presentation subscriptions; the pure navigation reducer cannot reproduce native ancestor changes. +Reuse the existing public-service application fixture and native acceptance cases for resident ownership, hidden synchronization, drafts, saving, removal/reopen and gesture behavior. +Adapt obsolete preview-tree and handoff-cover expectations to the fixed-host contract, without adding test cases or tests for test/CI infrastructure. +The attempted additional product regression write was rejected by automatic review under the user's latest testing constraint; it did not execute and is not retried. Existing native run82 supplies the observed repeated mount/restore and ancestry failure before implementation. +Retain the existing color and native gesture checks; verify fixed-host lifecycle with temporary external diagnostics and actual GUI evidence. +Run full source build and regression on final production bytes, independent review and actual synthetic Simulator acceptance. +Compare the same optimized fixture, image data, native gestures and time windows against profiles79/80 and lightweight run82; count patch operations, mount/restore callbacks and display gaps, without adding animation duration or a handoff cover. + +Validation uses the existing checks and preserved pre-change failure evidence; no new test cases are added. + +## Implementation steps + +1. Preserve the three validated local changes as a local baseline commit and save their patch externally. +2. Adapt existing application-view expectations and unscoped picker selectors to the retained-host structure; run the existing 155-case suite. +3. Run the existing media semantics and native lifecycle checks, preserving pre-change run82 as the native structural failure baseline. +4. In app/application.ml, mount one keyed collection of full-owner presentation hosts outside the changing focus model. +Create a host only for the focused graph or an explicitly prepared target; retain it until that exact runtime retires. +Each host gets an owner-qualified dispatch, a private presentation Signal, fixed runtime-store references and its own header Signal. +Freeze host inputs while hidden, resume the latest graph state on prepare/focus, and keep callback lifecycle checks independent of the current graph. +5. Keep each graph's existing native navigation revision and owner fence inside the homogeneous host. +The first implementation retains native navigation UI per graph to avoid weakening the existing path/owner fence; it does not clone platform/auth/sync services. +Mount the shared picker/startup overlay outside the hosts, so opening it does not dispose a retained Timeline. +6. In app/journal_header.ml and its application interface, publish a reactive graph-pages payload and owner-keyed host slots without rebuilding host children on focus or preview revision. +7. Gate Journal_media_view.Store notifications while a graph's presentation is hidden; coalesce dirty subscribers and catch up once on activation. +Retain real data updates and resource cleanup. +8. In swift/JournalChrome.swift, render fixed owner-keyed native panes at stable ForEach positions. +Change translation, input and accessibility roles only; keep image enabled appearance, right-only direction admission, cancel and fast-gesture fences. +Remove snapshot-based handoff work from the transition path. +Keep per-pane scroll scope immutable, with only its records role changing. +9. Adapt native lifecycle checks to the retained-host contract, run GREEN, then simplify only after the affected regressions pass. +10. Build the final experimental optimized Simulator fixture with the maintained private toolchain and exact source manifest. +Run real cached/delayed/mixed-height image cases, saved scroll positions, drafts, pending Save, hidden synchronization, management/removal/reopen, navigation return, cancellation and repeated gestures. +Run final full checks, independent review and performance comparison; save and upload only synthetic original-speed videos below10MB each. + +## Testing Details + +Wire assertions verify actual mounted ancestry and public service effects, not helper data formats. +Native tests and buffered diagnostic runs verify actual UI lifecycle and input, which the OCaml mounting boundary cannot execute. +Existing tests are reused for drafts, saving, graph management and lifecycle contracts. +The synthetic Simulator is52A4C83E-57D2-4676-A164-BF80E91BBF67; the other two devices and real graphs remain untouched. +All generated hosts, logs, artifacts and reports stay in evidence/fixed-timeline-hosts or the existing image evidence folder. + +## Implementation Details + +- Host identity is account generation, graph UUID and graph generation; presentation tickets remain current without changing host identity. +- At most three native page/navigation hosts exist, with lazy initial presentation construction. +- Hidden DB/sync remains live; row, media and header presentation subscriptions freeze. +- Late callbacks retain their original owner and cannot route to the visible graph. +- Cancellation, external selection, graph removal, account exit and scene changes fence stale animations. +- No new visible Timeline UI, left-swipe switching, graph eviction or persistent credentials. +- No protected spec contract or Dune change is planned. +- Final source evidence must supersede earlier green source fingerprints. +- Performance claims distinguish optimized Simulator fixture evidence from shipping-device guarantees. + +## Question + +No user decision remains for the approved host structure. +The Planning Documents helper and spec-dev-tool executable were unavailable at planning time; naming follows this skill's numbered implementation-plan convention and the plan will be checked with repository tooling if it becomes available. +Publishing a PR or a new TestFlight remains outside this stage's authorization. + +--- diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index af710d1..2718d06 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -4287,7 +4287,13 @@ let restore_visible core id = let run_session core (identity : graph_scope) event = let core=remember_current core in match List.find_opt (fun s -> s.saved_identity=identity) core.resident_sessions with - | None -> unchanged core + | None -> + (match event with + | Asset_requested request -> + {next=core;effects=[asset_output request (Error Asset_cancelled)]} + | Protected_requested request -> + {next=core;effects=[protected_output request (Error(Effect_failed "Protected-value request expired"))]} + | _->unchanged core) | Some session -> let visible=visible_identity core in let shared=core.public_state.snapshot in @@ -4306,6 +4312,9 @@ let run_session core (identity : graph_scope) event = let updated=if fresh=identity then updated else {updated with resident_sessions=List.filter(fun session->session.saved_identity<>identity) updated.resident_sessions} in let next=remember_session fresh updated |> fun core -> restore_visible core visible in + (* A cleanup-only lens must not replace the account's picker or startup + authority when no graph is visible. Its tickets still advance above. *) + let next=if visible=None then {next with public_state=core.public_state} else next in {next;effects=if visible=Some fresh.graph_id then transition.effects else scoped_publications fresh transition.effects} let empty_session core = @@ -4375,11 +4384,27 @@ let resume_resident_network core = next,effects @ (if original=Some session.saved_identity.graph_id then resumed.effects else scoped_publications session.saved_identity resumed.effects)) (remember_current core,[]) sessions in {next=restore_visible next original;effects} +let cleanup_session core session = + let empty=freeze_session session.saved_identity (initial core.config |> Result.get_ok) in + {empty with saved_asset_known_scopes=session.saved_asset_known_scopes; + saved_asset_retirement_observed=session.saved_asset_retirement_observed; + saved_assets_pending=session.saved_assets_pending; + saved_protected_pending=session.saved_protected_pending} let prune_sessions core = - {core with resident_sessions=List.filter (fun s -> - List.mem s.saved_identity.graph_id core.resident_order || s.saved_current_graph_scope<>None - || s.saved_websocket_owner<>None || s.saved_pending_effects<>[] - || s.saved_assets_pending<>[] || s.saved_protected_pending<>[]) core.resident_sessions} + {core with resident_sessions=List.filter_map (fun session -> + if List.mem session.saved_identity.graph_id core.resident_order + || session.saved_current_graph_scope<>None || session.saved_websocket_owner<>None + || session.saved_pending_effects<>[] || session.saved_assets_pending<>[] + || session.saved_protected_pending<>[] + then Some session + else if session.saved_asset_known_scopes=[] + || session.saved_identity.account.account_generation<>core.public_state.snapshot.startup.account_generation + then None + else ( + (* Retained native pages may release their admitted file leases after + the graph has closed. Keep only cleanup ownership and retirement + fences; the closed graph's snapshot, key and network state are gone. *) + Some(cleanup_session core session))) core.resident_sessions} let step core event = match event with | Resident_graphs_changed ids -> configure_residents core ids @@ -4405,11 +4430,19 @@ let step core event = | _ -> {transition with next}) | Local_graph_outbox_changed scope -> run_session core scope Local_outbox_changed | Account_authenticated {user_id} when user_id=None || user_id<>core.user_id -> - let retired=List.filter (fun s -> List.mem s.saved_identity.graph_id core.resident_order) (remember_current core).resident_sessions in - let closes=List.concat_map (fun s -> (step_graph_policy (restore_session core s) Graph_picker_requested).effects - |> scoped_publications s.saved_identity) retired in + let retired=(remember_current core).resident_sessions in + let closed,closes=List.fold_left(fun(sessions,effects) session -> + if not(List.mem session.saved_identity.graph_id core.resident_order) then session::sessions,effects + else let part=step_graph_policy (restore_session core session) Graph_picker_requested in + freeze_session session.saved_identity part.next::sessions, + effects @ scoped_publications session.saved_identity part.effects) ([],[]) retired in let signed_out=step_graph_policy core event in - {next={signed_out.next with resident_order=[];resident_sessions=[]; + (* Account cleanup owns the old physical files. Already admitted requests + must still receive their completion while their native owners retire. *) + let cleanup=List.filter_map(fun session -> + if session.saved_assets_pending=[] && session.saved_protected_pending=[] then None + else Some(cleanup_session signed_out.next session)) closed in + {next={signed_out.next with resident_order=[];resident_sessions=cleanup; public_state={signed_out.next.public_state with snapshot={signed_out.next.public_state.snapshot with resident_graphs=[]}}}; effects=closes @ signed_out.effects} | Online_cancelled scope when startup_scope_is_current core scope -> diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index e3b1cf5..0c6a93c 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -5,101 +5,30 @@ import UIKit #endif #if os(iOS) -/// Bridges one completed swipe until the replacement List has restored layout. -/// The cover contains only this window's pixels and never leaves memory. +/// Fences one gesture and a committed focus request by its original owners. +/// The target page stays mounted while Save may defer the focus publication. @MainActor final class JournalGraphHandoff: ObservableObject { struct Request { let id: UUID; let source: String; let target: String } @Published private(set) var request: Request? - weak var anchor: Anchor? - private var deadline: Task? private var gestureID: UUID? - private var reachedTarget = false - - func startGesture() -> UUID { cancel(); let id = UUID(); gestureID = id; return id } + func startGesture() -> UUID { cancel(); let id=UUID();gestureID=id;return id } func acceptsGesture(_ id: UUID) -> Bool { gestureID == id } var currentGesture: UUID? { gestureID } - - final class Anchor: UIImageView { - weak var owner: JournalGraphHandoff? - override func didMoveToWindow() { - super.didMoveToWindow() - if window == nil { owner?.cancel() } - } - override func layoutSubviews() { - super.layoutSubviews() - if let image, abs(image.size.width - bounds.width) > 0.5 || abs(image.size.height - bounds.height) > 0.5, - let request = owner?.request { - Task { @MainActor [weak owner] in owner?.invalidate(request.id) } - } - } - } - - func capture() -> UIImage? { - guard let anchor, let window = anchor.window, anchor.bounds.width > 0, - anchor.bounds.height > 0 else { return nil } - let frame = anchor.convert(anchor.bounds, to: window) - var drawn = false - let image = UIGraphicsImageRenderer(size: anchor.bounds.size).image { _ in - drawn = window.drawHierarchy(in: CGRect(origin: CGPoint(x: -frame.minX, y: -frame.minY), - size: window.bounds.size), afterScreenUpdates: false) - } - return drawn ? image : nil - } - - @discardableResult func begin(source: String, target: String, image: UIImage?) -> UUID? { - cancel() - guard let image else { return nil } - let id = UUID() - anchor?.image = image - reachedTarget = false - request = Request(id: id, source: source, target: target) - return id + @discardableResult func begin(source: String, target: String) -> UUID { + cancel();let id=UUID();request=Request(id:id,source:source,target:target);return id } func finish(_ id: UUID, page: String, current: String?) -> Bool { - guard let request, request.id == id, request.target == page, current == page else { return false } - cancel() - return true + guard let request,request.id==id,request.target==page,current==page else {return false} + cancel();return true } func focusChanged(_ page: String?) { - guard let request else { cancel(); return } - if page == request.target { - guard !reachedTarget else { return } - reachedTarget = true - let id = request.id - // Focus can be queued behind a Save. Until it commits, removing the - // landed target would reveal the source again. Only target layout has - // a deadline; its loading/error UI is safe to reveal after focus. - deadline = Task { @MainActor [weak self] in - do { try await Task.sleep(for: .seconds(2)) } catch { return } - self?.expire(id) - } - } else if reachedTarget || page != request.source { cancel() } - } - func expire(_ id: UUID) { if request?.id == id && reachedTarget { cancel() } } - func invalidate(_ id: UUID) { if request?.id == id { cancel() } } - func retain(_ pages: [String]) { - if let request, !pages.contains(request.source) || !pages.contains(request.target) { cancel() } + guard let request else {cancel();return} + if page != request.source {cancel()} } - func cancel() { - deadline?.cancel(); deadline = nil - gestureID = nil; reachedTarget = false - anchor?.image = nil - request = nil + func retain(_ pages:[String]) { + if let request,!pages.contains(request.source) || !pages.contains(request.target) {cancel()} } -} - -private struct JournalGraphHandoffAccess: UIViewRepresentable { - let owner: JournalGraphHandoff - func makeUIView(context: Context) -> JournalGraphHandoff.Anchor { - let view = JournalGraphHandoff.Anchor() - view.isUserInteractionEnabled = false - view.accessibilityElementsHidden = true - view.contentMode = .scaleToFill - view.owner = owner - owner.anchor = view - return view - } - func updateUIView(_ view: JournalGraphHandoff.Anchor, context: Context) {} + func cancel(){gestureID=nil;request=nil} } #endif @@ -125,6 +54,7 @@ struct JournalSheetDismissButton: SwiftUI.View { private enum Mode: String, Decodable { case page, header, detail, unlock, startup case graphPages = "graph-pages" + case timelineHost = "timeline-host" case graphPicker = "graph-picker" case toolbarControl = "toolbar-control" case detailText = "detail-text" @@ -153,6 +83,9 @@ struct JournalSheetDismissButton: SwiftUI.View { let targetID: String? let targetPageID: String? let residentPageIDs: [String]? + let slotOwners: [String]? + let ownerID: String? + let visible: Bool? } /// Syntax and reference resolution belong to OCaml; this maps presentation data. @@ -195,132 +128,246 @@ struct JournalSheetDismissButton: SwiftUI.View { } } private struct GraphPages: SwiftUI.View { - let context: LUIAppleExtensionViewContext - let properties: Properties - let current: AnyView - let preview: AnyView - @State private var translation: CGFloat = 0 - @State private var dragging = false - @State private var settling = false - @StateObject private var handoff = JournalGraphHandoff() + let context:LUIAppleExtensionViewContext + let properties:Properties + @State private var translation:CGFloat=0 + @State private var dragging=false + @State private var settling=false + @State private var committing=false + @State private var source:String? + @State private var target:String? + @StateObject private var handoff=JournalGraphHandoff() @Environment(\.scenePhase) private var scenePhase @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets @Environment(\.accessibilityReduceMotion) private var reduceMotion - private func emit(_ action: String) { - JournalExtensions.emit(context: context, payload: Data(action.utf8)) + /// A native containment boundary keeps each retained NavigationStack's + /// UIKit accessibility tree and hit testing isolated without disabled tint. + private struct Pane:UIViewControllerRepresentable { + let context:LUIAppleExtensionViewContext + let owner:String + let node:Int + let records:Bool + let shown:Bool + let interactive:Bool + + struct Style:Equatable { + let scene:ScenePhase + let scheme:ColorScheme + let contrast:ColorSchemeContrast + let scale:CGFloat + let size:DynamicTypeSize + let direction:LayoutDirection + let reduceMotion:Bool + let locale:Locale + let timeZone:TimeZone + let inset:CGFloat? + let frame:CGRect? + init(_ environment:EnvironmentValues) { + scene=environment.scenePhase;scheme=environment.colorScheme + contrast=environment.colorSchemeContrast;scale=environment.displayScale + size=environment.dynamicTypeSize + direction=environment.layoutDirection;reduceMotion=environment.accessibilityReduceMotion + locale=environment.locale;timeZone=environment.timeZone + inset=environment.journalContainerTopInset + frame=environment.journalStartupContainerFrame + } + nonisolated static func ==(a:Self,b:Self)->Bool { + a.scene==b.scene && a.scheme==b.scheme && a.contrast==b.contrast && a.scale==b.scale + && a.size==b.size && a.direction==b.direction + && a.reduceMotion==b.reduceMotion && a.locale==b.locale && a.timeZone==b.timeZone + && a.inset==b.inset && a.frame==b.frame + } + } + final class Controller:UIViewController { + let host:UIHostingController + var records=false + var interactive=false + var node = -1 + var style:Style? + init(rootView:AnyView) { + host=UIHostingController(rootView:rootView) + super.init(nibName:nil,bundle:nil) + } + required init?(coder:NSCoder) {fatalError("init(coder:) is not supported")} + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.isOpaque=false + view.isAccessibilityElement=false + addChild(host) + host.view.backgroundColor = .clear + host.view.isOpaque=false + host.view.translatesAutoresizingMaskIntoConstraints=false + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo:view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo:view.trailingAnchor), + host.view.topAnchor.constraint(equalTo:view.topAnchor), + host.view.bottomAnchor.constraint(equalTo:view.bottomAnchor)]) + host.didMove(toParent:self) + } + func setRole(shown:Bool,interactive:Bool) { + // The child is not the view managed by SwiftUI's representable, so + // parent visibility updates cannot overwrite this UIKit boundary. + host.view.isHidden = !shown + view.accessibilityElements = interactive ? nil : [] + view.accessibilityElementsHidden = !interactive + view.isUserInteractionEnabled=interactive + host.view.accessibilityElementsHidden = !interactive + host.view.isUserInteractionEnabled=interactive + } + func detach() { + host.willMove(toParent:nil) + host.view.removeFromSuperview() + host.removeFromParent() + } + } + private func content(_ environment:EnvironmentValues)->AnyView { + AnyView(context.content(for:node) + .environment(\.colorScheme,environment.colorScheme) + .environment(\.dynamicTypeSize,environment.dynamicTypeSize) + .environment(\.layoutDirection,environment.layoutDirection) + .environment(\.locale,environment.locale) + .environment(\.timeZone,environment.timeZone) + .environment(\.displayScale,environment.displayScale) + .environment(\.scenePhase,environment.scenePhase) + .environment(\.journalContainerTopInset,environment.journalContainerTopInset) + .environment(\.journalStartupContainerFrame,environment.journalStartupContainerFrame) + .environment(\.journalStartupClock,environment.journalStartupClock) + .environment(\.journalGraphScrollOffsets,environment.journalGraphScrollOffsets) + .environment(\.journalGraphScrollScope,JournalGraphScrollScope(pageID:owner,records:records)) + .accessibilityHidden(!interactive)) + } + func makeUIViewController(context:Context)->Controller { + let controller=Controller(rootView:content(context.environment)) + controller.records=records;controller.interactive=interactive;controller.node=node;controller.style=Style(context.environment) + controller.setRole(shown:shown,interactive:interactive) + return controller + } + func updateUIViewController(_ controller:Controller,context:Context) { + let style=Style(context.environment) + if controller.node != node || controller.records != records || controller.interactive != interactive || controller.style != style { + controller.node=node;controller.records=records;controller.interactive=interactive;controller.style=style + withTransaction(Transaction(animation:nil)) {controller.host.rootView=content(context.environment)} + } + controller.setRole(shown:shown,interactive:interactive) + } + static func dismantleUIViewController(_ controller:Controller,coordinator:()) { + controller.view.accessibilityElementsHidden=true + controller.view.isUserInteractionEnabled=false + controller.detach() + } + } + private var slots:[(owner:String,node:Int)] { + let owners=properties.slotOwners ?? [] + return owners.enumerated().compactMap { index,owner in + index width * 0.3 || (distance > 30 && velocity > 650) - settling = true - // Focus dispatch synchronously rebuilds the native page. Wait until the - // spring is removed so that work cannot interrupt its visible tail. - withAnimation(reduceMotion ? .linear(duration: 0.01) : .smooth(duration: 0.24), completionCriteria: .removed) { - translation = commit ? width : 0 + guard properties.enabled==true,let gesture=handoff.currentGesture,source==properties.pageID, + target==properties.targetPageID else {reset(notify:true);return} + dragging=false + let distance=pan.translation(in:pan.view).x,velocity=pan.velocity(in:pan.view).x + let commit=distance>width*0.3 || (distance>30 && velocity>650) + settling=true;committing=commit + withAnimation(reduceMotion ? .linear(duration:0.01) : .smooth(duration:0.24),completionCriteria:.removed) { + translation=commit ? width : 0 } completion: { - guard handoff.acceptsGesture(gesture) else { return } - if commit, let source = properties.pageID, let target = properties.targetPageID { - handoff.begin(source: source, target: target, image: handoff.capture()) - } - emit(commit ? "commit" : "cancel") - if !commit { settling = false } + guard properties.enabled==true,handoff.acceptsGesture(gesture),let source,let target, + source==properties.pageID,target==properties.targetPageID else {return} + if commit { + _=handoff.begin(source:source,target:target) + emit("commit",source:source,target:target) + } else {reset(notify:true)} } - case .cancelled, .failed: - guard dragging else { return } - handoff.cancel() - dragging = false - withAnimation(.smooth(duration: 0.2)) { translation = 0 } - emit("cancel") - default: break + case .cancelled,.failed:reset(notify:true) + default:break } } - private var presentationNonce: UUID? { - guard let request = handoff.request, request.target == properties.pageID else { return nil } - return request.id - } - private var presentationReady: (@MainActor @Sendable (String) -> Void)? { - guard let id = presentationNonce else { return nil } - return { page in _ = handoff.finish(id, page: page, current: properties.pageID) } - } - var body: some SwiftUI.View { + var body:some SwiftUI.View { GeometryReader { bounds in - ZStack(alignment: .topLeading) { - preview - .environment(\.journalGraphScrollScope, properties.targetPageID.map { JournalGraphScrollScope(pageID: $0, records: false) }) - .frame(width: bounds.size.width, height: bounds.size.height) - .offset(x: translation - bounds.size.width) - .allowsHitTesting(false) - .accessibilityHidden(true) - current - .environment(\.journalGraphScrollScope, properties.pageID.map { JournalGraphScrollScope(pageID: $0, records: true) }) - .environment(\.journalGraphPresentationNonce, presentationNonce) - .environment(\.journalGraphPresentationReady, presentationReady) - .frame(width: bounds.size.width, height: bounds.size.height) - .offset(x: translation) - .allowsHitTesting(handoff.request == nil) - .accessibilityHidden(handoff.request != nil) + ZStack(alignment:.topLeading) { + ForEach(slots,id:\.owner) { slot in + let current=slot.owner==properties.pageID + let ownsMotion=source==properties.pageID + let incoming=ownsMotion && slot.owner==target && (dragging || settling) + let shown=properties.visible==true && (current || incoming) + Pane(context:context,owner:slot.owner,node:slot.node, + records:current && properties.visible==true && !(settling && committing), + shown:shown, + interactive:current && properties.visible==true && !(settling && committing)) + .frame(width:bounds.size.width,height:bounds.size.height) + .offset(x:current ? (ownsMotion ? translation : 0) : incoming ? translation-bounds.size.width : -bounds.size.width) + .opacity(shown ? 1 : 0) + .zIndex(current ? 1 : incoming ? 0 : -1) + } } - .overlay(JournalGraphHandoffAccess(owner: handoff) - .frame(width: bounds.size.width, height: bounds.size.height) - .allowsHitTesting(false).accessibilityHidden(true)) .clipped() - .gesture(RightGraphPan(enabled: properties.enabled == true && !settling) { - handle($0, width: bounds.size.width) - }) + .gesture(RightGraphPan(enabled:properties.enabled==true && !settling) {handle($0,width:bounds.size.width)}) + .onChange(of:bounds.size) { _,_ in reset(notify:true) } } - .onAppear { graphScrollOffsets?.retain(properties.residentPageIDs ?? []) } - .onChange(of: properties.residentPageIDs) { _, ids in + // Each native hosting root owns the same window-sized canvas as the + // original app root; its own UIKit safe area is applied exactly once. + .ignoresSafeArea(.container) + .onAppear {graphScrollOffsets?.retain(properties.residentPageIDs ?? []);acknowledge()} + .onChange(of:properties.slotOwners) { _,_ in acknowledge() } + .onChange(of:properties.residentPageIDs) { _,ids in graphScrollOffsets?.retain(ids ?? []) - handoff.retain(ids ?? []) + if let source,let target,!(ids ?? []).contains(source) || !(ids ?? []).contains(target) {reset(notify:false)} } - .onChange(of: properties.pageID) { _, page in - handoff.focusChanged(page) - withTransaction(Transaction(animation: nil)) { - translation = 0; dragging = false - if handoff.request == nil { settling = false } - } + .onChange(of:properties.pageID) { _,page in + if let request=handoff.request,page==request.target { + _=handoff.finish(request.id,page:request.target,current:page) + } else {handoff.focusChanged(page)} + reset(notify:false);acknowledge() } - .onChange(of: properties.targetID) { _, target in - if target == nil && !dragging && handoff.request == nil { translation = 0; settling = false } + .onChange(of:properties.targetPageID) { _,next in + if let target,target != next {reset(notify:true)} } - .onChange(of: handoff.request?.id) { _, id in - if id == nil { - withTransaction(Transaction(animation: nil)) { translation = 0; settling = false } - } + .onChange(of:properties.enabled) { _,enabled in + if enabled != true && handoff.request == nil {reset(notify:true)} } - .onChange(of: scenePhase) { _, phase in - if phase != .active { - let wasSwitching = dragging || settling - handoff.cancel() - translation = 0; dragging = false; settling = false - if wasSwitching { emit("cancel") } - } + .onChange(of:properties.targetID) { _,prepared in + if prepared == nil && source==properties.pageID && (dragging || settling) {reset(notify:false)} } - .onDisappear { handoff.cancel() } - .onChange(of: properties.enabled) { _, enabled in - if enabled != true && dragging { - handoff.cancel() - dragging = false - withAnimation(.smooth(duration: 0.2)) { translation = 0 } - emit("cancel") - } + .onChange(of:properties.visible) { _,visible in + if visible != true {reset(notify:true)} else {acknowledge()} } + .onChange(of:scenePhase) { _,phase in if phase != .active {reset(notify:true)} } + .onDisappear {reset(notify:false)} } } + #endif private struct TitleRun: Decodable { @@ -506,13 +553,24 @@ struct JournalSheetDismissButton: SwiftUI.View { let properties = self.properties switch properties?.mode { case .graphPages: - if let properties, context.childIDs.count == 2 { + if let properties { #if os(iOS) - GraphPages(context: context, properties: properties, current: child(0), preview: child(1)) + GraphPages(context:context,properties:properties) #else - child(0) + ZStack { + ForEach(Array((properties.slotOwners ?? []).enumerated()),id:\.element) { index,owner in + child(index) + .environment(\.journalGraphScrollScope,JournalGraphScrollScope(pageID:owner, + records:owner==properties.pageID && properties.visible==true)) + .opacity(owner==properties.pageID && properties.visible==true ? 1 : 0) + .allowsHitTesting(owner==properties.pageID && properties.visible==true) + .accessibilityHidden(owner != properties.pageID || properties.visible != true) + } + } #endif } + case .timelineHost: + if context.childIDs.count==1 {child(0)} case .page: if let properties, context.childIDs.count == 3, properties.connecting != nil, properties.controls != nil { diff --git a/swift/JournalList.swift b/swift/JournalList.swift index d61d5f7..92a0952 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -5,7 +5,7 @@ import Observation import UIKit #endif -struct JournalGraphScrollScope { +struct JournalGraphScrollScope:Equatable { let pageID: String let records: Bool } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index d897acd..24c3d98 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -6535,6 +6535,13 @@ let test_graph_picker_controls scenario () = if List.assoc_opt key values = Some (`String value) then Some id else found) props None in let node name = match find "accessibility-identifier" name with | Some id -> id | None -> failwith ("Missing " ^ name) in + let picker_list () = + let marker=node "graph-picker-list" in + let rec belongs id = id=marker || match Hashtbl.find_opt page_parents id with + | None->false | Some parent->belongs parent in + Hashtbl.to_seq props |> Seq.find_map(fun(id,fields)-> + if List.assoc_opt "_extension" fields=Some(`String "journal-list") && belongs id then Some id else None) + |> Option.get in let press id = consume (hooks.dispatch (Lui_protocol.Press id)) in let wait label predicate = let deadline = Unix.gettimeofday () +. 3. in @@ -6580,16 +6587,16 @@ let test_graph_picker_controls scenario () = | Some (`String payload) when String.contains payload 'g' -> (try if Yojson.Safe.Util.(Yojson.Safe.from_string payload |> member "mode" |> to_string)="graph-pages" then Some id else None with _->None) | _->None) |> Option.get in - Alcotest.(check int) "native page retains both content and empty preview slots" 2 + Alcotest.(check int) "native page retains its materialized graph while picker covers it" 1 (Hashtbl.to_seq_values page_parents |> Seq.filter((=)page) |> Seq.length); wait "graph list mounts" (fun () -> Option.is_some (find "accessibility-identifier" "graph-picker-list")); if scenario = `Current then ( - let list = Option.get (find "_extension" "journal-list") in + let list = picker_list () in consume (hooks.extension_event list "event" {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":3,\"last\":7}"}|}); wait "visible resident local presence checks complete" (fun () -> Atomic.get inspections >= 4)); if scenario = `Late_presence then ( - let list = Option.get (find "_extension" "journal-list") in + let list = picker_list () in consume (hooks.extension_event list "event" {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":4,\"last\":5}"}|}); wait "automatic inspection is held in flight" (fun () -> Atomic.get inspections = 1)); @@ -6622,7 +6629,7 @@ let test_graph_picker_controls scenario () = (Option.is_some (find "text" long_name)) | `Visible -> let before = !root_renders in - let list = Option.get (find "_extension" "journal-list") in + let list = picker_list () in let range () = consume (hooks.extension_event list "event" {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":0,\"last\":9223372036854775807}"}|}) in range (); From 5db35c0f00b29ea27bfeb3738b3fe472b248cd7e Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 30 Oct 2026 02:08:36 +0800 Subject: [PATCH 4/4] Keep cold graph preparation under native page motion --- app/application.ml | 60 +++-- docs/agent-guide/001-fixed-timeline-hosts.md | 8 +- swift/JournalChrome.swift | 266 +++++++++++++++++-- swift/JournalList.swift | 35 ++- 4 files changed, 317 insertions(+), 52 deletions(-) diff --git a/app/application.ml b/app/application.ml index a165849..775aa62 100644 --- a/app/application.ml +++ b/app/application.ml @@ -3295,19 +3295,18 @@ let start | _->()); result in - let pending_focus_refresh = ref None in + let pending_scope_refreshes = Hashtbl.create 3 in let try_focus_refresh owner = match dispatch_worker (Graph_service.Get_graph_state_at owner) with - | Worker.Accepted _ | Stopping -> pending_focus_refresh:=None - | Full | Not_ready -> pending_focus_refresh:=Some owner + | Worker.Accepted _ | Stopping -> Hashtbl.remove pending_scope_refreshes (owner_key owner) + | Full | Not_ready -> Hashtbl.replace pending_scope_refreshes (owner_key owner) owner in let drain_focus_refresh () = Effect.of_thunk (fun () -> - match !pending_focus_refresh with - | Some owner when runtime_owner_is_live !state_ref owner - && model_key !state_ref=Some(owner_key owner) -> try_focus_refresh owner - | Some _ -> pending_focus_refresh:=None - | None -> ()) + Hashtbl.to_seq_values pending_scope_refreshes |> List.of_seq + |> List.iter (fun owner -> + if runtime_owner_is_live !state_ref owner then try_focus_refresh owner + else Hashtbl.remove pending_scope_refreshes (owner_key owner))) in let cancel_copy () = let state, _ = Journal_copy.step !copy_state Cancel in @@ -3838,9 +3837,9 @@ let start then cancel_copy (); let start_graph = match graph_state.phase with - | Graph_open when Option.is_some !processing_owner - && Option.map owner_key !processing_owner <> Option.map owner_key !visible_owner - && Option.map owner_key !processing_owner <> !preview_owner -> Effect.ignore + (* Prepare each resident's first feed once, without mounting its UI. + A first swipe can then display its own data even while another graph + has a pending save. Later hidden invalidations remain deferred. *) | Graph_open -> let graph_key = Option.map Logseq_db_types.Graph_types.Uuid.to_string graph_state.graph_id @@ -3966,6 +3965,12 @@ let start let termination_in_flight = ref false in let apply_manager_transition set_state set_state_and_effect manager_state = let manager = manager_state.Graph_service.snapshot in + let previous_scopes = match !state_ref.manager with + | None -> [] + | Some previous -> List.map (fun scope -> owner_key (timeline_owner_of_scope scope)) previous.resident_scopes + in + let admitted_scopes = List.filter (fun scope -> + not (List.mem (owner_key (timeline_owner_of_scope scope)) previous_scopes)) manager.resident_scopes in retire_absent_runtimes { !state_ref with manager=Some manager }; let previous_owner = Option.map @@ -4028,18 +4033,20 @@ let start Effect.bind update ~f:(fun () -> let snapshot = !state_ref in Effect.many - [ (* A new graph's open push can precede the membership publication - and be fenced by the old scope list. Pull its state on focus. *) - (if owner_changed && not graph_already_current then - Effect.of_thunk (fun () -> - Option.iter (fun graph_id -> - let owner : Graph_service.timeline_scope = - {startup={account_generation=manager.startup.account_generation; - presentation_generation=manager.startup.presentation_generation}; - graph_id;graph_generation=manager.startup.graph_generation} in - try_focus_refresh owner) - manager.selected_graph) - else Effect.ignore) + [ (* Open can precede membership and be fenced. Refresh every newly + admitted owner once, including hidden graphs, without mounting UI. *) + Effect.of_thunk (fun () -> + List.iter (fun scope -> try_focus_refresh (timeline_owner_of_scope scope)) admitted_scopes; + if owner_changed && not graph_already_current then + Option.iter (fun graph_id -> + let owner : Graph_service.timeline_scope = + {startup={account_generation=manager.startup.account_generation; + presentation_generation=manager.startup.presentation_generation}; + graph_id;graph_generation=manager.startup.graph_generation} in + if not (List.exists (fun scope -> + owner_key (timeline_owner_of_scope scope)=owner_key owner) admitted_scopes) + then try_focus_refresh owner) + manager.selected_graph) ; sign_out ; termination_ready ; observe_graph_state set_state set_state_and_effect snapshot.graph_state @@ -5719,7 +5726,7 @@ let start | _ -> state, Effect.ignore) | Sign_out -> pending_startup_commands:=[]; - pending_focus_refresh:=None; + Hashtbl.clear pending_scope_refreshes; cancel_copy (); retire_graph_scope (); started_graph_generation := None; @@ -6908,7 +6915,10 @@ let start ;"targetID",(match state.graph_preview with Some id->`String id | None->`Null) ;"targetPageID",(match next_resident_owner state with Some owner->`String(owner_key owner) | None->`Null) ;"residentPageIDs",`List(List.map(fun owner->`String(owner_key owner))live) - ;"slotOwners",`List(List.map(fun owner->`String(owner_key owner))next)]) in + ;"slotOwners",`List(List.map(fun owner->`String(owner_key owner))next) + ;"loadingPageIDs",`List(List.filter_map(fun owner->let model=host_model state owner in + if not model.feed_loaded || Option.is_some model.graph_error + then Some(`String(owner_key owner)) else None)next)]) in if Signal.sample (Signal.value payload)<>json then Signal.set payload json in let host owner_source host_context parent = diff --git a/docs/agent-guide/001-fixed-timeline-hosts.md b/docs/agent-guide/001-fixed-timeline-hosts.md index ac16245..ca53ba9 100644 --- a/docs/agent-guide/001-fixed-timeline-hosts.md +++ b/docs/agent-guide/001-fixed-timeline-hosts.md @@ -24,7 +24,7 @@ Adapt obsolete preview-tree and handoff-cover expectations to the fixed-host con The attempted additional product regression write was rejected by automatic review under the user's latest testing constraint; it did not execute and is not retried. Existing native run82 supplies the observed repeated mount/restore and ancestry failure before implementation. Retain the existing color and native gesture checks; verify fixed-host lifecycle with temporary external diagnostics and actual GUI evidence. Run full source build and regression on final production bytes, independent review and actual synthetic Simulator acceptance. -Compare the same optimized fixture, image data, native gestures and time windows against profiles79/80 and lightweight run82; count patch operations, mount/restore callbacks and display gaps, without adding animation duration or a handoff cover. +Compare the same optimized fixture, image data, native gestures and time windows against profiles79/80 and lightweight run82; count patch operations, mount/restore callbacks and display gaps, without increasing warm-switch animation duration. The user later explicitly approved a native first-open preparation transition; measure that cold path separately from warm switching. Validation uses the existing checks and preserved pre-change failure evidence; no new test cases are added. @@ -45,7 +45,7 @@ Mount the shared picker/startup overlay outside the hosts, so opening it does no Retain real data updates and resource cleanup. 8. In swift/JournalChrome.swift, render fixed owner-keyed native panes at stable ForEach positions. Change translation, input and accessibility roles only; keep image enabled appearance, right-only direction admission, cancel and fast-gesture fences. -Remove snapshot-based handoff work from the transition path. +Remove the old preview-to-navigation handoff. Warm switches have no snapshot or readiness wait; an unopened page may use one transient source-only native snapshot and a bounded reveal after its own current List layout. Keep per-pane scroll scope immutable, with only its records role changing. 9. Adapt native lifecycle checks to the retained-host contract, run GREEN, then simplify only after the affected regressions pass. 10. Build the final experimental optimized Simulator fixture with the maintained private toolchain and exact source manifest. @@ -65,6 +65,10 @@ All generated hosts, logs, artifacts and reports stay in evidence/fixed-timeline - Host identity is account generation, graph UUID and graph generation; presentation tickets remain current without changing host identity. - At most three native page/navigation hosts exist, with lazy initial presentation construction. - Hidden DB/sync remains live; row, media and header presentation subscriptions freeze. +- Read each newly admitted full scope once to recover open pushes that preceded membership. Prepare its first metadata/feed without mounting a hidden page; later hidden invalidations stay deferred. +- An unopened target prepares on committed release, after source-only UIKit motion is submitted. The source snapshot follows the real pan, cancels back without preparation, and is released after the real target List has laid out its current payload. Warm targets retain direct page translation. +- A queued Save can delay asset-file acquisition; render the target's real cached text and dimensioned image placeholders instead of a blank screen. Do not claim those placeholders are loaded images. +- A closed graph keeps only lightweight known-scope cleanup ownership until admitted resource releases finish; stale-account cleanup is pruned. Physical lease-table diagnostics must demonstrate release, not just cancelled callers. - Late callbacks retain their original owner and cannot route to the visible graph. - Cancellation, external selection, graph removal, account exit and scene changes fence stale animations. - No new visible Timeline UI, left-swipe switching, graph eviction or persistent credentials. diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 0c6a93c..13b02c3 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -30,6 +30,164 @@ import UIKit } func cancel(){gestureID=nil;request=nil} } + +/// Cold pages are constructed under a real native transition. Warm pages never +/// allocate a snapshot or wait for this layout handshake. +@MainActor final class JournalGraphColdMotion:ObservableObject { + struct Ticket {let id:UUID;let source:String;let target:String} + @Published private(set) var ticket:Ticket? + @Published private(set) var layoutNonce:UUID? + private final class WeakView {weak var value:UIView?;init(_ value:UIView){self.value=value}} + final class Surface:UIView { + let canvas=UIView() + let image=UIImageView() + override init(frame:CGRect) { + super.init(frame:frame) + backgroundColor = .clear;isUserInteractionEnabled=false;accessibilityElementsHidden=true + canvas.backgroundColor = .systemBackground;canvas.isUserInteractionEnabled=false + canvas.accessibilityElementsHidden=true;canvas.isHidden=true + canvas.frame=bounds;canvas.autoresizingMask=[.flexibleWidth,.flexibleHeight] + image.frame=canvas.bounds;image.autoresizingMask=[.flexibleWidth,.flexibleHeight] + image.contentMode = .scaleToFill + addSubview(canvas);canvas.addSubview(image) + } + required init?(coder:NSCoder){fatalError("init(coder:) is not supported")} + } + private var views:[String:WeakView]=[:] + private weak var surface:Surface? + private var moved=false + private var laidOut=false + private var revealing=false + private var reduced=false + private var fallback:Task? + func attach(_ surface:Surface){self.surface=surface} + func register(_ owner:String,view:UIView){views[owner]=WeakView(view)} + func unregister(_ owner:String,view:UIView) { + if views[owner]?.value === view {views.removeValue(forKey:owner)} + if ticket?.source==owner || ticket?.target==owner {cancel()} + } + func retain(_ owners:[String]) { + views=views.filter{owners.contains($0.key)} + if let ticket,(!owners.contains(ticket.source) || !owners.contains(ticket.target)){cancel()} + } + func begin(source:String,target:String)->Bool { + guard let view=views[source]?.value,view.window != nil,let surface, + view.bounds.width>0,view.bounds.height>0 else{return false} + cancel() + let format=UIGraphicsImageRendererFormat() + format.scale=view.window?.screen.scale ?? 1 + var drawn=false + let image=UIGraphicsImageRenderer(bounds:view.bounds,format:format).image{_ in + drawn=view.drawHierarchy(in:view.bounds,afterScreenUpdates:false) + } + guard drawn else{return false} + ticket=Ticket(id:UUID(),source:source,target:target) + moved=false;laidOut=false;revealing=false + surface.canvas.alpha=1;surface.canvas.isHidden=false + surface.image.image=image;surface.image.transform = .identity + return true + } + func move(_ x:CGFloat) { + guard ticket != nil,let surface else{return} + UIView.performWithoutAnimation{surface.image.transform=CGAffineTransform(translationX:x,y:0)} + } + func commit(width:CGFloat,reduced:Bool,prepare:@escaping()->Void,complete:@escaping()->Void,cancelled:@escaping()->Void) { + guard let ticket,let surface else{return} + self.reduced=reduced + UIView.animate(withDuration:reduced ? 0.01 : 0.24,delay:0, + options:[.curveEaseOut,.allowUserInteraction,.beginFromCurrentState]) { + surface.image.transform=CGAffineTransform(translationX:width,y:0) + } completion:{[weak self] finished in + guard let self,self.ticket?.id==ticket.id else{return} + guard finished else{self.cancel();cancelled();return} + self.moved=true;self.laidOut=false;self.layoutNonce=UUID() + complete();self.revealIfReady() + } + // Commit the source-only native motion before constructing the cold page. + // This is not evidence that preparation itself stopped blocking the CPU. + CATransaction.flush() + DispatchQueue.main.async{[weak self] in + guard self?.ticket?.id==ticket.id else{return};prepare() + } + } + func returnToSource(reduced:Bool,complete:@escaping()->Void) { + guard let ticket,let surface else{complete();return} + UIView.animate(withDuration:reduced ? 0.01 : 0.24,delay:0, + options:[.curveEaseOut,.allowUserInteraction,.beginFromCurrentState]) { + surface.image.transform = .identity + } completion:{[weak self] _ in + guard self?.ticket?.id==ticket.id else{return};self?.cancel();complete() + } + } + func ready(page:String,nonce:UUID) { + guard let ticket,layoutNonce==nonce,ticket.target==page else{return} + laidOut=true;revealIfReady() + } + func focused(_ page:String?) { + guard let ticket,ticket.target==page else{return} + fallback?.cancel() + fallback=Task{@MainActor [weak self] in + try? await Task.sleep(for:.seconds(1)) + guard !Task.isCancelled,self?.ticket?.id==ticket.id else{return} + // Reveal the real focused Loading/error content if it has no List. + self?.laidOut=true;self?.revealIfReady() + } + } + private func revealIfReady() { + guard moved,laidOut,!revealing,let ticket,let surface else{return} + revealing=true;fallback?.cancel();fallback=nil + if reduced {cancel();return} + UIView.animate(withDuration:0.15,delay:0, + options:[.curveEaseIn,.allowUserInteraction,.beginFromCurrentState]) { + surface.canvas.alpha=0 + } completion:{[weak self] _ in + guard self?.ticket?.id==ticket.id else{return};self?.cancel() + } + } + func cancel() { + fallback?.cancel();fallback=nil + surface?.canvas.layer.removeAllAnimations();surface?.image.layer.removeAllAnimations() + if let surface {UIView.performWithoutAnimation{ + surface.canvas.isHidden=true;surface.canvas.alpha=1 + surface.image.image=nil;surface.image.transform = .identity + }} + moved=false;laidOut=false;revealing=false;layoutNonce=nil;ticket=nil + } +} +/// The target's own Loading/error body has no List readiness callback. +/// Report only a fresh post-slide native layout, never mere view creation. +private struct JournalGraphLoadingLayout:UIViewRepresentable { + let nonce:UUID + let ready:()->Void + final class Anchor:UIView { + var nonce:UUID? + var reported:UUID? + var ready:(()->Void)? + override func layoutSubviews() { + super.layoutSubviews() + guard let nonce,reported != nonce,window != nil,bounds.width>0,bounds.height>0 else{return} + reported=nonce + DispatchQueue.main.async{[weak self] in + guard let self,self.nonce==nonce,self.window != nil else{return};self.ready?() + } + } + } + func makeUIView(context:Context)->Anchor { + let view=Anchor();view.isUserInteractionEnabled=false;view.accessibilityElementsHidden=true;return view + } + func updateUIView(_ view:Anchor,context:Context) { + view.nonce=nonce;view.ready=ready;view.setNeedsLayout() + } + static func dismantleUIView(_ view:Anchor,coordinator:()) {view.nonce=nil;view.ready=nil} +} +private struct JournalGraphColdSurface:UIViewRepresentable { + let owner:JournalGraphColdMotion + func makeUIView(context:Context)->JournalGraphColdMotion.Surface { + let view=JournalGraphColdMotion.Surface();owner.attach(view);return view + } + func updateUIView(_ view:JournalGraphColdMotion.Surface,context:Context){} +} + #endif /// The form sheet's native card transform still leaves at least a 44pt target. @@ -84,6 +242,7 @@ struct JournalSheetDismissButton: SwiftUI.View { let targetPageID: String? let residentPageIDs: [String]? let slotOwners: [String]? + let loadingPageIDs: [String]? let ownerID: String? let visible: Bool? } @@ -137,6 +296,7 @@ struct JournalSheetDismissButton: SwiftUI.View { @State private var source:String? @State private var target:String? @StateObject private var handoff=JournalGraphHandoff() + @StateObject private var coldMotion=JournalGraphColdMotion() @Environment(\.scenePhase) private var scenePhase @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -150,6 +310,13 @@ struct JournalSheetDismissButton: SwiftUI.View { let records:Bool let shown:Bool let interactive:Bool + let coldMotion:JournalGraphColdMotion + let coldNonce:UUID? + let loading:Bool + private var presentationReady:(@MainActor @Sendable (String)->Void)? { + guard let coldNonce else{return nil} + return {page in coldMotion.ready(page:page,nonce:coldNonce)} + } struct Style:Equatable { let scene:ScenePhase @@ -179,13 +346,30 @@ struct JournalSheetDismissButton: SwiftUI.View { && a.inset==b.inset && a.frame==b.frame } } + @MainActor final class LoadingGate { + var token=UUID() + var enabled=false + var owner:String? + var nonce:UUID? + weak var motion:JournalGraphColdMotion? + func ready(token:UUID,owner:String,nonce:UUID) { + guard self.token==token,enabled,self.owner==owner,self.nonce==nonce else{return} + motion?.ready(page:owner,nonce:nonce) + } + } final class Controller:UIViewController { let host:UIHostingController + let loadingGate:LoadingGate var records=false var interactive=false var node = -1 var style:Style? - init(rootView:AnyView) { + var coldNonce:UUID? + var loading=false + var owner:String? + weak var coldMotion:JournalGraphColdMotion? + init(rootView:AnyView,loadingGate:LoadingGate) { + self.loadingGate=loadingGate host=UIHostingController(rootView:rootView) super.init(nibName:nil,bundle:nil) } @@ -223,8 +407,9 @@ struct JournalSheetDismissButton: SwiftUI.View { host.removeFromParent() } } - private func content(_ environment:EnvironmentValues)->AnyView { - AnyView(context.content(for:node) + private func content(_ environment:EnvironmentValues,gate:LoadingGate)->AnyView { + let loadingToken=gate.token + return AnyView(context.content(for:node) .environment(\.colorScheme,environment.colorScheme) .environment(\.dynamicTypeSize,environment.dynamicTypeSize) .environment(\.layoutDirection,environment.layoutDirection) @@ -237,25 +422,39 @@ struct JournalSheetDismissButton: SwiftUI.View { .environment(\.journalStartupClock,environment.journalStartupClock) .environment(\.journalGraphScrollOffsets,environment.journalGraphScrollOffsets) .environment(\.journalGraphScrollScope,JournalGraphScrollScope(pageID:owner,records:records)) - .accessibilityHidden(!interactive)) + .environment(\.journalGraphPresentationNonce,coldNonce) + .environment(\.journalGraphPresentationReady,presentationReady) + .accessibilityHidden(!interactive) + .background { + if loading,let coldNonce { + JournalGraphLoadingLayout(nonce:loadingToken) {gate.ready(token:loadingToken,owner:owner,nonce:coldNonce)} + } + }) } func makeUIViewController(context:Context)->Controller { - let controller=Controller(rootView:content(context.environment)) + let gate=LoadingGate();gate.enabled=loading;gate.owner=owner;gate.nonce=coldNonce;gate.motion=coldMotion + let controller=Controller(rootView:content(context.environment,gate:gate),loadingGate:gate) controller.records=records;controller.interactive=interactive;controller.node=node;controller.style=Style(context.environment) + controller.coldNonce=coldNonce;controller.loading=loading;controller.owner=owner;controller.coldMotion=coldMotion controller.setRole(shown:shown,interactive:interactive) + coldMotion.register(owner,view:controller.view) return controller } func updateUIViewController(_ controller:Controller,context:Context) { let style=Style(context.environment) - if controller.node != node || controller.records != records || controller.interactive != interactive || controller.style != style { - controller.node=node;controller.records=records;controller.interactive=interactive;controller.style=style - withTransaction(Transaction(animation:nil)) {controller.host.rootView=content(context.environment)} + if controller.node != node || controller.records != records || controller.interactive != interactive || controller.style != style || controller.coldNonce != coldNonce || controller.loading != loading { + controller.loadingGate.token=UUID();controller.loadingGate.enabled=loading + controller.loadingGate.owner=owner;controller.loadingGate.nonce=coldNonce + controller.node=node;controller.records=records;controller.interactive=interactive;controller.style=style;controller.coldNonce=coldNonce;controller.loading=loading + withTransaction(Transaction(animation:nil)) {controller.host.rootView=content(context.environment,gate:controller.loadingGate)} } controller.setRole(shown:shown,interactive:interactive) } static func dismantleUIViewController(_ controller:Controller,coordinator:()) { + controller.loadingGate.enabled=false;controller.loadingGate.token=UUID();controller.loadingGate.nonce=nil controller.view.accessibilityElementsHidden=true controller.view.isUserInteractionEnabled=false + if let owner=controller.owner {controller.coldMotion?.unregister(owner,view:controller.view)} controller.detach() } } @@ -272,27 +471,32 @@ struct JournalSheetDismissButton: SwiftUI.View { guard let data=try? JSONEncoder().encode(value) else {return} JournalExtensions.emit(context:context,payload:data) } - private func reset(notify:Bool) { + private func reset(notify:Bool,preserveCold:Bool=false) { if notify,source==properties.pageID {emit("cancel",source:source,target:target)} handoff.cancel() + if !preserveCold {coldMotion.cancel()} withTransaction(Transaction(animation:nil)) { translation=0;dragging=false;settling=false;committing=false;source=nil;target=nil } } private func acknowledge() { - if properties.visible==true,slots.contains(where:{$0.owner==properties.pageID}) {emit("presented")} + if properties.visible==true,coldMotion.ticket?.target != properties.pageID, + slots.contains(where:{$0.owner==properties.pageID}) {emit("presented")} } private func handle(_ pan:UIPanGestureRecognizer,width:CGFloat) { switch pan.state { case .began: - guard properties.enabled==true,!settling, + guard properties.enabled==true,!settling,coldMotion.ticket==nil, let from=properties.pageID,let to=properties.targetPageID, from != to,(properties.residentPageIDs ?? []).contains(to) else {return} _=handoff.startGesture();source=from;target=to;dragging=true - emit("prepare",source:from,target:to) + let cold = !slots.contains(where:{$0.owner==to}) && coldMotion.begin(source:from,target:to) + if !cold {emit("prepare",source:from,target:to)} translation=min(width,max(0,pan.translation(in:pan.view).x)) + coldMotion.move(translation) case .changed where dragging: translation=min(width,max(0,pan.translation(in:pan.view).x)) + coldMotion.move(translation) case .ended where dragging: guard properties.enabled==true,let gesture=handoff.currentGesture,source==properties.pageID, target==properties.targetPageID else {reset(notify:true);return} @@ -300,6 +504,22 @@ struct JournalSheetDismissButton: SwiftUI.View { let distance=pan.translation(in:pan.view).x,velocity=pan.velocity(in:pan.view).x let commit=distance>width*0.3 || (distance>30 && velocity>650) settling=true;committing=commit + if coldMotion.ticket != nil { + if commit { + withTransaction(Transaction(animation:nil)){translation=width} + coldMotion.commit(width:width,reduced:reduceMotion, + prepare:{emit("prepare",source:source,target:target)},complete:{ + guard properties.enabled==true,handoff.acceptsGesture(gesture),let source,let target, + source==properties.pageID,target==properties.targetPageID else{return} + _=handoff.begin(source:source,target:target) + emit("commit",source:source,target:target) + },cancelled:{reset(notify:true)}) + } else { + withTransaction(Transaction(animation:nil)){translation=0} + coldMotion.returnToSource(reduced:reduceMotion){reset(notify:true)} + } + return + } withAnimation(reduceMotion ? .linear(duration:0.01) : .smooth(duration:0.24),completionCriteria:.removed) { translation=commit ? width : 0 } completion: { @@ -322,18 +542,23 @@ struct JournalSheetDismissButton: SwiftUI.View { let ownsMotion=source==properties.pageID let incoming=ownsMotion && slot.owner==target && (dragging || settling) let shown=properties.visible==true && (current || incoming) + let covered=coldMotion.ticket?.target==slot.owner + let coldNonce=covered ? coldMotion.layoutNonce : nil Pane(context:context,owner:slot.owner,node:slot.node, - records:current && properties.visible==true && !(settling && committing), + records:current && properties.visible==true && !(settling && committing) && !covered, shown:shown, - interactive:current && properties.visible==true && !(settling && committing)) + interactive:current && properties.visible==true && !(settling && committing) && !covered, + coldMotion:coldMotion,coldNonce:coldNonce,loading:(properties.loadingPageIDs ?? []).contains(slot.owner)) .frame(width:bounds.size.width,height:bounds.size.height) .offset(x:current ? (ownsMotion ? translation : 0) : incoming ? translation-bounds.size.width : -bounds.size.width) .opacity(shown ? 1 : 0) .zIndex(current ? 1 : incoming ? 0 : -1) } } + .background(Color(uiColor:.systemBackground)) .clipped() - .gesture(RightGraphPan(enabled:properties.enabled==true && !settling) {handle($0,width:bounds.size.width)}) + .overlay{JournalGraphColdSurface(owner:coldMotion).allowsHitTesting(false).accessibilityHidden(true)} + .gesture(RightGraphPan(enabled:properties.enabled==true && !settling && coldMotion.ticket==nil) {handle($0,width:bounds.size.width)}) .onChange(of:bounds.size) { _,_ in reset(notify:true) } } // Each native hosting root owns the same window-sized canvas as the @@ -341,21 +566,26 @@ struct JournalSheetDismissButton: SwiftUI.View { .ignoresSafeArea(.container) .onAppear {graphScrollOffsets?.retain(properties.residentPageIDs ?? []);acknowledge()} .onChange(of:properties.slotOwners) { _,_ in acknowledge() } + .onChange(of:coldMotion.ticket?.id) { _,id in if id==nil {acknowledge()} } .onChange(of:properties.residentPageIDs) { _,ids in + coldMotion.retain(ids ?? []) graphScrollOffsets?.retain(ids ?? []) if let source,let target,!(ids ?? []).contains(source) || !(ids ?? []).contains(target) {reset(notify:false)} } .onChange(of:properties.pageID) { _,page in + let keepCold=committing && coldMotion.ticket?.target==page if let request=handoff.request,page==request.target { _=handoff.finish(request.id,page:request.target,current:page) } else {handoff.focusChanged(page)} - reset(notify:false);acknowledge() + reset(notify:false,preserveCold:keepCold) + if keepCold {coldMotion.focused(page)} + acknowledge() } .onChange(of:properties.targetPageID) { _,next in - if let target,target != next {reset(notify:true)} + if source==properties.pageID,let target,target != next {reset(notify:true)} } .onChange(of:properties.enabled) { _,enabled in - if enabled != true && handoff.request == nil {reset(notify:true)} + if enabled != true && handoff.request == nil && coldMotion.ticket?.target != properties.pageID {reset(notify:true)} } .onChange(of:properties.targetID) { _,prepared in if prepared == nil && source==properties.pageID && (dragging || settling) {reset(notify:false)} diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 92a0952..6894d61 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -202,6 +202,12 @@ extension EnvironmentValues { private var active = false private var queued: [(RowLease, Bool)] = [] + func representsCurrentPayload(_ current:LUIAppleExtensionViewContext)->Bool { + guard active,!dirty,snapshot != nil,context?.nodeID==current.nodeID, + case let .string(value)=current.property("payload") else{return false} + return payload==value + } + var range: Range? { guard let snapshot else { return nil } var first = Int.max @@ -397,6 +403,13 @@ extension EnvironmentValues { } @MainActor private final class TopScroll: ObservableObject { + var presentationLayoutRevision=0 + func didLayout() { + presentationLayoutRevision+=1 + #if os(iOS) + reportPresentationLayout() + #endif + } var active = false var handledToken: Int64 = 0 private var userInteracting = false @@ -586,6 +599,7 @@ extension EnvironmentValues { override func layoutSubviews() { super.layoutSubviews() + owner?.didLayout() resolveIfNeeded() } @@ -632,7 +646,10 @@ extension EnvironmentValues { return anchor } - func updateUIView(_ anchor: Anchor, context: Context) { anchor.resolveIfNeeded() } + func updateUIView(_ anchor: Anchor, context: Context) { + anchor.resolveIfNeeded() + if owner.presentationLayout != nil {anchor.setNeedsLayout()} + } } #endif @@ -650,6 +667,7 @@ extension EnvironmentValues { @State private var scrollMounted = false @State private var boundScrollIdentity: String? @State private var presentedScrollIdentity: String? + @State private var minimumPresentationLayout=0 @Environment(\.journalGraphScrollScope) private var graphScrollScope @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets @Environment(\.journalGraphPresentationReady) private var presentationReady @@ -701,10 +719,11 @@ extension EnvironmentValues { private func reportPresentationLayout() { guard scrollMounted, presentationNonce != nil, let identity = boundScrollIdentity, identity == scrollIdentity, - presentedScrollIdentity != identity, let scope = graphScrollScope, scope.records, - properties?.retention_key != nil, let presentationReady else { return } + presentedScrollIdentity != identity, let scope = graphScrollScope, + prepared.representsCurrentPayload(context), properties?.retention_key != nil, let presentationReady else { return } #if os(iOS) - guard let scroll = topScroll.scrollView else { return } + guard topScroll.presentationLayoutRevision>=minimumPresentationLayout, + let scroll = topScroll.scrollView else { return } let offset = scroll.contentOffset.y + scroll.adjustedContentInset.top if let expected = restorationY { guard abs(offset - expected) <= 1 else { return } @@ -719,7 +738,11 @@ extension EnvironmentValues { private func bindPresentationLayout() { if presentationNonce != nil { + minimumPresentationLayout=topScroll.presentationLayoutRevision+1 topScroll.presentationLayout = { reportPresentationLayout() } + #if os(iOS) + topScroll.cover?.setNeedsLayout() + #endif } else { topScroll.presentationLayout = nil } @@ -1085,9 +1108,7 @@ extension EnvironmentValues { .onChange(of: presentationNonce) { _, _ in presentedScrollIdentity = nil bindPresentationLayout() - #if os(iOS) - if scrollMounted { topScroll.reportPresentationLayout() } - #endif + // Wait for the real native post-layout callback for this payload. } .onChange(of: pendingScroll?.request.token) { _, _ in performPendingScroll() } }