diff --git a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md index 10e275c..e4e6c70 100644 --- a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md +++ b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md @@ -200,6 +200,19 @@ fprintf(stderr, "[EVT-DBG] extension_event node=%d name=%s payload=%s\n", node, Console via `xcrun simctl launch --console-pty com.logseq.journal > log 2>&1` (stderr is the only diag channel). `git checkout` the file to revert. +## lui-migration notes (branch devin/lui-replacement, verified iOS 26.5) + +- **Crash vs stall triage**: `invalidBatch("...")` + `JournalRuntime.swift:175 Fatal error` in stderr → app exits to home screen (a real crash — wire-schema violations are fatal, e.g. nested `list_item` without `expanded`, `sheet` with `min-width`, `toolbar` without `label`). Distinct from the "Downloading graph" parked-fiber stall (process alive at 0% CPU, screen stuck). On silent failures grep stderr for `Invalid_argument("property is unsupported by node kind` or `invalidBatch`. +- **Row right-alignment pitfall**: `Lui_elements.row` default `main=start` appends a trailing `Spacer` inside the HStack (`LUIRowLayoutPolicy.showsTrailingSpacer`), so a row used as a container is full-width with content packed at the LEADING edge — outer `spacer` siblings or `main=end` on a wrapper row cannot push the inner row's content right. To right-align chrome (e.g. the account capsule) put `~main:`end_` on the controls row itself. Also observed: `align`'s `alignment` prop on `overlay` children appeared not to land (capsule stayed top-left under `align `top_trailing` + content-sized column) — prefer `row ~main:`end_` inside the overlay. +- **Attach affordances (paperclip, composer doc/photo) reach OCaml but the picker may not present**: the press dispatches fine (add a temporary `fprintf(stderr, "[EVT-DBG] %s node=%lld\n", name, node)` in `dispatch_long` in `app/journal_lui_bridge.c` to confirm). If the press lands but no picker shows, check `LUIFilePickerView` — the journal mounts `file_picker` as a LEAF (no children), and a `Group { ForEach([]) }` produces NO rendered view, so `.fileImporter`/`.photosPicker`/`.sheet` and `onAppear` never install. The node needs a real placeholder view when `visibleChildren` is empty (e.g. `Color.clear.frame(width: 1, height: 1)` inside the Group) — verified that this makes the importer present. +- **Icon-only `Lui_elements.button`s need `~label`** — e.g. the composer pending-chip remove button (`xmark.circle.fill`, no `~text`/`~label`) caused `invalidBatch("button requires an accessible name")` → fatalError when the picked response rendered the pending strip. Grep journal code for `button ~icon` call sites lacking `~label` before exercising paths that mount them. +- **Media visibility appears unwired post-migration**: `Journal_media_runtime.root_visible`/`asset_visible` are only reachable via `"root"`/`"asset"` media-session actions, and nothing in journal OCaml emits them (the old native extension, kind_id 2105, self-reported visibility). Result: `media_views` never populate for browsed blocks → `file_image` thumbnails, external `link` rows, and "Open attachment" buttons never render; only the post-import optimistic `file-image` emit occurs, into the owning block's row label — which may sit in a backgrounded (non-visible) page subtree. Check whether `Journal_media_view.view`'s column should get `~on_appear` wiring to emit the `root` action. +- **Seed file-picker fixtures into "On My iPhone"** by copying files into `data/Containers/Shared/AppGroup//File Provider Storage/` where the group's `.com.apple.mobile_container_manager.metadata.plist` `MCMMetadataIdentifier` = `group.com.apple.FileProvider.LocalStorage` (the UUID differs per sim). The document picker's Browse tab → On My iPhone shows them. +- **Watch disk space**: a 100%-full disk silently kills `--console-pty` stderr capture and breaks staged-pick temp copies (pick lands, no chip, no error). Check `df -h /` if console goes quiet mid-run. +- **Row "..." context menu DOES work now** — opens Change status + "Delete block and descendants"; the delete removed the row and showed a "Block and descendants removed" Undo toast, and the deletion persisted across relaunch (supersedes the older note that context-menu actions were dead-wired — the lui path dispatches real actions). +- **iOS a11y bridge exposes ~6 nodes** for the LUI-rendered app (SwiftUI remote tree isn't introspectable) — locate controls by zooming screenshots + coordinate taps; sim mouse drags do not reliably trigger `swipeActions` (use the "..." context menu as the equivalent delete path). +- **Append "+" on a block detail page creates a top-level day entry**, not a nested child of the viewed block — detail bodies list a block's children, so leaf blocks render an empty body (not a bug). + ## Devin Secrets Needed - `LOGSEQ_JOURNAL_USERNAME`, `LOGSEQ_JOURNAL_PASSWORD` — Cognito sign-in diff --git a/app/application.ml b/app/application.ml index 8f7c708..7e0d06f 100644 --- a/app/application.ml +++ b/app/application.ml @@ -249,6 +249,7 @@ type state = ; asset_settings_open : bool ; media_views : Journal_media_runtime.view Media_views.t ; import_completion : (string * string option) option + ; media_preview : string option ; pending_replace : string option ; replace_request : int ; capture_pick_request : int @@ -312,6 +313,7 @@ let initial_state = ; asset_settings_open = false ; media_views = Media_views.empty ; import_completion = None + ; media_preview = None ; pending_replace = None ; replace_request = 0 ; capture_pick_request = 0 @@ -1620,19 +1622,16 @@ module Favorites_list = struct let rows = List.map2 (fun key label -> - let label = - if Keys.mem key block_keys - then - V.Navigation_link.create - ~key:(Ui.Key.string ("favorite-open:" ^ key)) - ~activation_id:key - ~enabled:actions_enabled - ~on_activate:(bind_action on_open key) - ~label - () - else label - in - V.Native_list.row ~key:(Ui.Key.string key) ~separator:Hidden label) + let is_block = Keys.mem key block_keys in + V.Native_list.row + ~key:(Ui.Key.string key) + ~separator:Hidden + ?on_press: + (if is_block && actions_enabled + then Some (bind_action on_open key) + else None) + ?icon:(if is_block then Some (Ui.journal_icon "chevron.right") else None) + label) keys children in @@ -1754,7 +1753,7 @@ let favorites_view type composer_assets = { request : Journal_asset_import.request ; camera : bool - ; completion : (string * string option) option + ; pending : Journal_asset_import.staged list ; on_attach : Journal_asset_import.source -> unit ; on_event : string -> unit } @@ -1861,10 +1860,10 @@ let composer_content Journal_asset_import.view ~key:(Ui.Key.string (scope ^ "-asset-import")) ~enabled:(enabled && not saving) - ~completion:assets.completion + ~completion:(Journal_asset_import.staged_completion assets.request assets.pending) ~replacement:None ~request:assets.request - ~pending:(Journal_capture.pending_attachments capture) + ~pending:assets.pending ~on_select:assets.on_event (V.Body.static content) ;; @@ -3031,6 +3030,8 @@ let response_tag = function | 20 -> 21 | 22 -> 23 | 25 -> 26 + | 28 -> 29 + | 30 -> 31 | tag -> tag ;; @@ -3843,13 +3844,34 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = | Error _ -> Effect.ignore | Ok payload -> apply_authenticated_user payload))) in + let seed_asset_settings = + platform_request Journal_platform.asset_recent_days_request ~f:(fun result -> + match Result.bind result Journal_platform.decode_asset_recent_days with + | Error _ -> Effect.ignore + | Ok days -> + (match Journal_asset_policy.settings ~recent_days:days with + | Error _ -> Effect.ignore + | Ok settings -> + Effect.of_thunk (fun () -> + (* A user edit that landed before the seed wins. *) + if Option.is_none !asset_settings + then ( + asset_settings := Some settings; + let current = !state_ref in + if current.graph_state.phase = Graph_open + then + refresh_assets + ~graph_generation:current.graph_state.generation + current.calendar)))) + in let calendar_startup = Effect.bind (sample_calendar ()) ~f:(function | Error error -> set_state (fun state -> { state with graph_error = Some (Calendar_startup_failure error) }) | Ok calendar -> - Effect.bind (install_calendar set_state calendar) ~f:(fun () -> managed_startup)) + Effect.bind (install_calendar set_state calendar) ~f:(fun () -> + Effect.many [ managed_startup; seed_asset_settings ])) in let calendar_tick_effect () : unit Effect.t = Effect.bind @@ -4396,6 +4418,9 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = (Journal_uploads.retry uploads operation)) | Some (Days settings) -> Effect.of_thunk (fun () -> + emit_platform_request + (Journal_platform.set_asset_recent_days_request + (Journal_asset_policy.recent_days settings)); (* The extension re-emits its preference on every remount; only a real change may refresh (each refresh republishes the model and would loop under the full-remount view). *) @@ -4497,7 +4522,8 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = { state with modal = No_modal }) | Ui.Event.Payload.Text action -> if String.starts_with ~prefix:"media:" action - then + then ( + let preview_request = ref None in Effect.bind (Effect.of_thunk (fun () -> sync_media snapshot; @@ -4529,10 +4555,20 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = ~asset:(text "asset") | "reuse-next" -> Journal_media_runtime.reuse_next media_runtime ~root | "reuse-cancel" -> Journal_media_runtime.end_reuse media_runtime ~root + | "preview" -> preview_request := Some (text "asset") | _ -> () with | _ -> ())) - ~f:(fun () -> flush_media set_state) + ~f:(fun () -> + Effect.many + [ flush_media set_state + ; (match !preview_request with + | Some path -> + update (fun state -> { state with media_preview = Some path }) + | None -> Effect.ignore) + ])) + else if String.equal action "media-preview-dismissed" + then update (fun state -> { state with media_preview = None }) else if String.equal action "open-asset-import" then update (fun state -> @@ -4553,7 +4589,9 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = else if String.starts_with ~prefix:"import-asset:" action then ( let import_payload = String.sub action 13 (String.length action - 13) in - if Journal_asset_import.is_dismissal import_payload + if Journal_asset_import.is_error_dismissal import_payload + then update (fun state -> { state with import_completion = None }) + else if Journal_asset_import.is_dismissal import_payload then update (fun state -> { state with pending_replace = None }) else ( match Journal_routes.detail snapshot.routes with @@ -4565,7 +4603,16 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = in let source = Result.bind target (fun target -> - Journal_asset_import.decode ~target import_payload) + Journal_asset_import.decode + ~target + ~replace_reference: + (match snapshot.pending_replace with + | Some reference -> + (match Logseq_db_types.Graph_types.Uuid.of_string reference with + | Ok reference -> Some reference + | Error _ -> None) + | None -> None) + import_payload) in (match source with | Error _ -> update (fun state -> { state with pending_replace = None }) @@ -5230,13 +5277,13 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = let sync_error = Option.map (fun notice -> sync_failure_message notice.failure) state.sync_error in - let capture_assets ~camera = + let capture_assets ~camera ~capture = { request = Journal_asset_import.staged_request ~id:state.capture_pick_request ~source:state.capture_pick_source ; camera - ; completion = state.import_completion + ; pending = Journal_capture.pending_attachments capture ; on_attach = (fun source -> Ui.Event.Handler.Private.invoke @@ -5297,7 +5344,7 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = (match Journal_capture.phase capture with | Failed message -> Some message | Editing | Saving -> None)) - ~assets:(Some (capture_assets ~camera:true))) + ~assets:(Some (capture_assets ~camera:true ~capture))) ]) state.direct_capture | _ -> None @@ -5411,7 +5458,10 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = | Failed message -> Some message | Editing | Saving -> None)) ~assets: - (Some (capture_assets ~camera:(state.environment.platform = "ios")))) + (Some + (capture_assets + ~camera:(state.environment.platform = "ios") + ~capture))) state.direct_capture) | Append_sheet -> Option.bind (Journal_routes.detail state.routes) (fun detail -> @@ -5528,12 +5578,32 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = (Journal_uploads.sync state.uploads (upload_context state))) ~offline:state.asset_offline ~presented:state.asset_settings_open + ~days: + (Journal_asset_policy.recent_days + (Option.value ~default:Journal_asset_policy.default_settings !asset_settings)) ~on_event:(fun value -> Ui.Event.Handler.Private.invoke dispatch (Ui.Event.Payload.Text ("asset-settings:" ^ value))) body in + let body = + match state.media_preview with + | None -> body + | Some path -> + Ui.element + (Lui_elements.column + ~grow:1.0 + [ Ui.mount body + ; Lui_elements.file_preview + ~path + ~on_dismiss:(fun _ -> + Ui.Event.Handler.Private.invoke + dispatch + (Ui.Event.Payload.Text "media-preview-dismissed")) + [] + ]) + in V.Body.theme ~data:(application_theme ()) (V.Body.static body) in let view _context model_signal _send = @@ -5576,14 +5646,7 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = true) } in - let app = - Lui_app.create_with_extensions - backend - Journal_lui_native.registry - initial_state - update - view - in + let app = Lui_app.create backend initial_state update view in app_cell := Some app; let context = { app; pump; client; send_action; apply_platform; running } in current_app := Some context; diff --git a/app/dune b/app/dune index 774c324..b816a39 100644 --- a/app/dune +++ b/app/dune @@ -6,7 +6,6 @@ journal_bridge journal_environment journal_ids - journal_lui_native journal_pump journal_view journal_uploads diff --git a/app/journal_asset_import.ml b/app/journal_asset_import.ml index 39a4e75..4b667e3 100644 --- a/app/journal_asset_import.ml +++ b/app/journal_asset_import.ml @@ -1,5 +1,6 @@ module Ui = Journal_view module Uuid = Logseq_db_types.Graph_types.Uuid +module V = Ui.View type source = | Files @@ -19,9 +20,16 @@ let source_of_string = function | _ -> None ;; -(* [request] mirrors the extension's request prop; [staged] requests copy the - pick into a temp file on the host so the path outlives the picker's - security scope — needed when the selection is attached later (composer). *) +let lui_source = function + | Files -> `files + | Photos -> `photos + | Camera -> `camera +;; + +(* [request] selects which picker arm a [file_picker] element should present. + [staged] requests copy the pick into a temp file so the path outlives the + picker's retained scope — needed when the selection is attached later + (composer). *) type request = { id : int ; source : source @@ -31,8 +39,39 @@ type request = let file_request ~id = { id; source = Files; staged = false } let staged_request ~id ~source = { id; source; staged = true } -(* A picked asset held for a later import: the wire pick fields plus [token], - which identifies the pending item for the extension's remove event. *) +(* The picker token encodes the journal request so picked payloads carry the + same routing information the extension used to attach itself. *) +let request_token (request : request) = + Printf.sprintf + "journal-import:%d:%s:%d" + request.id + (source_to_string request.source) + (if request.staged then 1 else 0) +;; + +let request_staged token = + match String.split_on_char ':' token with + | [ "journal-import"; _; _; staged ] -> staged = "1" + | _ -> false +;; + +let request_id_of_token token = + match String.split_on_char ':' token with + | [ "journal-import"; id; _; _ ] -> int_of_string_opt id + | _ -> None +;; + +(* Staged picks are journal-owned temp copies already, so the picker can + release its retained file as soon as the first pick lands — echoing the + request token as the completion prop does that. *) +let staged_completion request pending = + match pending with + | [] -> None + | _ :: _ -> Some (request_token request, None) +;; + +(* A picked asset held for a later import: the pick fields plus [token], which + identifies the pending item for the remove affordance. *) type staged = { token : string ; operation : Uuid.t @@ -49,74 +88,152 @@ let staged_path (staged : staged) = staged.source_file let staged_title (staged : staged) = staged.title let staged_type (staged : staged) = staged.file_type -let parse payload = - let ( let* ) = Result.bind in +(* Import identities are random UUIDv4s, matching the UUIDs the retired Swift + extension generated for each pick. *) +let uuid_v4 () = + let bytes = Bytes.create 16 in + (try + let ic = open_in_bin "/dev/urandom" in + really_input ic bytes 0 16; + close_in ic + with + | _ -> ()); + Bytes.set bytes 6 (Char.chr (Char.code (Bytes.get bytes 6) land 0x0f lor 0x40)); + Bytes.set bytes 8 (Char.chr (Char.code (Bytes.get bytes 8) land 0x3f lor 0x80)); + let hex = Buffer.create 36 in + Bytes.iteri + (fun index byte -> + if index = 4 || index = 6 || index = 8 || index = 10 then Buffer.add_char hex '-'; + Buffer.add_string hex (Printf.sprintf "%02x" (Char.code byte))) + bytes; + Buffer.contents hex +;; + +let fresh_uuid () = Uuid.of_string (uuid_v4 ()) + +let fresh_uuids () = + match fresh_uuid (), fresh_uuid (), fresh_uuid () with + | Ok asset, Ok local_mutation, Ok metadata_mutation -> + Ok (asset, local_mutation, metadata_mutation) + | _ -> Error "Unable to create an import identity; try again." +;; + +(* [completion] reports an operation id for in-place imports; the picker + retains the picked file under its *request* token, so the echo needs this + operation -> request mapping recorded when the pick decodes. *) +let request_of_operation : (string, string) Hashtbl.t = Hashtbl.create 8 + +type picked_file = + { request : string + ; path : string + ; title : string + ; file_type : string + } + +(* The lui picker reports {"request":token,"files":[{path,name,content-type}]}; + journal keeps the extension-era naming (extension of [name] for [file_type]). *) +let decode_picked payload = try - let json = Yojson.Basic.from_string payload in - let field key = - match Yojson.Basic.Util.member key json with - | `String value -> Ok value - | _ -> Error "Invalid attachment selection" - in - let uuid key = - let* text = field key in - Result.map_error (fun _ -> "Invalid import identity") (Uuid.of_string text) - in - let* operation = uuid "operation" in - let* asset = uuid "asset" in - let* local_mutation = uuid "localMutation" in - let* metadata_mutation = uuid "metadataMutation" in - let* replace_reference = - match json with - | `Assoc fields -> - (match List.assoc_opt "replaceReference" fields with - | Some `Null | Some (`String "") -> Ok None - | Some (`String id) -> Result.map Option.some (Uuid.of_string id) - | _ -> Error "Invalid replacement reference") - | _ -> Error "Invalid attachment selection" - in - let* source_file = field "path" in - let* title = field "title" in - let* file_type = field "type" in - let request_id = - match Yojson.Basic.Util.member "request" json with - | `Assoc request_fields -> - (match List.assoc_opt "id" request_fields with - | Some (`Int id) -> Some id - | _ -> None) - | _ -> None - in - Ok - ( { token = Uuid.to_string operation - ; operation - ; asset - ; local_mutation - ; metadata_mutation - ; source_file - ; title - ; file_type - } - , replace_reference - , request_id ) + match Yojson.Basic.from_string payload with + | `Assoc fields -> + (match List.assoc_opt "request" fields, List.assoc_opt "files" fields with + | Some ((`String _ | `Int _) as token), Some (`List (`Assoc file :: _)) -> + let token_string = + match token with + | `String s -> s + | `Int i -> string_of_int i + | _ -> "" + in + let field key = + match List.assoc_opt key file with + | Some (`String value) -> Ok value + | _ -> Error "Invalid attachment selection" + in + (match field "path", field "name" with + | Ok path, Ok name -> + Ok + { request = token_string + ; path + ; title = name + ; file_type = + String.lowercase_ascii + (String.sub + (Filename.extension name) + 1 + (max 0 (String.length (Filename.extension name) - 1))) + } + | _ -> Error "Invalid attachment selection") + | _ -> Error "Invalid attachment selection") + | _ -> Error "Invalid attachment selection" with | _ -> Error "Invalid attachment selection" ;; -let decode ~target payload = - Result.map - (fun ((pick : staged), replace_reference, _) -> - Logseq_db_types.Asset_import. - { operation = pick.operation - ; asset = pick.asset - ; target - ; replace_reference - ; local_mutation = pick.local_mutation - ; metadata_mutation = pick.metadata_mutation - ; source_file = pick.source_file - ; title = pick.title - ; file_type = pick.file_type - }) - (parse payload) +let file_extension path = + let ext = Filename.extension path in + if String.length ext > 1 + then String.lowercase_ascii (String.sub ext 1 (String.length ext - 1)) + else "bin" +;; + +(* Staged picks get an immediate journal-owned temp copy: the picker's own + retained/temp files are released as soon as the completion token echoes. *) +let stage_copy path = + let dest = + Filename.concat + (Filename.get_temp_dir_name ()) + (Printf.sprintf "journal-import-%s.%s" (uuid_v4 ()) (file_extension path)) + in + try + let ic = open_in_bin path in + let length = in_channel_length ic in + let contents = really_input_string ic length in + close_in ic; + let oc = open_out_bin dest in + output_string oc contents; + close_out oc; + Ok dest + with + | _ -> Error "Unable to access the selected file. Please try again." +;; + +let staged_of_pick (file : picked_file) ~source_file = + match fresh_uuid (), fresh_uuids () with + | Ok operation, Ok (asset, local_mutation, metadata_mutation) -> + Ok + { token = Uuid.to_string operation + ; operation + ; asset + ; local_mutation + ; metadata_mutation + ; source_file + ; title = file.title + ; file_type = file.file_type + } + | _ -> Error "Unable to create an import identity; try again." +;; + +let decode ~target ~replace_reference payload = + match decode_picked payload, fresh_uuids () with + | Ok file, Ok (asset, local_mutation, metadata_mutation) -> + (match fresh_uuid () with + | Error _ -> Error "Unable to create an import identity; try again." + | Ok operation -> + Hashtbl.replace request_of_operation (Uuid.to_string operation) file.request; + Ok + Logseq_db_types.Asset_import. + { operation + ; asset + ; target + ; replace_reference + ; local_mutation + ; metadata_mutation + ; source_file = file.path + ; title = file.title + ; file_type = file.file_type + }) + | Error message, _ -> Error message + | _, Error message -> Error message ;; let to_import (staged : staged) ~target : Logseq_db_types.Asset_import.t = @@ -146,33 +263,26 @@ let decode_event payload = | `Assoc fields -> (match List.assoc_opt "action" fields with | Some (`String "dismissed") -> Ok Dismissed - | Some (`String "unavailable") -> - Ok - (Unavailable - (match List.assoc_opt "reason" fields with - | Some (`String reason) -> reason - | _ -> "This attachment source is not available.")) | Some (`String "remove") -> (match List.assoc_opt "token" fields with | Some (`String token) -> Ok (Removed token) | _ -> Error "Invalid attachment selection") | _ -> - Result.map - (fun (staged, _, request_id) -> Picked (staged, request_id)) - (parse payload)) + (match decode_picked payload with + | Error message -> Error message + | Ok file -> + if request_staged file.request + then ( + match stage_copy file.path with + | Error message -> Error message + | Ok source_file -> + Result.map + (fun staged -> Picked (staged, request_id_of_token file.request)) + (staged_of_pick file ~source_file)) + else Error "Invalid attachment selection")) | _ -> Error "Invalid attachment selection" ;; -let extension = - Ui.Native_widget.Extension.create - ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2104) - ~version:1 - ~capabilities:[ Stateful; Resource; Semantics ] - ~encode_props:(fun props -> Bytes.of_string (Yojson.Basic.to_string props)) - ~decode_event:(fun ~event_id:_ bytes -> Ok (Bytes.to_string bytes)) - () -;; - (* Staged picks live under the host temp directory as [journal-import-*]; delete only files matching that contract so a user path is never removed. *) let discard_staged_file (staged : staged) = @@ -188,47 +298,131 @@ let is_dismissal payload = | _ -> false ;; -let view ~key ~enabled ~completion ~replacement ~request ~pending ~on_select body = - let operation, error = - match completion with - | None -> `Null, `Null - | Some (operation, error) -> - ( `String operation - , (match error with - | None -> `Null - | Some message -> `String message) ) +let is_error_dismissal payload = + match + try Yojson.Basic.from_string payload with + | _ -> `Null + with + | `Assoc fields -> + (match List.assoc_opt "action" fields with + | Some (`String "error-dismissed") -> true + | _ -> false) + | _ -> false +;; + +let emit_json on_select fields = on_select (Yojson.Basic.to_string (`Assoc fields)) + +let is_image_type file_type = + List.mem + file_type + [ "jpg"; "jpeg"; "png"; "gif"; "webp"; "heic"; "heif"; "tiff"; "bmp" ] +;; + +(* Pending strip: 48pt thumbnails (or a glyph) with a corner remove button, + scrolled horizontally — the layout the extension's PendingCell produced. *) +let pending_chip ~on_select (item : staged) = + let thumb = + if is_image_type item.file_type + then + Lui_elements.file_image + ~path:item.source_file + ~max_pixel_size:96 + ~width:48 + ~height:48 + ~corner_radius:8 + [] + else Lui_elements.icon ~name:(Ui.journal_icon "doc") ~width:48 ~height:48 [] in - Ui.Native_widget.widget - extension - ~key - ~props: - (`Assoc - [ "enabled", `Bool enabled - ; "completion", operation - ; "error", error - ; ( "replace" - , match replacement with - | None -> `Null - | Some root -> `String root ) - ; ( "request" - , `Assoc - [ "id", `Int request.id - ; "source", `String (source_to_string request.source) - ; "staged", `Bool request.staged - ] ) - ; ( "pending" - , `List - (List.map - (fun (item : staged) -> - `Assoc - [ "token", `String item.token - ; "path", `String item.source_file - ; "title", `String item.title - ; "type", `String item.file_type - ]) - pending) ) + Lui_elements.overlay + ~accessibility_identifier:("journal-asset-pending:" ^ item.token) + [ Lui_elements.column + ~gap:2 + ~width:56 + [ thumb + ; Ui.mount + (V.text + ~style: + (Ui.Style.Text_style.create ~foreground:Ui.Style.Text_style.Secondary ()) + ~line_limit:1 + item.title) + ] + ; Lui_elements.align + `top_trailing + (Lui_elements.button + ~icon:(Ui.journal_icon "xmark.circle.fill") + ~label:("Remove " ^ item.title) + ~accessibility_identifier:("journal-asset-remove:" ^ item.token) + ~on_press:(fun _ -> + emit_json + on_select + [ "action", `String "remove"; "token", `String item.token ]) + []) + ] +;; + +let view ~key ~enabled ~completion ~replacement:_ ~request ~pending ~on_select body = + let token = request_token request in + (* The completion prop echoes the picker's request token: staged picks + report the token directly, in-place imports resolve it through the + operation id recorded at pick time. *) + let completion_token = + Option.bind completion (fun (value, _) -> + if request.staged then Some value else Hashtbl.find_opt request_of_operation value) + in + let picker = + Lui_elements.file_picker + ~source:(lui_source request.source) + ?request:(if request.id > 0 then Some (`String token) else None) + ?types: + (match request.source with + | Files -> None + | Photos | Camera -> Some "public.image") + ~disabled:(not enabled) + ?completion:(Option.map (fun t -> `String t) completion_token) + ~on_picked:(fun event -> + match event with + | Lui_protocol.Picked (_, payload) -> on_select payload + | _ -> ()) + ~on_dismiss:(fun _ -> emit_json on_select [ "action", `String "dismissed" ]) + [] + in + let content = + Lui_elements.column + ~gap:8 + ((match pending with + | [] -> [] + | pending -> + [ Lui_elements.scroll + ~orientation:`horizontal + ~height:72 + [ Lui_elements.row + ~gap:12 + ~padding_horizontal:4 + (List.map (pending_chip ~on_select) pending) + ] ]) - ~on_event:on_select - ~children:[ Ui.View.Body.Private.to_widget body ] - () + @ [ Ui.mount (V.Body.Private.to_widget body) ]) + in + let alert = + match completion with + | Some (_, Some message) -> + Some + (Lui_elements.dialog + ~text:"Unable to import file" + ~description:message + ~on_dismiss:(fun _ -> + emit_json on_select [ "action", `String "error-dismissed" ]) + [ Lui_elements.button + ~text:"OK" + ~on_press:(fun _ -> + emit_json on_select [ "action", `String "error-dismissed" ]) + [] + ]) + | _ -> None + in + Ui.element ~key (fun context parent -> + let node = content context parent in + ignore (picker context (Some node)); + Option.iter (fun alert -> ignore (alert context (Some node))) alert; + node) ;; diff --git a/app/journal_asset_import.mli b/app/journal_asset_import.mli index f1ec92c..d191884 100644 --- a/app/journal_asset_import.mli +++ b/app/journal_asset_import.mli @@ -29,6 +29,10 @@ val staged_path : staged -> string val staged_title : staged -> string val staged_type : staged -> string +(** Completion prop value for a staged request: once the first staged pick is + held journal-side the picker's retained copy can be released. *) +val staged_completion : request -> staged list -> (string * string option) option + (** Build the worker import for a staged pick targeting [target]. *) val to_import : staged @@ -37,6 +41,7 @@ val to_import val decode : target:Logseq_db_types.Graph_types.Uuid.t + -> replace_reference:Logseq_db_types.Graph_types.Uuid.t option -> string -> (Logseq_db_types.Asset_import.t, string) result @@ -54,6 +59,9 @@ val discard_staged_file : staged -> unit val is_dismissal : string -> bool +(** Import-error alert dismissal, reported on the same channel as picks. *) +val is_error_dismissal : string -> bool + val view : key:Journal_view.Key.t -> enabled:bool diff --git a/app/journal_asset_policy.ml b/app/journal_asset_policy.ml index 18ecaef..d2a129b 100644 --- a/app/journal_asset_policy.ml +++ b/app/journal_asset_policy.ml @@ -15,6 +15,7 @@ let settings ~recent_days = ;; let default_settings = 7 +let recent_days settings = settings let previous_day day = let candidate = day - 1 in diff --git a/app/journal_asset_policy.mli b/app/journal_asset_policy.mli index 9e3dc5c..a4f6afb 100644 --- a/app/journal_asset_policy.mli +++ b/app/journal_asset_policy.mli @@ -10,6 +10,7 @@ type settings val settings : recent_days:int -> (settings, string) result val default_settings : settings +val recent_days : settings -> int val recent_interval : settings -> today:int -> (int * int) option type query = diff --git a/app/journal_asset_settings.ml b/app/journal_asset_settings.ml index 8555e59..d2ff098 100644 --- a/app/journal_asset_settings.ml +++ b/app/journal_asset_settings.ml @@ -1,4 +1,5 @@ module Ui = Journal_view +module V = Ui.View type event = | Days of Journal_asset_policy.settings @@ -26,16 +27,6 @@ let decode payload = else None ;; -let extension = - Ui.Native_widget.Extension.create - ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2106) - ~version:1 - ~capabilities:[ Stateful; Semantics ] - ~encode_props:(fun json -> Yojson.Basic.to_string json |> Bytes.of_string) - ~decode_event:(fun ~event_id:_ bytes -> Ok (Bytes.to_string bytes)) - () -;; - let describe (status : Journal_asset_policy.offline) = let files = Printf.sprintf "%d of %d attachments available offline" status.ready status.total @@ -51,34 +42,89 @@ let describe (status : Journal_asset_policy.offline) = | Complete -> files ^ "; downloads pending" ;; -let view ~uploads ~offline ~presented ~on_event child = +let secondary value = + Ui.mount + (V.text + ~style:(Ui.Style.Text_style.create ~foreground:Ui.Style.Text_style.Secondary ()) + value) +;; + +(* One upload row inside the settings form: spinner while a retry is in + flight, title + secondary message, and a retry affordance when the upload + can be retried. *) +let upload_row ~on_event (row : Journal_uploads.row) = + Lui_elements.row + ~key:("journal-upload-row:" ^ row.id) + ~accessibility_identifier:("journal-upload:" ^ row.id) + ~gap:8 + ~cross:`start + [ (if row.busy then Lui_elements.spinner [] else Lui_elements.row ~width:0 []) + ; Lui_elements.column + ~gap:2 + ~cross:`start + ~grow:1. + [ Ui.mount (V.text row.title); secondary row.message ] + ; (if row.retry + then + Lui_elements.button + ~text:"Retry" + ~accessibility_identifier:("journal-upload-retry:" ^ row.id) + ~on_press:(fun _ -> on_event ("retry:" ^ row.id)) + [] + else Lui_elements.row ~width:0 []) + ] +;; + +let view ~uploads ~offline ~presented ~days ~on_event child = let recent, favorites = match offline with | None -> "Waiting for a graph", "Waiting for a graph" | Some (recent, favorites) -> describe recent, describe favorites in - Ui.Native_widget.widget - extension + let sheet = + if presented + then + [ Lui_elements.sheet + ~key:"journal-asset-settings-sheet" + ~text:"Attachment settings" + ~style_class:"navigation-form" + ~detents:"medium,large" + ~sizing:"form" + ~on_dismiss:(fun _ -> on_event "dismissed") + [ Lui_elements.column + ~style_class:"form" + ~gap:0 + ([ Lui_elements.heading ~level:3 ~value:"Offline attachments" [] + ; Lui_elements.number_stepper + ~accessibility_identifier:"journal-asset-days" + ~value:(float_of_int days) + ~min:0. + ~max:3660. + ~step:1. + ~text:(Printf.sprintf "Recent journal days: %d" days) + ~on_value_changed:(fun event -> + match event with + | Lui_protocol.ValueChanged (_, value) -> + on_event ("days:" ^ Int.to_string (int_of_float value)) + | _ -> ()) + [] + ] + @ List.map (upload_row ~on_event) uploads + @ [ secondary recent; secondary favorites ]) + ; Lui_elements.toolbar + ~label:"Attachment settings toolbar" + [ Lui_elements.button + ~text:"Done" + ~style_class:"confirmation-action" + ~accessibility_identifier:"journal-asset-settings-done" + ~on_press:(fun _ -> on_event "dismissed") + [] + ] + ] + ] + else [] + in + Ui.element ~key:(Ui.Key.string "asset-settings") - ~props: - (`Assoc - [ "presented", `Bool presented - ; "recent", `String recent - ; "favorites", `String favorites - ; ( "uploads" - , `List - (List.map - (fun (row : Journal_uploads.row) -> - `Assoc - [ "id", `String row.id - ; "title", `String row.title - ; "message", `String row.message - ; "busy", `Bool row.busy - ; "retry", `Bool row.retry - ]) - uploads) ) - ]) - ~on_event - ~children:[ child ] - () + (Lui_elements.column ~grow:1.0 (Ui.mount child :: sheet)) ;; diff --git a/app/journal_asset_settings.mli b/app/journal_asset_settings.mli index 1bad36d..e8a41a0 100644 --- a/app/journal_asset_settings.mli +++ b/app/journal_asset_settings.mli @@ -11,6 +11,7 @@ val view : uploads:Journal_uploads.row list -> offline:(Journal_asset_policy.offline * Journal_asset_policy.offline) option -> presented:bool + -> days:int -> on_event:(string -> unit) -> Ui.View.t -> Ui.View.t diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml index 4b690c3..519aae2 100644 --- a/app/journal_bridge.ml +++ b/app/journal_bridge.ml @@ -40,6 +40,16 @@ let toggle_changed node checked = let radio_changed node = dispatch_lui (Lui_protocol.Change node) let slider_changed node value = dispatch_lui (Lui_protocol.ValueChanged (node, value)) + +let scroll_completed node token outcome = + dispatch_lui (Lui_protocol.ScrollCompleted (node, token, outcome)) +;; + +let visible_range node first last = + dispatch_lui (Lui_protocol.VisibleRange (node, first, last)) +;; + +let picked node payload = dispatch_lui (Lui_protocol.Picked (node, payload)) let extension_event node name payload = (hooks ()).extension_event node name payload let pump () = (hooks ()).pump () let platform_event payload = (hooks ()).platform_event payload @@ -61,6 +71,9 @@ let register hooks = Callback.register "lui_ocaml_toggle_changed" toggle_changed; Callback.register "lui_ocaml_radio_changed" radio_changed; Callback.register "lui_ocaml_slider_changed" slider_changed; + Callback.register "lui_ocaml_scroll_completed" scroll_completed; + Callback.register "lui_ocaml_visible_range" visible_range; + Callback.register "lui_ocaml_picked" picked; Callback.register "lui_ocaml_dispose" dispose; Callback.register "lui_ocaml_root_node" root_node; Callback.register "journal_ocaml_extension_event" extension_event; diff --git a/app/journal_header.ml b/app/journal_header.ml index 78c7dfd..b7479fb 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -19,39 +19,23 @@ module V = Ui.View let test_id id view = V.with_test_id (Ui.Test_id.string id) view -let chrome = - Ui.Native_widget.Extension.create - ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2103) - ~version:2 - ~capabilities:[ Stateful; Semantics ] - ~encode_props:(fun props -> Yojson.Basic.to_string props |> Bytes.of_string) - ~decode_event:(fun ~event_id:_ _ -> Error "Chrome uses child control events") - () -;; - +(* Feedback banners pin below (or above) the safe-area edge and shrink to the + tighter layout first, exactly like the old chrome extension's ViewThatFits + preference order. *) let feedback ~key ~top ~visible ~compact ~expanded body = - Ui.Native_widget.widget - chrome + Ui.element ~key - ~props: - (`Assoc [ "mode", `String "feedback"; "top", `Bool top; "visible", `Bool visible ]) - ~on_event:(fun _ -> ()) - (* Chrome slots are positional on the native side — absent slots must - still mount a (zero-size) node or the host's index lookup shifts. *) - ~children: - [ V.Body.Private.to_widget body; V.column [ compact ]; V.column [ expanded ] ] - () + (Lui_elements.edge_inset + ~edge:(if top then `top else `bottom) + ~visible + ~background:"bar" + [ Ui.mount (V.Body.Private.to_widget body) + ; Lui_elements.view_that_fits [ Ui.mount compact; Ui.mount expanded ] + ]) |> V.Body.static ;; -let date_header ~title = - Ui.Native_widget.widget - chrome - ~props:(`Assoc [ "mode", `String "header"; "title", `String title ]) - ~on_event:(fun _ -> ()) - ~children:[] - () -;; +let date_header ~title = Ui.element (Lui_elements.heading ~level:3 ~value:title []) let detail ~on_back ~actions body = let back = @@ -61,13 +45,20 @@ let detail ~on_back ~actions body = () |> test_id "BackButton" in - Ui.Native_widget.widget - chrome + (* Controls span the bar; the title floats centered over them. *) + Ui.element ~key:(Ui.Key.string "journal-detail-header") - ~props:(`Assoc [ "mode", `String "detail"; "title", `String "Block" ]) - ~on_event:(fun _ -> ()) - ~children:[ V.Body.Private.to_widget body; back; V.buttons ~actions () ] - () + (Lui_elements.edge_inset + ~edge:`top + [ Ui.mount (V.Body.Private.to_widget body) + ; Lui_elements.overlay + [ Lui_elements.row + ~padding_horizontal:16 + ~min_height:44 + [ Ui.mount back; Lui_elements.spacer []; Ui.mount (V.buttons ~actions ()) ] + ; Lui_elements.align `center (Lui_elements.heading ~level:5 ~value:"Block" []) + ] + ]) |> V.Body.static ;; @@ -237,53 +228,71 @@ let view body in let body = body |> V.Body.with_test_id (Ui.Test_id.string "journal-root-navigation") in - let body = - Ui.Native_widget.widget - chrome - ~key - ~props: - (`Assoc - [ "mode", `String "page" - ; ( "title" - , match context with - | Journals -> `Null - | Favorites -> `String "Favorites" ) - ; "connecting", `Bool (sync_phase = Some Graph_service.Connecting) - ; "controls", `Bool (Option.is_some cluster) - ]) - ~on_event:(fun _ -> ()) - (* Keep absent slots mounted so the native child indexes stay stable. *) - ~children: - [ V.Body.Private.to_widget body - ; V.column (Option.to_list cluster) - ; V.column - [ (if sync_phase = Some Graph_service.Connecting - then - V.progress ~style:Circular () - |> V.semantics ~properties:(Ui.Semantics.create ~label:"Connecting" ()) - |> test_id "journal-header-sync-progress" - else V.empty ()) + let connecting = sync_phase = Some Graph_service.Connecting in + let controls = + Lui_elements.row + ~main:`end_ + ~gap:8 + ((if connecting then [ Ui.mount (V.progress ~style:Circular ()) ] else []) + @ + match cluster with + | Some cluster -> [ Ui.mount cluster ] + | None -> []) + in + (* Page chrome: controls float top-trailing when there is no title; a titled + page pins a top bar with the centered title and trailing controls. *) + let chrome = + match context with + | Journals -> + Lui_elements.overlay + [ Ui.mount (V.Body.Private.to_widget body) + ; Lui_elements.align + `top_trailing + (Lui_elements.row + ~main:`end_ + ~padding_horizontal:16 + ~padding_vertical:8 + [ controls ]) + ] + | Favorites -> + Lui_elements.edge_inset + ~edge:`top + [ Ui.mount (V.Body.Private.to_widget body) + ; Lui_elements.overlay + [ Lui_elements.row + ~padding_horizontal:16 + ~min_height:44 + [ Lui_elements.spacer []; controls ] + ; Lui_elements.align + `center + (Lui_elements.heading + ~level:5 + ~value:"Favorites" + ~accessibility_identifier:"favorites-header-title" + []) ] ] - () - |> test_id "journal-floating-chrome" - |> V.Body.static + in + let body = + Ui.element ~key chrome |> test_id "journal-floating-chrome" |> V.Body.static in if platform <> "ios" || Option.is_none capture_expanded then - (* The native inset reserves scrolling space; each buttons composite + (* The inset reserves scrolling space; each buttons composite supplies its own glass without system toolbar chrome. *) - Ui.Native_widget.widget - chrome + Ui.element ~key:(Ui.Key.string "journal-bottom-controls") - ~props:(`Assoc [ "mode", `String "bottom-controls" ]) - ~on_event:(fun _ -> ()) - ~children: - [ V.Body.Private.to_widget body - ; V.row ~spacing:16. [ destinations; V.spacer (); capture ] - |> test_id "journal-bottom-controls" - ] - () + (Lui_elements.edge_inset + ~edge:`bottom + [ Ui.mount (V.Body.Private.to_widget body) + ; Lui_elements.column + ~padding_horizontal:16 + ~padding_vertical:8 + [ Ui.mount + (V.row ~spacing:16. [ destinations; V.spacer (); capture ] + |> test_id "journal-bottom-controls") + ] + ]) |> V.Body.static else body ;; diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index e42cab7..e5ebb6c 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -162,6 +162,48 @@ LUI_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { return result; } +LUI_EXPORT int32_t lui_ocaml_scroll_completed( + int64_t node, + int64_t token, + const char *outcome) { + int result = 0; + caml_leave_blocking_section(); + const value *dispatch = caml_named_value("lui_ocaml_scroll_completed"); + if (dispatch != NULL) { + result = emit_patch("lui_ocaml_scroll_completed", caml_callback3_exn( + *dispatch, Val_long(node), Val_long(token), caml_copy_string(outcome))); + } + caml_enter_blocking_section(); + return result; +} + +LUI_EXPORT int32_t lui_ocaml_visible_range( + int64_t node, + int64_t first, + int64_t last) { + int result = 0; + caml_leave_blocking_section(); + const value *dispatch = caml_named_value("lui_ocaml_visible_range"); + if (dispatch != NULL) { + result = emit_patch("lui_ocaml_visible_range", caml_callback3_exn( + *dispatch, Val_long(node), Val_long(first), Val_long(last))); + } + caml_enter_blocking_section(); + return result; +} + +LUI_EXPORT int32_t lui_ocaml_picked(int64_t node, const char *payload) { + int result = 0; + caml_leave_blocking_section(); + const value *dispatch = caml_named_value("lui_ocaml_picked"); + if (dispatch != NULL) { + result = emit_patch("lui_ocaml_picked", caml_callback2_exn( + *dispatch, Val_long(node), caml_copy_string(payload))); + } + caml_enter_blocking_section(); + return result; +} + LUI_EXPORT int32_t lui_ocaml_stop(void) { int result = 0; caml_leave_blocking_section(); diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml deleted file mode 100644 index 9a9f62e..0000000 --- a/app/journal_lui_native.ml +++ /dev/null @@ -1,151 +0,0 @@ -open Lui_protocol -open Lui_extension - -let chrome_identifier = "journal-chrome" -let asset_import_identifier = "journal-asset-import" -let media_identifier = "journal-media" -let asset_settings_identifier = "journal-asset-settings" -let list_identifier = "journal-list" - -let apple_profiles = - [ { profile_os = MacOS; profile_host = SwiftUIHost } - ; { profile_os = IOS; profile_host = SwiftUIHost } - ] -;; - -let all_host_profiles = - apple_profiles - @ [ { profile_os = MacOS; profile_host = FlutterHost } - ; { profile_os = IOS; profile_host = FlutterHost } - ; { profile_os = AndroidOS; profile_host = FlutterHost } - ] -;; - -let payload_property = property "payload" StringScalar true None - -let event_schema = - event - "event" - [ event_field "id" IntScalar true; event_field "payload" StringScalar true ] -;; - -let registry = - let registry = Lui_extension.registry () in - (* Journal native views nest: chrome slots hold page content (including - other chrome sections, lists, and media), and list rows hold media and - chrome section headers. Every component accepts all journal extensions - as children; the schema must stay in sync with the fingerprint the - Apple host computes in JournalExtensions.swift. *) - let children = - [ chrome_identifier - ; asset_import_identifier - ; media_identifier - ; asset_settings_identifier - ; list_identifier - ] - in - register_component - registry - (component chrome_identifier apple_profiles true children [ payload_property ] []); - register_component - registry - (component - asset_import_identifier - all_host_profiles - true - children - [ payload_property ] - [ event_schema ]); - register_component - registry - (component - media_identifier - all_host_profiles - true - children - [ payload_property ] - [ event_schema ]); - register_component - registry - (component - asset_settings_identifier - all_host_profiles - true - children - [ payload_property ] - [ event_schema ]); - register_component - registry - (component - list_identifier - all_host_profiles - true - children - [ payload_property ] - [ event_schema ]); - freeze registry; - registry -;; - -type event = - { identifier : string - ; node : int - ; event_id : int - ; payload : string - } - -let decode_event = function - | ExtensionEvent (node, identifier, name, values) - when String.equal name "event" - && (String.equal identifier chrome_identifier - || String.equal identifier asset_import_identifier - || String.equal identifier media_identifier - || String.equal identifier asset_settings_identifier - || String.equal identifier list_identifier) -> - (match String_map.find_opt "id" values, String_map.find_opt "payload" values with - | Some (IntValue event_id), Some (StringValue payload) -> - Some { identifier; node; event_id; payload } - | _ -> None) - | _ -> None -;; - -let mount ?key ~payload ~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 handler -> - Lui_ui.on_event context node (fun raw -> - match decode_event raw with - | Some event -> handler event - | None -> ())) - on_event; - Lui_elements.attach context parent node; - Lui_elements.mount_children context node children; - node -;; - -let chrome ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> - mount ?key ~payload ~children ?on_event chrome_identifier context parent -;; - -let asset_import ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> - mount ?key ~payload ~children ?on_event asset_import_identifier context parent -;; - -let media ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> - mount ?key ~payload ~children ?on_event media_identifier context parent -;; - -let asset_settings ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> - mount ?key ~payload ~children ?on_event asset_settings_identifier context parent -;; - -let list ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> - mount ?key ~payload ~children ?on_event list_identifier context parent -;; diff --git a/app/journal_lui_native.mli b/app/journal_lui_native.mli deleted file mode 100644 index 892b8e7..0000000 --- a/app/journal_lui_native.mli +++ /dev/null @@ -1,83 +0,0 @@ -(** Journal-specific lui extension components. - - Replaces the [Ui.Native_widget.Extension] registrations that previously - carried kinds 2103-2106 over the bonsai_swiftui native-widget channel. - Each component ships its properties as one [payload] string field holding - the same JSON object the Swift [Properties] structs already decode, and - reports events through one ["event"] extension event with [id] (int) and - [payload] (JSON string) fields, matching the old - [BonsaiNativeEvent(id, payload)] contract. *) - -(** Lui extension identifiers (slugs). *) -val chrome_identifier : string - -val asset_import_identifier : string -val media_identifier : string -val asset_settings_identifier : string -val list_identifier : string - -(** Extension schemas shared with the Apple/Flutter hosts. *) -val registry : Lui_extension.extension_registry - -(** A journal extension event decoded from the lui event stream. *) -type event = - { identifier : string - ; node : int - ; event_id : int - ; payload : string - } - -(** Decodes a lui [ExtensionEvent] into a journal [event]; returns [None] for - events that are not journal extension events or are malformed. *) -val decode_event : Lui_protocol.event -> event option - -(** Low-level mount helper shared by the element constructors and the - [Journal_view.Native_widget] shim. [payload] is the JSON-encoded - properties object (the same JSON the previous [~encode_props] produced); - [on_event] receives decoded journal extension events. *) -val mount - : ?key:string - -> payload:string - -> children:Lui_elements.t list - -> ?on_event:(event -> unit) - -> string - -> Lui_elements.t - -val chrome - : ?key:string - -> payload:string - -> ?on_event:(event -> unit) - -> Lui_elements.t list - -> Lui_elements.t - -val asset_import - : ?key:string - -> payload:string - -> ?on_event:(event -> unit) - -> Lui_elements.t list - -> Lui_elements.t - -val media - : ?key:string - -> payload:string - -> ?on_event:(event -> unit) - -> Lui_elements.t list - -> Lui_elements.t - -val asset_settings - : ?key:string - -> payload:string - -> ?on_event:(event -> unit) - -> Lui_elements.t list - -> Lui_elements.t - -(** Native virtualized collection (grouped sections, scroll positioning, - visible-range paging, swipe actions). Section and row structure rides in - [payload]; each row's content element mounts as an extension child in the - order described by the payload's content indexes. *) -val list - : ?key:string - -> payload:string - -> ?on_event:(event -> unit) - -> Lui_elements.t list - -> Lui_elements.t diff --git a/app/journal_media_view.ml b/app/journal_media_view.ml index c81ae29..8069daa 100644 --- a/app/journal_media_view.ml +++ b/app/journal_media_view.ml @@ -1,35 +1,150 @@ module Ui = Journal_view +module V = Ui.View -let extension = - Ui.Native_widget.Extension.create - ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2105) - ~version:1 - ~capabilities:[ Stateful; Semantics ] - ~encode_props:(fun json -> Yojson.Basic.to_string json |> Bytes.of_string) - ~decode_event:(fun ~event_id:_ bytes -> Ok (Bytes.to_string bytes)) - () +(* The media action channel is unchanged: events keep arriving as + "media-session::media:" Text payloads. *) +let emit ~root ~on_event action ?asset () = + on_event + (Yojson.Basic.to_string + (`Assoc + [ "action", `String action + ; "root", `String root + ; "asset", `String (Option.value ~default:"" asset) + ; "visible", `Bool true + ])) ;; -let item_json (item : Journal_media_runtime.item) = - let width, height = - match item.asset.dimensions with - | Some (w, h) -> w, h - | None -> 4, 3 +let emit_handler ~root ~on_event action ?asset () : Ui.Event.handler = + Ui.Event.Handler.create (fun _ -> emit ~root ~on_event action ?asset ()) +;; + +let is_http value = + match String.index_opt value ':' with + | Some index -> + let scheme = String.lowercase_ascii (String.sub value 0 index) in + scheme = "http" || scheme = "https" + | None -> false +;; + +let is_image_type file_type = + List.mem + (String.lowercase_ascii file_type) + [ "jpg"; "jpeg"; "png"; "gif"; "webp"; "heic"; "heif"; "tiff"; "bmp" ] +;; + +let item_value (item : Journal_media_runtime.item) = + match item.presentation with + | Journal_media.File path -> path + | Journal_media.External url -> url + | Journal_media.Placeholder message -> message + | Journal_media.Hidden -> "Waiting for file" +;; + +(* Local files render through [file_image] (tap mounts a [file_preview] via the + "preview" action); externals open via [link]; everything else is a retryable + placeholder. *) +let item_view ~root ~on_event (item : Journal_media_runtime.item) = + let invoke (handler : Ui.Event.handler) (_ : Lui_protocol.event) = + Ui.Event.Handler.Private.invoke handler Ui.Event.Payload.Unit in - let kind, value = + let preview = emit_handler ~root ~on_event "preview" ~asset:(item_value item) () in + let appear = emit_handler ~root ~on_event "asset" ~asset:item.token () in + let on_appear (_ : Lui_protocol.event) = + Ui.Event.Handler.Private.invoke appear Ui.Event.Payload.Unit + in + let mount : Lui_elements.t = match item.presentation with - | Journal_media.File path -> "file", path - | External url -> "external", url - | Placeholder message -> "placeholder", message - | Hidden -> "placeholder", "Waiting for file" + | Journal_media.File path when is_image_type item.file_type -> + Lui_elements.file_image + ~path + ~max_pixel_size:1024 + ~max_height:240 + ~accessibility_identifier:("journal-media:" ^ item.token) + ~on_appear + ~on_press:(invoke preview) + [] + | Journal_media.File _ -> + Lui_elements.button + ~text:"Open attachment" + ~icon:(Ui.journal_icon "doc") + ~accessibility_identifier:("journal-media:" ^ item.token) + ~on_appear + ~on_press:(invoke preview) + [] + | Journal_media.External url when is_http url -> + Lui_elements.link + ~url + ~text:"Open external attachment" + ~icon:(Ui.journal_icon "arrow.up.right.square") + ~accessibility_identifier:("journal-media:" ^ item.token) + ~on_appear + [] + | _ -> + let retry = emit_handler ~root ~on_event "retry" ~asset:item.token () in + Lui_elements.column + ~gap:8 + ~accessibility_identifier:("journal-media:" ^ item.token) + ~on_appear + [ Ui.mount + (V.row + ~spacing:8. + [ V.symbol ~name:"photo" () + ; V.text + ~style: + (Ui.Style.Text_style.create + ~foreground:Ui.Style.Text_style.Secondary + ()) + (item_value item) + ]) + ; Ui.mount + (V.buttons + ~actions: + [ V.buttons_action + ~label:"Retry" + ~icon:"arrow.clockwise" + ~on_press:retry + () + ] + ()) + ] + in + Ui.element mount +;; + +let picker_mounts ~root ~on_event (picker : Journal_media_runtime.picker) = + let invoke handler (_ : Lui_protocol.event) = + Ui.Event.Handler.Private.invoke handler Ui.Event.Payload.Unit in - `Assoc - [ "id", `String item.token - ; "kind", `String kind - ; "value", `String value - ; "type", `String item.file_type - ; "width", `Int width - ; "height", `Int height + let candidate (item : Journal_media_runtime.item) = + Lui_elements.button + ~text:(if String.equal item.file_type "" then "file" else item.file_type) + ~icon:(Ui.journal_icon "doc") + ~disabled:picker.busy + ~accessibility_identifier:("journal-media-candidate:" ^ item.token) + ~on_press: + (invoke (emit_handler ~root ~on_event "reuse-select" ~asset:item.token ())) + [] + in + List.concat + [ (if picker.busy && picker.candidates = [] + then [ Ui.mount (V.loading ~message:"Loading attachments" ()) ] + else []) + ; List.map candidate picker.candidates + ; (if picker.candidates_more + then + [ Lui_elements.button + ~text:"More attachments" + ~disabled:picker.busy + ~on_press:(invoke (emit_handler ~root ~on_event "reuse-next" ())) + [] + ] + else []) + ; [ Lui_elements.button + ~text:"Cancel" + ~variant:`secondary + ~on_press:(invoke (emit_handler ~root ~on_event "reuse-cancel" ())) + [] + ] ] ;; @@ -39,30 +154,68 @@ let view ~scope ~root ~media ~editable ~on_event child = | None -> [], false, None, None | Some view -> view.Journal_media_runtime.items, view.more, view.error, view.picker in - Ui.Native_widget.widget - extension - ~key:(Ui.Key.string ("media:" ^ scope ^ ":" ^ root)) - ~props: - (`Assoc - [ "root", `String root - ; "items", `List (List.map item_json items) - ; "more", `Bool more - ; "editable", `Bool editable - ; ( "picker" - , match picker with - | None -> `Null - | Some picker -> - `Assoc - [ "items", `List (List.map item_json picker.candidates) - ; "more", `Bool picker.candidates_more - ; "busy", `Bool picker.busy - ] ) - ; ( "error" - , match error with - | None -> `Null - | Some message -> `String message ) - ]) - ~on_event - ~children:[ child ] - () + let invoke handler (_ : Lui_protocol.event) = + Ui.Event.Handler.Private.invoke handler Ui.Event.Payload.Unit + in + let actions_menu = + if editable + then + [ Lui_elements.menu + ~icon:(Ui.journal_icon "ellipsis.circle") + ~label:"Attachment actions" + ~accessibility_identifier:"journal-media-actions" + [ Lui_elements.menu_item + ~text:"Replace file…" + ~on_press:(invoke (emit_handler ~root ~on_event "replace" ())) + [] + ; Lui_elements.menu_item + ~text:"Reuse existing…" + ~on_press:(invoke (emit_handler ~root ~on_event "reuse" ())) + [] + ] + ] + else [] + in + let appear = emit_handler ~root ~on_event "root" () in + let content = + Lui_elements.column + ~gap:8 + ~cross:`start + ~on_appear:(fun _ -> Ui.Event.Handler.Private.invoke appear Ui.Event.Payload.Unit) + ((Ui.mount child + :: + (match actions_menu with + | [] -> [] + | menu -> [ Lui_elements.row [ Lui_elements.spacer []; Lui_elements.row menu ] ]) + ) + @ List.map (fun item -> Ui.mount (item_view ~root ~on_event item)) items + @ (match picker with + | None -> [] + | Some picker -> picker_mounts ~root ~on_event picker) + @ (match error with + | None -> [] + | Some message -> + [ Ui.mount + (V.text + ~style: + (Ui.Style.Text_style.create + ~foreground:Ui.Style.Text_style.Secondary + ()) + message) + ; Lui_elements.button + ~text:"Retry attachments" + ~on_press:(invoke (emit_handler ~root ~on_event "retry" ())) + [] + ]) + @ + if more + then + [ Lui_elements.button + ~text:"Next attachments" + ~on_press:(invoke (emit_handler ~root ~on_event "next" ())) + [] + ] + else []) + in + Ui.element ~key:(Ui.Key.string ("media:" ^ scope ^ ":" ^ root)) content ;; diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index ad5c20c..806d6d7 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -102,13 +102,9 @@ let view ~separator:Hidden ~swipe_actions ~context_menu - (V.Navigation_link.create - ~key:(Ui.Key.string ("open:" ^ id)) - ~activation_id:id - ~enabled:actions_enabled - ~on_activate:(action on_open id) - ~label:child - ()) + ?on_press:(if actions_enabled then Some (action on_open id) else None) + ~icon:(Ui.journal_icon "chevron.right") + child in let sections = List.map diff --git a/app/journal_platform.ml b/app/journal_platform.ml index 044f4fa..f2b0928 100644 --- a/app/journal_platform.ml +++ b/app/journal_platform.ml @@ -262,3 +262,22 @@ let notice_cancel_request ~token = |> encode_envelope 27 |> Result.get_ok ;; + +(* Host-owned asset preferences (tag 28 get / tag 30 set) replace the + UserDefaults coupling the retired asset-settings extension relied on. *) +let asset_recent_days_request = encode_envelope 28 Bytes.empty |> Result.get_ok + +let decode_asset_recent_days bytes = + Result.bind (decode_envelope [ 29 ] bytes) (fun payload -> + decode_json_object "asset-recent-days response" payload (function + | [ ("days", `Int days) ] -> Ok days + | _ -> Error "asset-recent-days response fields are invalid")) +;; + +let set_asset_recent_days_request days = + `Assoc [ "days", `Int days ] + |> Yojson.Safe.to_string + |> Bytes.of_string + |> encode_envelope 30 + |> Result.get_ok +;; diff --git a/app/journal_platform.mli b/app/journal_platform.mli index af7f8b8..643092d 100644 --- a/app/journal_platform.mli +++ b/app/journal_platform.mli @@ -56,3 +56,11 @@ val decode_notice_response : token:int64 -> bytes -> (notice_result, string) res (** OCaml -> host request cancelling a pending notice (tag 27). *) val notice_cancel_request : token:int64 -> bytes + +(** OCaml -> host asset-preference lookup (tag 28); response arrives on tag 29. *) +val asset_recent_days_request : bytes + +val decode_asset_recent_days : bytes -> (int, string) result + +(** OCaml -> host asset-preference write (tag 30); response arrives on tag 31. *) +val set_asset_recent_days_request : int -> bytes diff --git a/app/journal_view.ml b/app/journal_view.ml index 3234c2f..b86c1bb 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -4,10 +4,9 @@ This module preserves the shape of the previous BonsaiSwiftUI view API (V.*, Ui.Event.*, Ui.Key, Ui.Test_id, Ui.Style, Ui.Text_editing, - Ui.Native_widget, Ui.View.Native_list, ...) on top of Lui_elements so the - application layer ports mechanically. Elements carry their optional key - and test_id so [For_testing] can recover them like the old widget - identity did. *) + Ui.View.Native_list, ...) on top of Lui_elements so the application layer + ports mechanically. Elements carry their optional key and test_id so + [For_testing] can recover them like the old widget identity did. *) type label_content = { title : string @@ -516,12 +515,6 @@ module View = struct | Destructive | Cancel - let variant = function - | Destructive -> "destructive" - | Cancel -> "secondary" - | Normal -> "default" - ;; - (* Element variants are typed; [Normal] writes nothing, matching the mount sites that only override the variant for a non-Normal role. *) let lui_variant : t -> Lui_elements.variant option = function @@ -1754,10 +1747,10 @@ module View = struct | Inset | Inset_grouped - let style_name = function - | Plain -> "plain" - | Inset -> "inset" - | Inset_grouped -> "inset_grouped" + let lui_style : style -> Lui_elements.list_style = function + | Plain -> `plain + | Inset -> `inset + | Inset_grouped -> `inset_grouped ;; type separator = @@ -1765,10 +1758,10 @@ module View = struct | Hidden | Visible - let separator_name = function - | Automatic -> "automatic" - | Hidden -> "hidden" - | Visible -> "visible" + let lui_separator : separator -> Lui_elements.separator_visibility option = function + | Automatic -> None + | Hidden -> Some `hidden + | Visible -> Some `visible ;; type row_kind = @@ -1787,6 +1780,8 @@ module View = struct ; kind : row_kind ; content : t ; on_expanded_changed : Event.handler option + ; on_press : Event.handler option + ; icon : Lui_elements.icon option } type section = @@ -1797,7 +1792,16 @@ module View = struct ; rows : row list } - let row ~key ?test_id ?(separator = Automatic) ?swipe_actions ?context_menu content = + let row + ~key + ?test_id + ?(separator = Automatic) + ?swipe_actions + ?context_menu + ?on_press + ?icon + content + = { key ; test_id ; separator @@ -1806,6 +1810,8 @@ module View = struct ; kind = Row ; content ; on_expanded_changed = None + ; on_press + ; icon } ;; @@ -1828,6 +1834,8 @@ module View = struct ; kind = Disclosure { expanded; children } ; content = label ; on_expanded_changed = Some on_expanded_changed + ; on_press = None + ; icon = None } ;; @@ -1835,290 +1843,171 @@ module View = struct { section_key = key; header; footer; separator; rows } ;; - let swipe_json (actions : Swipe_actions.t) = - `Assoc - [ ( "actions" - , `List - (List.map - (fun (a : Swipe_actions.action) -> - `Assoc - [ "key", `String a.key - ; "enabled", `Bool a.enabled - ; "role", `String (Button_role.variant a.role) - ; ( "symbol" - , match a.symbol with - | Some s -> `String s - | None -> `Null ) - ; ( "side" - , `String - (match a.side with - | Start -> "start" - | End -> "end") ) - ; "title", `String a.title - ; "background", `String a.background - ]) - actions) ) - ] + let swipe_action_mount (a : Swipe_actions.action) : Lui_elements.swipe_action_el = + Lui_elements.swipe_action + ~key:a.key + ~text:a.title + ?icon:(Option.map journal_icon a.symbol) + ?variant:(Button_role.lui_variant a.role) + ~edge: + (match a.side with + | Start -> `leading + | End -> `trailing) + ~background:a.background + ~disabled:(not a.enabled) + ~on_press:(fun _ -> invoke a.on_press Event.Payload.Unit) + [] ;; - let context_menu_json (actions : Context_menu.t) = - `Assoc - [ ( "actions" - , `List - (List.map - (fun (a : Context_menu.action) -> - `Assoc - [ "key", `String a.key - ; "enabled", `Bool a.enabled - ; ( "role" - , `String - (match a.role with - | Normal -> "normal" - | Destructive -> "destructive") ) - ; ( "symbol" - , match a.symbol with - | Some s -> `String s - | None -> `Null ) - ; "title", `String a.title - ]) - actions) ) - ] + let context_menu_mount (actions : Context_menu.t) : Lui_elements.t = + Lui_elements.context_menu + (List.map + (fun (a : Context_menu.action) -> + Lui_elements.menu_item + ~key:a.key + ~text:a.title + ?icon:(Option.map journal_icon a.symbol) + ?variant: + (match a.role with + | Context_menu.Destructive -> Some `destructive + | Normal -> None) + ~disabled:(not a.enabled) + ~on_press:(fun _ -> invoke a.on_press Event.Payload.Unit) + []) + actions) ;; - (* Content elements (headers, rows, footers, disclosure labels) mount as - extension children in a deterministic order; the payload lists their - index so the host binds each child node to its list position. *) - let build sections ~style ~scroll_request ~track_visible ~track_scroll = - let contents = ref [] in - let push element = - contents := !contents @ [ element ]; - List.length !contents - 1 - in - let rec row_json (row : row) = - let content_index = push row.content in - let base = - [ "key", `String row.key - ; "content_index", `Int content_index - ; "separator", `String (separator_name row.separator) - ] - in - let base = - match row.test_id with - | Some id -> ("test_id", `String id) :: base - | None -> base - in - let base = - match row.swipe_actions with - | Some actions -> ("swipe", swipe_json actions) :: base - | None -> base - in - let base = - match row.context_menu with - | Some menu -> ("context_menu", context_menu_json menu) :: base - | None -> base - in + (* Rows map onto [list_item]: non-list-item children render as the row + content, [list_item] children become nested disclosure rows. *) + let rec row_mount (row : row) : Lui_elements.t = + let disclosure = match row.kind with - | Row -> `Assoc (("type", `String "row") :: base) - | Disclosure { expanded; children } -> - `Assoc - (("type", `String "disclosure") - :: ("expanded", `Bool expanded) - :: ("children", `List (List.map row_json children)) - :: base) - in - let section_json (section : section) = - `Assoc - [ "key", `String section.section_key - ; "separator", `String (separator_name section.separator) - ; ( "header_index" - , match section.header with - | Some header -> `Int (push header) - | None -> `Null ) - ; ( "footer_index" - , match section.footer with - | Some footer -> `Int (push footer) - | None -> `Null ) - ; "rows", `List (List.map row_json section.rows) - ] + | Row -> None + | Disclosure { expanded; children } -> Some (expanded, children) in - let payload = - `Assoc - [ "style", `String (style_name style) - ; "sections", `List (List.map section_json sections) - ; ( "scroll_request" - , match scroll_request with - | None -> `Null - | Some request -> - `Assoc - [ "token", `String (Int64.to_string request.token) - ; ( "target" - , `Assoc - [ "section", `String request.target.section - ; ( "row_path" - , `List - (List.map (fun key -> `String key) request.target.row_path) - ) - ] ) - ; ( "anchor" - , match request.anchor with - | Some Top -> `String "top" - | Some Center -> `String "center" - | Some Bottom -> `String "bottom" - | None -> `Null ) - ; ( "animated" - , match request.animated with - | Some value -> `Bool value - | None -> `Null ) - ] ) - ; "track_visible_range", `Bool track_visible - ; "track_scroll_completion", `Bool track_scroll - ] + let item = + Lui_elements.list_item + ~key:row.key + ?accessibility_identifier:row.test_id + ?separator:(lui_separator row.separator) + ?swipe_actions:(Option.map (List.map swipe_action_mount) row.swipe_actions) + ?expanded:(Option.map fst disclosure) + ?on_press: + (Option.map + (fun handler (_ : Lui_protocol.event) -> invoke handler Event.Payload.Unit) + row.on_press) + ?icon:row.icon + ?icon_placement:(Option.map (fun _ -> `trailing) row.icon) + ?on_toggle: + (Option.map + (fun handler (event : Lui_protocol.event) -> + match event with + | ToggleChanged (_, expanded) -> + invoke handler (Event.Payload.Bool expanded) + | _ -> ()) + row.on_expanded_changed) + (row.content.mount + :: + (match disclosure with + | None -> [] + | Some (_, children) -> List.map row_mount children)) in - Yojson.Basic.to_string payload, List.rev !contents |> List.rev + match row.context_menu with + | None -> item + | Some menu -> + fun context parent -> + let node = item context parent in + ignore (context_menu_mount menu context (Some node)); + node + ;; + + let section_mount (section : section) : Lui_elements.t = + Lui_elements.list_section + ~key:section.section_key + ?separator:(lui_separator section.separator) + ?header:(Option.map (fun header -> header.mount) section.header) + ?footer:(Option.map (fun footer -> footer.mount) section.footer) + (List.map row_mount section.rows) ;; - let decode_outcome = function - | `String "succeeded" -> Event.Payload.Succeeded - | `String "missing_target" -> Missing_target - | `String "cancelled" -> Cancelled - | `String "superseded" -> Superseded - | `String "positioning_failed" -> Positioning_failed + let outcome_of_string = function + | "succeeded" -> Event.Payload.Succeeded + | "missing-target" | "missing_target" -> Missing_target + | "cancelled" -> Cancelled + | "superseded" -> Superseded | _ -> Positioning_failed ;; + (* lui resolves scroll targets against the row (or section) key; the + hierarchical path collapses to its leaf — journal row keys are + already unique. *) + let scroll_target_key (target : target) = + match List.rev target.row_path with + | key :: _ -> key + | [] -> target.section + ;; + let vertical ?key ~style ?scroll_request ?on_scroll_completed ?on_visible_range - ?(on_row_event : Event.handler option) sections = - let payload, contents = - build - sections - ~style - ~scroll_request - ~track_visible:(Option.is_some on_visible_range) - ~track_scroll:(Option.is_some on_scroll_completed) - in - (* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool}; - the owning row's handler receives Bool like the old disclosure callback. *) - let expanded_handlers = - let rec collect acc (row : row) = - match row.kind with - | Row -> acc - | Disclosure { children; _ } -> - List.fold_left collect ((row.key, row.on_expanded_changed) :: acc) children - in - List.fold_left - (fun acc section -> List.fold_left collect acc section.rows) - [] - sections - in - let on_event (event : Journal_lui_native.event) = - match - try Yojson.Basic.from_string event.payload with - | _ -> `Null - with - | `Assoc fields -> - (match List.assoc_opt "type" fields with - | Some (`String "visible_range") -> - Option.iter - (fun handler -> - let get_int64 name = - match List.assoc_opt name fields with - | Some (`Int v) -> Int64.of_int v - | Some (`String s) -> Int64.of_string s - | _ -> 0L - in - invoke - handler - (Event.Payload.Visible_range - { first_index = get_int64 "first" - ; last_exclusive = get_int64 "last" - })) - on_visible_range - | Some (`String "scroll_completed") -> - Option.iter - (fun handler -> - let token = - match List.assoc_opt "token" fields with - | Some (`Int v) -> Int64.of_int v - | Some (`String s) -> Int64.of_string s - | _ -> 0L - in - let outcome = - match List.assoc_opt "outcome" fields with - | Some json -> decode_outcome json - | None -> Event.Payload.Positioning_failed - in - invoke handler (Event.Payload.Native_list_completion { token; outcome })) - on_scroll_completed - | Some (`String "expanded") -> - (match List.assoc_opt "key" fields, List.assoc_opt "expanded" fields with - | Some (`String key), Some (`Bool expanded) -> - List.iter - (fun (row_key, handler) -> - if String.equal row_key key - then - Option.iter - (fun handler -> invoke handler (Event.Payload.Bool expanded)) - handler) - expanded_handlers - | _ -> ()) - | Some (`String "row_event") -> - Option.iter - (fun handler -> - match List.assoc_opt "payload" fields with - | Some (`String payload) -> + element ?key (fun context parent -> + Lui_elements.list + ?key + ~style:(lui_style style) + ?scroll_target: + (Option.map + (fun (r : scroll_request) -> scroll_target_key r.target) + scroll_request) + ?scroll_anchor: + (Option.bind scroll_request (fun (r : scroll_request) -> + Option.map + (fun anchor -> + match anchor with + | Top -> `top + | Center -> `center + | Bottom -> `bottom) + r.anchor)) + ?scroll_token: + (Option.map (fun (r : scroll_request) -> Int64.to_int r.token) scroll_request) + ?scroll_animated:(Option.bind scroll_request (fun r -> r.animated)) + ~track_visible_range:(Option.is_some on_visible_range) + ?on_scroll_completed: + (Option.map + (fun handler (event : Lui_protocol.event) -> + match event with + | ScrollCompleted (_, token, outcome) -> invoke handler - (Event.Payload.Native_event - { kind_id = Journal_ids.Native_widget.Kind_id.of_int 0 - ; version = 0 - ; event_id = event.event_id - ; payload = Bytes.of_string payload + (Event.Payload.Native_list_completion + { token = Int64.of_int token + ; outcome = outcome_of_string outcome }) | _ -> ()) - on_row_event - | _ -> ()) - | _ -> () - in - element ?key (fun context parent -> - Journal_lui_native.mount - ?key - ~payload - ~children:(List.map (fun element -> element.mount) contents) - ~on_event - Journal_lui_native.list_identifier + on_scroll_completed) + ?on_visible_range: + (Option.map + (fun handler (event : Lui_protocol.event) -> + match event with + | VisibleRange (_, first, last) -> + invoke + handler + (Event.Payload.Visible_range + { first_index = Int64.of_int first + ; last_exclusive = Int64.of_int last + }) + | _ -> ()) + on_visible_range) + (List.map section_mount sections) context parent) ;; end - module Navigation_link = struct - let create ?key ~activation_id:_ ?(enabled = true) ~on_activate ~label () = - element - ?key - (Lui_elements.list_item - ~disabled:(not enabled) - ?on_press: - (if enabled - then Some (fun _ -> invoke on_activate Event.Payload.Unit) - else None) - (* NavigationLink draws a trailing disclosure accessory; LUI list - items have none, so carry the chevron as an inline trailing - icon. *) - ~icon:(journal_icon "chevron.right") - ~icon_placement:`trailing - (* A list-item must carry text or children; mount the label as the - item content so composite labels render too. *) - [ label.mount ]) - ;; - end - module Navigation_stack = struct type destination = { page_key : string @@ -2204,12 +2093,31 @@ module View = struct ~presented ~on_presented_changed ?(interactive_dismiss = true) - ?sizing:_ - ?detents:_ + ?sizing + ?detents ?(title = "") ~content base = + let sizing_string = + match sizing with + | None | Some Automatic -> None + | Some Form -> Some "form" + | Some Fitted -> Some "fitted" + in + let detents_string = + match detents with + | None | Some [] -> None + | Some detents -> + Some + (String.concat + "," + (List.map + (function + | Medium -> "medium" + | Large -> "large") + detents)) + in element ?key (fun context parent -> Lui_elements.column ~grow:1.0 @@ -2219,6 +2127,8 @@ module View = struct then [ Lui_elements.sheet ~text:(if String.equal title "" then "Sheet" else title) + ?sizing:sizing_string + ?detents:detents_string ?on_dismiss: (if interactive_dismiss then @@ -2463,122 +2373,3 @@ module View = struct } ;; end - -module Native_widget = struct - module Capability = struct - type t = - | Stateful - | Resource - | Semantics - | Semantics_canvas - | Virtualized - - let bit = function - | Stateful -> 0L - | Resource -> 1L - | Semantics -> 2L - | Semantics_canvas -> 3L - | Virtualized -> 4L - ;; - - let bits capabilities = - List.fold_left - (fun acc capability -> - Int64.logor acc (Int64.shift_left 1L (Int64.to_int (bit capability)))) - 0L - capabilities - ;; - end - - module Extension = struct - type ('props, 'event) t = - { identifier : string - ; kind_id : Journal_ids.Native_widget.Kind_id.t - ; version : int - ; encode_props : 'props -> bytes - ; decode_event : - event_id:Journal_ids.Native_widget.Event_id.t - -> bytes - -> ('event, string) result - } - - let identifier_of_kind_id kind_id = - match Journal_ids.Native_widget.Kind_id.to_int kind_id with - | 2103 -> Journal_lui_native.chrome_identifier - | 2104 -> Journal_lui_native.asset_import_identifier - | 2105 -> Journal_lui_native.media_identifier - | 2106 -> Journal_lui_native.asset_settings_identifier - | other -> invalid_arg ("unregistered journal extension kind " ^ string_of_int other) - ;; - - let create ~kind_id ~version ~capabilities:_ ~encode_props ~decode_event () = - { identifier = identifier_of_kind_id kind_id - ; kind_id - ; version - ; encode_props - ; decode_event - } - ;; - end - - let decode extension event = - extension.Extension.decode_event - ~event_id: - (Journal_ids.Native_widget.Event_id.of_int event.Journal_lui_native.event_id) - (Bytes.of_string event.Journal_lui_native.payload) - ;; - - let event_handler ?name:_ extension callback = - Event.Handler.create (fun payload -> - match payload with - | Event.Payload.Native_event { event_id; payload; _ } -> - (match - extension.Extension.decode_event - ~event_id:(Journal_ids.Native_widget.Event_id.of_int event_id) - payload - with - | Ok event -> callback event - | Error _ -> ()) - | _ -> ()) - ;; - - let mount extension ?key ~props ~on_event ~children context parent = - let payload = Bytes.to_string (extension.Extension.encode_props props) in - Journal_lui_native.mount - ?key - ~payload - ~children:(List.map (fun child -> child.mount) children) - ~on_event:(fun event -> - match decode extension event with - | Ok decoded -> on_event decoded - | Error _ -> ()) - extension.Extension.identifier - context - parent - ;; - - let widget extension ?key ~props ~on_event ?(children = []) () = - element ?key (mount extension ~props ~on_event ~children) - ;; - - let widget_with_handler extension ?key ~props ~on_event ?(children = []) () = - element ?key (fun context parent -> - let payload = Bytes.to_string (extension.Extension.encode_props props) in - Journal_lui_native.mount - ?key - ~payload - ~children:(List.map (fun element -> element.mount) children) - ~on_event:(fun event -> - Event.Handler.Private.invoke - on_event - (Event.Payload.Native_event - { kind_id = extension.Extension.kind_id - ; version = extension.Extension.version - ; event_id = event.Journal_lui_native.event_id - ; payload = Bytes.of_string event.Journal_lui_native.payload - })) - extension.Extension.identifier - context - parent) - ;; -end diff --git a/app/journal_view.mli b/app/journal_view.mli index 0f165bf..4b8a9fa 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -3,8 +3,6 @@ type t -val mount : t -> Lui_elements.t - module Key : sig type t @@ -20,6 +18,19 @@ module Test_id : sig val to_string : t -> string end +val element + : ?key:Key.t + -> ?test_id:Test_id.t + -> ?menu_item_mount:Lui_elements.t + -> Lui_elements.t + -> t + +val mount : t -> Lui_elements.t + +(** lui icon properties accept built-in names or [app:] names registered + by the host; journal symbols slug onto the [app:] form. *) +val journal_icon : string -> Lui_elements.icon + module Event : sig module Payload : sig type text_selection = @@ -782,6 +793,8 @@ module View : sig -> ?separator:separator -> ?swipe_actions:Swipe_actions.t -> ?context_menu:Context_menu.t + -> ?on_press:Event.handler + -> ?icon:Lui_elements.icon -> t -> row @@ -811,22 +824,10 @@ module View : sig -> ?scroll_request:scroll_request -> ?on_scroll_completed:Event.handler -> ?on_visible_range:Event.handler - -> ?on_row_event:Event.handler -> section list -> t end - module Navigation_link : sig - val create - : ?key:Key.t - -> activation_id:string - -> ?enabled:bool - -> on_activate:Event.handler - -> label:t - -> unit - -> t - end - module Navigation_stack : sig type destination @@ -943,57 +944,3 @@ module View : sig -> Menu.entry list -> buttons_action end - -module Native_widget : sig - module Capability : sig - type t = - | Stateful - | Resource - | Semantics - | Semantics_canvas - | Virtualized - - val bit : t -> int64 - val bits : t list -> int64 - end - - module Extension : sig - type ('props, 'event) t - - val create - : kind_id:Journal_ids.Native_widget.Kind_id.t - -> version:int - -> capabilities:Capability.t list - -> encode_props:('props -> bytes) - -> decode_event: - (event_id:Journal_ids.Native_widget.Event_id.t - -> bytes - -> ('event, string) result) - -> unit - -> ('props, 'event) t - end - - val event_handler - : ?name:string - -> ('props, 'event) Extension.t - -> ('event -> unit) - -> Event.handler - - val widget - : ('props, 'event) Extension.t - -> ?key:Key.t - -> props:'props - -> on_event:('event -> unit) - -> ?children:View.t list - -> unit - -> View.t - - val widget_with_handler - : ('props, 'event) Extension.t - -> ?key:Key.t - -> props:'props - -> on_event:Event.handler - -> ?children:View.t list - -> unit - -> View.t -end diff --git a/apple-tests/amplify/JournalAmplifyAcceptance.swift b/apple-tests/amplify/JournalAmplifyAcceptance.swift index 3fca03c..2fbd207 100644 --- a/apple-tests/amplify/JournalAmplifyAcceptance.swift +++ b/apple-tests/amplify/JournalAmplifyAcceptance.swift @@ -31,8 +31,7 @@ struct JournalAmplifyAcceptance: View { // bridge attaches inside JournalRuntime.start(). JournalRuntimeHost( platform: platform, - payload: (try? JournalNativeServices.startupPayload()) ?? Data(), - extensions: (try? JournalExtensions.registry()) ?? LUIAppleExtensionRegistry()) + payload: (try? JournalNativeServices.startupPayload()) ?? Data()) .environment(\.scenePhase, .active) .frame(height: 80) }.task { diff --git a/apple-tests/native-outline/outline_probe.ml b/apple-tests/native-outline/outline_probe.ml index c24235e..c75cbde 100644 --- a/apple-tests/native-outline/outline_probe.ml +++ b/apple-tests/native-outline/outline_probe.ml @@ -1,13 +1,15 @@ -(* Headless acceptance probe for journal-list disclosure expansion and - context-menu row actions, built on Lui_app + the journal extension mounts. - Dropped into a generated host as app/application.ml (see - tool/test_swiftui_outline.py). Row actions surface through the extension - "event" channel: the host emits {"type":"row_event","payload":} - and {"type":"expanded","key":..,"expanded":..}; expansion and row presses - drive the reducer below. *) +(* Headless acceptance probe for native disclosure expansion and + context-menu/swipe row actions, built on Lui_app + the production + [Journal_view.Native_list] builder (lui list elements). Dropped into a + generated host as app/application.ml (see tool/test_swiftui_outline.py). + Row actions surface through the lui press channel as [Unit] payloads; + disclosure toggles arrive as [Bool] payloads through + [on_expanded_changed] and drive the reducer below. *) open Lui_protocol open Lui_elements +module Ui = Journal_view +module V = Ui.View type model = { observed : string @@ -25,113 +27,79 @@ let reducer model = function | Expand value -> { model with expanded = value } ;; -let on_list_event send (event : Journal_lui_native.event) = - match - try Yojson.Basic.from_string event.payload with - | _ -> `Null - with - | `Assoc fields -> - (match List.assoc_opt "type" fields with - | Some (`String "expanded") -> - (match List.assoc_opt "key" fields, List.assoc_opt "expanded" fields with - | Some (`String _), Some (`Bool value) -> ignore (send (Expand value)) - | _ -> ()) - | Some (`String "row_event") -> - (match List.assoc_opt "payload" fields with - | Some (`String inner) -> - (match - try Yojson.Basic.from_string inner with - | _ -> `Null - with - | `Assoc inner_fields -> - (match - List.assoc_opt "row" inner_fields, List.assoc_opt "key" inner_fields - with - | Some (`String row), Some (`String key) -> - ignore (send (Observe (key ^ ":" ^ row))) - | _ -> ()) - | _ -> ()) - | _ -> ()) - | _ -> ()) - | _ -> () +let key value = Ui.Key.string value +let test_id value = Ui.Test_id.string value + +(* [Observed] keeps the old contract shape: {"action":"delete","row":}. *) +let delete_handler send row_id = + Ui.Event.Handler.create ~name:("delete-" ^ row_id) (fun _ -> + ignore + (send (Observe (Printf.sprintf "{\"action\":\"delete\",\"row\":\"%s\"}" row_id)))) ;; -(* The payload mirrors Journal_view.Native_list's build output: sections and - row descriptors in JSON, content elements mounted as extension children in - the order the payload's content_index fields reference. *) -let outline_list ~expanded send : Lui_elements.t = - let contents = ref [] in - let push element = - contents := element :: !contents; - List.length !contents - 1 - in - let context_menu_json = - ( "context_menu" - , `Assoc - [ ( "actions" - , `List - [ `Assoc - [ "key", `String "delete" - ; "enabled", `Bool true - ; "role", `String "destructive" - ; "symbol", `Null - ; "title", `String "Delete" - ] - ] ) - ] ) - in - let row ~id ~label = - `Assoc - [ "type", `String "row" - ; "key", `String id - ; "content_index", `Int (push (text ~value:label [])) - ; "separator", `String "hidden" - ; context_menu_json - ] +let row_actions send row_id = + let swipe_actions = + V.Swipe_actions.create + ~allows_full_swipe:false + ~actions: + [ V.Swipe_actions.action + ~key:(key ("delete:" ^ row_id)) + ~side:End + ~title:"Delete" + ~role:Destructive + ~background:Journal_visual_tokens.delete_action_background + ~on_press:(delete_handler send row_id) + () + ] + () in - let disclosure ~id ~expanded children = - `Assoc - [ "type", `String "disclosure" - ; "key", `String id - ; "content_index", `Int (push (text ~value:"Parent row" [])) - ; "separator", `String "hidden" - ; "expanded", `Bool expanded - ; "children", `List children - ] + let context_menu = + V.Context_menu.create + ~actions: + [ V.Context_menu.action + ~key:(key "delete") + ~role:Destructive + ~title:"Delete" + ~on_press:(delete_handler send row_id) + () + ] + () in - let payload = - Yojson.Basic.to_string - (`Assoc - [ "style", `String "plain" - ; ( "sections" - , `List - [ `Assoc - [ "key", `String "rows" - ; "separator", `String "hidden" - ; "header_index", `Null - ; "footer_index", `Null - ; ( "rows" - , `List - [ disclosure - ~id:"parent" - ~expanded - [ row ~id:"child" ~label:"Child row" - ; row ~id:"branch" ~label:"Unloaded branch row" - ] - ; row ~id:"sibling" ~label:"Sibling row" - ] ) - ] - ] ) - ; "scroll_request", `Null - ; "track_visible_range", `Bool false - ; "track_scroll_completion", `Bool false - ]) + swipe_actions, context_menu +;; + +let outline_list ~expanded send : V.t = + let leaf id = + let swipe_actions, context_menu = row_actions send id in + V.Native_list.row + ~key:(key id) + ~separator:Hidden + ~swipe_actions + ~context_menu + (V.text id) in - Journal_lui_native.list - ~key:"outline" - ~payload - ~children:(List.rev !contents) - ~on_event:(on_list_event send) + let swipe_actions, context_menu = row_actions send "parent" in + V.Native_list.vertical + ~key:(key "outline") + ~style:Plain + [ V.Native_list.section + ~key:(key "rows") + ~separator:Hidden + [ V.Native_list.disclosure_row + ~key:(key "parent") + ~separator:Hidden + ~swipe_actions + ~context_menu + ~expanded + ~on_expanded_changed: + (Ui.Event.Handler.create ~name:"expand-parent" (function + | Ui.Event.Payload.Bool value -> ignore (send (Expand value)) + | _ -> ())) + ~label:(V.text "Parent row") + [ leaf "child"; leaf "branch" ] + ; leaf "sibling" + ] + ] ;; let view _context model_source send = @@ -139,7 +107,7 @@ let view _context model_source send = column ~gap:16 [ text ~value:("Observed: " ^ model.observed) [] - ; outline_list ~expanded:model.expanded send + ; Ui.mount (outline_list ~expanded:model.expanded send) ] ;; diff --git a/apple-tests/warm-start/JournalWarmStartAcceptance.swift b/apple-tests/warm-start/JournalWarmStartAcceptance.swift index 4ec59bf..de8722a 100644 --- a/apple-tests/warm-start/JournalWarmStartAcceptance.swift +++ b/apple-tests/warm-start/JournalWarmStartAcceptance.swift @@ -150,8 +150,7 @@ private struct WarmFixture: Decodable { do { let headless = JournalApplicationPlatform(services: services) let runtime = try JournalRuntime( - platform: headless, startupPayload: payload, - extensionRegistry: try JournalExtensions.registry()) + platform: headless, startupPayload: payload) runtime.start() for index in 0..<5 { runtime.pump() @@ -204,7 +203,6 @@ private struct WarmFixture: Decodable { #endif @State private var probe: WarmProbe @State private var activeScene = true - private let registry: LUIAppleExtensionRegistry init() { // This executable is test-only; never access the user's native secrets. @@ -240,7 +238,6 @@ private struct WarmFixture: Decodable { missing: arguments.contains("--missing-key"), report: path.appendingPathExtension("observations.jsonl")) probe.observePlatform() _probe = State(initialValue: probe) - self.registry = try JournalExtensions.registry() } catch { fatalError("Fixture setup failed: \(error)") } } @@ -289,8 +286,7 @@ private struct WarmFixture: Decodable { if ProcessInfo.processInfo.arguments.contains("--pump-only") { Text("Inspecting the public native runtime").task { await probe.inspectFirstFrame() } } else { - JournalRuntimeHost(platform: probe.platform, payload: probe.payload, - extensions: registry) + JournalRuntimeHost(platform: probe.platform, payload: probe.payload) .font(.body) .preferredColorScheme(ProcessInfo.processInfo.arguments.contains("--dark-appearance") ? .dark : ProcessInfo.processInfo.arguments.contains("--light-appearance") ? .light : nil) diff --git a/logseq_journal.opam b/logseq_journal.opam index 6f1bd62..369debb 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#58ad2dcd7ea62adb86c714e3c6403872efae5b9b"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#12d95adfe87341d3003e78ee20d47922771d044b"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#976b40f1770a65b3464df1ef38d1550f1d8a43dd"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index c158e4e..02cbfee 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -23,7 +23,7 @@ pin-depends: [ ] [ "lui.0.1.0" - "git+https://github.com/logseq/lui.git#58ad2dcd7ea62adb86c714e3c6403872efae5b9b" + "git+https://github.com/logseq/lui.git#12d95adfe87341d3003e78ee20d47922771d044b" ] [ "melange-edn-core.0.5.0" diff --git a/swift/App.swift b/swift/App.swift index 32e0984..5aa3d7f 100644 --- a/swift/App.swift +++ b/swift/App.swift @@ -27,10 +27,8 @@ import AppKit private struct JournalRuntimeSetup { let payload: Data - let extensions: LUIAppleExtensionRegistry @MainActor init() throws { payload = try JournalNativeServices.startupPayload() - extensions = try JournalExtensions.registry() } } @@ -51,7 +49,7 @@ private struct JournalHost: View { } actions: { Button("Retry") { setup = nil; retry += 1 } } case .success(let setup): JournalRuntimeHost( - platform: platform, payload: setup.payload, extensions: setup.extensions) + platform: platform, payload: setup.payload) .font(.body) .safeAreaInset(edge: .top, spacing: 0) { if platform.authenticationRequired { diff --git a/swift/JournalAssetImport.swift b/swift/JournalAssetImport.swift deleted file mode 100644 index 5f57008..0000000 --- a/swift/JournalAssetImport.swift +++ /dev/null @@ -1,389 +0,0 @@ -import LUIAppleBackend -import Observation -import PhotosUI -import SwiftUI -import UniformTypeIdentifiers -#if os(iOS) -import UIKit -#endif - -@MainActor enum JournalAssetImport { - struct Request: Decodable { - let id: Int - let source: String? - let staged: Bool? - } - - struct PendingItem: Decodable, Identifiable { - let token: String - let path: String - let title: String - let type: String? - var id: String { token } - var fileType: String { type ?? "bin" } - var isImage: Bool { JournalMedia.imageTypes.contains(fileType) } - } - - struct Properties: Decodable { - let enabled: Bool - let completion: String? - let error: String? - let replace: String? - let request: Request? - let pending: [PendingItem]? - } - - @Observable final class Selection { - var operation: String? - private var source: URL? - private var scoped = false - - func retain(_ url: URL, operation: String) { - release() - source = url - scoped = url.startAccessingSecurityScopedResource() - self.operation = operation - } - - func release() { - if scoped { source?.stopAccessingSecurityScopedResource() } - source = nil - scoped = false - operation = nil - } - } - - /// One pending-attachment chip: thumbnail (images only) over a file icon, - /// with a remove affordance that emits the extension's `remove` event. - private struct PendingCell: SwiftUI.View { - let item: PendingItem - let onRemove: () -> Void - @State private var image: CGImage? - @State private var decodeFailed = false - - var body: some SwiftUI.View { - VStack(spacing: 2) { - Group { - if let image { - Image(decorative: image, scale: 1).resizable().scaledToFill() - } else { - Image(systemName: item.isImage && !decodeFailed ? "photo" : "doc") - .font(.title3) - .foregroundStyle(.secondary) - } - } - .frame(width: 48, height: 48) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - Text(item.title) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 56) - } - .overlay(alignment: .topTrailing) { - Button(action: onRemove) { - Image(systemName: "xmark.circle.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .padding(2) - .accessibilityIdentifier("journal-asset-remove:" + item.token) - } - .task(id: item.path) { - guard item.isImage else { return } - image = nil - decodeFailed = false - let decoded = await JournalMediaDecoder.shared.load(item.path) - guard !Task.isCancelled else { return } - image = decoded - decodeFailed = decoded == nil - } - .accessibilityIdentifier("journal-asset-pending:" + item.token) - } - } - - #if os(iOS) - private struct CameraPicker: UIViewControllerRepresentable { - let onImage: (UIImage) -> Void - let onCancel: () -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(onImage: onImage, onCancel: onCancel) - } - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = .camera - picker.delegate = context.coordinator - return picker - } - - func updateUIViewController(_: UIImagePickerController, context _: Context) {} - - final class Coordinator: NSObject, UIImagePickerControllerDelegate, - UINavigationControllerDelegate - { - let onImage: (UIImage) -> Void - let onCancel: () -> Void - - init(onImage: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { - self.onImage = onImage - self.onCancel = onCancel - } - - func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - if let image = info[.originalImage] as? UIImage { onImage(image) } - picker.dismiss(animated: true) - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - onCancel() - picker.dismiss(animated: true) - } - } - } - #endif - - struct View: SwiftUI.View { - let context: LUIAppleExtensionViewContext - @State private var selection = Selection() - @State private var filePresented = false - @State private var photosPresented = false - @State private var cameraPresented = false - @State private var photoItem: PhotosPickerItem? - @State private var handled = false - @State private var lastRequest = 0 - @State private var error: String? - - private var properties: Properties? { - JournalExtensions.decode(Properties.self, context: context) - } - - private var request: Request? { properties?.request } - - private func emit(_ object: [String: Any]) -> Bool { - guard let data = try? JSONSerialization.data(withJSONObject: object) - else { return false } - return JournalExtensions.emit(context: context, payload: data) - } - - private func emitDismissed() { - guard !handled else { return } - handled = true - _ = emit(["action": "dismissed"]) - } - - private func emitUnavailable(_ reason: String) { - handled = true - _ = emit([ - "action": "unavailable", - "reason": reason, - "request": request?.id ?? 0, - ]) - } - - private func emitRemove(_ token: String) { - _ = emit(["action": "remove", "token": token]) - } - - private func emitPick(path: String, title: String, type: String, retained: URL?) { - handled = true - let operation = UUID().uuidString.lowercased() - if let retained { selection.retain(retained, operation: operation) } - let ok = emit([ - "operation": operation, - "asset": UUID().uuidString.lowercased(), - "localMutation": UUID().uuidString.lowercased(), - "metadataMutation": UUID().uuidString.lowercased(), - "path": path, - "title": title, - "replaceReference": properties?.replace ?? NSNull(), - "type": type.isEmpty ? "bin" : type, - "request": [ - "id": request?.id ?? 0, - "source": request?.source ?? "files", - "staged": request?.staged ?? false, - ], - ] as [String: Any]) - if !ok { - selection.release() - error = "The destination is no longer available. Select the file again." - } - } - - /// Staged requests copy the pick into a temp file so the path stays valid - /// after the picker's security scope is released (attach-on-save). - private func stagedCopy(of url: URL, title: String) -> (path: String, title: String)? { - let ext = url.pathExtension.lowercased() - let name = - "journal-import-" + UUID().uuidString.lowercased() - + (ext.isEmpty ? "" : "." + ext) - let dest = FileManager.default.temporaryDirectory.appendingPathComponent(name) - try? FileManager.default.removeItem(at: dest) - do { - try FileManager.default.copyItem(at: url, to: dest) - return (dest.path(percentEncoded: false), title) - } catch { - return nil - } - } - - private func stageData(_ data: Data, ext: String, title: String) { - let dest = FileManager.default.temporaryDirectory - .appendingPathComponent("journal-import-" + UUID().uuidString.lowercased() + "." + ext) - do { - try data.write(to: dest) - emitPick(path: dest.path(percentEncoded: false), title: title, type: ext, retained: nil) - } catch { - self.error = "Unable to save the image. Please try again." - } - } - - private func handleFilePick(_ source: URL) { - let title = source.lastPathComponent - let type = source.pathExtension.lowercased() - if request?.staged == true { - selection.retain(source, operation: UUID().uuidString.lowercased()) - let staged = stagedCopy(of: source, title: title) - selection.release() - guard let staged else { - self.error = "Unable to access the selected file. Please try again." - return - } - emitPick(path: staged.path, title: staged.title, type: type, retained: nil) - } else { - emitPick( - path: source.path(percentEncoded: false), title: title, type: type, - retained: source) - } - } - - private func importPhoto(_ item: PhotosPickerItem) async { - photoItem = nil - guard let data = try? await item.loadTransferable(type: Data.self) - else { - self.error = "Unable to read the selected photo. Please try again." - return - } - let ext = - item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg" - stageData(data, ext: ext, title: "photo." + ext) - } - - var body: some SwiftUI.View { - VStack(spacing: 8) { - if let pending = properties?.pending, !pending.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(pending) { item in - PendingCell(item: item) { emitRemove(item.token) } - } - } - .padding(.horizontal, 4) - } - .frame(height: 72) - } - context.content - } - .fileImporter( - isPresented: $filePresented, allowedContentTypes: [.item], - allowsMultipleSelection: false - ) { result in - guard context.isUserInteractionEnabled else { return } - do { - guard let source = try result.get().first else { - if properties?.replace != nil { emitDismissed() } - return - } - handleFilePick(source) - } catch { - selection.release() - if (error as NSError).code == NSUserCancelledError { - if properties?.replace != nil { emitDismissed() } - } else { - self.error = "Unable to access the selected file. Please try again." - } - } - } - .photosPicker( - isPresented: $photosPresented, selection: $photoItem, matching: .images - ) - .sheet(isPresented: $cameraPresented) { - #if os(iOS) - CameraPicker( - onImage: { image in - if let data = image.jpegData(compressionQuality: 0.9) { - stageData(data, ext: "jpg", title: "camera.jpg") - } else { - self.error = "Unable to save the image. Please try again." - } - }, - onCancel: { - if properties?.replace != nil { emitDismissed() } - }) - #else - EmptyView() - #endif - } - .onChange(of: filePresented) { _, isPresented in - if isPresented { - handled = false - } else if properties?.replace != nil { - // iOS never invokes the fileImporter completion on Cancel, so treat - // closing an armed picker without a pick as a dismissal. - emitDismissed() - } - } - .onChange(of: photosPresented) { _, isPresented in - if isPresented { handled = false } - } - .onChange(of: photoItem) { _, item in - guard let item else { return } - Task { await importPhoto(item) } - } - .onChange(of: request?.id) { _, id in - guard let id, id != lastRequest, context.isUserInteractionEnabled, - properties?.enabled == true, selection.operation == nil - else { return } - lastRequest = id - handled = false - switch request?.source ?? "files" { - case "files": - filePresented = true - case "photos": - photosPresented = true - case "camera": - #if os(iOS) - if UIImagePickerController.isSourceTypeAvailable(.camera) { - cameraPresented = true - } else { - emitUnavailable("The camera is not available on this device.") - } - #else - emitUnavailable("Camera capture is not available on this platform.") - #endif - default: - emitUnavailable("Unknown attachment source.") - } - } - .onChange(of: properties?.completion) { _, operation in - guard let operation, operation == selection.operation else { return } - selection.release() - error = properties?.error - } - .onDisappear { selection.release() } - .alert( - "Unable to import file", - isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } }) - ) { - Button("OK", role: .cancel) { error = nil } - } message: { Text(error ?? "") } - } - } -} diff --git a/swift/JournalAssetSettings.swift b/swift/JournalAssetSettings.swift deleted file mode 100644 index ef30687..0000000 --- a/swift/JournalAssetSettings.swift +++ /dev/null @@ -1,99 +0,0 @@ -import LUIAppleBackend -import SwiftUI - -@MainActor enum JournalAssetSettings { - struct Upload: Decodable, Identifiable { - let id: String - let title: String - let message: String - let busy: Bool - let retry: Bool - } - struct Properties: Decodable { - let presented: Bool - let recent: String - let favorites: String - let uploads: [Upload] - } - struct View: SwiftUI.View { - let context: LUIAppleExtensionViewContext - private let preferences = JournalAssetPreferences(defaults: .standard) - @State private var days = JournalAssetPreferences(defaults: .standard).recentDays - @State private var deliveredDays: Int? - - private var properties: Properties? { - JournalExtensions.decode(Properties.self, context: context) - } - - @discardableResult private func emit(_ value: String) -> Bool { - JournalExtensions.emit(context: context, payload: Data(value.utf8)) - } - private func deliver() { - if deliveredDays != days && emit("days:\(days)") { deliveredDays = days } - } - var body: some SwiftUI.View { - context.content - // The root column's proposal is the full viewport; expand to fill it - // and anchor the page at the top so bar rows don't drift to center. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .task { deliver() } - .onChange(of: days) { _, value in - if preferences.save(recentDays: value) { deliver() } - } - .sheet(isPresented: Binding( - get: { properties?.presented == true }, - set: { if !$0 { emit("dismissed") } })) { - NavigationStack { - Form { - Section("Offline attachments") { - Stepper("Recent journal days: \(days)", value: $days, in: JournalAssetPreferences.allowedDays) - VStack(alignment: .leading, spacing: 8) { - Text("Recent journals: " + (properties?.recent ?? "")) - .font(.footnote).fixedSize(horizontal: false, vertical: true) - Text("Favorites: " + (properties?.favorites ?? "")) - .font(.footnote).fixedSize(horizontal: false, vertical: true) - if let properties, !properties.uploads.isEmpty { - Text("Uploads").font(.headline) - ForEach(properties.uploads) { upload in - HStack(alignment: .top, spacing: 12) { - if upload.busy { ProgressView().controlSize(.small).accessibilityLabel(upload.message) } - VStack(alignment: .leading, spacing: 4) { - Text(upload.title).font(.body).lineLimit(2) - Text(upload.message).font(.caption).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 0) - if upload.retry { - Button("Retry") { emit("retry:" + upload.id) } - .buttonStyle(.borderless) - .accessibilityLabel("Retry upload: " + upload.title) - .accessibilityIdentifier("journal-upload-retry:" + upload.id) - } - } - .accessibilityIdentifier("journal-upload:" + upload.id) - } - } - Text("Downloads attachments from today and the preceding days. Set to 0 to disable recent-journal downloads.") - .font(.footnote).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Text("Favorites include their complete subtrees, regardless of this setting.") - .font(.footnote).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .formStyle(.grouped) - .navigationTitle("Attachment settings") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { emit("dismissed") } - } - } - } - .presentationSizing(.form) - .presentationDetents([.medium, .large]) - .frame(minWidth: 360, idealWidth: 440, minHeight: 280) - } - } - } -} diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift deleted file mode 100644 index 47372ec..0000000 --- a/swift/JournalChrome.swift +++ /dev/null @@ -1,181 +0,0 @@ -import LUIAppleBackend -import SwiftUI - -/// Native chrome layout only. List sections own all date scrolling and pinning. -@MainActor enum JournalChrome { - private enum Mode: String, Decodable { - case feedback, page, header, detail - case bottomControls = "bottom-controls" - } - - private struct Properties: Decodable { - let mode: Mode - let top: Bool? - let visible: Bool? - let title: String? - let connecting: Bool? - let controls: Bool? - } - - fileprivate struct ControlsSizeKey: EnvironmentKey { - static let defaultValue = CGSize.zero - } - - private struct FloatingChrome: SwiftUI.View { - let content: AnyView - let cluster: AnyView - let progress: AnyView - let properties: Properties - @State private var controlsSize = CGSize.zero - - var body: some SwiftUI.View { - GeometryReader { bounds in - content.frame(width: bounds.size.width, height: bounds.size.height) - .scrollContentBackground(.hidden) - #if os(iOS) - .scrollEdgeEffectHidden(true, for: .bottom) - #endif - .environment(\.journalControlsSize, controlsSize) - #if os(iOS) - .toolbar(.hidden, for: .navigationBar) - #endif - .overlay(alignment: .topTrailing) { - if properties.title == nil { - controls.padding(.trailing, 16) - } - } - .safeAreaInset(edge: .top, spacing: 0) { - if let title = properties.title { - ZStack { - Text(title) - .font(.headline) - .accessibilityAddTraits(.isHeader) - .accessibilityIdentifier("favorites-header-title") - HStack { - Spacer() - controls - } - } - .frame(minHeight: 44) - .padding(.horizontal, 16) - } - } - } - } - - private var controls: some SwiftUI.View { - HStack(spacing: 8) { - if properties.connecting! { progress.controlSize(.small).fixedSize() } - if properties.controls! { cluster } - } - .fixedSize() - .onGeometryChange(for: CGSize.self) { $0.size } action: { controlsSize = $0 } - } - } - - private struct SectionDate: SwiftUI.View { - let title: String - @Environment(\.journalControlsSize) private var controlsSize - - var body: some SwiftUI.View { - HStack(spacing: 0) { - Text(title) - .font(.title2.weight(.semibold)) - .foregroundStyle(.primary) - .monospacedDigit() - .textCase(nil) - .lineLimit(1) - .minimumScaleFactor(0.5) - .accessibilityAddTraits(.isHeader) - Spacer(minLength: controlsSize.width > 0 ? controlsSize.width + 16 : 0) - } - } - } - - struct View: SwiftUI.View { - let context: LUIAppleExtensionViewContext - - private var properties: Properties? { - JournalExtensions.decode(Properties.self, context: context) - } - - private func child(_ index: Int) -> AnyView { - guard context.childIDs.count > index else { return AnyView(EmptyView()) } - return context.content(for: context.childIDs[index]) - } - - var body: some SwiftUI.View { - switch properties?.mode { - case .feedback: - if let properties, context.childIDs.count == 3, - properties.top != nil, properties.visible != nil { - GeometryReader { bounds in - child(0).frame(width: bounds.size.width, height: bounds.size.height) - } - .safeAreaInset(edge: properties.top! ? .top : .bottom, spacing: 0) { - if properties.visible! { - ViewThatFits(in: .horizontal) { - child(1) - child(2) - } - .frame(maxWidth: .infinity) - .background(.bar) - } - } - } - case .page: - if let properties, context.childIDs.count == 3, - properties.connecting != nil, properties.controls != nil { - FloatingChrome(content: child(0), cluster: child(1), - progress: child(2), properties: properties) - } - case .header: - if let properties, context.childIDs.isEmpty, let title = properties.title { - SectionDate(title: title) - } - case .detail: - if let title = properties?.title, context.childIDs.count == 3 { - GeometryReader { bounds in - child(0).frame(width: bounds.size.width, height: bounds.size.height) - } - #if os(iOS) - .toolbar(.hidden, for: .navigationBar) - #endif - .safeAreaInset(edge: .top, spacing: 0) { - ZStack { - Text(title).font(.headline).accessibilityAddTraits(.isHeader) - HStack { - child(1).fixedSize() - Spacer() - child(2).fixedSize() - } - } - .frame(minHeight: 44) - .padding(.horizontal, 16) - } - } - case .bottomControls: - if context.childIDs.count == 2 { - GeometryReader { bounds in - child(0).frame(width: bounds.size.width, height: bounds.size.height) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - child(1) - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - } - case .none: - EmptyView() - } - } - } -} - -private extension EnvironmentValues { - var journalControlsSize: CGSize { - get { self[JournalChrome.ControlsSizeKey.self] } - set { self[JournalChrome.ControlsSizeKey.self] = newValue } - } -} diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift deleted file mode 100644 index 5672eed..0000000 --- a/swift/JournalExtensions.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation -import SwiftUI -import LUIAppleBackend - -/// Reproduces `Lui_extension.fingerprint` in OCaml (src/lui_extension.ml): -/// the backend rejects `create-extension` ops whose fingerprint does not -/// match the registered schema byte-for-byte. -enum JournalExtensionFingerprint { - struct Property { - let name: String - let kind: String - let required: Bool - let defaultValue: String? - } - struct Event { - let name: String - let fields: [(name: String, kind: String, required: Bool)] - } - - static func make( - identifier: String, - profiles: [String], - standardChildren: Bool, - children: [String], - properties: [Property], - events: [Event] - ) -> String { - func token(_ value: String) -> String { - "\(value.utf8.count):\(value)" - } - func propertyToken(_ property: Property) -> String { - let fallback = property.defaultValue.map { "some:s\(token($0))" } ?? "none" - return token(property.name) + ":" + property.kind + ":" - + (property.required ? "required" : "optional") + ":" + fallback - } - func eventToken(_ event: Event) -> String { - let fields = event.fields - .map { token($0.name) + ":" + $0.kind + ":" - + ($0.required ? "required" : "optional") } - .sorted() - .joined(separator: ",") - return token(event.name) + "[" + fields + "]" - } - return "lui-extension-v1|" + token(identifier) - + "|profiles:" + profiles.sorted().joined(separator: ",") - + "|standard-children:" + (standardChildren ? "1" : "0") - + "|children:" + children.sorted().map(token).joined(separator: ",") - + "|properties:" + properties.map(propertyToken).sorted().joined(separator: ",") - + "|events:" + events.map(eventToken).sorted().joined(separator: ",") - } -} - -@MainActor enum JournalExtensions { - private static let appleProfiles = ["macos/swiftui", "ios/swiftui"] - private static let allHostProfiles = - appleProfiles + ["macos/flutter", "ios/flutter", "android/flutter"] - private static let payloadProperty = - JournalExtensionFingerprint.Property( - name: "payload", kind: "string", required: true, defaultValue: nil) - private static let event = - JournalExtensionFingerprint.Event( - name: "event", - fields: [(name: "id", kind: "int", required: true), - (name: "payload", kind: "string", required: true)]) - - private static func fingerprint( - identifier: String, profiles: [String], standardChildren: Bool, events: Bool - ) -> String { - JournalExtensionFingerprint.make( - identifier: identifier, profiles: profiles, - standardChildren: standardChildren, children: journalChildIdentifiers, - properties: [payloadProperty], - events: events ? [event] : []) - } - - /// Journal native views nest (chrome slots hold page content including - /// other chrome sections, lists, and media; list rows hold media and - /// chrome section headers), so every component accepts all journal - /// extensions as children. Must stay in sync with `journal_lui_native.ml`. - private static let journalChildIdentifiers = [ - "journal-chrome", - "journal-asset-import", - "journal-media", - "journal-asset-settings", - "journal-list", - ] - - private static let eventSchema = LUIExtensionEvent( - name: "event", - fields: [ - .init(name: "id", kind: .int, isRequired: true), - .init(name: "payload", kind: .string, isRequired: true), - ]) - - /// Decodes the required `payload` string property into the same Codable - /// `Properties` structs the bonsai native views decoded. - static func decode( - _ type: Properties.Type, context: LUIAppleExtensionViewContext - ) -> Properties? { - guard case let .string(json) = context.property("payload"), - let value = try? JSONDecoder().decode(type, from: Data(json.utf8)) - else { return nil } - return value - } - - /// Forwards one journal event on the `"event"` schema, matching the old - /// `BonsaiNativeEvent(id, payload)` contract. Returns delivery success. - @discardableResult - static func emit( - context: LUIAppleExtensionViewContext, id: Int = 1, payload: Data - ) -> Bool { - guard context.isUserInteractionEnabled else { return false } - return (try? context.emit( - name: "event", - values: [ - "id": .int(id), - "payload": .string(String(decoding: payload, as: UTF8.self)), - ])) != nil - } - - private static func journalExtension( - identifier: String, profiles: [String], standardChildren: Bool, events: Bool, - viewFactory: @escaping LUIAppleExtension.ViewFactory - ) -> LUIAppleExtension { - LUIAppleExtension( - identifier: identifier, - fingerprint: fingerprint( - identifier: identifier, profiles: profiles, - standardChildren: standardChildren, events: events), - acceptsStandardChildren: standardChildren, - childIdentifiers: journalChildIdentifiers, - properties: [.init(name: "payload", kind: .string, isRequired: true)], - events: events ? [eventSchema] : [], - viewFactory: viewFactory) - } - - static func chromeExtension() -> LUIAppleExtension { - journalExtension(identifier: "journal-chrome", profiles: appleProfiles, - standardChildren: true, events: false) { context in - AnyView(JournalChrome.View(context: context)) - } - } - - static func assetImportExtension() -> LUIAppleExtension { - journalExtension(identifier: "journal-asset-import", profiles: allHostProfiles, - standardChildren: true, events: true) { context in - AnyView(JournalAssetImport.View(context: context)) - } - } - - static func mediaExtension() -> LUIAppleExtension { - journalExtension(identifier: "journal-media", profiles: allHostProfiles, - standardChildren: true, events: true) { context in - AnyView(JournalMedia.View(context: context)) - } - } - - static func assetSettingsExtension() -> LUIAppleExtension { - journalExtension(identifier: "journal-asset-settings", profiles: allHostProfiles, - standardChildren: true, events: true) { context in - AnyView(JournalAssetSettings.View(context: context)) - } - } - - static func listExtension() -> LUIAppleExtension { - journalExtension(identifier: "journal-list", profiles: allHostProfiles, - standardChildren: true, events: true) { context in - AnyView(JournalList.View(context: context)) - } - } - - static func registry() throws -> LUIAppleExtensionRegistry { - let registry = LUIAppleExtensionRegistry() - for journalExtension in [ - chromeExtension(), - assetImportExtension(), - mediaExtension(), - assetSettingsExtension(), - listExtension(), - ] { - try registry.register(journalExtension) - } - return registry - } -} diff --git a/swift/JournalList.swift b/swift/JournalList.swift deleted file mode 100644 index d274f9e..0000000 --- a/swift/JournalList.swift +++ /dev/null @@ -1,378 +0,0 @@ -import LUIAppleBackend -import SwiftUI - -/// SwiftUI host for the `journal-list` extension — the lui replacement for -/// bonsai's `Native_list` family (grouped sections, disclosure rows, scroll -/// position requests, visible-range paging, swipe + context actions). -/// -/// Rows carry arbitrary content: each row's `content_index` (and each -/// section's `header_index`/`footer_index`) indexes `context.childIDs` and is -/// rendered via `context.content(for:)`. Row activation is handled inside -/// lui (the content mounts `Navigation_link`-style nodes); this view only -/// emits the auxiliary events: -/// -/// { "type": "visible_range", "first": , "last": } -/// { "type": "scroll_completed", "token": "", "outcome": "" } -/// { "type": "expanded", "key": "", "expanded": } -/// { "type": "row_event", "payload": "{\"key\":\"\",\"row\":\"\"}" } -/// -/// Positions are flat indices across the displayed rows in payload order -/// (disclosure children count only while the parent is expanded). -@MainActor enum JournalList { - struct Action: Decodable, Identifiable { - let key: String - let enabled: Bool? - let role: String? - let symbol: String? - let side: String? - let title: String - let background: String? - var id: String { key } - } - struct ActionGroup: Decodable { let actions: [Action] } - struct Row: Decodable, Identifiable { - let type: String - let key: String - let content_index: Int - let separator: String? - let test_id: String? - let swipe: ActionGroup? - let context_menu: ActionGroup? - let expanded: Bool? - let children: [Row]? - var id: String { key } - var isDisclosure: Bool { type == "disclosure" } - } - struct SectionModel: Decodable, Identifiable { - let key: String - let separator: String? - let header_index: Int? - let footer_index: Int? - let rows: [Row] - var id: String { key } - } - struct ScrollRequest: Decodable { - struct Target: Decodable { - let section: String - let row_path: [String] - } - let token: String - let target: Target - let anchor: String? - let animated: Bool? - } - struct Properties: Decodable { - let style: String - let sections: [SectionModel] - let scroll_request: ScrollRequest? - let track_visible_range: Bool? - let track_scroll_completion: Bool? - } - - struct View: SwiftUI.View { - let context: LUIAppleExtensionViewContext - @State private var visible: Set = [] - @State private var delivered: (first: Int, last: Int)? - @State private var handledScrollToken: Int64 = 0 - @State private var scrollProxy: ScrollViewProxy? - @State private var pendingScroll: (id: String, anchor: UnitPoint)? - @State private var visibleEmitTask: Task? - - private var properties: Properties? { - JournalExtensions.decode(Properties.self, context: context) - } - - private func emit(_ fields: [String: Any]) { - guard let data = try? JSONSerialization.data( - withJSONObject: fields, options: [.sortedKeys]) - else { return } - JournalExtensions.emit(context: context, payload: data) - } - - private func rowEvent(action: String, row: String) { - guard let inner = try? JSONSerialization.data( - withJSONObject: ["key": action, "row": row], options: [.sortedKeys]), - let payload = String(data: inner, encoding: .utf8) - else { return } - emit(["type": "row_event", "payload": payload]) - } - - private func childContent(_ index: Int) -> AnyView { - guard index >= 0, index < context.childIDs.count else { - return AnyView(EmptyView()) - } - return context.content(for: context.childIDs[index]) - } - - /// Rows displayed in payload order; disclosure children appear only while - /// their parent row is expanded. The OCaml side maps these positions to - /// its own row indices. - private var positions: [String: Int] { - guard let properties else { return [:] } - var map: [String: Int] = [:] - var index = 0 - func walk(_ row: Row) { - map[row.key] = index - index += 1 - if row.isDisclosure, row.expanded == true { - (row.children ?? []).forEach(walk) - } - } - for section in properties.sections { - section.rows.forEach(walk) - } - return map - } - - private func updateVisibleRange() { - guard let properties, properties.track_visible_range == true else { return } - let ordered = visible.sorted() - guard let first = ordered.first, let last = ordered.last else { - delivered = nil - return - } - let range = (first, last + 1) - guard delivered?.first != range.0 || delivered?.last != range.1 else { return } - delivered = range - // Cells flicker in/out while the collection re-layouts; emit only the - // settled range so an oscillating boundary row cannot flood the bridge. - visibleEmitTask?.cancel() - visibleEmitTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 80_000_000) - guard !Task.isCancelled else { return } - emit(["type": "visible_range", "first": range.0, "last": range.1]) - } - } - - private func completeScroll(_ token: String, _ outcome: String) { - emit(["type": "scroll_completed", "token": token, "outcome": outcome]) - } - - /// Walk target.row_path from the section's top-level rows; intermediate - /// elements descend into disclosure children. - private func resolveTarget(_ target: ScrollRequest.Target) -> Row? { - guard let section = properties?.sections.first(where: { - $0.key == target.section - }) else { return nil } - var candidates = section.rows - var row: Row? - for (index, key) in target.row_path.enumerated() { - row = candidates.first(where: { $0.key == key }) - guard let current = row else { return nil } - if index + 1 < target.row_path.count { - candidates = current.children ?? [] - } - } - return row - } - - private func applyScrollRequest(_ request: ScrollRequest) { - guard let token = Int64(request.token), token > handledScrollToken else { - return - } - handledScrollToken = token - guard let properties else { return } - guard let row = resolveTarget(request.target) else { - completeScroll(request.token, "missing_target") - return - } - // A row hidden by a collapsed ancestor cannot be scrolled to; the OCaml - // decoder has no hidden_target variant, so report missing_target. - guard positions[row.key] != nil else { - completeScroll(request.token, "missing_target") - return - } - let anchor: UnitPoint = - switch request.anchor { - case "center": .center - case "bottom": .bottom - default: .top - } - pendingScroll = (id: row.key, anchor: anchor) - if properties.track_scroll_completion == true { - completeScroll(request.token, "succeeded") - } - } - - private func performPendingScroll() { - guard let pending = pendingScroll, let proxy = scrollProxy else { return } - pendingScroll = nil - proxy.scrollTo(pending.id, anchor: pending.anchor) - } - - private func separatorVisibility(_ name: String?) -> Visibility { - switch name { - case "hidden": .hidden - case "visible": .visible - default: .automatic - } - } - - private static func actionTint(_ background: String?) -> Color? { - guard let background, background != "transparent" else { return nil } - var hex = background - if hex.hasPrefix("#") { hex.removeFirst() } - guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil } - return Color( - red: Double((value >> 16) & 0xff) / 255, - green: Double((value >> 8) & 0xff) / 255, - blue: Double(value & 0xff) / 255) - } - - private func actionInteractive(_ action: Action) -> Bool { - action.enabled != false && context.isUserInteractionEnabled - } - - private func actionLabel(_ action: Action) -> some SwiftUI.View { - Group { - if let symbol = action.symbol { - Label(action.title, systemImage: symbol) - } else { - Text(action.title) - } - } - } - - @ViewBuilder private func rowActions(_ row: Row) -> some SwiftUI.View { - childContent(row.content_index) - .id(row.key) - .listRowSeparator(separatorVisibility(row.separator)) - .accessibilityIdentifier(row.test_id ?? row.key) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - ForEach(row.swipe?.actions.filter { $0.side == "start" } ?? []) { action in - Button { rowEvent(action: action.key, row: row.key) } label: { - actionLabel(action) - } - .disabled(!actionInteractive(action)) - .tint(Self.actionTint(action.background)) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - ForEach(row.swipe?.actions.filter { $0.side != "start" } ?? []) { action in - Button(role: action.role == "destructive" ? .destructive : nil) { - rowEvent(action: action.key, row: row.key) - } label: { - actionLabel(action) - } - .disabled(!actionInteractive(action)) - .tint(Self.actionTint(action.background)) - } - } - .contextMenu { - ForEach(row.context_menu?.actions ?? []) { action in - Button(role: action.role == "destructive" ? .destructive : nil) { - rowEvent(action: action.key, row: row.key) - } label: { - actionLabel(action) - } - .disabled(!actionInteractive(action)) - } - } - } - - // Opaque `some View` can't express the recursive disclosure shape. - private func rowBody(_ row: Row) -> AnyView { - let content = rowActions(row) - .onAppear { - if let position = positions[row.key] { - DispatchQueue.main.async { - visible.insert(position) - updateVisibleRange() - } - } - } - .onDisappear { - if let position = positions[row.key] { - DispatchQueue.main.async { - visible.remove(position) - updateVisibleRange() - } - } - } - if row.isDisclosure { - return AnyView(DisclosureGroup( - isExpanded: Binding( - get: { row.expanded == true }, - set: { value in - emit(["type": "expanded", "key": row.key, "expanded": value]) - }) - ) { - ForEach(row.children ?? []) { child in - rowBody(child) - } - } label: { - content - }) - } - return AnyView(content) - } - - private var style: ListStyleConfiguration { - switch properties?.style { - case "inset": return .inset - case "inset_grouped": return .insetGrouped - default: return .plain - } - } - - var body: some SwiftUI.View { - ScrollViewReader { proxy in - List { - ForEach(properties?.sections ?? []) { section in - Section { - ForEach(section.rows) { row in - rowBody(row) - } - } header: { - if let header = section.header_index { - childContent(header) - } - } footer: { - if let footer = section.footer_index { - childContent(footer) - } - } - .listSectionSeparator(separatorVisibility(section.separator)) - } - } - .modifier(ListStyleModifier(style: style)) - .onAppear { - scrollProxy = proxy - performPendingScroll() - if let request = properties?.scroll_request { applyScrollRequest(request) } - } - .onChange(of: pendingScroll?.id) { _, _ in performPendingScroll() } - } - .onChange(of: properties?.scroll_request?.token) { _, _ in - if let request = properties?.scroll_request { applyScrollRequest(request) } - } - } - } - - private enum ListStyleConfiguration { - case plain, inset, insetGrouped - } - - private struct ListStyleModifier: ViewModifier { - let style: ListStyleConfiguration - func body(content: Content) -> some SwiftUI.View { - Group { - #if os(macOS) - if style == .plain { - content.listStyle(.plain) - } else { - // inset_grouped has no macOS equivalent — the inset style is the - // closest native presentation. - content.listStyle(.inset) - } - #else - if style == .plain { - content.listStyle(.plain) - } else { - content.listStyle(.insetGrouped) - } - #endif - } - } - } -} diff --git a/swift/JournalMedia.swift b/swift/JournalMedia.swift deleted file mode 100644 index 613a8d1..0000000 --- a/swift/JournalMedia.swift +++ /dev/null @@ -1,170 +0,0 @@ -import LUIAppleBackend -import ImageIO -import QuickLook -import SwiftUI - -actor JournalMediaDecoder { - static let shared = JournalMediaDecoder() - private let cache = NSCache() - init() { cache.totalCostLimit = 32 * 1024 * 1024; cache.countLimit = 32 } - func clear() { cache.removeAllObjects() } - func load(_ path: String) -> CGImage? { - guard !Task.isCancelled else { return nil } - if let image = cache.object(forKey: path as NSString) { return image } - guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, - [kCGImageSourceShouldCache: false] as CFDictionary), - let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: 1024, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true, - ] as CFDictionary), !Task.isCancelled else { return nil } - cache.setObject(image, forKey: path as NSString, cost: image.bytesPerRow * image.height) - return image - } -} - -@MainActor enum JournalMedia { - nonisolated static let imageTypes: Set = [ - "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", - "avif", - ] - struct Item: Decodable, Identifiable { - let id: String - let kind: String - let value: String - let type: String - let width: Int - let height: Int - var isImage: Bool { JournalMedia.imageTypes.contains(type) } - } - struct Picker: Decodable { - let items: [Item] - let more: Bool - let busy: Bool - } - struct Properties: Decodable { - let root: String - let items: [Item] - let more: Bool - let editable: Bool - let picker: Picker? - let error: String? - } - private struct MediaItem: SwiftUI.View { - let item: Item - let emit: (String, String, Bool) -> Void - @State private var image: CGImage? - @State private var preview: URL? - @State private var decodeFailed = false - @ViewBuilder private var content: some SwiftUI.View { - Group { - if item.kind == "file" && item.isImage { - Group { - if let image { - Image(decorative: image, scale: 1).resizable().scaledToFit() - } else if decodeFailed { - Button("Open image") { preview = URL(fileURLWithPath: item.value) } - } else { ProgressView("Opening image") } - } - .contentShape(Rectangle()) - .onTapGesture { preview = URL(fileURLWithPath: item.value) } - .task(id: item.value) { - image = nil; decodeFailed = false - let decoded = await JournalMediaDecoder.shared.load(item.value) - guard !Task.isCancelled else { return } - image = decoded; decodeFailed = decoded == nil - } - } else if item.kind == "file" { - Button { preview = URL(fileURLWithPath: item.value) } label: { Label("Open attachment", systemImage: "doc") } - } else if item.kind == "external", let url = URL(string: item.value), ["https", "http"].contains(url.scheme?.lowercased() ?? "") { - Link(destination: url) { Label("Open external attachment", systemImage: "arrow.up.right.square") } - } else { - VStack(spacing: 8) { - Label(item.value, systemImage: "photo") - .font(.caption).foregroundStyle(.secondary) - Button("Retry") { emit("retry", item.id, true) }.font(.caption) - } - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - } - } - } - var body: some SwiftUI.View { - Group { - if item.isImage { - Color.clear - .aspectRatio(CGFloat(max(item.width, 1)) / CGFloat(max(item.height, 1)), contentMode: .fit) - .frame(maxWidth: .infinity, maxHeight: 240) - .overlay { content } - } else { content.frame(minHeight: 48) } - } - .quickLookPreview($preview) - .onDisappear { image = nil } - .accessibilityIdentifier("journal-media:" + item.id) - } - } - struct View: SwiftUI.View { - let context: LUIAppleExtensionViewContext - - private var properties: Properties? { - JournalExtensions.decode(Properties.self, context: context) - } - - private func emit(_ action: String, _ asset: String = "", _ visible: Bool = true) { - guard let properties, let data = try? JSONSerialization.data(withJSONObject: [ - "action": action, "root": properties.root, "asset": asset, "visible": visible, - ]) else { return } - JournalExtensions.emit(context: context, payload: data) - } - - var body: some SwiftUI.View { - VStack(alignment: .leading, spacing: 8) { - context.content - if let properties { - if properties.editable { - HStack(alignment: .top) { - Spacer(minLength: 8) - Menu { - Button("Replace file\u{2026}") { emit("replace") } - Button("Reuse existing\u{2026}") { emit("reuse") } - } label: { - Label("Attachment actions", systemImage: "ellipsis.circle") - .labelStyle(.iconOnly) - } - .menuIndicator(.hidden) - .accessibilityIdentifier("journal-media-actions") - } - } - ForEach(properties.items) { item in MediaItem(item: item, emit: emit) } - if let picker = properties.picker { - if picker.busy, picker.items.isEmpty { - ProgressView("Loading attachments").font(.caption) - } - ForEach(picker.items) { item in - Button { emit("reuse-select", item.id) } label: { - Label(item.type.isEmpty ? "file" : item.type, systemImage: "doc") - } - .disabled(picker.busy) - .accessibilityIdentifier("journal-media-candidate:" + item.id) - } - if picker.more { - Button("More attachments") { emit("reuse-next") } - .disabled(picker.busy) - } - Button("Cancel", role: .cancel) { emit("reuse-cancel") } - .font(.caption) - } - if let error = properties.error { - Text(error).font(.caption).foregroundStyle(.secondary) - Button("Retry attachments") { emit("retry") } - } - if properties.more { Button("Next attachments") { emit("next") } } - } - } - .buttonStyle(.borderless) - .onDisappear { Task { await JournalMediaDecoder.shared.clear() } } - } - } -} diff --git a/swift/JournalPlatformServices.swift b/swift/JournalPlatformServices.swift index 3c6d809..01d0923 100644 --- a/swift/JournalPlatformServices.swift +++ b/swift/JournalPlatformServices.swift @@ -26,6 +26,7 @@ struct JournalLocalAccount: Equatable, Sendable { private(set) var timelinePresented = false private var authenticationGeneration: UInt64 = 0 private var signingOut = false + private let assetPreferences = JournalAssetPreferences(defaults: .standard) init(auth: any JournalAuthCapability, account: JournalAccountStore, managedSyncOrigin: String) { @@ -87,6 +88,11 @@ struct JournalLocalAccount: Equatable, Sendable { // Notice requests are intercepted by JournalApplicationPlatform before // reaching services. throw Failure.unavailable + case .assetRecentDays: + return .assetRecentDays(days: assetPreferences.recentDays) + case .setAssetRecentDays(let days): + _ = assetPreferences.save(recentDays: days) + return .assetRecentDaysStored } } diff --git a/swift/JournalPlatformWire.swift b/swift/JournalPlatformWire.swift index 1681cdf..27af604 100644 --- a/swift/JournalPlatformWire.swift +++ b/swift/JournalPlatformWire.swift @@ -8,6 +8,8 @@ enum JournalPlatformWire { case idToken(challengeID: String) case showNotice(token: String, message: String, actionLabel: String?, durationMs: Int) case cancelNotice(token: String) + case assetRecentDays + case setAssetRecentDays(days: Int) } enum Response: Equatable { case authenticatedUser(String?) @@ -16,6 +18,8 @@ enum JournalPlatformWire { case localAccount(userID: String, origin: String) case noLocalAccount case notice(token: String, result: String) + case assetRecentDays(days: Int) + case assetRecentDaysStored } enum NoticeResult: String { case action, dismiss, swipe, timeout @@ -39,13 +43,14 @@ enum JournalPlatformWire { let payload = Data(packet[headerSize...]) let tag = integer(packet, at: 6, count: 2) switch tag { - case 6, 10, 13, 20, 22: + case 6, 10, 13, 20, 22, 28: guard payload.isEmpty else { throw Failure.invalidPacket } switch tag { case 6: return .authenticatedUser case 10: return .signOut case 13: return .terminationReady case 20: return .localAccount + case 28: return .assetRecentDays default: return .timelinePresented } case 8: @@ -73,6 +78,12 @@ enum JournalPlatformWire { throw Failure.invalidPacket } return .cancelNotice(token: token) + case 30: + let fields = try object(payload) + guard fields.count == 1, let days = fields["days"] as? Int else { + throw Failure.invalidPacket + } + return .setAssetRecentDays(days: days) default: throw Failure.invalidPacket } @@ -102,6 +113,10 @@ enum JournalPlatformWire { return try json(tag: 23, ["presented": true]) case .notice(let token, let result): return try json(tag: 26, ["token": token, "result": result]) + case .assetRecentDays(let days): + return try json(tag: 29, ["days": days]) + case .assetRecentDaysStored: + return try json(tag: 31, ["stored": true]) } } diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift index 5d3c91e..a43fa3a 100644 --- a/swift/JournalRuntime.swift +++ b/swift/JournalRuntime.swift @@ -42,6 +42,20 @@ private func luiOCamlToggleChanged(_ node: Int64, _ checked: Int32) -> Int32 private func luiOCamlRadioChanged(_ node: Int64) -> Int32 @_silgen_name("lui_ocaml_slider_changed") private func luiOCamlSliderChanged(_ node: Int64, _ value: Double) -> Int32 +@_silgen_name("lui_ocaml_scroll_completed") +private func luiOCamlScrollCompleted( + _ node: Int64, + _ token: Int64, + _ outcome: UnsafePointer? +) -> Int32 +@_silgen_name("lui_ocaml_visible_range") +private func luiOCamlVisibleRange( + _ node: Int64, + _ first: Int64, + _ last: Int64 +) -> Int32 +@_silgen_name("lui_ocaml_picked") +private func luiOCamlPicked(_ node: Int64, _ payload: UnsafePointer?) -> Int32 @_silgen_name("journal_ocaml_extension_event") private func journalOCamlExtensionEvent( _ node: Int64, @@ -105,14 +119,12 @@ private let platformRequest: PlatformRequestCallback = { data, length in init( platform: JournalApplicationPlatform, - startupPayload: Data, - extensionRegistry: LUIAppleExtensionRegistry + startupPayload: Data ) throws { self.platform = platform self.startupPayload = startupPayload - backend = try LUIAppleBackend( - appIcons: journalAppIcons, - extensionRegistry: extensionRegistry + backend = LUIAppleBackend( + appIcons: journalAppIcons ) backend.onEvent = { [weak self] event in self?.handle(event) } } @@ -216,6 +228,12 @@ private let platformRequest: PlatformRequestCallback = { data, length in case let .change(node): _ = luiOCamlRadioChanged(Int64(node)) case let .valueChanged(node, value): _ = luiOCamlSliderChanged(Int64(node), value) + case let .scrollCompleted(node, token, outcome): + outcome.withCString { _ = luiOCamlScrollCompleted(Int64(node), Int64(token), $0) } + case let .visibleRange(node, first, last): + _ = luiOCamlVisibleRange(Int64(node), Int64(first), Int64(last)) + case let .picked(node, payload): + payload.withCString { _ = luiOCamlPicked(Int64(node), $0) } case let .extension(node, _, name, values): guard let payload = Self.encodeExtensionValues(values) else { return } name.withCString { eventName in diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 46e4d50..3294eb7 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -9,7 +9,6 @@ import SwiftUI struct JournalRuntimeHost: View { let platform: JournalApplicationPlatform let payload: Data - let extensions: LUIAppleExtensionRegistry @State private var runtime: JournalRuntime? var body: some SwiftUI.View { @@ -20,6 +19,9 @@ struct JournalRuntimeHost: View { // `placement` prop hoist into real platform chrome. NavigationStack { LUISwiftUIRoot(backend: runtime.backend, rootID: rootID) + #if os(iOS) + .toolbar(.hidden, for: .navigationBar) + #endif } } else { ProgressView("Opening journal") @@ -32,8 +34,7 @@ struct JournalRuntimeHost: View { do { let next = try JournalRuntime( platform: platform, - startupPayload: payload, - extensionRegistry: extensions) + startupPayload: payload) next.start() runtime = next } catch { diff --git a/test/journal_adaptive_test.ml b/test/journal_adaptive_test.ml index 9c4cde0..4bc818c 100644 --- a/test/journal_adaptive_test.ml +++ b/test/journal_adaptive_test.ml @@ -109,9 +109,8 @@ let test_ios_capsules_mount_outside_toolbars () = ~body:(V.Body.static (V.text "Page content")) in let app = - Lui_app.create_with_extensions + Lui_app.create backend - Journal_lui_native.registry () (fun () () -> ()) (fun _context _model _send -> Ui.mount (V.Body.Private.to_widget view)) @@ -399,9 +398,8 @@ let test_detail_capsules_mount_outside_toolbars () = } in let app = - Lui_app.create_with_extensions + Lui_app.create backend - Journal_lui_native.registry () (fun () () -> ()) (fun _context _model _send -> Ui.mount (V.Body.Private.to_widget view)) diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index 08221fe..5d2fac4 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -210,18 +210,19 @@ let test_direct_capture_preserves_source_and_mutation_identity () = "direct Capture retry did not return to Saving" ;; -let staged_pick ~operation ~path ~title ~file_type = +let staged_pick ~operation:_ ~path ~title ~file_type:_ = + (* Staged picks copy the source into a journal-owned temp file, so the + fixture needs a real file on disk. The picker payload carries the + staged request token plus the source path and display name. *) + let channel = open_out_bin path in + output_string channel "pick"; + close_out channel; match Journal_asset_import.decode_event (Printf.sprintf - {|{"operation":"%s","asset":"%s","localMutation":"%s","metadataMutation":"%s","path":"%s","title":"%s","type":"%s","replaceReference":null}|} - operation - "70000000-0000-4000-a000-00000000a001" - "70000000-0000-4000-a000-00000000a002" - "70000000-0000-4000-a000-00000000a003" + {|{"request":"journal-import:7:files:1","files":[{"path":"%s","name":"%s"}]}|} path - title - file_type) + title) with | Ok (Journal_asset_import.Picked (staged, _)) -> staged | _ -> fail "staged pick did not decode" @@ -290,8 +291,8 @@ let test_direct_capture_pending_attachments () = block_id "attachment imports lost the captured block"; require_string - "/tmp/a.png" - (Journal_asset_import.staged_path staged) + ".png" + (Filename.extension (Journal_asset_import.staged_path staged)) "attachment imports lost the staged path" | _ -> fail "attachment imports did not expose the pending pick"); require diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 303e148..b545b76 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#58ad2dcd7ea62adb86c714e3c6403872efae5b9b" + "git+https://github.com/logseq/lui.git#12d95adfe87341d3003e78ee20d47922771d044b" 1; require_occurrences root @@ -1665,7 +1665,7 @@ let () = ; "Refresh the authorized graph catalog" ; "pending local" ; "then returns to graph selection" - ; "Lui_app.create_with_extensions" + ; "Lui_app.create" ; "application_theme" ; "V.Sheet.create" ; "Journal_platform.show_notice_request" diff --git a/tool/test_swiftui_editor.py b/tool/test_swiftui_editor.py index 97effce..a000982 100644 --- a/tool/test_swiftui_editor.py +++ b/tool/test_swiftui_editor.py @@ -54,8 +54,7 @@ auth: ProbeAuth(), account: JournalAccountStore(load: { nil }, save: { _ in }, clear: {}), managedSyncOrigin: "https://example.invalid")), - payload: (try? JournalNativeServices.startupPayload()) ?? Data(), - extensions: (try? JournalExtensions.registry()) ?? LUIAppleExtensionRegistry()) + payload: (try? JournalNativeServices.startupPayload()) ?? Data()) .frame(minWidth: 480, minHeight: 300) } } diff --git a/tool/test_swiftui_outline.py b/tool/test_swiftui_outline.py index bf18da5..9a59dfa 100644 --- a/tool/test_swiftui_outline.py +++ b/tool/test_swiftui_outline.py @@ -2,9 +2,9 @@ Stages an LUI probe host (tool/lui_probe_host.py) embedding apple-tests/native-outline/outline_probe.ml — a Lui_app signal+update probe -mounting the `journal-list` extension (Journal_lui_native.list) with -disclosure rows; expand + row events are decoded back through the extension -event contract. +mounting the production Journal_view.Native_list builder (lui list elements) +with disclosure rows; expand + row press events arrive through the lui +event channel. The probe's OCaml complete object is produced by the workspace build (see tool/lui_probe_host.py): pass it via --native-object, otherwise the host links @@ -50,8 +50,7 @@ auth: ProbeAuth(), account: JournalAccountStore(load: { nil }, save: { _ in }, clear: {}), managedSyncOrigin: "https://example.invalid")), - payload: (try? JournalNativeServices.startupPayload()) ?? Data(), - extensions: (try? JournalExtensions.registry()) ?? LUIAppleExtensionRegistry()) + payload: (try? JournalNativeServices.startupPayload()) ?? Data()) .frame(minWidth: 480, minHeight: 320) } }