diff --git a/platform/native/lui_gallery/lui_gallery.ml b/platform/native/lui_gallery/lui_gallery.ml index 824e62a6..51c97bac 100644 --- a/platform/native/lui_gallery/lui_gallery.ml +++ b/platform/native/lui_gallery/lui_gallery.ml @@ -238,6 +238,7 @@ let hooks_of ?(text_ops = fun id r c s -> stub_text_ops id r c s) e = | Some s -> s | None -> Lui_paint.state_neutral); scroll_of = (fun id -> Hashtbl.find_opt e.scrolls id); + mounted_children = (fun _ -> None); text_ops; image_of = (fun id -> if Hashtbl.mem e.images id then Some test_image else None); diff --git a/platform/native/lui_host/lui_host.ml b/platform/native/lui_host/lui_host.ml index a9ebdc5a..dc21a8c1 100644 --- a/platform/native/lui_host/lui_host.ml +++ b/platform/native/lui_host/lui_host.ml @@ -27,6 +27,7 @@ module type LAYOUT_ENGINE = sig val sync : ?scale:float -> ?measure:text_measure -> ?hidden:(int -> bool) -> ?ctx_anchor:(float * float) -> + ?vport:(int -> (float * float) option) -> width:float -> height:float -> t -> Lui_store.t -> unit val invalidate : t -> unit @@ -35,6 +36,17 @@ module type LAYOUT_ENGINE = sig val rect : t -> int -> Lui_scene.rect option + (* Virtual-list surface: engines without virtualization return + [[]]/[None] everywhere, which reproduces the unwindowed path. *) + val vlists : t -> int list + val mounted_range : t -> int -> (int * int) option + val mounted_children : t -> int -> int list option + val virtual_extent : t -> int -> float option + val virtual_item_rect : t -> int -> index:int -> Lui_scene.rect option + val virtual_index_of_key : t -> Lui_store.t -> int -> string -> int option + val virtual_index_of_id : t -> Lui_store.t -> int -> int -> int option + val virtual_view : t -> int -> (float * float) option + (** Store ids whose absolute rect changed under the last {!sync}; drained by the paint pass to re-emit only moved subtrees. *) val take_rect_changes : t -> (int, unit) Hashtbl.t @@ -46,10 +58,19 @@ type layout_state = { lsync : scale:float -> measure:text_measure -> hidden:(int -> bool) -> ctx_anchor:(float * float) option -> + vport:(int -> (float * float) option) -> width:float -> height:float -> Lui_store.t -> unit; linvalidate : unit -> unit; lhook : int -> Lui_paint.placement; lrect : int -> rect option; + llists : unit -> int list; + lrange : int -> (int * int) option; + lchildren : int -> int list option; + lextent : int -> float option; + litem : int -> index:int -> rect option; + lkey : int -> string -> int option; + lid : int -> int -> int option; + lview : int -> (float * float) option; lchanges : unit -> (int, unit) Hashtbl.t; } @@ -77,11 +98,15 @@ type t = { (* Pointer point an unanchored context-menu opens at — a thunk so the accessor reads the live position each repaint. *) mutable ctx_anchor : unit -> (float * float) option; + (* Virtual-list viewport feed: node id → content-space visible range + (start, extent) evaluated during every layout sync. *) + mutable vport : int -> (float * float) option; } let no_measure _id _text _offered_w _offered_h = None let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scale () = + let store = Lui_store.create () in let layout = match layout with | None -> None @@ -89,24 +114,33 @@ let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scal let engine = L.create () in Some { lsync = - (fun ~scale ~measure ~hidden ~ctx_anchor ~width ~height store -> - L.sync ~scale ~measure ~hidden ?ctx_anchor ~width ~height + (fun ~scale ~measure ~hidden ~ctx_anchor ~vport ~width ~height store -> + L.sync ~scale ~measure ~hidden ?ctx_anchor ~vport ~width ~height engine store); linvalidate = (fun () -> L.invalidate engine); lhook = L.layout_hook engine; lrect = L.rect engine; + llists = (fun () -> L.vlists engine); + lrange = (fun id -> L.mounted_range engine id); + lchildren = (fun id -> L.mounted_children engine id); + lextent = (fun id -> L.virtual_extent engine id); + litem = (fun id ~index -> L.virtual_item_rect engine id ~index); + lkey = (fun id key -> L.virtual_index_of_key engine store id key); + lid = (fun id cid -> L.virtual_index_of_id engine store id cid); + lview = (fun id -> L.virtual_view engine id); lchanges = (fun () -> L.take_rect_changes engine) } in let mask_atlas = Atlas.create ~bpp:1 ~w:512 ~h:512 in let color_atlas = Atlas.create ~bpp:4 ~w:512 ~h:512 in - { store = Lui_store.create (); + { store; scene = Lui_scene.create ~mask_atlas ~color_atlas; hooks; layout; measure; renderer; width; height; scale; dirty = false; retained = Lui_retained.create (); paint_hints = Hashtbl.create 16; moved_hints = Hashtbl.create 16; hidden = (fun _ -> false); - ctx_anchor = (fun () -> None) } + ctx_anchor = (fun () -> None); + vport = (fun _ -> None) } let store h = h.store let scene h = h.scene @@ -120,6 +154,39 @@ let set_ctx_anchor h accessor = h.ctx_anchor <- (match accessor with Some f -> f | None -> fun () -> None) +(* Install the virtual-list viewport feed: [f id] reports the node's + content-space visible range (start, extent, device px) — scroll + offset for a directly-scrolled list, the mapped clip for one nested + in an outer scroller. Evaluated inside every layout sync. *) +let set_vport h f = h.vport <- f + +(* Virtual-list queries the driver needs for hit-test slicing, scroll + caps and scroll-to-item: all forward to the layout engine and + answer [[]]/[None] when no engine is installed. *) +let layout_vlists h = + match h.layout with Some l -> l.llists () | None -> [] + +let layout_mounted_range h id = + match h.layout with Some l -> l.lrange id | None -> None + +let layout_mounted_children h id = + match h.layout with Some l -> l.lchildren id | None -> None + +let layout_virtual_extent h id = + match h.layout with Some l -> l.lextent id | None -> None + +let layout_virtual_item h id ~index = + match h.layout with Some l -> l.litem id ~index | None -> None + +let layout_virtual_index_of_key h id key = + match h.layout with Some l -> l.lkey id key | None -> None + +let layout_virtual_index_of_id h id cid = + match h.layout with Some l -> l.lid id cid | None -> None + +let layout_virtual_view h id = + match h.layout with Some l -> l.lview id | None -> None + (* The layout engine memoizes on (store revision, frame, input identities); a measure/hidden closure whose answers changed under a stable identity — e.g. a font stack that finished loading — needs @@ -170,7 +237,7 @@ let repaint h = paints) and the current frame size — layout is the single place that resolves both. *) l.lsync ~scale:h.scale ~measure:h.measure ~hidden:h.hidden - ~ctx_anchor:(h.ctx_anchor ()) + ~ctx_anchor:(h.ctx_anchor ()) ~vport:h.vport ~width:(float_of_int h.width) ~height:(float_of_int h.height) h.store; (* The engine hook yields to an explicitly supplied @@ -195,7 +262,11 @@ let repaint h = | Some c -> c | None -> { r = 0; g = 0; b = 0; a = 0 } in + let vlists = + match h.layout with Some l -> Some (l.llists ()) | None -> None + in Lui_retained.paint_retained h.retained hooks h.store h.scene ?moved + ?vlists ~hints:h.paint_hints ~width:h.width ~height:h.height ~scale:h.scale ~clear (); Hashtbl.reset h.paint_hints; diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index 1350d473..9665bb89 100644 --- a/platform/native/lui_host/lui_host.mli +++ b/platform/native/lui_host/lui_host.mli @@ -29,6 +29,7 @@ module type LAYOUT_ENGINE = sig val sync : ?scale:float -> ?measure:text_measure -> ?hidden:(int -> bool) -> ?ctx_anchor:(float * float) -> + ?vport:(int -> (float * float) option) -> width:float -> height:float -> t -> Lui_store.t -> unit val invalidate : t -> unit @@ -39,6 +40,17 @@ module type LAYOUT_ENGINE = sig val rect : t -> int -> Lui_scene.rect option + (* Virtual-list surface: engines without virtualization return + [[]]/[None] everywhere, which reproduces the unwindowed path. *) + val vlists : t -> int list + val mounted_range : t -> int -> (int * int) option + val mounted_children : t -> int -> int list option + val virtual_extent : t -> int -> float option + val virtual_item_rect : t -> int -> index:int -> Lui_scene.rect option + val virtual_index_of_key : t -> Lui_store.t -> int -> string -> int option + val virtual_index_of_id : t -> Lui_store.t -> int -> int -> int option + val virtual_view : t -> int -> (float * float) option + (** Store ids whose absolute rect changed under the last {!sync}; drained by the paint pass to re-emit only moved subtrees. *) val take_rect_changes : t -> (int, unit) Hashtbl.t @@ -137,4 +149,25 @@ val layout_rect : t -> int -> Lui_scene.rect option the hit-testing surface for the window driver. [None] when the host has no engine or the mirror skipped the id. *) +val set_vport : t -> (int -> (float * float) option) -> unit +(** Install the virtual-list viewport feed: [f id] reports the node's + content-space visible range (start, extent, device px) — the scroll + offset for a directly-scrolled list, or the on-screen clip mapped + into the list's content when an outer scroller clips it. Evaluated + inside every engine sync, including memo hits. *) + +(** {2 Virtual lists} + + Forwards to the layout engine's virtual-list surface; all answer + [[]]/[None] when no engine is installed. *) + +val layout_vlists : t -> int list +val layout_mounted_range : t -> int -> (int * int) option +val layout_mounted_children : t -> int -> int list option +val layout_virtual_extent : t -> int -> float option +val layout_virtual_item : t -> int -> index:int -> Lui_scene.rect option +val layout_virtual_index_of_key : t -> int -> string -> int option +val layout_virtual_index_of_id : t -> int -> int -> int option +val layout_virtual_view : t -> int -> (float * float) option + val backend : t -> Lui_protocol.platform_profile -> Lui_protocol.backend diff --git a/platform/native/lui_input/lui_scroll.ml b/platform/native/lui_input/lui_scroll.ml index 407926e7..945a88cd 100644 --- a/platform/native/lui_input/lui_scroll.ml +++ b/platform/native/lui_input/lui_scroll.ml @@ -35,12 +35,27 @@ type state = { (* tracked-list ids found by the last store scan *) } +(* Layout-engine forwards for virtual-list math: item counts, + content-space item rects (mounted or not), key → index lookup and + the last fed viewport — the pieces scroll-target, visible-range + and reveal logic need on logical items that may not be mounted. *) +type vlist = { + vh_count : int -> int option; + vh_item : int -> index:int -> rect option; + vh_index_of_key : int -> string -> int option; + vh_view : int -> (float * float) option; +} + type env = { content_rect : int -> rect; (* unshifted (content-space) rect — the driver's layout view *) rects_epoch : int; (* layout epoch; bumped whenever the rects feeding [content_rect] were recomputed *) + vlist : vlist option; + (* extent model for "virtual-list" containers; [None] keeps the + mounted-children path, which is also what engines without + virtualization answer *) } let initial = @@ -138,6 +153,50 @@ let report_vrange env store st id = ( { st with vrange = IdMap.add id (f, l) st.vrange }, [ `Dispatch (VisibleRange (id, f, l)) ] )) end + else if Lui_query.kind_name store id = "virtual-list" then + (* Same report over the extent model: the fed viewport and a + binary search on item rects — no item walk at any size. *) + match + ( env.vlist, + Lui_query.data_attr store id "data-track-visible-range" ) + with + | Some vh, Some "true" -> ( + match (vh.vh_view id, vh.vh_count id) with + | Some (vs, ve), Some n when n > 0 -> + let key = (Lui_store.revision store, vs, env.rects_epoch) in + (match IdMap.find_opt id st.vrange_key with + | Some k when k = key -> (st, []) + | _ -> + let st = + { st with vrange_key = IdMap.add id key st.vrange_key } + in + let cr = env.content_rect id in + if rect_empty cr then (st, []) + else begin + (* Largest index whose content-space top is ≤ y; -1 + before the first item. *) + let item_at y = + let rec go lo hi = + if lo > hi then hi + else + let mid = (lo + hi) / 2 in + match vh.vh_item id ~index:mid with + | Some r when r.Lui_scene.y -. cr.Lui_scene.y <= y -> + go (mid + 1) hi + | _ -> go lo (mid - 1) + in + go 0 (n - 1) + in + let f = max 0 (item_at vs) in + let l = max 0 (item_at (vs +. ve -. 0.001)) in + match IdMap.find_opt id st.vrange with + | Some (f', l') when f' = f && l' = l -> (st, []) + | _ -> + ( { st with vrange = IdMap.add id (f, l) st.vrange }, + [ `Dispatch (VisibleRange (id, f, l)) ] ) + end) + | _ -> (st, [])) + | _ -> (st, []) else (st, []) let set_offset env store st axis id off = @@ -294,7 +353,54 @@ let sync env store st = acts := `Dispatch (ScrollCompleted (id, tok, "missing-target")) :: !acts)) - | None -> ()) + | None -> () + else if Lui_query.kind_name store id = "virtual-list" then + (* The schema wires scroll-target on ListContainer only; + virtual lists take the same triple over data-attrs and + resolve the key through the extent model — the target + row does not need to be mounted. *) + match + ( Option.bind + (Lui_query.data_attr store id "data-scroll-token") + (fun s -> int_of_string_opt (String.trim s)), + Lui_query.data_attr store id "data-scroll-target", + env.vlist ) + with + | Some tok, Some target, Some vh when target <> "" -> ( + match IdMap.find_opt id !st.tokens with + | Some prev when prev = tok -> () + | _ -> + st := { !st with tokens = IdMap.add id tok !st.tokens }; + (match vh.vh_index_of_key id target with + | Some i -> + let cid = Lui_store.child_id_at store id i in + if cid >= 0 then begin + let anchor = + match + Lui_query.data_attr store id "data-scroll-anchor" + with + | Some a -> a + | None -> "" + in + let st', a = + scroll_align env store !st id cid anchor + in + st := st'; + acts := List.rev_append a !acts; + acts := + `Dispatch (ScrollCompleted (id, tok, "succeeded")) + :: !acts + end + else + acts := + `Dispatch + (ScrollCompleted (id, tok, "missing-target")) + :: !acts + | None -> + acts := + `Dispatch (ScrollCompleted (id, tok, "missing-target")) + :: !acts)) + | _ -> ()) (Lui_store.preorder store); (* The tracked-list ids are store-derived too — refreshed under the same gate so the per-sync report loop only walks actual @@ -304,8 +410,12 @@ let sync env store st = lists = List.filter (fun id -> - Lui_query.kind_name store id = "list" - && Lui_store.bool_prop store id "track-visible-range") + let k = Lui_query.kind_name store id in + (k = "list" + && Lui_store.bool_prop store id "track-visible-range") + || (k = "virtual-list" + && Lui_query.data_attr store id "data-track-visible-range" + = Some "true")) (Lui_store.preorder store) }; (!st, List.rev !acts) diff --git a/platform/native/lui_input/test/lui_input_test.ml b/platform/native/lui_input/test/lui_input_test.ml index b7022f17..6f9efd9b 100644 --- a/platform/native/lui_input/test/lui_input_test.ml +++ b/platform/native/lui_input/test/lui_input_test.ml @@ -426,7 +426,8 @@ let scroll_env () = (fun id -> if id = 5 then rect 0. 0. 100. 100. else rect 0. 0. 100. 20.); - rects_epoch = 1 } + rects_epoch = 1; + vlist = None } let test_scroll_offsets () = let store = store_of base_ops in diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 8313e66c..cb98593b 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -17,6 +17,73 @@ type text_measure = int -> string -> float -> float -> (float * float) option let no_measure _id _text _offered_w _offered_h = None let show_all _id = false +(* Content-space visible range fed to a virtual list: (start, extent) + in the list's own border-box coordinates. *) +type viewport = float * float + +let no_vport _id = None + +(* ---------- virtual lists ---------- + + A [virtual-list] node's store children are the logical items; only + the mounted window [first, last) of item indices gets flex trees — + the node itself stays a leaf in the main mirror whose intrinsic + main size is the virtual content extent. Row subtrees live in a + side forest per virtual list; their absolute rects are composed + (list content origin + model offset + row-relative layout) into + [t.rects], so paint, hit-testing and scroll math see ordinary + rects while unmounted items cost nothing. *) + +(* One mounted row's flex subtree and the store ids it registered — + the subtree is dropped wholesale when the row leaves the window. *) +type vrow = { + rf : Lui_flex.node; + rids : int list; +} + +type vlist = { + vid : int; + mutable count : int; + mutable kids : int array; (* logical child ids, per structural sync *) + mutable estimate : float; (* unmeasured row main extent, device px *) + mutable overscan : int; (* extra rows mounted past each view edge *) + mutable gap : float; (* resolved row gap, device px *) + mutable pad0 : float; (* main-axis padding, near and far edges *) + mutable pad1 : float; + mutable bor0 : float; (* main-axis border *) + mutable bor1 : float; + mutable origin_main : float; (* border-box main origin, frame px *) + mutable origin_cross : float; (* content-box cross origin *) + mutable cross_size : float; (* content-box cross size: row width *) + measured : (int, float) Hashtbl.t; (* index → measured main extent *) + mutable bit : float array; (* Fenwick tree over stride = ext + gap *) + mutable total : float; (* padding-box content span *) + mutable first : int; (* mounted window [first, last) *) + mutable last : int; + mutable vs : float; (* last viewport fed (content space) *) + mutable ve : float; + mutable windowed : bool; (* a viewport was supplied → slice *) + rows : (int, vrow) Hashtbl.t; (* index → mounted row subtree *) + nodes : (int, Lui_flex.node) Hashtbl.t; (* store id → flex node, rows *) + mutable mounted_ids : int list; (* ids whose rects are registered *) + mutable key_index : (string, int) Hashtbl.t option; (* lazy key → index *) + mutable id_index : (int, int) Hashtbl.t option; (* lazy store id → index *) + mutable built_cross : float; (* cross size the mounted rows laid out at *) +} + +(* The sync inputs a scroll-driven remount needs to rebuild row + subtrees on a memo-hit path: the live store plus the style/measure + context the rows were built under. *) +type saved_ctx = { + sc_scale : float; + sc_vw : float; + sc_vh : float; + sc_tm : text_measure; + sc_hidden : int -> bool; + sc_anchor : (float * float) option; + sc_store : Lui_store.t; +} + (* One raw child's produced expansion inside its parent's produced list: [gens] is the flat list of store ids that own flex nodes and land, in order, inside the flex children of the parent's flex owner @@ -36,6 +103,8 @@ type t = { nodes : (int, Lui_flex.node) Hashtbl.t; (* store id → flex node *) applied : (int, Lui_flex.style) Hashtbl.t; (* store id → applied style *) rects : (int, Lui_scene.rect) Hashtbl.t; + vlists : (int, vlist) Hashtbl.t; (* store id → virtual-list state *) + mutable vctx : saved_ctx option; (* inputs of the last full sync *) (* Store ids whose produced subtree needs a rect re-read after the incremental pass ([frame_id] = everything). *) rect_dirty : (int, unit) Hashtbl.t; @@ -85,6 +154,8 @@ let create () = nodes = Hashtbl.create 64; applied = Hashtbl.create 64; rects = Hashtbl.create 64; + vlists = Hashtbl.create 4; + vctx = None; rect_dirty = Hashtbl.create 16; rect_changed = Hashtbl.create 64; pkids = Hashtbl.create 64; @@ -640,6 +711,24 @@ let ordered_child_ids store id kind : int list = | Some ("bottom" | "trailing") -> ids | _ -> pinned @ [ content ]) +(* Resolve a length against an owner dimension; non-absolute specs + count as zero for the purposes of these patches. *) +let len_px ~owner (l : Lui_flex.length) : float = + match l with + | Pt v -> v + | Percent p -> owner *. p /. 100. + | _ -> 0. + +(* One named edge of an [edges] record, following the engine's cascade: + named side > axis shorthand (horizontal/vertical) > all. *) +let edge_px ~owner named axis (e : Lui_flex.edges) : float = + let defined (l : Lui_flex.length) = + match l with Unset | Auto -> false | _ -> true + in + if defined named then len_px ~owner named + else if defined axis then len_px ~owner axis + else len_px ~owner e.all + (* ---------- mirror ---------- *) let display_none store id = pstr store id "display" = Some "none" @@ -684,8 +773,243 @@ type ctx = { owner : (int, int) Hashtbl.t; grids : (int * Lui_flex.node * Lui_flex.style * float) list ref; popups : (Lui_flex.node * int) list ref; (* flex node, store id *) + vlists : (int, vlist) Hashtbl.t; (* virtual-list states (t.vlists) *) + (* Virtual lists a journal op touched this sync — resynced before + layout (row forests are rebuilt, never edited in place). *) + vresync : (int, unit) Hashtbl.t; } +(* ---------- virtual-list extent model ---------- + + Per-item main extents feed a Fenwick tree over stride = extent + gap, + so index→offset and offset→index are both O(log n): prefix(i) is + the content-space offset of item i inside the padding box. *) + +let vlist_ext (v : vlist) i = + match Hashtbl.find_opt v.measured i with + | Some h -> h + | None -> v.estimate + +(* Sum of strides[0..i) — item i's offset within the padding box. *) +let bit_sum (bit : float array) i = + let rec go i acc = + if i <= 0 then acc else go (i - (i land (-i))) (acc +. bit.(i)) + in + go i 0. + +let bit_add (bit : float array) i d = + let n = Array.length bit - 1 in + let rec go i = + if i <= n then begin + bit.(i) <- bit.(i) +. d; + go (i + (i land (-i))) + end + in + go (i + 1) + +(* Largest i ∈ [0,n] with prefix(i) ≤ y: the index of the item whose + span contains y (n when y lies past the content end). *) +let bit_lower (bit : float array) y = + let n = Array.length bit - 1 in + let idx = ref 0 and acc = ref 0. in + let k = ref 1 in + while !k * 2 <= n do + k := !k * 2 + done; + while !k > 0 do + let nx = !idx + !k in + if nx <= n && !acc +. bit.(nx) <= y then begin + idx := nx; + acc := !acc +. bit.(nx) + end; + k := !k lsr 1 + done; + !idx + +let vlist_total (v : vlist) = + v.pad0 + +. (if v.count = 0 then 0. else bit_sum v.bit v.count -. v.gap) + +. v.pad1 + +let vlist_rebuild_bit (v : vlist) = + let n = v.count in + let bit = Array.make (n + 1) 0. in + for i = 0 to n - 1 do + bit.(i + 1) <- vlist_ext v i +. v.gap + done; + for i = 1 to n do + let j = i + (i land (-i)) in + if j <= n then bit.(j) <- bit.(j) +. bit.(i) + done; + v.bit <- bit; + v.total <- vlist_total v + +let vlist_measure (v : vlist) i h = + let old = vlist_ext v i in + if Float.abs (h -. old) > 0.01 then begin + Hashtbl.replace v.measured i h; + bit_add v.bit i (h -. old); + v.total <- v.total +. (h -. old) + end + +let vlist_state ctx id = + match Hashtbl.find_opt ctx.vlists id with + | Some v -> v + | None -> + let v = + { + vid = id; + count = 0; + kids = [||]; + estimate = 0.; + overscan = 4; + gap = 0.; + pad0 = 0.; + pad1 = 0.; + bor0 = 0.; + bor1 = 0.; + origin_main = 0.; + origin_cross = 0.; + cross_size = 0.; + measured = Hashtbl.create 16; + bit = [| 0. |]; + total = 0.; + first = 0; + last = 0; + vs = 0.; + ve = 0.; + windowed = false; + rows = Hashtbl.create 16; + nodes = Hashtbl.create 64; + mounted_ids = []; + key_index = None; + id_index = None; + built_cross = 0.; + } + in + Hashtbl.replace ctx.vlists id v; + v + +(* data-attrs is the wire-clean channel for virtual-list config that + the schema does not model: [data-item-extent] is the estimated row + main extent in logical px, [data-overscan] the extra rows mounted + past each viewport edge, [data-scroll-target]/[data-scroll-token]/ + [data-scroll-anchor] the scroll-request triple and + [data-track-visible-range] the visible-range opt-in. *) +let vlist_data_attr store id name = + match Lui_store.prop store id "data-attrs" with + | Some (StringValue s) -> ( + try List.assoc_opt name (Lui_protocol.data_attrs_decode s) + with Invalid_argument _ -> None) + | _ -> None + +(* M3 virtualizes the vertical axis only: a horizontal virtual-list + keeps the ordinary flow path (all children in the mirror). *) +let vlist_vertical store id = + pstr store id "orientation" <> Some "horizontal" + +(* Pull the model inputs off the resolved style and the data-attrs. + [style] is the vlist's own flex style — padding, border and the + row gap come straight from it. *) +let vlist_configure ctx store (v : vlist) (style : Lui_flex.style) = + (* The logical child ids snapshot: [count] stays O(1) on the store, + but a per-row indexed probe on the intrusive child list is O(i), + so the window machinery reads this array instead. One O(count) + materialization per structural sync — the same order the mirror + build already pays. *) + v.kids <- Array.of_list (Lui_store.child_ids store v.vid); + let count = Lui_store.child_count store v.vid in + let gap = len_px ~owner:ctx.vh style.row_gap in + let estimate = + match + Option.bind + (vlist_data_attr store v.vid "data-item-extent") + (fun s -> float_of_string_opt (String.trim s)) + with + | Some f when f > 0. -> f *. ctx.scale + | _ -> ( + match if count > 0 then v.kids.(0) else -1 with + | cid when cid >= 0 -> ( + match pnum store cid "height" with + | Some h -> h *. ctx.scale + | None -> 28. *. ctx.scale) + | _ -> 28. *. ctx.scale) + in + let overscan = + match + Option.bind + (vlist_data_attr store v.vid "data-overscan") + (fun s -> int_of_string_opt (String.trim s)) + with + | Some n -> max 0 n + | None -> 4 + in + (* Configure runs once per structural sync; child props may have + changed under a stable shape, so the lazy lookup indexes rebuild + on demand. *) + v.key_index <- None; + v.id_index <- None; + let shape_changed = + count <> v.count || estimate <> v.estimate || gap <> v.gap + in + v.count <- count; + v.estimate <- estimate; + v.overscan <- overscan; + v.gap <- gap; + v.pad0 <- + edge_px ~owner:ctx.vh style.padding.top style.padding.vertical + style.padding; + v.pad1 <- + edge_px ~owner:ctx.vh style.padding.bottom style.padding.vertical + style.padding; + v.bor0 <- + edge_px ~owner:ctx.vh style.border.top style.border.vertical + style.border; + v.bor1 <- + edge_px ~owner:ctx.vh style.border.bottom style.border.vertical + style.border; + if shape_changed then begin + Hashtbl.filter_map_inplace + (fun i h -> if i < count then Some h else None) + v.measured; + vlist_rebuild_bit v + end + else v.total <- vlist_total v + +(* The leaf patch: a self-sizing virtual list claims the virtual + content extent as its minimum main size, so the frame grows to the + full logical content exactly like the un-windowed flow did. *) +let vlist_leaf_style ctx (v : vlist) (style : Lui_flex.style) : + Lui_flex.style = + match style.height with + | Unset -> + let want = v.bor0 +. v.total +. v.bor1 in + let cur = len_px ~owner:ctx.vh style.min_height in + { style with min_height = Pt (Float.max cur want) } + | _ -> style + +(* Mounted window [first, last) of item indices: the fed viewport + expanded by [overscan] strides, clamped to \[0, count]. *) +let vlist_range (v : vlist) = + if (not v.windowed) || v.count = 0 then (0, v.count) + else begin + let over = + float_of_int v.overscan *. Float.max (v.estimate +. v.gap) 1. + in + let item_at y = + min (v.count - 1) + (bit_lower v.bit (Float.max 0. (y -. v.bor0 -. v.pad0))) + in + let first = max 0 (item_at (v.vs -. over)) in + let last = + min v.count + (bit_lower v.bit + (Float.max 0. (v.vs +. v.ve +. over -. v.bor0 -. v.pad0)) + + 1) + in + (first, max first last) + end + let kind_of ctx id = if id = frame_id then "frame" else Lui_store.kind ctx.store id (* What a store node produces into its flex parent's children. *) @@ -798,6 +1122,22 @@ let rec build ctx ~owner_id ~pkind ~pid ~index id : (Lui_flex.node * int) list = style_of ~scale:ctx.scale ~viewport:(ctx.vw, ctx.vh) ctx.store id |> adjust ~pkind ~pid ~index ctx.store id in + if kind = "virtual-list" && vlist_vertical ctx.store id then begin + (* Real virtualization: the list is a leaf in this mirror — only + the mounted row window gets flex trees (side forest). Its + produced child list stays empty; [pkids] records the leaf. *) + let v = vlist_state ctx id in + vlist_configure ctx ctx.store v style; + let style = vlist_leaf_style ctx v style in + let node = Lui_flex.create ~style [] in + Hashtbl.replace ctx.nodes id node; + Hashtbl.replace ctx.applied id style; + Hashtbl.replace ctx.owner id owner_id; + Hashtbl.replace ctx.pkids id []; + register ctx id kind node style; + [ (node, id) ] + end + else let cids = ordered_child_ids ctx.store id kind in let kids, () = expand_children ctx ~owner:id ~pkind:kind ~pid:id ~raw_parent:id @@ -850,24 +1190,6 @@ and expand_children ctx ~owner ~pkind ~pid ~raw_parent ~first cids = The default is centered in the containing block — the flex parent's border box, or the offered frame for a store-root popup. *) -(* Resolve a length against an owner dimension; non-absolute specs - count as zero for the purposes of these patches. *) -let len_px ~owner (l : Lui_flex.length) : float = - match l with - | Pt v -> v - | Percent p -> owner *. p /. 100. - | _ -> 0. - -(* One named edge of an [edges] record, following the engine's cascade: - named side > axis shorthand (horizontal/vertical) > all. *) -let edge_px ~owner named axis (e : Lui_flex.edges) : float = - let defined (l : Lui_flex.length) = - match l with Unset | Auto -> false | _ -> true - in - if defined named then len_px ~owner named - else if defined axis then len_px ~owner axis - else len_px ~owner e.all - (* Pin every flex child of a [columns = n] grid to its real share of the row so [n] cells plus [(n-1)] gaps fit one line. The produced children come from pkids — the mirror is persistent, so a reverse @@ -1226,6 +1548,382 @@ let layout_frame root ~width ~height : float * float = Lui_flex.compute_layout root ~width:ew ~height:eh (); (ew, eh) +(* ---------- mirror purge and virtual-list ancestry ---------- + + Shared by the virtual-list passes below and the incremental journal: + row subtrees register into the mirror's shadow tables (pkids, + sparent, owner) even though their flex nodes live in the list's own + [v.nodes] table, so dropping a row must scrub both. *) + +(* Remove id's flex node and every built descendant's mirror state — + shadow tables included, so a dropped row leaves no trace the + incremental path could trip over. *) +let rec purge ctx id = + (match Hashtbl.find_opt ctx.pkids id with + | Some es -> + Hashtbl.remove ctx.pkids id; + List.iter (fun e -> purge ctx e.raw) es + | None -> ()); + Hashtbl.remove ctx.nodes id; + Hashtbl.remove ctx.applied id; + Hashtbl.remove ctx.sparent id; + Hashtbl.remove ctx.owner id; + Hashtbl.remove ctx.eng.rects id; + (* A dropped rect is a geometry change too: paint consumers holding + the old box must re-derive (their layout hook now answers zero). *) + Hashtbl.replace ctx.eng.rect_changed id (); + unregister ctx id + +let vlist_of ctx id = if Hashtbl.mem ctx.vlists id then Some id else None + +(* The live virtual list [id] sits inside, if any — strict ancestors + only. [sparent] covers mounted row subtrees (row roots point at the + list); the store parent covers everything else: unmounted items and + nodes the journal just moved. *) +let rec vlist_ancestor ctx id = + if id = frame_id then None + else + let above p = + match vlist_of ctx p with + | Some _ as v -> v + | None -> vlist_ancestor ctx p + in + match Hashtbl.find_opt ctx.sparent id with + | Some p -> above p + | None -> ( + match Lui_store.parent ctx.store id with + | Some p -> above p + | None -> None) + +(* Marks the enclosing list for resync; true when [id] sits inside one. *) +let under_vlist ctx id = + match vlist_ancestor ctx id with + | Some v -> Hashtbl.replace ctx.vresync v (); true + | None -> false + +(* Same, but [id] itself counts: the parent argument of a child op is + the list it lands in, not a node inside it. *) +let in_vlist ctx id = + match vlist_of ctx id with + | Some v -> Hashtbl.replace ctx.vresync v (); true + | None -> under_vlist ctx id + +(* ---------- virtual-list post-layout passes ---------- + + After the mirror computes, each virtual list learns its own content + geometry (origin, cross size) from the resolved rect, mounts the + window of row subtrees the fed viewport covers, measures them into + the extent model, and composes their absolute rects into [t.rects]. *) + +let vlist_geometry ctx (v : vlist) = + match Hashtbl.find_opt ctx.nodes v.vid with + | None -> () + | Some f -> + let ar = Lui_flex.absolute_layout f in + let s = + match Hashtbl.find_opt ctx.applied v.vid with + | Some s -> s + | None -> Lui_flex.default_style + in + let bl = edge_px ~owner:ar.w s.border.left s.border.horizontal s.border + and br = edge_px ~owner:ar.w s.border.right s.border.horizontal s.border + and pl = edge_px ~owner:ar.w s.padding.left s.padding.horizontal s.padding + and pr = edge_px ~owner:ar.w s.padding.right s.padding.horizontal s.padding in + v.bor0 <- edge_px ~owner:ar.h s.border.top s.border.vertical s.border; + v.bor1 <- edge_px ~owner:ar.h s.border.bottom s.border.vertical s.border; + v.pad0 <- edge_px ~owner:ar.h s.padding.top s.padding.vertical s.padding; + v.pad1 <- edge_px ~owner:ar.h s.padding.bottom s.padding.vertical s.padding; + v.origin_main <- ar.y; + v.origin_cross <- ar.x +. bl +. pl; + v.cross_size <- Float.max 0. (ar.w -. bl -. br -. pl -. pr) + +(* Drop one mounted row: its shadow registrations (rows share the + mirror's shadow tables, which is how journal ops routed to a row + find the list), its per-list flex nodes and its composed rects. + A subtree that moved out of the list mid-batch keeps its new mirror + slot — its raw chain no longer reaches this list. *) +let drop_vrow ctx (v : vlist) i (row : vrow) = + List.iter + (fun id -> + match vlist_ancestor ctx id with + | Some a when a = v.vid -> purge ctx id + | _ -> ()) + row.rids; + List.iter (fun id -> Hashtbl.remove v.nodes id) row.rids; + Hashtbl.remove v.rows i + +(* Mount the row subtrees for [first, last): drop the ones that left + the window, build the ones that entered. A row is a standalone flex + tree measured against the list's content width; its laid-out main + extent refines the model ([vlist_measure]). *) +let build_vrows _t ctx store (v : vlist) ~first ~last = + let stale = ref [] in + Hashtbl.iter + (fun i (_ : vrow) -> if i < first || i >= last then stale := i :: !stale) + v.rows; + List.iter + (fun i -> + match Hashtbl.find_opt v.rows i with + | Some row -> drop_vrow ctx v i row + | None -> ()) + !stale; + v.built_cross <- v.cross_size; + for i = first to last - 1 do + if not (Hashtbl.mem v.rows i) then begin + let cid = if i < Array.length v.kids then v.kids.(i) else -1 in + if cid >= 0 then begin + (* Row-local context: registrations land in the list's own + node table so [collect] stays a mirror-only pass. The shadow + tables are shared with the mirror — row roots point their + [sparent] at the list so incremental ops on row descendants + resolve to a virtual-list resync. *) + let rctx = + { ctx with + nodes = v.nodes; + applied = Hashtbl.create 8; + grids = ref []; + popups = ref [] } + in + let pairs = + build rctx ~owner_id:v.vid ~pkind:"virtual-list" ~pid:v.vid + ~index:i cid + in + Hashtbl.replace ctx.sparent cid v.vid; + let kids = List.map fst pairs in + let root = + match kids with + | [ n ] -> n + | _ -> Lui_flex.create kids + in + Lui_flex.compute_layout root ~width:v.cross_size + ~height:Lui_flex.undefined (); + refine_grids rctx; + refine_popups rctx; + (if !(rctx.grids) <> [] || !(rctx.popups) <> [] then + Lui_flex.compute_layout root ~width:v.cross_size + ~height:Lui_flex.undefined ()); + vlist_measure v i (Lui_flex.absolute_layout root).h; + Hashtbl.replace v.rows i + { rf = root; rids = Lui_store.preorder ~root:cid store } + end + end + done + +(* Feed the viewport accessor and remount any list whose window moved. + Returns true when row sets changed. *) +let vlists_mount (t : t) ctx store ~vport = + let changed = ref false in + Hashtbl.iter + (fun _ (v : vlist) -> + (match vport v.vid with + | Some (vs, ve) -> + v.windowed <- true; + v.vs <- vs; + v.ve <- ve + | None -> v.windowed <- false); + (if v.cross_size <> v.built_cross && Hashtbl.length v.rows > 0 + then begin + (* The content width moved under the mounted forest on the + persistent mirror — the rows must lay out again at the new + width, a slide of the window alone would not catch it. *) + let idx = Hashtbl.fold (fun i _ acc -> i :: acc) v.rows [] in + List.iter + (fun i -> + match Hashtbl.find_opt v.rows i with + | Some row -> drop_vrow ctx v i row + | None -> ()) + idx + end); + let f, l = vlist_range v in + if f <> v.first || l <> v.last || Hashtbl.length v.rows < l - f + then begin + v.first <- f; + v.last <- l; + build_vrows t ctx store v ~first:f ~last:l; + changed := true + end) + t.vlists; + !changed + +(* Measurements shift the virtual extent: repatch the leaf min-height + of every self-sizing list whose total moved. Returns true when any + style changed (caller re-runs the frame layout). *) +let vlists_patch (t : t) ctx = + let moved = ref false in + Hashtbl.iter + (fun _ (v : vlist) -> + let total = vlist_total v in + if Float.abs (total -. v.total) > 0.001 then v.total <- total; + match + (Hashtbl.find_opt ctx.nodes v.vid, Hashtbl.find_opt ctx.applied v.vid) + with + | Some f, Some (s : Lui_flex.style) -> ( + match s.height with + | Unset -> + let want = v.bor0 +. v.total +. v.bor1 in + let cur = len_px ~owner:ctx.vh s.min_height in + if Float.abs (want -. cur) > 0.001 then begin + let s' = { s with min_height = Lui_flex.Pt want } in + Lui_flex.set_style f s'; + Hashtbl.replace ctx.applied v.vid s'; + moved := true + end + | _ -> ()) + | _ -> ()) + t.vlists; + !moved + +(* Composed rects for mounted row subtrees: model offset on the main + axis, content origin on the cross axis. Stale ids from the previous + window are dropped first. *) +let collect_vlist t (v : vlist) = + (* Remember the previous window's boxes so the paint pass only sees + rows whose rect actually moved, appeared or vanished. *) + let prev = Hashtbl.create (List.length v.mounted_ids) in + List.iter + (fun id -> + (match Hashtbl.find_opt t.rects id with + | Some r -> Hashtbl.replace prev id r + | None -> ()); + Hashtbl.remove t.rects id) + v.mounted_ids; + let ids = ref [] in + Hashtbl.iter + (fun i (row : vrow) -> + let ry = v.origin_main +. v.bor0 +. v.pad0 +. bit_sum v.bit i in + let rr = Lui_flex.absolute_layout row.rf in + List.iter + (fun id -> + match Hashtbl.find_opt v.nodes id with + | Some f -> + let r = Lui_flex.absolute_layout f in + (* Row rects share the engine's snapped scene_rect — + paint, hit-test and retained pieces all read the same + device-pixel geometry. *) + let r' = + Lui_scene.rect_snap + { + Lui_scene.x = v.origin_cross +. (r.x -. rr.x); + y = ry +. (r.y -. rr.y); + w = r.w; + h = r.h; + } + in + (match Hashtbl.find_opt prev id with + | Some old when old = r' -> () + | _ -> Hashtbl.replace t.rect_changed id ()); + Hashtbl.remove prev id; + Hashtbl.replace t.rects id r'; + ids := id :: !ids + | None -> ()) + row.rids) + v.rows; + (* Rows that slid out of the window lost their rect. *) + Hashtbl.iter (fun id _ -> Hashtbl.replace t.rect_changed id ()) prev; + v.mounted_ids <- !ids + +let vlists_collect (t : t) = + Hashtbl.iter (fun _ v -> collect_vlist t v) t.vlists + +(* Post-layout pass over every live list: prune dead ones (dropped, + hidden, re-kinded or flipped to horizontal), learn geometry, mount + the fed windows, and repatch leaf extents when measurements moved + them. [reset] drops every mounted row first — the rebuild path uses + it since row forests were built under the previous sync's context; + the incremental path keeps rows the journal did not touch. *) +let vlists_pass (t : t) ctx ~vport ~width ~height ~reset = + let dead = ref [] in + Hashtbl.iter + (fun _ (v : vlist) -> + let alive = + Lui_store.kind ctx.store v.vid = "virtual-list" + && vlist_vertical ctx.store v.vid + && Hashtbl.mem t.nodes v.vid + in + if not alive then dead := v :: !dead) + t.vlists; + List.iter + (fun (v : vlist) -> + (* Rows and their composed rects die with the list. *) + let idx = Hashtbl.fold (fun i row acc -> (i, row) :: acc) v.rows [] in + List.iter (fun (i, row) -> drop_vrow ctx v i row) idx; + Hashtbl.reset v.nodes; + Hashtbl.remove t.vlists v.vid) + !dead; + if Hashtbl.length t.vlists > 0 then begin + (if reset then + Hashtbl.iter + (fun _ (v : vlist) -> + let idx = + Hashtbl.fold (fun i row acc -> (i, row) :: acc) v.rows [] + in + List.iter (fun (i, row) -> drop_vrow ctx v i row) idx) + t.vlists); + Hashtbl.iter (fun _ v -> vlist_geometry ctx v) t.vlists; + ignore (vlists_mount t ctx ctx.store ~vport); + if vlists_patch t ctx then begin + (* A resized leaf shifts every rect after it — scoped re-reads + cannot cover that, so refresh the whole frame. *) + Hashtbl.replace t.rect_dirty frame_id (); + match t.root with + | Some root -> + let ew, eh = layout_frame root ~width ~height in + t.extent <- (ew, eh); + Hashtbl.iter (fun _ v -> vlist_geometry ctx v) t.vlists; + ignore (vlists_mount t ctx ctx.store ~vport) + | None -> () + end + end + +(* A build ctx reconstructed from the saved sync inputs — used by the + memo-hit path to remount row windows without a mirror rebuild. The + shadow tables are shared with the mirror for the same reason as in + [build_vrows]: mounted rows must stay reachable through the raw + chain. *) +let ctx_of_saved (t : t) (sc : saved_ctx) : ctx = + { scale = sc.sc_scale; + vw = sc.sc_vw; + vh = sc.sc_vh; + tm = sc.sc_tm; + hidden = sc.sc_hidden; + ctx_anchor = sc.sc_anchor; + store = sc.sc_store; + eng = t; + nodes = t.nodes; + applied = t.applied; + pkids = t.pkids; + sparent = t.sparent; + owner = t.owner; + grids = ref []; + popups = ref []; + vlists = t.vlists; + vresync = Hashtbl.create 4 } + +(* Memo-hit path: only the fed viewports moved since the last layout — + remount the windows that slid, repatch extents, recompose rects. + Everything else (mirror, main rects) stands as it was. *) +let vlists_update (t : t) ~vport = + if Hashtbl.length t.vlists > 0 then + match t.vctx with + | Some sc -> + let ctx = ctx_of_saved t sc in + if vlists_mount t ctx sc.sc_store ~vport then begin + if vlists_patch t ctx then begin + (match t.root with + | Some root -> + let ew, eh = + layout_frame root ~width:sc.sc_vw ~height:sc.sc_vh + in + t.extent <- (ew, eh); + Hashtbl.iter (fun _ v -> vlist_geometry ctx v) t.vlists; + collect t + | None -> ()) + end; + vlists_collect t + end + | None -> () + + let compute t ~width ~height = match t.root with | None -> () @@ -1422,19 +2120,33 @@ let restyle_gen ctx o g = style_of ~scale:ctx.scale ~viewport:(ctx.vw, ctx.vh) ctx.store g |> adjust ~pkind:(kind_of ctx o) ~pid:o ~index ctx.store g in - let kids = Lui_flex.children f in - let cids = - ordered_child_ids ctx.store g (Lui_store.kind ctx.store g) - in - let style = popup_min_style ctx g cids kids style in - let m = measure_for ctx g ~kids in - Lui_flex.set_style f style; - Lui_flex.set_measure f m; - Hashtbl.replace ctx.applied g style; - Hashtbl.replace ctx.owner g o; - let kind = Lui_store.kind ctx.store g in - unregister ctx g; - register ctx g kind f style + (match Hashtbl.find_opt ctx.vlists g with + | Some v -> + (* A virtual list restyles like its build: configure the model + off the raw style, then claim the virtual extent — children + are row subtrees, never produced gens. *) + vlist_configure ctx ctx.store v style; + let style = vlist_leaf_style ctx v style in + Lui_flex.set_style f style; + Lui_flex.set_measure f None; + Hashtbl.replace ctx.applied g style; + Hashtbl.replace ctx.owner g o; + unregister ctx g; + register ctx g "virtual-list" f style + | None -> + let kids = Lui_flex.children f in + let cids = + ordered_child_ids ctx.store g (Lui_store.kind ctx.store g) + in + let style = popup_min_style ctx g cids kids style in + let m = measure_for ctx g ~kids in + Lui_flex.set_style f style; + Lui_flex.set_measure f m; + Hashtbl.replace ctx.applied g style; + Hashtbl.replace ctx.owner g o; + let kind = Lui_store.kind ctx.store g in + unregister ctx g; + register ctx g kind f style) | None -> () (* Parent kinds whose child adjustment depends on sibling position: @@ -1494,23 +2206,6 @@ let detach_from_parent ctx id = mark_affected ctx (children_owner ctx par) | None -> () -(* Remove id's flex node and every built descendant's mirror state. *) -let rec purge ctx id = - (match Hashtbl.find_opt ctx.pkids id with - | Some es -> - Hashtbl.remove ctx.pkids id; - List.iter (fun e -> purge ctx e.raw) es - | None -> ()); - Hashtbl.remove ctx.nodes id; - Hashtbl.remove ctx.applied id; - Hashtbl.remove ctx.sparent id; - Hashtbl.remove ctx.owner id; - Hashtbl.remove ctx.eng.rects id; - (* A dropped rect is a geometry change too: paint consumers holding - the old box must re-derive (their layout hook now answers zero). *) - Hashtbl.replace ctx.eng.rect_changed id (); - unregister ctx id - (* display/structure churn on id: rebuild its produced expansion in place — the subtree is re-mirrored (a rare op). *) let reexpand ctx id = @@ -1559,29 +2254,94 @@ let restyle_children ctx id = (pkids_of ctx id); mark_affected ctx o +(* A journal op landed on a live virtual list or inside one: rows + index a logical model that may have changed and row subtrees are + never edited in place, so the mounted forest drops wholesale and the + leaf is restyled/reconfigured — the post-layout pass remounts the + window. Subtrees that moved out of the list mid-batch keep their new + mirror slot. *) +let resync_vlist ctx (v : vlist) = + let idx = Hashtbl.fold (fun i row acc -> (i, row) :: acc) v.rows [] in + List.iter (fun (i, row) -> drop_vrow ctx v i row) idx; + Hashtbl.reset v.nodes; + v.mounted_ids <- []; + match + (Hashtbl.find_opt ctx.nodes v.vid, Hashtbl.find_opt ctx.owner v.vid) + with + | Some f, Some o -> + let index = produced_index ctx o v.vid in + let style = + style_of ~scale:ctx.scale ~viewport:(ctx.vw, ctx.vh) ctx.store v.vid + |> adjust ~pkind:(kind_of ctx o) ~pid:o ~index ctx.store v.vid + in + vlist_configure ctx ctx.store v style; + let style = vlist_leaf_style ctx v style in + Lui_flex.set_style f style; + Hashtbl.replace ctx.applied v.vid style; + mark_affected ctx o + | _ -> () + let apply_journal_op ctx (op : patch_op) = + (* A virtual list owns its rows: ops landing inside one mark it for + resync rather than editing the persistent mirror — the side + forest rebuilds from the store before layout. *) match op with | SetProp (id, p, _) | RemoveProp (id, p) -> ( let m = Lui_store.mask_of_prop (Lui_wire_schema.property_name p) in if m land (Lui_store.d_structure lor Lui_store.d_layout lor Lui_store.d_layout_kids) = 0 then () - else if Lui_store.mask_has m Lui_store.d_structure then reexpand ctx id - else begin - if Lui_store.mask_has m Lui_store.d_layout then restyle_self ctx id; - if Lui_store.mask_has m Lui_store.d_layout_kids then - restyle_children ctx id - end) + else if under_vlist ctx id then () + else + match vlist_of ctx id with + | Some _ -> + (* The list's own props feed the row model (gap, padding, + estimate) — resync restyles the leaf and rebuilds rows. *) + Hashtbl.replace ctx.vresync id (); + if Lui_store.mask_has m Lui_store.d_structure then reexpand ctx id + | None -> + if + Lui_store.kind ctx.store id = "virtual-list" + && vlist_vertical ctx.store id + then + (* A horizontal virtual-list turning vertical becomes a leaf — + rebuild its produced expansion in place. *) + reexpand ctx id + else if Lui_store.mask_has m Lui_store.d_structure then reexpand ctx id + else begin + if Lui_store.mask_has m Lui_store.d_layout then restyle_self ctx id; + if Lui_store.mask_has m Lui_store.d_layout_kids then + restyle_children ctx id + end) | SetExtensionProp (id, _, _) | RemoveExtensionProp (id, _) -> - (* Unknown prop semantics: restyle the node and its children. *) - restyle_self ctx id; - restyle_children ctx id + if under_vlist ctx id then () + else ( + match vlist_of ctx id with + | Some _ -> Hashtbl.replace ctx.vresync id () + | None -> + (* Unknown prop semantics: restyle the node and its children. *) + restyle_self ctx id; + restyle_children ctx id) | CreateNode (id, _) | CreateExtension (id, _, _) | DropNode id | DetachSubtree id -> - detach_from_parent ctx id; - purge ctx id - | InsertChild (p, c, _) | MoveChild (p, c, _) -> attach_child ctx ~parent:p ~child:c - | RemoveChild (p, c) -> detach_child ctx ~parent:p ~child:c + if under_vlist ctx id then purge ctx id + else begin + detach_from_parent ctx id; + purge ctx id + end + | InsertChild (p, c, _) | MoveChild (p, c, _) -> ( + (match Hashtbl.find_opt ctx.sparent c with + | Some op when op <> p -> ignore (in_vlist ctx op) + | _ -> ()); + if in_vlist ctx p then begin + (* The child is list data now: detach its old mirror slot and + scrub its shadow — the resync materializes rows itself. *) + detach_from_parent ctx c; + purge ctx c + end + else attach_child ctx ~parent:p ~child:c) + | RemoveChild (p, c) -> + if in_vlist ctx p then () else detach_child ctx ~parent:p ~child:c (* Rect refresh scoped to dirty owner subtrees: every produced descendant of a dirty owner gets its rect re-read (a resized node @@ -1605,7 +2365,8 @@ let ctx_of t store ~scale ~measure ~hidden ctx_anchor ~width ~height : ctx = { scale; vw = width; vh = height; tm = measure; hidden; ctx_anchor; store; eng = t; nodes = t.nodes; applied = t.applied; pkids = t.pkids; sparent = t.sparent; owner = t.owner; - grids = t.grids; popups = t.popups } + grids = t.grids; popups = t.popups; + vlists = t.vlists; vresync = Hashtbl.create 4 } (* Wipe the mirror and re-expand the frame: first sync, invalidate, or a journal that no longer reaches our cursor. *) @@ -1633,7 +2394,7 @@ let rebuild ctx (t : t) ~width ~height = Hashtbl.reset t.rect_dirty; Hashtbl.replace t.rect_dirty frame_id () -let finish ctx (t : t) ~width ~height = +let finish ctx (t : t) ~width ~height ~vport ~full = match t.root with | None -> () | Some root -> @@ -1643,10 +2404,19 @@ let finish ctx (t : t) ~width ~height = (if !(ctx.grids) <> [] || !(ctx.popups) <> [] then Lui_flex.compute_layout root ~width:ew ~height:eh ()); t.extent <- (ew, eh); - refresh_rects ctx + (* Virtual lists learn their content geometry only after the frame + layout exists: geometry comes off the fresh rects, the fed + windows mount (a rebuilt mirror resets the side forests), and + measured rows can still grow a leaf — [vlists_pass] re-runs the + frame layout itself when they do. *) + vlists_pass t ctx ~vport ~width ~height ~reset:full; + refresh_rects ctx; + (* Row rects are composed off the final mirror geometry — always + recomposed, never part of the scoped refresh. *) + vlists_collect t let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) - ?ctx_anchor ~width ~height (t : t) store = + ?ctx_anchor ?(vport = no_vport) ~width ~height (t : t) store = let inputs_changed = t.synced_scale <> scale || t.synced_w <> width || t.synced_h <> height @@ -1661,7 +2431,12 @@ let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) if (not inputs_changed) && t.synced_rev >= 0 && t.synced_lrev = Lui_store.layout_rev store - then t.synced_rev <- Lui_store.revision store + then begin + t.synced_rev <- Lui_store.revision store; + (* Memo-hit: only the fed viewports may have moved — remount the + virtual-list windows that slid instead of touching the mirror. *) + vlists_update t ~vport + end else begin t.stat_syncs <- t.stat_syncs + 1; let ctx = ctx_of t store ~scale ~measure ~hidden ctx_anchor ~width ~height in @@ -1703,10 +2478,16 @@ let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) gens) (pkids_of ctx frame_id); commit ctx frame_id; + (* Ops that touched a virtual list resync the whole side + forest — mounted rows are rebuilt, never patched. *) + Hashtbl.iter + (fun _ (v : vlist) -> + if Hashtbl.mem ctx.vresync v.vid then resync_vlist ctx v) + t.vlists; false in if full then rebuild ctx t ~width ~height; - finish ctx t ~width ~height; + finish ctx t ~width ~height ~vport ~full; t.synced_rev <- Lui_store.revision store; t.synced_lrev <- Lui_store.layout_rev store; t.synced_scale <- scale; @@ -1714,9 +2495,109 @@ let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) t.synced_h <- height; t.synced_anchor <- ctx_anchor; t.synced_measure <- measure; - t.synced_hidden <- hidden + t.synced_hidden <- hidden; + t.vctx <- + Some + { sc_scale = scale; sc_vw = width; sc_vh = height; + sc_tm = measure; sc_hidden = hidden; sc_anchor = ctx_anchor; + sc_store = store } end +(* ---------- virtual-list queries ---------- *) + +let vlists (t : t) = + Hashtbl.fold (fun id _ acc -> id :: acc) t.vlists [] + +(* The mounted child-index window paint/hit-test slices to. An + unwindowed list reports its full range — identical behavior to an + unsliced walk, at slice cost. *) +let mounted_range (t : t) id = + match Hashtbl.find_opt t.vlists id with + | Some v -> Some (v.first, v.last) + | None -> None + +(* The mounted window's child store ids — the slice paint and + hit-test enumerate. Read off [v.kids] so a mid-list window costs + O(window), not a linked-list walk to [first]. *) +let mounted_children (t : t) id = + match Hashtbl.find_opt t.vlists id with + | Some v -> + let n = max 0 (min v.last (Array.length v.kids) - v.first) in + Some (Array.to_list (Array.sub v.kids v.first n)) + | None -> None + +let mounted_count (t : t) id = + match Hashtbl.find_opt t.vlists id with + | Some v -> Hashtbl.length v.rows + | None -> 0 + +(* Total virtual content extent (border-box span, device px). *) +let virtual_extent (t : t) id = + match Hashtbl.find_opt t.vlists id with + | Some v -> Some (v.bor0 +. v.total +. v.bor1) + | None -> None + +(* The item's rect in engine-absolute (content-space) coordinates — + works for unmounted indices too, which is what makes scroll-to-item + reach a row that does not exist yet. *) +let virtual_item_rect (t : t) id ~index : Lui_scene.rect option = + match Hashtbl.find_opt t.vlists id with + | Some v when index >= 0 && index < v.count -> + Some + { Lui_scene.x = v.origin_cross; + y = v.origin_main +. v.bor0 +. v.pad0 +. bit_sum v.bit index; + w = v.cross_size; + h = vlist_ext v index } + | _ -> None + +(* The last viewport the vport accessor fed this list — what the + interaction layer reports as the visible range. *) +let virtual_view (t : t) id = + match Hashtbl.find_opt t.vlists id with + | Some v when v.windowed -> Some (v.vs, v.ve) + | _ -> None + +(* key → item index, lazily built once per list shape. *) +let virtual_index_of_key (t : t) store id key = + match Hashtbl.find_opt t.vlists id with + | None -> None + | Some v -> + let m = + match v.key_index with + | Some m -> m + | None -> + let m = Hashtbl.create 64 in + for i = 0 to v.count - 1 do + let cid = v.kids.(i) in + if cid >= 0 then + match Lui_store.string_prop store cid "key" with + | Some k -> Hashtbl.replace m k i + | None -> () + done; + v.key_index <- Some m; + m + in + Hashtbl.find_opt m key + +(* store id → item index (same lazy index as above, reversed). *) +let virtual_index_of_id (t : t) _store id cid = + match Hashtbl.find_opt t.vlists id with + | None -> None + | Some v -> + let m = + match v.id_index with + | Some m -> m + | None -> + let m = Hashtbl.create 64 in + for i = 0 to v.count - 1 do + let k = v.kids.(i) in + if k >= 0 then Hashtbl.replace m k i + done; + v.id_index <- Some m; + m + in + Hashtbl.find_opt m cid + let content_extent (t : t) = t.extent let layout_hook (t : t) id = diff --git a/platform/native/lui_layout/lui_layout.mli b/platform/native/lui_layout/lui_layout.mli index d5bf5e31..813b5041 100644 --- a/platform/native/lui_layout/lui_layout.mli +++ b/platform/native/lui_layout/lui_layout.mli @@ -67,6 +67,14 @@ val style_of : val measure_of : text_measure -> id:int -> text:string -> Lui_flex.measure +(** A content-space visible range fed to a [virtual-list]: [(start, + extent)] in the list's own border-box coordinates — [scroll offset] + for the simple scrolled-viewport case, or the on-screen clip mapped + into the list's content when an outer scroller clips it. [None] + keeps the list unwindowed (every row mounted, today's behavior). *) + +type viewport = float * float + (** [sync ?scale ?measure ~width ~height t store] rebuilds the flex mirror from the store's current structure and runs layout for a [width]x[height] device-px frame. Root-level nodes that are not @@ -87,13 +95,77 @@ val measure_of : like [background] never re-enters the mirror), the frame geometry, [?ctx_anchor]'s value, and the [?measure]/[?hidden] functions' identities. Scroll offsets are not an input — a frame that only - scrolls pays no layout at all. *) + scrolls pays no layout at all. + + [?vport] maps a node id to the visible range of a [virtual-list] + ({!viewport}), evaluated per virtual list on every call — including + memo hits, where a moved viewport remounts only the row window that + slid (no mirror rebuild). Lists the accessor leaves at [None] + materialize every child like ordinary containers. *) val sync : ?scale:float -> ?measure:text_measure -> ?hidden:(int -> bool) -> ?ctx_anchor:(float * float) -> + ?vport:(int -> viewport option) -> width:float -> height:float -> t -> Lui_store.t -> unit +(** {2 Virtual lists} + + A [virtual-list] keeps every child in the store as its logical + model but materializes flex trees only for the mounted window of + item indices — visible range plus overscan. The list itself stays + a leaf in the mirror whose intrinsic main size is the virtual + content extent, so frame extent, scroll caps and hit clips behave + exactly as if all rows were laid out. + + Config rides [data-attrs] on the node (no schema change): + [data-item-extent] — estimated row main extent in logical px (per- + child [height] or a 28px default otherwise); [data-overscan] — + extra rows mounted past each view edge (4 default). Rows are + measured once mounted, so a mixed-height list converges to real + positions; [data-scroll-target]/[data-scroll-token]/ + [data-scroll-anchor] name a scroll request the host resolves + through {!virtual_index_of_key}, and [data-track-visible-range] + opts the list into visible-range reports through {!virtual_view}. *) + +(** Ids of the [virtual-list] nodes the last sync registered. *) +val vlists : t -> int list + +(** [mounted_range t id] is the mounted child-index window + \[first, last) of a virtual list — the slice paint and hit-test + enumerate instead of all children. [None] for non-virtual ids. *) +val mounted_range : t -> int -> (int * int) option + +(** [mounted_count t id] is how many row subtrees are materialized. *) +val mounted_count : t -> int -> int + +(** [mounted_children t id] is the mounted window's child store ids, + in order — the slice paint and hit-test enumerate instead of all + children. O(window); [None] for non-virtual ids. *) +val mounted_children : t -> int -> int list option + +(** [virtual_extent t id] is the list's total virtual content extent + on the main axis (border-box span, device px) — what a scroll cap + is computed against when the mounted rows do not reach the end. *) +val virtual_extent : t -> int -> float option + +(** [virtual_item_rect t id ~index] is the item's rect in + engine-absolute (content-space) coordinates — available for + unmounted indices too, so scroll-to-item can reach a row that is + not materialized yet. *) +val virtual_item_rect : t -> int -> index:int -> Lui_scene.rect option + +(** [virtual_view t id] is the last {!viewport} fed to this list — + what the interaction layer reports as the visible range. *) +val virtual_view : t -> int -> viewport option + +(** [virtual_index_of_key t store id key] resolves a child [key] prop + to its item index — O(n) once per list shape, O(1) after. *) +val virtual_index_of_key : t -> Lui_store.t -> int -> string -> int option + +(** [virtual_index_of_id t store id child] is the child's item index. *) +val virtual_index_of_id : t -> Lui_store.t -> int -> int -> int option + (** [invalidate t] forces the next {!sync} to rebuild the mirror. Needed when a function input keeps its identity but changes its answers — e.g. a text measure backed by a font stack that finishes diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 894bef0f..df897bec 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -350,6 +350,137 @@ let test_ac_row () = check (float eps) "text h" 10. (place p 10).h; check (float eps) "wrap h" 10. (place p 4).h +(* ---------- virtual lists ---------- *) + +(* root > virtual-list(2) > n 20-px rows keyed "row-i". *) +let vlist_store ?(item_h = 20.) ~n () = + let store = Lui_store.create () in + let ops = + ref + [ InsertChild (1, 2, 0); CreateNode (2, VirtualList); + CreateNode (1, Root) ] + in + for i = 0 to n - 1 do + let id = 10 + i in + ops := + InsertChild (2, id, i) + :: SetProp (id, KeyValue, StringValue (Printf.sprintf "row-%d" i)) + :: SetProp (id, HeightValue, FloatValue item_h) + :: CreateNode (id, ListItem) + :: !ops + done; + Lui_store.apply_batch store { generation = 1; ops = List.rev !ops }; + store + +let vport y h id = if id = 2 then Some (y, h) else None + +(* The mounted window is the fed viewport plus overscan — 1000 logical + rows never materialize more than a couple dozen. *) +let test_vlist_window_mounts_slice () = + let store = vlist_store ~n:1000 () in + let t = Lui_layout.create () in + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. + ~vport:(vport 0. 200.) t store; + let f, l = Option.get (Lui_layout.mounted_range t 2) in + check int "first" 0 f; + (* 200px viewport over 20px rows = 10 rows (+1 touching) and 4 + overscan past the end *) + check int "last" 15 l; + check bool "mounted bounded" true + (Lui_layout.mounted_count t 2 <= 15); + check int "one vlist" 1 (List.length (Lui_layout.vlists t)); + (* the list claims the full virtual extent as its frame height *) + check (float eps) "extent" 20000. + (Option.get (Lui_layout.virtual_extent t 2)); + check_some_rect "vlist" 0. 0. 100. 20000. (Lui_layout.rect t 2); + (* mounted rows sit at model positions *) + check_some_rect "row0" 0. 0. 100. 20. (Lui_layout.rect t 10); + check_some_rect "row5" 0. 100. 100. 20. (Lui_layout.rect t 15); + (* unmounted rows have no rect *) + check bool "unmounted no rect" true + (Lui_layout.rect t (10 + 500) = None) + +(* A memo-hit sync only remounts the window that slid — same store + revision, no mirror rebuild, just a different fed viewport. *) +let test_vlist_window_moves () = + let store = vlist_store ~n:1000 () in + let t = Lui_layout.create () in + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. + ~vport:(vport 0. 200.) t store; + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. + ~vport:(vport 500. 200.) t store; + let f, l = Option.get (Lui_layout.mounted_range t 2) in + (* [420,780) over 20px rows: items 21..39 *) + check int "first" 21 f; + check int "last" 40 l; + check bool "mounted bounded" true + (Lui_layout.mounted_count t 2 <= 19); + check_some_rect "row30" 0. 600. 100. 20. (Lui_layout.rect t 40); + check bool "before window" true (Lui_layout.rect t 30 = None); + check bool "after window" true (Lui_layout.rect t 50 = None); + (* scroll-to-item reaches unmounted rows through the model *) + check (option int) "key" (Some 30) + (Lui_layout.virtual_index_of_key t store 2 "row-30"); + check_some_rect "item500" 0. 10000. 100. 20. + (Lui_layout.virtual_item_rect t 2 ~index:500) + +(* Measured extents: a row taller than the estimate refines the model + once mounted, and the total reflects measured + estimated parts. *) +let test_vlist_measured_extents () = + let store = Lui_store.create () in + let ops = + ref + [ InsertChild (1, 2, 0); CreateNode (2, VirtualList); + CreateNode (1, Root) ] + in + for i = 0 to 99 do + let id = 10 + i in + ops := + InsertChild (2, id, i) + :: SetProp (id, HeightValue, FloatValue (if i = 0 then 100. else 20.)) + :: CreateNode (id, ListItem) + :: !ops + done; + Lui_store.apply_batch store { generation = 1; ops = List.rev !ops }; + let t = Lui_layout.create () in + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. + ~vport:(vport 0. 200.) t store; + (* Estimate = first child's height (100) → the initial window is + [0,600) → rows 0..6; mounting them measures rows 1-6 at 20, so + the post-patch remount reaches further down the shrunk strides: + rows 0..10. Total = 100 + 10*20 + 89*100 estimated. *) + let f, l = Option.get (Lui_layout.mounted_range t 2) in + check int "first" 0 f; + check int "last" 11 l; + check (float eps) "extent" 9200. + (Option.get (Lui_layout.virtual_extent t 2)); + (* row 1 sits right after the 100px row 0 *) + check_some_rect "row1" 0. 100. 100. 20. (Lui_layout.rect t 11) + +(* A gap between rows participates in the stride: positions and the + total both include it. *) +let test_vlist_gap () = + let store = vlist_store ~n:100 () in + apply store [ SetProp (2, Gap, IntValue 10) ]; + let t = Lui_layout.create () in + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. + ~vport:(vport 0. 200.) t store; + (* 100 rows * 20 + 99 gaps * 10 = 2990 *) + check (float eps) "extent" 2990. + (Option.get (Lui_layout.virtual_extent t 2)); + check_some_rect "row2" 0. 60. 100. 20. (Lui_layout.rect t 12) + +(* No vport → the list is unwindowed and mounts every row (the legacy + behavior hosts without a viewport feed keep). *) +let test_vlist_unwindowed () = + let store = vlist_store ~n:50 () in + let t = Lui_layout.create () in + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. t store; + let f, l = Option.get (Lui_layout.mounted_range t 2) in + check int "all" 50 (l - f); + check int "mounted" 50 (Lui_layout.mounted_count t 2); + check_some_rect "row49" 0. 980. 100. 20. (Lui_layout.rect t 59) + (* Pixel snapping contract: at a fractional backing scale the exported rects land on device-pixel edges, and siblings stacked without a gap share their boundary exactly — the same snapped geometry feeds @@ -378,6 +509,7 @@ let test_snapped_edges () = second child shares exactly. *) check_rect "child1" 5. 5. 191. 30. r3; check_rect "child2 shared edge" 5. 35. 191. 30. r4 + (* ---------- incremental mirror ---------- *) let rect_or_zero t id = @@ -531,6 +663,32 @@ let test_incremental_large_tree () = check_equiv "big restyle" store t; check bool "fast incremental" true (dt < 0.5) +(* Journal ops that land inside a virtual list resync its side forest + instead of rebuilding the mirror — composed row rects still match a + fresh full layout of the same store. *) +let test_vlist_incremental_child () = + let store = vlist_store ~n:30 () in + let t = Lui_layout.create () in + (* Frame and measure inputs match [check_equiv]'s oracle exactly — + no vport, so the whole list mounts like an ordinary column. *) + Lui_layout.sync ~width:200. ~height:100. t store; + check int "mounted all" 30 (Lui_layout.mounted_count t 2); + (* Layout prop on a mounted row replays through the journal. *) + apply store [ SetProp (15, HeightValue, IntValue 60) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check bool "incremental path" true (Lui_layout.stat_steps t > 0); + check_equiv "row resized" store t; + (* A structural op under the list: a new row splices in. *) + apply store + [ CreateNode (200, ListItem); InsertChild (2, 200, 3); + SetProp (200, HeightValue, IntValue 25) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "row inserted" store t; + (* And one removed again, then dropped. *) + apply store [ RemoveChild (2, 12); DropNode 12 ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "row dropped" store t + let () = run "lui_layout" [ ("layout", @@ -551,6 +709,13 @@ let () = test_case "root popup" `Quick test_root_popup_centered_in_frame; test_case "menu panel" `Quick test_menu_panel; test_case "ac row" `Quick test_ac_row; + test_case "vlist window" `Quick test_vlist_window_mounts_slice; + test_case "vlist window moves" `Quick test_vlist_window_moves; + test_case "vlist measured" `Quick test_vlist_measured_extents; + test_case "vlist gap" `Quick test_vlist_gap; + test_case "vlist unwindowed" `Quick test_vlist_unwindowed; + test_case "vlist incremental child" `Quick + test_vlist_incremental_child; test_case "snapped edges" `Quick test_snapped_edges; test_case "incremental equivalence" `Quick test_incremental_equivalence; diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index d679a49a..ea20f596 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -422,6 +422,11 @@ type hooks = { layout : int -> placement; (* node id → layout result *) state_of : int -> state; (* node id → interaction state *) scroll_of : int -> scroll_metrics option; (* scroll container metrics *) + mounted_children : int -> int list option; + (* virtualized container id → mounted child store ids: the child + walk enumerates only that slice, so a scroll frame over a 100k + virtual list touches ~30 rows, not 100k. [None] enumerates + every child (ordinary containers). *) text_ops : int -> rect -> color -> string -> op list; (* text engine *) image_of : int -> image option; (* node id → decoded image *) image_in : int -> rect -> image option; @@ -467,6 +472,7 @@ let default_hooks = layout = (fun _ -> placement_of_rect (rect 0. 0. 0. 0.)); state_of = (fun _ -> state_neutral); scroll_of = (fun _ -> None); + mounted_children = (fun _ -> None); text_ops = (fun _ _ _ _ -> []); image_of = (fun _ -> None); image_in = (fun _ _ -> None); @@ -2329,8 +2335,7 @@ let zindex store id = exits on the first nonzero z-index so a lone stacked sibling is the only reason to sort — most lists have none and pay one prop lookup per child, no fingerprint list, no sort. *) -let ordered_children store id = - let kids = Lui_store.children store id in +let z_ordered store kids = if List.exists (fun c -> zindex store (Lui_store.node_id c) <> 0) kids then List.stable_sort @@ -2341,6 +2346,8 @@ let ordered_children store id = kids else kids +let ordered_children store id = z_ordered store (Lui_store.children store id) + let node_shown store id = (match Lui_store.prop store id "visible" with | Some (Lui_protocol.BoolValue false) -> false @@ -2393,8 +2400,16 @@ type 'a branch = { node's chrome cost, keeping only the promoted-popup search and the [ghost_enter] context update (a popup's alpha and hit flags still accumulate its clipped-away ancestors). *) -let walk ~scale ~viewport store ~layout ~enter ~ghost_enter ~leave ctx0 = +let walk ?(mounted = fun _ -> None) ~scale ~viewport store ~layout ~enter + ~ghost_enter ~leave ctx0 = let deferred = Queue.create () in + (* Virtualized containers walk only their mounted window slice. *) + let kids id = + match mounted id with + | Some ids -> + z_ordered store (List.filter_map (Lui_store.find_opt store) ids) + | None -> ordered_children store id + in (* A clip context that misses [viewport] entirely is dead: nothing inside it can paint or be hit — except promoted popups, which escape to the overlay pass. *) @@ -2426,7 +2441,7 @@ let walk ~scale ~viewport store ~layout ~enter ~ghost_enter ~leave ctx0 = let r = resolve_rect ~scale ~viewport store id kind p parent_r in let ctx = ghost_enter ctx id contents in let child_parent = if contents then parent_r else r in - List.iter (ghost ctx child_parent) (ordered_children store id) + List.iter (ghost ctx child_parent) (kids id) end and body ctx clip parent_r id kind = let contents = unboxed store id in @@ -2454,8 +2469,7 @@ let walk ~scale ~viewport store ~layout ~enter ~ghost_enter ~leave ctx0 = let br = enter ctx f in if br.b_down then let child_clip = if clips_own then Some clipped else clip in - List.iter (go br.b_ctx child_clip f.f_parent_r) - (ordered_children store id); + List.iter (go br.b_ctx child_clip f.f_parent_r) (kids id); if br.b_emit then leave br.b_ctx f in List.iter @@ -2617,6 +2631,7 @@ let paint hooks store scene ~width ~height ~scale ~clear = deferred = Queue.create () } in walk ~scale ~viewport:ctx.viewport store ~layout:hooks.layout + ~mounted:hooks.mounted_children ~enter:(paint_enter ctx store scene) ~ghost_enter:(paint_ghost_enter ctx store) ~leave:(paint_leave ctx store scene) 1.; diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index e6796a12..ba021a11 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -99,16 +99,23 @@ type retained = { (* Instrumentation for tests/benchmarks. *) mutable stat_repaints : int; mutable stat_visits : int; + (* Virtualized containers: the mounted window each kids list was + last built from, and the hook answering the current one. A slid + window re-derives that container's kids. *) + vwin : (int, int list) Hashtbl.t; + mutable mounted : int -> int list option; } let create () = { frags = Hashtbl.create 256; fparent = Hashtbl.create 256; pend_kids = Hashtbl.create 16; pend_segs = Hashtbl.create 64; rev = -1; vp = rect 0. 0. 0. 0.; scale = 1.; - dirty = false; mgen = 0; stat_repaints = 0; stat_visits = 0 } + dirty = false; mgen = 0; stat_repaints = 0; stat_visits = 0; + vwin = Hashtbl.create 4; mounted = (fun _ -> None) } let invalidate (rt : retained) = Hashtbl.reset rt.frags; + Hashtbl.reset rt.vwin; rt.dirty <- true let stat_repaints (rt : retained) = rt.stat_repaints @@ -254,7 +261,14 @@ let build_kids rt store (f : frag) id = ( cid, Lui_paint.popup_kind (Lui_store.node_kind c) && not (Lui_paint.unboxed store cid) )) - (Lui_paint.ordered_children store id); + (match rt.mounted id with + | Some ids -> + Hashtbl.replace rt.vwin id ids; + Lui_paint.z_ordered store + (List.filter_map (Lui_store.find_opt store) ids) + | None -> + Hashtbl.remove rt.vwin id; + Lui_paint.ordered_children store id); (* Re-point parent links: entries this list dropped keep whatever parent claimed them since (a reparent op already marked it). *) List.iter @@ -591,7 +605,7 @@ let rec emit_seg (scene : Lui_scene.t) m (f : frag) = | Sub g -> emit_seg scene m g) f.seg -let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints +let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists ~width ~height ~scale ~clear () = let vp = rect 0. 0. (float width) (float height) in if vp <> rt.vp || scale <> rt.scale then begin @@ -601,6 +615,18 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints rt.scale <- scale end; if Lui_store.revision store <> rt.rev then advance rt store; + rt.mounted <- hooks.Lui_paint.mounted_children; + (* A virtual list whose mounted window slid (scroll, resize) has a + stale kids list even though no store op named it. *) + (match vlists with + | Some ids -> + List.iter + (fun id -> + let now = rt.mounted id in + let before = Hashtbl.find_opt rt.vwin id in + if now <> before then mark_rekids rt id) + ids + | None -> ()); (match moved with | Some mv -> Hashtbl.iter (fun id () -> mark_geom rt id) mv | None -> ()); diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 520fadad..7fc22ebd 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -68,6 +68,29 @@ let test_children_order () = check int "second green" 255 b.fcolor.g | _ -> fail "expected two Fill ops in paint order" +(* The mounted_children hook restricts the child walk to its window — + the mechanism a virtual list uses to skip unmounted rows without + the paint pass touching them at all. *) +let test_mounted_range () = + let t = store () in + apply t + ([ Lui_protocol.CreateNode (1, VirtualList) ] + @ List.flatten + (List.init 10 (fun i -> + [ Lui_protocol.CreateNode (10 + i, ListItem); + SetProp (10 + i, BackgroundValue, StringValue "#ff0000"); + Lui_protocol.InsertChild (1, 10 + i, i) ]))); + let h = + { (hooks ()) with + Lui_paint.mounted_children = + (fun id -> if id = 1 then Some [ 12; 13; 14 ] else None) } + in + let s = paint_with h t ~width:100 ~height:10000 () in + let fills = + List.filter (function Lui_scene.Fill _ -> true | _ -> false) s.ops + in + check int "sliced fills" 3 (List.length fills) + let test_invisible () = let t = store () in apply t @@ -818,6 +841,7 @@ let () = test_case "viewport cull" `Quick test_viewport_cull; test_case "z-index" `Quick test_zindex; test_case "display contents" `Quick test_display_contents; + test_case "mounted range" `Quick test_mounted_range; test_case "cull: escaped child" `Quick test_cull_escape_child; test_case "cull: fixed descendant" `Quick test_cull_escape_fixed; test_case "cull: promoted popup" `Quick test_cull_escape_popup; diff --git a/platform/native/lui_paint/test/lui_retained_test.ml b/platform/native/lui_paint/test/lui_retained_test.ml index 28be3a2a..06ba465e 100644 --- a/platform/native/lui_paint/test/lui_retained_test.ml +++ b/platform/native/lui_paint/test/lui_retained_test.ml @@ -321,6 +321,57 @@ let test_wide_removal () = check bool "bounded repaints" true (Lui_retained.stat_repaints env.rt - r0 < 320) +(* A virtual list under retained paint: both walks enumerate the + mounted window slice, and a scroll-driven remount must rebuild the + list's kids list — never replaying pieces of rows that slid out. *) +let vlist_store env ~n = + let ops = + ref + [ InsertChild (1, 2, 0); CreateNode (2, VirtualList); + CreateNode (1, Root) ] + in + for i = 0 to n - 1 do + let id = 10 + i in + ops := + InsertChild (2, id, i) + :: SetProp (id, KeyValue, StringValue (Printf.sprintf "row-%d" i)) + :: SetProp (id, HeightValue, FloatValue 20.) + :: SetProp (id, BackgroundValue, StringValue "#112233") + :: CreateNode (id, ListItem) + :: !ops + done; + apply env.store (List.rev !ops) + +let vframe env ~vport = + Lui_layout.sync ~scale:1. ~measure ~width:200. ~height:200. ~vport + env.eng env.store; + let moved = Lui_layout.take_rect_changes env.eng in + let h = + { (hooks env) with + layout = Lui_layout.layout_hook env.eng; + mounted_children = Lui_layout.mounted_children env.eng } + in + let full = scene () in + env.cur := full; + Lui_paint.paint h env.store full ~width:200 ~height:200 ~scale:1. + ~clear:(color 0 0 0 0); + env.cur := env.kept; + Lui_retained.paint_retained env.rt h env.store env.kept ~moved + ~vlists:(Lui_layout.vlists env.eng) ~width:200 ~height:200 ~scale:1. + ~clear:(color 0 0 0 0) (); + check_scene full env.kept + +let test_vlist_scroll () = + let env = env () in + vlist_store env ~n:1000; + let vport y h id = if id = 2 then Some (y, h) else None in + vframe env ~vport:(vport 0. 200.); + (* Each scroll remounts a different window; the retained scene must + equal a fresh full paint on every frame, back-scrolls included. *) + vframe env ~vport:(vport 500. 200.); + vframe env ~vport:(vport 9500. 200.); + vframe env ~vport:(vport 0. 200.) + (* Deterministic random op sequences: every frame cross-checks the retained scene against a full repaint. *) let test_random_equivalence () = @@ -420,4 +471,5 @@ let () = test_case "state hint" `Quick test_state_hint; test_case "drop subtree" `Quick test_drop_subtree; test_case "wide removal" `Quick test_wide_removal; + test_case "vlist scroll" `Quick test_vlist_scroll; test_case "random equivalence" `Quick test_random_equivalence ] ) ] diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 131d4480..ae65df60 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -166,6 +166,10 @@ type change = { crev : int; cop : patch_op } type t = { nodes : (int, node) Hashtbl.t; + roots : (int, unit) Hashtbl.t; + (* Ids whose node is stored with no live parent — kept + incrementally so [root_ids] does not fold the whole table on + every paint. *) dirty : (int, dirty_mask) Hashtbl.t; mutable generation : int; mutable revision : int; @@ -173,8 +177,6 @@ type t = { layout-kids bits). The layout memo keys on it so pure paint churn leaves the flex mirror untouched. *) mutable layout_rev : int; - mutable roots_cache : int list; - mutable roots_rev : int; journal : change Queue.t; mutable journal_len : int; (* Journal completeness: every op whose [crev] exceeds @@ -187,9 +189,9 @@ type t = { let journal_cap = 8192 let create () = - { nodes = Hashtbl.create 64; dirty = Hashtbl.create 64; + { nodes = Hashtbl.create 64; roots = Hashtbl.create 8; + dirty = Hashtbl.create 64; generation = 0; revision = 0; layout_rev = 0; - roots_cache = []; roots_rev = -1; journal = Queue.create (); journal_len = 0; journal_base = -1 } let node_count t = Hashtbl.length t.nodes @@ -348,6 +350,7 @@ let rec drop t id = | None -> ()) | None -> ()); Hashtbl.remove t.nodes id; + Hashtbl.remove t.roots id; mark_dirty_mask t id (d_structure lor d_paint) (* Insert child at index within parent, detaching it from its previous @@ -370,6 +373,7 @@ let insert_child t parent child index = | None -> ()); insert_at p c index; c.parent <- Some parent; + Hashtbl.remove t.roots child; mark_dirty_mask t parent (d_structure lor d_layout lor d_paint); mark_dirty_mask t child (d_structure lor d_paint) | _ -> () @@ -394,6 +398,7 @@ let apply_op t = function the new node starts with an empty child list — orphans. *) if Hashtbl.mem t.nodes id then drop t id; Hashtbl.replace t.nodes id (new_node id (Lui_wire_schema.node_kind_name k)); + Hashtbl.replace t.roots id (); mark_dirty_mask t id d_all; journal_push t op | CreateExtension (id, identifier, _fp) as op -> @@ -401,6 +406,7 @@ let apply_op t = function let n = new_node id ("extension:" ^ identifier) in n.ext_id <- Some identifier; Hashtbl.replace t.nodes id n; + Hashtbl.replace t.roots id (); mark_dirty_mask t id d_all; journal_push t op | (DropNode id | DetachSubtree id) as op -> @@ -469,6 +475,7 @@ let apply_op t = function scan None p.first_child; c.parent <- None end; + Hashtbl.replace t.roots child (); mark_dirty_mask t parent (d_structure lor d_layout lor d_paint); mark_dirty_mask t child (d_structure lor d_paint) | _ -> ()); @@ -497,6 +504,7 @@ let is_editable_text_kind = function let ext_id t id = match find_opt t id with Some n -> n.ext_id | None -> None let parent t id = match find_opt t id with Some n -> n.parent | None -> None + let children t id = match find_opt t id with | Some n -> node_children_list n @@ -507,6 +515,29 @@ let child_ids t id = | Some n -> List.map node_id (node_children_list n) | None -> [] +(* Indexed access for virtualized containers: walks the intrusive + list from the nearer end — O(min(i, count-i)). Meant for one-shot + lookups (scroll-target); per-frame window slices come from the + layout engine's own mounted-window snapshot. *) +let child_id_at t id i = + match find_opt t id with + | Some n when i >= 0 && i < n.child_count -> ( + let rec fwd k cur = + match cur with + | Some c when k = 0 -> c.id + | Some c -> fwd (k - 1) c.next_sib + | None -> -1 + in + let rec bwd k cur = + match cur with + | Some c when k = 0 -> c.id + | Some c -> bwd (k + 1) c.prev_sib + | None -> -1 + in + if i <= n.child_count / 2 then fwd i n.first_child + else bwd (i - n.child_count + 1) n.last_child) + | _ -> -1 + let last_child_id t id = match find_opt t id with | Some n -> Option.map node_id n.last_child @@ -529,23 +560,12 @@ let rec depth t id = | _ -> 0 (* Ids whose parent chain reaches no stored node: the forest roots. - The set only moves when structure ops run, so the scan+sort is - memoized on [layout_rev] — callers on per-frame paths stay O(roots) - between them. *) + The set is maintained by the mutation paths above, so this stays + O(#roots) instead of folding the whole table — a paint that asks + for roots of a 100k-row virtual list must not scan all of them. *) let root_ids t = - if t.roots_rev <> t.layout_rev then begin - t.roots_cache <- - Hashtbl.fold - (fun id n acc -> - match n.parent with - | None -> id :: acc - | Some pid when not (Hashtbl.mem t.nodes pid) -> id :: acc - | Some _ -> acc) - t.nodes [] - |> List.sort compare; - t.roots_rev <- t.layout_rev - end; - t.roots_cache + Hashtbl.fold (fun id () acc -> id :: acc) t.roots [] + |> List.sort compare (* Preorder ids under [root] (or the whole forest when None). *) let preorder ?root t = diff --git a/platform/native/lui_store/lui_store.mli b/platform/native/lui_store/lui_store.mli index cb053eb3..297901ec 100644 --- a/platform/native/lui_store/lui_store.mli +++ b/platform/native/lui_store/lui_store.mli @@ -74,8 +74,15 @@ val ext_id : t -> int -> string option val parent : t -> int -> int option val children : t -> int -> node list val child_ids : t -> int -> int list -val last_child_id : t -> int -> int option val child_count : t -> int -> int + +(** [child_id_at t id i] is the [i]-th child id of [id], or [-1] when + out of range or [id] is unknown. Walks from the nearer end — + O(min(i, count-i)); per-frame window slices come from the layout + engine's mounted-window snapshot instead. *) + +val child_id_at : t -> int -> int -> int +val last_child_id : t -> int -> int option val prop : t -> int -> string -> Lui_protocol.wire_value option val bool_prop : t -> int -> string -> bool val string_prop : t -> int -> string -> string option diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 91e6aaf7..37fe2d43 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -476,6 +476,19 @@ module Ui = struct c_rect : int -> float * float * float; } + (* Layout-engine forwards for virtual-list math: item counts, + content-space item rects (mounted or not), key → index lookup + and the last fed viewport — the pieces scroll-target, + visible-range and reveal logic need on logical items. The + record is defined in {!Lui_scroll} — the machine that consumes + it — and re-exported here for the drivers that install it. *) + type vlist_hook = Lui_scroll.vlist = { + vh_count : int -> int option; + vh_item : int -> index:int -> rect option; + vh_index_of_key : int -> string -> int option; + vh_view : int -> (float * float) option; + } + type t = { mutable scale : float; mutable dirty : bool; @@ -490,6 +503,9 @@ module Ui = struct (Lui_store.t -> int -> string -> lw:float -> lh:float -> caret_ops) option; mutable content_rect : int -> rect; + (* Layout engine forwards for virtual-list math, consumed by the + scroll machine through {!senv}. *) + mutable vlist_hook : Lui_scroll.vlist option; (* composed machine states — see platform/native/lui_input *) mutable pointer : Lui_pointer.state; mutable focus : Lui_focus.state; @@ -532,6 +548,7 @@ module Ui = struct (fun _store _id s -> (8. *. float (String.length s), 16.)); hit_text = None; caret_ops = None; content_rect = (fun _ -> rect 0. 0. 0. 0.); + vlist_hook = None; pointer = Lui_pointer.initial; focus = Lui_focus.initial; session = Lui_text_input.initial; @@ -554,6 +571,7 @@ module Ui = struct let set_caret_ops t f = t.caret_ops <- Some f let notify_layout t = t.rects_epoch <- t.rects_epoch + 1 let set_content_rect t f = t.content_rect <- f + let set_vlist_hook t h = t.vlist_hook <- h (* ---- state accessors -------------------------------------------- *) @@ -681,7 +699,8 @@ module Ui = struct let senv t : Lui_scroll.env = { Lui_scroll.content_rect = t.content_rect; - rects_epoch = t.rects_epoch } + rects_epoch = t.rects_epoch; + vlist = t.vlist_hook } (* The public [Input] vocabulary predates the machines' own event types — identical shapes, so the boundary converts. *) @@ -2044,6 +2063,42 @@ module Assembly = struct Float.min_float, Float.min_float) (Lui_store.preorder ~root:id (store_of ())) in + (* Virtual-list viewport feed: the node's content-space visible + range — its own scroll offset plus how far the frame clip has + scrolled into its (shifted) box. Outer scroller clips are not + tracked, so a clipped-away list just mounts a few extra + rows. *) + let vport_of id = + if Lui_store.kind (store_of ()) id <> "virtual-list" then None + else begin + let er = engine_rect id in + let sx, sy = scroll_shift_of id in + let shifted = + { er with + Lui_scene.x = er.Lui_scene.x -. sx; + y = er.Lui_scene.y -. sy } + in + let scn = + match !host_cell with + | Some h -> Lui_host.scene h + | None -> failwith "vport before host" + in + let frame = + Lui_scene.rect 0. 0. (float scn.Lui_scene.width) + (float scn.Lui_scene.height) + in + let clip = Lui_scene.rect_intersect shifted frame in + Some + ( Ui.scroll_offset ui id + +. Float.max 0. (clip.Lui_scene.y -. shifted.Lui_scene.y), + Float.max 0. clip.Lui_scene.h ) + end + in + let vport_of id = + match !host_cell with + | Some _ -> vport_of id + | None -> None + in let hooks = Lui_paint. { color_of = Theme.color_of; @@ -2054,14 +2109,29 @@ module Assembly = struct match !host_cell with | Some h -> Ui.state_of ui (Lui_host.store h) id | None -> Lui_paint.state_neutral); + mounted_children = + (fun id -> + match !host_cell with + | Some h -> Lui_host.layout_mounted_children h id + | None -> None); scroll_of = (fun id -> let cap = Ui.scroll_cap ui id in if cap <= 0.001 then None else let cr = engine_rect id in - let _, top, _, bot = content_box id in - let content = Float.max (bot -. top) 0.001 in + let content = + match + !host_cell + |> Option.map (fun h -> + Lui_host.layout_virtual_extent h id) + |> Option.join + with + | Some ext -> Float.max ext 0.001 + | None -> + let _, top, _, bot = content_box id in + Float.max (bot -. top) 0.001 + in Some Lui_paint. { sm_offset = Ui.scroll_offset ui id /. cap; @@ -2163,12 +2233,24 @@ module Assembly = struct | id when Lui_store.kind store id = "context-menu" -> Some (Ui.pointer ui) | _ -> None)); + (* Virtual lists: feed each node's content-space visible range + during layout sync so only the mounted window is + materialized. A scroll move repaints (the offset lives in the + scroll machine's dirty path), the repaint re-syncs the engine + and the engine re-reads this feed — that is the scroll-driven + remount. *) + Lui_host.set_vport host vport_of; (* Populate the hit-test mirror from the layout engine: each node's shifted rect clipped to its scrollable ancestors' viewports (so scrolled-off content can't be hit), then each scroll container's range on both axes from its unshifted content extent. Runs after every repaint while the engine is fresh. *) + (* Scroll-container ids memoized on the store revision: a scroll + frame changes no props, so the cap refresh below does not + re-walk the whole tree on every frame (a 100k-row list has ~3 + scrollables). *) + let scrollables = ref (-1, []) in let populate () = Hashtbl.reset rects; (* One preorder pass carrying the ancestors' accumulated scroll @@ -2208,24 +2290,51 @@ module Assembly = struct let child_parent = if Lui_paint.unboxed store id then parent_r else resolved in + (* A virtual list only hit-tests its mounted window — + unmounted rows have no rect and cannot be hit anyway. *) + let cids = + match Lui_host.layout_mounted_children host id with + | Some ids -> ids + | None -> Lui_store.child_ids store id + in List.iter (fun c -> visit c ~sx:sx' ~sy:sy' ~parent_r:child_parent) - (Lui_store.child_ids store id) + cids in List.iter (fun id -> visit id ~sx:0. ~sy:0. ~parent_r:viewport) (Lui_store.root_ids store); + let srev = Lui_store.revision store in + let sids = + match !scrollables with + | r, ids when r = srev -> ids + | _ -> + let ids = + List.filter + (fun id -> Ui.scrollable store id) + (Lui_store.preorder store) + in + scrollables := (srev, ids); + ids + in List.iter (fun id -> - if Ui.scrollable store id then - let cr = engine_rect id in + let cr = engine_rect id in + match Lui_host.layout_virtual_extent host id with + | Some ext -> + (* Virtual extent, not the mounted rows' box: the cap + spans the full logical content. *) + Ui.set_scroll_cap ui id + (Float.max 0. (ext -. cr.Lui_scene.h)); + Ui.set_scroll_cap_x ui id 0. + | None -> let l, t, rgt, b = content_box id in Ui.set_scroll_cap ui id (Float.max 0. (b -. t -. cr.Lui_scene.h)); Ui.set_scroll_cap_x ui id (Float.max 0. (rgt -. l -. cr.Lui_scene.w))) - (Lui_store.preorder store); + sids; (* Rects were just rebuilt — visible-range memos key on the epoch. *) Ui.notify_layout ui @@ -2257,6 +2366,40 @@ module Assembly = struct (* Keyboard caret moves run on the same layouts: grapheme clusters horizontally, visual (soft-wrapped) lines vertically. *) Ui.set_caret_ops ui (fun _store id s ~lw ~lh -> caret_ops id s ~lw ~lh); - Ui.set_content_rect ui engine_rect; + (* Virtual lists: expose the extent model to the scroll machine + (scroll-target, visible-range), and synthesize rects for + unmounted rows so reveal/scroll-align work on logical + items. *) + Ui.set_vlist_hook ui + (Some + Ui. + { vh_count = + (fun id -> + if Lui_store.kind store id = "virtual-list" + then Some (Lui_store.child_count store id) + else None); + vh_item = + (fun id ~index -> + Lui_host.layout_virtual_item host id ~index); + vh_index_of_key = + (fun id key -> + Lui_host.layout_virtual_index_of_key host id key); + vh_view = + (fun id -> Lui_host.layout_virtual_view host id) }); + Ui.set_content_rect ui + (fun id -> + let r = engine_rect id in + if not (Lui_scene.rect_empty r) then r + else + (* Unmounted virtual-list row: the model still knows where + it sits, so scroll-to-item reaches it. *) + match Lui_store.parent store id with + | Some p -> ( + match Lui_host.layout_virtual_index_of_id host p id with + | Some i -> + Option.value ~default:r + (Lui_host.layout_virtual_item host p ~index:i) + | None -> r) + | None -> r); { host; ui; rects; scale_cell; engine_rect; populate; caret_rect } end diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index 94f407dd..791f3824 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -326,6 +326,20 @@ module Ui : sig val set_content_rect : t -> (int -> Lui_scene.rect) -> unit + (** Layout-engine forwards for virtual-list math: the pieces + scroll-target resolution, visible-range reports and + reveal-on-focus need on logical items that may not be mounted. + [vh_view] is the last viewport the driver fed the engine; + [vh_item] answers content-space rects for any item index. *) + type vlist_hook = { + vh_count : int -> int option; + vh_item : int -> index:int -> Lui_scene.rect option; + vh_index_of_key : int -> string -> int option; + vh_view : int -> (float * float) option; + } + + val set_vlist_hook : t -> vlist_hook option -> unit + (** Tell the Ui that layout rects were recomputed (the driver's populate/refresh pass). Bumps the internal layout epoch that visible-range memoization keys on — call it wherever the rects diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index f8b88296..b25e985e 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -1976,6 +1976,76 @@ let test_scroll_request () = ignore (Ui.scroll_by ui s 2 (-50.)); Alcotest.(check (float 0.001)) "by" 150. (Ui.scroll_offset ui 2) +(* Virtual lists take the scroll-request triple over data-attrs and + resolve the key through the extent model — the target row does not + need a mounted rect, the driver's synthesized content_rect stands + in. *) +let test_vlist_scroll_request () = + let ops = + [ CreateNode (1, Root); CreateNode (2, VirtualList); + InsertChild (1, 2, 0); + SetProp + (2, DataAttrs, + StringValue + (Lui_protocol.data_attrs_encode + [ ("data-scroll-target", "item-9"); + ("data-scroll-token", "1"); + ("data-track-visible-range", "true") ])) ] + @ List.flatten + (List.init 10 (fun i -> + [ CreateNode (10 + i, ListItem); + SetProp + (10 + i, KeyValue, + StringValue (Printf.sprintf "item-%d" i)); + InsertChild (2, 10 + i, i) ])) + in + let s = store_of ops in + let ui = Ui.create () in + (* The driver's content_rect hook: the container's box, and the + model rect for rows whether mounted or not. *) + Ui.set_content_rect ui + (fun id -> + if id = 2 then rect 0. 0. 100. 60. + else rect 0. (float (id - 10) *. 20.) 100. 20.); + Ui.set_vlist_hook ui + (Some + Ui. + { vh_count = (fun id -> if id = 2 then Some 10 else None); + vh_item = + (fun _ ~index -> + Some (rect 0. (float index *. 20.) 100. 20.)); + vh_index_of_key = + (fun _ k -> + if String.length k > 5 && String.sub k 0 5 = "item-" + then int_of_string_opt (String.sub k 5 (String.length k - 5)) + else None); + vh_view = (fun _ -> Some (0., 60.)) }); + Ui.set_scroll_cap ui 2 200.; + let acts = Ui.store_changed ui s in + (match press_actions acts with + | [ VisibleRange (2, 0, 2); ScrollCompleted (2, 1, "succeeded") ] + | [ ScrollCompleted (2, 1, "succeeded"); VisibleRange (2, 0, 2) ] + | [ ScrollCompleted (2, 1, "succeeded") ] -> () + | a -> Alcotest.failf "expected ScrollCompleted, got %d actions" (List.length a)); + (* item-9 at y=180 in a 60px view: minimal reveal scrolls to 140 *) + Alcotest.(check (float 0.001)) "scrolled" 140. (Ui.scroll_offset ui 2); + (* a missing key reports missing-target *) + Lui_store.apply_batch s + { generation = 2; + ops = + [ SetProp + (2, DataAttrs, + StringValue + (Lui_protocol.data_attrs_encode + [ ("data-scroll-target", "nope"); + ("data-scroll-token", "2") ])) ] }; + (match press_actions (Ui.store_changed ui s) with + | [ ScrollCompleted (2, 2, "missing-target") ] + | [ VisibleRange _; ScrollCompleted (2, 2, "missing-target") ] + | [ ScrollCompleted (2, 2, "missing-target"); VisibleRange _ ] -> + () + | _ -> Alcotest.fail "expected missing-target") + let test_drag_drop () = let s = store_of @@ -2324,8 +2394,8 @@ module Fake_layout = struct | Some (FloatValue f) -> f | _ -> 0. - let sync ?scale:_ ?measure:_ ?hidden:_ ?ctx_anchor:_ ~width ~height - t store = + let sync ?scale:_ ?measure:_ ?hidden:_ ?ctx_anchor:_ ?vport:_ + ~width ~height t store = Hashtbl.reset t; let rec place id ~x ~y ~w = Hashtbl.replace t id (rect x y w (h_of store id)); @@ -2349,6 +2419,17 @@ module Fake_layout = struct let rect t id = Hashtbl.find_opt t id + (* No virtualization — the empty surface every non-virtualizing + engine answers. *) + let vlists _ = [] + let mounted_range _ _ = None + let mounted_children _ _ = None + let virtual_extent _ _ = None + let virtual_item_rect _ _ ~index:_ = None + let virtual_index_of_key _ _ _ _ = None + let virtual_index_of_id _ _ _ _ = None + let virtual_view _ _ = None + (* The stub re-places the whole tree every sync — every rect is potentially fresh. *) let take_rect_changes t = @@ -2624,7 +2705,9 @@ let () = Alcotest.test_case "control keys" `Quick test_control_keys ] ); ( "list", [ Alcotest.test_case "typeahead" `Quick test_list_typeahead; - Alcotest.test_case "scroll request" `Quick test_scroll_request ] ); + Alcotest.test_case "scroll request" `Quick test_scroll_request; + Alcotest.test_case "vlist scroll request" `Quick + test_vlist_scroll_request ] ); ( "dnd", [ Alcotest.test_case "drag drop" `Quick test_drag_drop; Alcotest.test_case "file drop" `Quick test_file_drop ] ); diff --git a/platform/native/perf/harness.ml b/platform/native/perf/harness.ml index 4da68e71..0dc0c9e2 100644 --- a/platform/native/perf/harness.ml +++ b/platform/native/perf/harness.ml @@ -246,11 +246,15 @@ let visible_children t ~root ~clip = 0 let paint_hooks t = + let mounted_children = Lui_layout.mounted_children t.engine in if Hashtbl.length t.scroll = 0 then - { t.hooks with layout = Lui_layout.layout_hook t.engine } + { t.hooks with + layout = Lui_layout.layout_hook t.engine; + mounted_children } else { t.hooks with + mounted_children; layout = (fun id -> let p = Lui_layout.layout_hook t.engine id in @@ -261,6 +265,33 @@ let paint_hooks t = { p.p_rect with y = p.p_rect.y -. sy }); } +(* Virtual-list viewport feed, same composition the window driver + makes: the container's scroll offset plus how far the frame clip + has scrolled into its shifted box. *) +let vport_of t id = + if Lui_store.kind (store t) id <> "virtual-list" then None + else begin + let er = + match Lui_layout.rect t.engine id with + | Some r -> r + | None -> rect 0. 0. 0. 0. + in + let shifted = { er with y = er.y -. scroll_shift t id } in + let clip = + rect_intersect shifted + (rect 0. 0. (float_of_int t.width) (float_of_int t.height)) + in + Some + ( Option.value ~default:0. (Hashtbl.find_opt t.scroll id) + +. Float.max 0. (clip.y -. shifted.y), + Float.max 0. clip.h ) + end + +(* Mounted window [first, last) and mounted row count for a virtual + list id — the structural bound the scenarios assert. *) +let mounted_range t id = Lui_layout.mounted_range t.engine id +let mounted_count t id = Lui_layout.mounted_count t.engine id + (* ---------- timed stages (each returns elapsed ms) ---------- *) let apply t batch = @@ -269,7 +300,7 @@ let apply t batch = let layout t = snd (timed (fun () -> - Lui_layout.sync ~scale:t.scale ~measure + Lui_layout.sync ~scale:t.scale ~measure ~vport:(vport_of t) ~width:(float_of_int t.width) ~height:(float_of_int t.height) t.engine (store t))) diff --git a/platform/native/perf/lui_perf_test.ml b/platform/native/perf/lui_perf_test.ml index 83de384f..7ad4e921 100644 --- a/platform/native/perf/lui_perf_test.ml +++ b/platform/native/perf/lui_perf_test.ml @@ -34,6 +34,11 @@ let test_visible_window () = let capacity = float_of_int height /. 28. +. 2. in Alcotest.(check bool) "visible bounded" true (visible > 0. && visible <= capacity); + (* The milestone's structural bound: 10k logical rows mount only the + viewport + overscan — nowhere near the item count. *) + let mounted = find rows ~scenario:"vlist-10k" ~metric:"mounted rows" in + Alcotest.(check bool) "mounted bounded" true + (mounted > 0. && mounted <= 200.); let ops = find rows ~scenario:"vlist-10k" ~metric:"scene ops/frame" in Alcotest.(check bool) "ops emitted" true (ops > visible) diff --git a/platform/native/perf/scenarios.ml b/platform/native/perf/scenarios.ml index 28dbf46d..9ff80b4b 100644 --- a/platform/native/perf/scenarios.ml +++ b/platform/native/perf/scenarios.ml @@ -134,6 +134,9 @@ let vlist ~n ~frames ~velocity ~item_h ~width ~height = ms_row name "frame total p50" (H.stats_of !ta).p50; ms_row name "frame total p95" (H.stats_of !ta).p95; row name "visible children" (string_of_int visible) "nodes"; + (* The virtualization bound: logical rows the pipeline actually + materializes — viewport + overscan, independent of n. *) + row name "mounted rows" (string_of_int (H.mounted_count t 2)) "rows"; row name "scene ops/frame" (string_of_int ops) "ops"; ] diff --git a/src/lui_elements.ml b/src/lui_elements.ml index 7570e77a..530e2163 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -880,9 +880,27 @@ let list ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?pad mount_children context node children; node -let virtual_list ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?opacity ?opacity_signal ?list_marker ?transition (children : t list) : t = +let virtual_list ?key ?overscan ?item_extent ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?opacity ?opacity_signal ?list_marker ?transition (children : t list) : t = fun context parent -> let node = Lui_ui.virtual_list context in + (* Virtualization config rides data-attrs: the schema does not model + virtual-list props, so overscan/item-extent travel as + [data-overscan]/[data-item-extent]. *) + let vlist_attrs = + (match overscan with + | Some n -> [ ("data-overscan", string_of_int n) ] + | None -> []) + @ + match item_extent with + | Some f -> [ ("data-item-extent", string_of_float f) ] + | None -> [] + in + let data_attrs = + match data_attrs, vlist_attrs with + | _, [] -> data_attrs + | None, extra -> Some extra + | Some user, extra -> Some (extra @ user) + in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~margin ~margin_horizontal ~margin_vertical ~margin_top ~margin_right ~margin_bottom ~margin_left ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~data_attrs ~data_attrs_signal ~on_appear ~on_pointer_enter ~on_pointer_leave ~transition; Option.iter (Lui_ui.float_property context node Opacity) opacity; Option.iter (Lui_ui.float_property_signal context node Opacity) opacity_signal; diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 3cc634b2..cd938eac 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -995,8 +995,16 @@ val list : ?opacity_signal:float Signal.signal -> ?list_marker:list_marker -> ?transition:string -> t list -> t +(* [virtual_list] keeps all children in the model but materializes + only the visible window plus [?overscan] rows (backends that + virtualize — web and native). [?item_extent] is the estimated row + main extent in logical px, used to size unmeasured rows on the + native backend; both ride [data-attrs] as + [data-overscan]/[data-item-extent]. *) val virtual_list : ?key:string -> + ?overscan:int -> + ?item_extent:float -> ?gap:int -> ?main:main_alignment -> ?cross:cross_alignment ->