diff --git a/app/application.ml b/app/application.ml index a179c2e..775aa62 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 = @@ -3293,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 @@ -3424,7 +3425,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 +3480,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 @@ -3827,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 @@ -3955,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 @@ -4017,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 @@ -5457,17 +5475,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)-> @@ -5706,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; @@ -5986,25 +6006,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 +6107,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 +6122,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 +6290,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 +6542,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 +6677,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,71 +6854,173 @@ 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 - let content=render_graph_timeline ~interactive:false ~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) + ;"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 - 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 56551e8..4968851 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,48 @@ 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") + // 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?.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 transition") + _ = handoff.begin(source: "A", target: "B") + handoff.cancel() + 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"]) 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..44ad66c 100644 --- a/apple-tests/native-list/JournalResidentGraphAcceptance.swift +++ b/apple-tests/native-list/JournalResidentGraphAcceptance.swift @@ -1,10 +1,12 @@ 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 { - @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 +36,96 @@ 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) + } + /// 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/docs/agent-guide/001-fixed-timeline-hosts.md b/docs/agent-guide/001-fixed-timeline-hosts.md new file mode 100644 index 0000000..ca53ba9 --- /dev/null +++ b/docs/agent-guide/001-fixed-timeline-hosts.md @@ -0,0 +1,85 @@ +# 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 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. + +## 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 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. +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. +- 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. +- 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 8be4210..13b02c3 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -4,6 +4,192 @@ import SwiftUI import UIKit #endif +#if os(iOS) +/// 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? + private var gestureID: UUID? + func startGesture() -> UUID { cancel(); let id=UUID();gestureID=id;return id } + func acceptsGesture(_ id: UUID) -> Bool { gestureID == id } + var currentGesture: UUID? { gestureID } + @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 + } + func focusChanged(_ page: String?) { + guard let request else {cancel();return} + if page != request.source {cancel()} + } + func retain(_ pages:[String]) { + if let request,!pages.contains(request.source) || !pages.contains(request.target) {cancel()} + } + 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. struct JournalSheetDismissButton: SwiftUI.View { var label: LocalizedStringKey = "Close" @@ -26,6 +212,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" @@ -54,6 +241,10 @@ struct JournalSheetDismissButton: SwiftUI.View { let targetID: String? let targetPageID: String? let residentPageIDs: [String]? + let slotOwners: [String]? + let loadingPageIDs: [String]? + let ownerID: String? + let visible: Bool? } /// Syntax and reference resolution belong to OCaml; this maps presentation data. @@ -96,84 +287,317 @@ 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 + 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() + @StateObject private var coldMotion=JournalGraphColdMotion() + @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 + 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 + 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 + } + } + @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? + 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) + } + 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,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) + .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)) + .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 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.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() + } + } + 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 - withAnimation(reduceMotion ? .linear(duration: 0.01) : .smooth(duration: 0.24), completionCriteria: .logicallyComplete) { - 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 + 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: { - 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 } - dragging = false - withAnimation(.smooth(duration: 0.2)) { translation = 0 } - emit("cancel") - default: break + case .cancelled,.failed:reset(notify:true) + default:break } } - 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) }) - .frame(width: bounds.size.width, height: bounds.size.height) - .offset(x: translation) + 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) + 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) && !covered, + shown:shown, + 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) } } - .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 + // 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: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.targetID) { _, target in - if target == nil && !dragging { translation = 0; settling = 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,preserveCold:keepCold) + if keepCold {coldMotion.focused(page)} + acknowledge() } - .onChange(of: properties.enabled) { _, enabled in - if enabled != true && dragging { - dragging = false - withAnimation(.smooth(duration: 0.2)) { translation = 0 } - emit("cancel") - } + .onChange(of:properties.targetPageID) { _,next in + if source==properties.pageID,let target,target != next {reset(notify:true)} + } + .onChange(of:properties.enabled) { _,enabled in + 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)} } + .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 { @@ -359,13 +783,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 cb9f318..6894d61 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -5,14 +5,28 @@ import Observation import UIKit #endif -struct JournalGraphScrollScope { +struct JournalGraphScrollScope:Equatable { let pageID: String let records: Bool } 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 } @@ -188,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 @@ -383,9 +403,18 @@ 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 + var presentationLayout: (() -> Void)? + private var reportingLayout = false private(set) var atTop = false #if os(iOS) weak var scrollView: UIScrollView? @@ -411,9 +440,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 @@ -558,11 +599,13 @@ extension EnvironmentValues { override func layoutSubviews() { super.layoutSubviews() + owner?.didLayout() resolveIfNeeded() } 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 +618,7 @@ extension EnvironmentValues { if let scrollView = self.findScrollView(candidate) { owner.scrollView = scrollView owner.applyRestoredOffset() + owner.reportPresentationLayout() return } ancestor = candidate.superview @@ -602,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 @@ -619,8 +666,12 @@ extension EnvironmentValues { @State private var restorationY: CGFloat? @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 + @Environment(\.journalGraphPresentationNonce) private var presentationNonce @Environment(\.accessibilityReduceMotion) private var reduceMotion init(context: LUIAppleExtensionViewContext) { @@ -638,6 +689,7 @@ extension EnvironmentValues { } private func restoreScrollPosition() { + presentedScrollIdentity = nil restoringPosition = true restorationY = nil boundScrollIdentity = scrollIdentity @@ -664,6 +716,38 @@ extension EnvironmentValues { } } + private func reportPresentationLayout() { + guard scrollMounted, presentationNonce != nil, + let identity = boundScrollIdentity, identity == scrollIdentity, + presentedScrollIdentity != identity, let scope = graphScrollScope, + prepared.representsCurrentPayload(context), properties?.retention_key != nil, let presentationReady else { return } + #if os(iOS) + 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 } + restorationY = nil + restoringPosition = false + } + #endif + guard !restoringPosition else { return } + presentedScrollIdentity = identity + presentationReady(scope.pageID) + } + + private func bindPresentationLayout() { + if presentationNonce != nil { + minimumPresentationLayout=topScroll.presentationLayoutRevision+1 + topScroll.presentationLayout = { reportPresentationLayout() } + #if os(iOS) + topScroll.cover?.setNeedsLayout() + #endif + } 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 +1049,7 @@ extension EnvironmentValues { } .onAppear { topScroll.active = true + bindPresentationLayout() prepared.bind(context) scrollMounted = true restoreScrollPosition() @@ -974,6 +1059,8 @@ extension EnvironmentValues { } } .onDisappear { + topScroll.presentationLayout = nil + presentedScrollIdentity = nil scrollMounted = false boundScrollIdentity = nil restoringPosition = true @@ -991,6 +1078,7 @@ extension EnvironmentValues { topScroll.geometryChanged(false) } .onChange(of: context.nodeID) { _, _ in + bindPresentationLayout() restoringPosition = true boundScrollIdentity = nil cancelPull() @@ -1002,12 +1090,26 @@ 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() + // Wait for the real native post-layout callback for this payload. + } .onChange(of: pendingScroll?.request.token) { _, _ in performPendingScroll() } } } 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 (); 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)